commit 69ea5091eb296b1ecb97771b3e2831b6cf9311ff Author: sevenhill Date: Wed Oct 7 22:06:07 2026 +0300 Import IKAR messenger client and server diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1e8e799 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,47 @@ + + +## Описание + + + +**Объём:** N файлов, +X / −Y. + +## Что вошло + + + +### 💬 Чаты и сообщения +- + +### 👤 Профиль и контакты +- + +### 🔒 Безопасность + +- + +### 🛠 Прочее +- + +## Коммиты + + +- `xxxxxxx` + +## Проверка + +- [ ] `flutter analyze` +- [ ] Сборка `qlyra` flavor +- [ ] Ручная проверка затронутых сценариев +- [ ] Регресс по безопасности (если затронута) diff --git a/.github/actions/setup-e2ee/action.yml b/.github/actions/setup-e2ee/action.yml new file mode 100644 index 0000000..b73e497 --- /dev/null +++ b/.github/actions/setup-e2ee/action.yml @@ -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 diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml new file mode 100644 index 0000000..f4738df --- /dev/null +++ b/.github/actions/setup-rust/action.yml @@ -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 diff --git a/.github/workflows/android-smoke.yml b/.github/workflows/android-smoke.yml new file mode 100644 index 0000000..f57cccb --- /dev/null +++ b/.github/workflows/android-smoke.yml @@ -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 diff --git a/.github/workflows/build-android-fcm.yml b/.github/workflows/build-android-fcm.yml new file mode 100644 index 0000000..fe83f6a --- /dev/null +++ b/.github/workflows/build-android-fcm.yml @@ -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 diff --git a/.github/workflows/build-android-play.yml b/.github/workflows/build-android-play.yml new file mode 100644 index 0000000..e232822 --- /dev/null +++ b/.github/workflows/build-android-play.yml @@ -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 diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml new file mode 100644 index 0000000..a086b8b --- /dev/null +++ b/.github/workflows/build-android.yml @@ -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 diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml new file mode 100644 index 0000000..569ee73 --- /dev/null +++ b/.github/workflows/build-ios.yml @@ -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' + + + + + get-task-allow + application-identifierru.qlyra.app + keychain-access-groups + ru.qlyra.app + + + 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 diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml new file mode 100644 index 0000000..69aa10b --- /dev/null +++ b/.github/workflows/build-linux.yml @@ -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 diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml new file mode 100644 index 0000000..81fd35b --- /dev/null +++ b/.github/workflows/build-macos.yml @@ -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 diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml new file mode 100644 index 0000000..7489aca --- /dev/null +++ b/.github/workflows/build-windows.yml @@ -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 diff --git a/.github/workflows/flutter-dev.yml b/.github/workflows/flutter-dev.yml new file mode 100644 index 0000000..0cf951b --- /dev/null +++ b/.github/workflows/flutter-dev.yml @@ -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 diff --git a/.github/workflows/flutter-main.yml b/.github/workflows/flutter-main.yml new file mode 100644 index 0000000..cad1f52 --- /dev/null +++ b/.github/workflows/flutter-main.yml @@ -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 diff --git a/.github/workflows/native-crypto.yml b/.github/workflows/native-crypto.yml new file mode 100644 index 0000000..8969774 --- /dev/null +++ b/.github/workflows/native-crypto.yml @@ -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 diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml new file mode 100644 index 0000000..330f9ee --- /dev/null +++ b/.github/workflows/osv-scanner.yml @@ -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 diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml new file mode 100644 index 0000000..0611fef --- /dev/null +++ b/.github/workflows/release-dev.yml @@ -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' + + + + + platform-application + get-task-allow + application-identifierru.qlyra.app + keychain-access-groups + ru.qlyra.app + com.apple.private.security.no-container + + + 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/* diff --git a/.github/workflows/release-main.yml b/.github/workflows/release-main.yml new file mode 100644 index 0000000..afc1bec --- /dev/null +++ b/.github/workflows/release-main.yml @@ -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' + + + + + platform-application + get-task-allow + application-identifierru.qlyra.app + keychain-access-groups + ru.qlyra.app + com.apple.private.security.no-container + + + 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/* diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..c8f85da --- /dev/null +++ b/.github/workflows/tests.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5e5572 --- /dev/null +++ b/.gitignore @@ -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-* + diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..cbeb1b9 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "third_party/rlottie"] + path = third_party/rlottie + url = https://github.com/Samsung/rlottie.git diff --git a/ANDROID_PERMISSIONS.md b/ANDROID_PERMISSIONS.md new file mode 100644 index 0000000..7e9716b --- /dev/null +++ b/ANDROID_PERMISSIONS.md @@ -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. diff --git a/PLUGINS.md b/PLUGINS.md new file mode 100644 index 0000000..d3c2271 --- /dev/null +++ b/PLUGINS.md @@ -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` | значения аргументов по имени; не заданные — пустая строка | +| `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`. + +## Разрешения + +Пользователь видит список разрешений при установке и выдаёт их все сразу. Вызов без +нужного разрешения завершается ошибкой `Нет разрешения `. + +| Разрешение | Что даёт | +|---------------------|----------| +| `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` — отправляет сообщение в текущий чат и +возвращает его id. Текст — до 65 536 символов. В зашифрованном чате текст шифруется +так же, как обычное сообщение. + +`chat.editText(messageId, text)` → `Promise` — меняет текст сообщения. Можно +редактировать только сообщения, отправленные этим же запуском команды. + +`chat.sendPhoto({ base64, url, filename, caption })` → `Promise` — отправляет +фото до 15 МБ. Нужен `base64` или `url`; если заданы оба, берётся `base64`. Загрузка +по `url` требует разрешения `network`. + +`chat.sendFile({ base64, url, filename })` → `Promise` — отправляет файл до +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` — показывает уведомление Komet внутри +приложения, пока открыт чат. + +### contact + +`contact.getPeer()` → `Promise` — данные собеседника в личном диалоге, +всегда свежие с сервера. Вне диалога возвращает `null`. + +`Peer`: `{ id, displayName, country, registrationTime, updateTime, options }`. +`registrationTime` и `updateTime` — миллисекунды Unix или `null`, `options` — массив +строк-флагов профиля. + +### runtime + +`runtime.sleep(milliseconds)` → `Promise` — пауза от 0 до 10 000 мс. + +`runtime.isOnline()` → `Promise` — есть ли соединение с сервером. + +`runtime.isActive()` → `Promise` — открыт ли ещё чат, из которого вызвана +команда. Проверяйте в длинных сценариях, чтобы не работать впустую. + +### storage + +Постоянное хранилище плагина. Оно переживает обновления и удаляется вместе с плагином. + +`storage.get(key)` → `Promise` — значение или `null`, если ключа нет. + +`storage.set(key, value)` → `Promise` — сохраняет любое JSON-значение. + +`storage.remove(key)` → `Promise` — удаляет ключ. + +Ключ — от 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`; +- обновление просит разрешения, которых нет у установленной версии, — такой плагин + нужно переустановить; +- пакет подписан другим ключом или подпись снята. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e27f313 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# ИКАР + +Иконка ИКАР + +Самостоятельный 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. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..cf85f67 --- /dev/null +++ b/analysis_options.yaml @@ -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 diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..cf547f7 --- /dev/null +++ b/android/.gitignore @@ -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 diff --git a/android/NOTICE b/android/NOTICE new file mode 100644 index 0000000..06a9081 --- /dev/null +++ b/android/NOTICE @@ -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 + diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..51b3545 --- /dev/null +++ b/android/app/build.gradle.kts @@ -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("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") +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d7583f8 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,207 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/ru/qlyra/app/BleContactExchange.kt b/android/app/src/main/kotlin/ru/qlyra/app/BleContactExchange.kt new file mode 100644 index 0000000..3db1c74 --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/BleContactExchange.kt @@ -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?) { + 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() + } +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/CallForegroundService.kt b/android/app/src/main/kotlin/ru/qlyra/app/CallForegroundService.kt new file mode 100644 index 0000000..9d4a096 --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/CallForegroundService.kt @@ -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() + } + } +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/CallNotifier.kt b/android/app/src/main/kotlin/ru/qlyra/app/CallNotifier.kt new file mode 100644 index 0000000..f7d07cd --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/CallNotifier.kt @@ -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) { + 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) { + Log.d("QlyraFcm", "finishCall keys=${data.keys}") + CallRinger.stop() + NotificationManagerCompat.from(ctx).cancel(CallConst.NOTIF_ID) + CallEvents.emit("ended") + } + + private fun isVideo(data: Map): 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}") + } + } +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/ChatNotifications.kt b/android/app/src/main/kotlin/ru/qlyra/app/ChatNotifications.kt new file mode 100644 index 0000000..50d28b9 --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/ChatNotifications.kt @@ -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 +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/ClipboardMedia.kt b/android/app/src/main/kotlin/ru/qlyra/app/ClipboardMedia.kt new file mode 100644 index 0000000..7f6d832 --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/ClipboardMedia.kt @@ -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(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, declaredMime: String?): List { + val root = File(context.cacheDir, CACHE_DIR) + prune(root) + val paths = ArrayList(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") + } + } +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/FkmChannel.kt b/android/app/src/main/kotlin/ru/qlyra/app/FkmChannel.kt new file mode 100644 index 0000000..474acd5 --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/FkmChannel.kt @@ -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("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("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>("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) -> Unit, + ): Boolean { + val data = call.argument>("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}") + } + } + } +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/FkmService.kt b/android/app/src/main/kotlin/ru/qlyra/app/FkmService.kt new file mode 100644 index 0000000..29d16e3 --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/FkmService.kt @@ -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) + } +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/IkarDeviceAuth.kt b/android/app/src/main/kotlin/ru/qlyra/app/IkarDeviceAuth.kt new file mode 100644 index 0000000..a4164f6 --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/IkarDeviceAuth.kt @@ -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() + 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) } + } + } +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/LaunchIntents.kt b/android/app/src/main/kotlin/ru/qlyra/app/LaunchIntents.kt new file mode 100644 index 0000000..2f4396c --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/LaunchIntents.kt @@ -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) + } + } +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/LauncherBadge.kt b/android/app/src/main/kotlin/ru/qlyra/app/LauncherBadge.kt new file mode 100644 index 0000000..815faad --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/LauncherBadge.kt @@ -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>("chats").orEmpty() + sync( + app, + enabled = call.argument("enabled") ?: false, + count = call.argument("count") ?: 0, + byChats = call.argument("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, + ) { + 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) +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/MainActivity.kt b/android/app/src/main/kotlin/ru/qlyra/app/MainActivity.kt new file mode 100644 index 0000000..8a10efb --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/MainActivity.kt @@ -0,0 +1,1245 @@ +package ru.ikar.app + +import android.Manifest +import android.app.KeyguardManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.IsoDep +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.util.Log +import android.view.WindowManager +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.ryanheise.audioservice.AudioServiceFragmentActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import android.media.MediaCodecInfo +import android.net.Uri +import androidx.media3.common.Effect +import androidx.media3.common.MediaItem +import androidx.media3.common.audio.ChannelMixingAudioProcessor +import androidx.media3.common.audio.ChannelMixingMatrix +import androidx.media3.effect.Presentation +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.Transformer +import androidx.media3.transformer.VideoEncoderSettings +import java.io.File +import java.net.NetworkInterface +import java.util.Collections +import java.util.Random +import java.util.concurrent.atomic.AtomicBoolean +import java.security.MessageDigest + +class MainActivity : AudioServiceFragmentActivity() { + + private val channelName = "ru.ikar.app/vpn_bypass" + private val iconPackage = MainActivity::class.java.name.substringBeforeLast('.') + private val iconComponents = mapOf( + "MainActivity" to "$iconPackage.MainActivity", + "MinimalIcon" to "$iconPackage.MinimalIcon", + ) + + private var nfcAdapter: NfcAdapter? = null + private var nfcEvents: EventChannel.EventSink? = null + private val nfcHandler = Handler(Looper.getMainLooper()) + private val nfcJitter = Random() + private val seenPeers = HashSet() + @Volatile private var nfcCycling = false + private val nfcReaderCallback = NfcAdapter.ReaderCallback { tag -> onNfcTagDiscovered(tag) } + + private var noteRecorder: VideoNoteRecorder? = null + private var ble: BleContactExchange? = null + private var pendingSelfId = 0L + private var pendingSelfPhone = 0L + private var pendingSession = "" + private var pendingPeer: NfcExchange.Peer? = null + @Volatile private var exchangingEmitted = false + + private var pendingCall: Map? = null + private var pendingChat: Long = 0L + private var pendingShare: Map? = null + private var pendingShareTask: java.util.concurrent.Future?>? = null + private val shareExecutor = java.util.concurrent.Executors.newSingleThreadExecutor() + private val shareHandler = Handler(Looper.getMainLooper()) + + private companion object { + @Volatile + var keepAwake = false + + const val LOG_TAG = "VpnBypass" + const val SHARE_TAG = "ShareIntake" + const val NFC_TAG = "NfcExchange" + const val NFC_PHASE_MIN_MS = 350L + const val NFC_PHASE_JITTER_MS = 400 + const val BLE_PERMS_REQUEST = 7711 + const val NOTE_PERMS_REQUEST = 7712 + val NFC_READER_FLAGS = NfcAdapter.FLAG_READER_NFC_A or + NfcAdapter.FLAG_READER_NFC_B or + NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK + } + + private fun applyIcon(name: String) { + val pm = packageManager + for ((alias, className) in iconComponents) { + val component = ComponentName(packageName, className) + val state = if (alias == name) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + pm.setComponentEnabledSetting( + component, + state, + PackageManager.DONT_KILL_APP, + ) + } + Handler(Looper.getMainLooper()).postDelayed({ + finishAndRemoveTask() + }, 250L) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + MediaExport.onActivityResult(this, requestCode, resultCode, data) + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + IkarDeviceAuth.register(flutterEngine) + LauncherBadge.attach(flutterEngine, this) + ClipboardMedia.attach(flutterEngine, applicationContext) + MediaExport.attach(flutterEngine, this) + + nfcAdapter = NfcAdapter.getDefaultAdapter(this) + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.ikar.app/update_integrity", + ).setMethodCallHandler { call, result -> + when (call.method) { + "verifyApkSigner" -> { + val path = call.argument("path") + if (path == null) { + result.error("INVALID_PATH", "APK path is required", null) + } else { + result.success(apkSignerMatchesInstalled(path)) + } + } + else -> result.notImplemented() + } + } + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.ikar.app/nfc", + ).setMethodCallHandler { call, result -> + when (call.method) { + "status" -> result.success(nfcStatus()) + "start" -> { + val selfId = longArg(call.argument("selfId")) + val selfPhone = longArg(call.argument("selfPhone")) + if (selfId <= 0L) { + result.error("INVALID_ID", "selfId must be positive", null) + } else { + startNfcExchange(selfId, selfPhone) + result.success(null) + } + } + "stop" -> { + stopNfcExchange() + result.success(null) + } + else -> result.notImplemented() + } + } + + EventChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.ikar.app/nfc_events", + ).setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + nfcEvents = events + } + + override fun onCancel(arguments: Any?) { + nfcEvents = null + } + }) + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + channelName, + ).setMethodCallHandler { call, result -> + when (call.method) { + "detectInterfaces" -> result.success(detectInterfaces()) + "bindToNonVpnNetwork" -> bindToNonVpnNetwork(result) + "unbindNetwork" -> result.success(unbindNetwork()) + else -> result.notImplemented() + } + } + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.ikar.app/app_icon", + ).setMethodCallHandler { call, result -> + when (call.method) { + "setAppIcon" -> { + val name = call.argument("name") + if (name == null || !iconComponents.containsKey(name)) { + result.error("INVALID_ICON", "Unknown icon: $name", null) + return@setMethodCallHandler + } + try { + applyIcon(name) + result.success(null) + } catch (e: Exception) { + result.error("APPLY_FAILED", e.message, null) + } + } + else -> result.notImplemented() + } + } + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.ikar.app/upload_service", + ).setMethodCallHandler { call, result -> + val ctx = this + fun uploadIntent(call: MethodCall, action: String) = + Intent(ctx, UploadForegroundService::class.java).apply { + this.action = action + putExtra(UploadForegroundService.EXTRA_TITLE, call.argument("title")) + putExtra(UploadForegroundService.EXTRA_BODY, call.argument("body") ?: "") + putExtra(UploadForegroundService.EXTRA_PROGRESS, call.argument("progress") ?: 0) + putExtra( + UploadForegroundService.EXTRA_INDETERMINATE, + call.argument("indeterminate") ?: true, + ) + } + + fun launch(intent: Intent, asForeground: Boolean) { + try { + if (asForeground && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(intent) + } else { + startService(intent) + } + } catch (e: Exception) { + Log.w("UploadService", "${intent.action} failed: ${e.message}") + } + } + + when (call.method) { + "start" -> { + launch(uploadIntent(call, UploadForegroundService.ACTION_START), true) + result.success(null) + } + "update" -> { + launch(uploadIntent(call, UploadForegroundService.ACTION_UPDATE), false) + result.success(null) + } + "stop" -> { + launch( + Intent(ctx, UploadForegroundService::class.java).apply { + action = UploadForegroundService.ACTION_STOP + }, + false, + ) + result.success(null) + } + else -> result.notImplemented() + } + } + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.ikar.app/video_note", + ).setMethodCallHandler { call, result -> + when (call.method) { + "permission" -> requestNotePermissions(result) + "init" -> { + val front = call.argument("front") ?: true + val size = call.argument("size") ?: 480 + val fps = call.argument("fps") ?: 30 + val rec = VideoNoteRecorder( + applicationContext, + flutterEngine.renderer, + size, + fps, + ) + noteRecorder?.dispose() + noteRecorder = rec + rec.init(front, call.argument("cameraId"), result) + } + "start" -> noteRecorder?.start(result) + ?: result.error("NOT_READY", "recorder not initialized", null) + "switch" -> noteRecorder?.switchCamera(result) + "torch" -> noteRecorder?.setTorch( + call.argument("on") ?: false, + result, + ) + ?: result.error("NOT_READY", "recorder not initialized", null) + "stop" -> noteRecorder?.stop(result) + ?: result.error("NOT_READY", "recorder not initialized", null) + "dispose" -> { + noteRecorder?.dispose() + noteRecorder = null + result.success(null) + } + else -> result.notImplemented() + } + } + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.ikar.app/video", + ).setMethodCallHandler { call, result -> + when (call.method) { + "cropSquare" -> { + val input = call.argument("input") + val output = call.argument("output") + val size = call.argument("size") ?: 480 + if (input == null || output == null) { + result.error("BAD_ARGS", "input/output required", null) + } else { + cropSquare(input, output, size, result) + } + } + "probe" -> { + val input = call.argument("input") + if (input == null) { + result.error("BAD_ARGS", "input required", null) + } else { + probeVideo(input, result) + } + } + "frames" -> { + val input = call.argument("input") + val times = call.argument>("times") + if (input == null || times == null) { + result.error("BAD_ARGS", "input/times required", null) + } else { + videoFrames( + input, + times, + call.argument("size") ?: 256, + call.argument("precise") == true, + result, + ) + } + } + "edit" -> editVideo(call, result) + "editProgress" -> editVideoProgress(result) + "editCancel" -> editVideoCancel(result) + else -> result.notImplemented() + } + } + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.ikar.app/calls", + ).setMethodCallHandler { call, result -> + when (call.method) { + "consumeInitialCall" -> { + val p = pendingCall + pendingCall = null + result.success(p) + } + "notifyAccepted" -> { + val caller = call.argument("caller") ?: "Звонок" + CallRinger.stop() + NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID) + CallForegroundService.start(applicationContext, caller) + result.success(null) + } + "ensureOngoing" -> { + val caller = call.argument("caller") ?: "Звонок" + CallForegroundService.start(applicationContext, caller) + result.success(null) + } + "setScreenShare" -> { + val enabled = call.argument("enabled") ?: false + val caller = call.argument("caller") ?: "Звонок" + CallForegroundService.setScreenShare( + applicationContext, + enabled, + caller, + ) + result.success(null) + } + "dropOngoing" -> { + CallForegroundService.stop(applicationContext) + result.success(null) + } + "notifyEnded" -> { + CallRinger.stop() + NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID) + CallForegroundService.stop(applicationContext) + clearCallWindowFlags() + result.success(null) + } + "cancelIncoming" -> { + CallRinger.stop() + NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID) + result.success(null) + } + "canUseFullScreenIntent" -> { + result.success( + NotificationManagerCompat.from(this).canUseFullScreenIntent(), + ) + } + "openFullScreenIntentSettings" -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + try { + startActivity( + Intent( + Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, + Uri.parse("package:$packageName"), + ), + ) + } catch (e: Exception) { + Log.w("QlyraFcm", "open FSI settings failed: ${e.message}") + } + } + result.success(null) + } + else -> result.notImplemented() + } + } + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.ikar.app/screen", + ).setMethodCallHandler { call, result -> + when (call.method) { + "setKeepAwake" -> { + setKeepAwake(call.argument("enabled") == true) + result.success(null) + } + else -> result.notImplemented() + } + } + + EventChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.ikar.app/calls_events", + ).setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + CallEvents.sink = events + } + + override fun onCancel(arguments: Any?) { + CallEvents.sink = null + } + }) + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.ikar.app/notifications", + ).setMethodCallHandler { call, result -> + when (call.method) { + "consumeInitialChat" -> { + stashChatOpen(intent, emit = false) + val chatId = pendingChat + pendingChat = 0L + result.success(if (chatId != 0L) chatId else null) + } + "setActiveChat" -> { + ChatNotifications.activeChatId = longArg(call.argument("chatId")) + result.success(null) + } + "clearActiveChat" -> { + ChatNotifications.activeChatId = 0L + result.success(null) + } + "cancelChat" -> { + QlyraNotifier(applicationContext).cancelChat( + longArg(call.argument("chatId")), + ) + result.success(null) + } + "notifiedChats" -> { + result.success(QlyraNotifier(applicationContext).notifiedChats()) + } + else -> result.notImplemented() + } + } + + EventChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.ikar.app/notification_events", + ).setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + ChatNotifications.sink = events + } + + override fun onCancel(arguments: Any?) { + ChatNotifications.sink = null + } + }) + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.ikar.app/share", + ).setMethodCallHandler { call, result -> + when (call.method) { + "consumeInitialShare" -> { + stashShare(intent, emit = false) + val ready = pendingShare + val task = pendingShareTask + if (ready != null) { + pendingShare = null + result.success(ready) + } else if (task != null) { + pendingShareTask = null + shareExecutor.execute { + val payload = try { + task.get() + } catch (e: Exception) { + Log.w(SHARE_TAG, "materialize failed: $e") + null + } + shareHandler.post { result.success(payload) } + } + } else { + result.success(null) + } + } + "clearCache" -> { + val paths = call.argument>("paths") ?: emptyList() + ShareIntake.clearCache(applicationContext, paths) + result.success(null) + } + else -> result.notImplemented() + } + } + + EventChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.ikar.app/share_events", + ).setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + ShareIntake.sink = events + } + + override fun onCancel(arguments: Any?) { + ShareIntake.sink = null + } + }) + + FkmChannel.attach(flutterEngine, this) + } + + @Suppress("DEPRECATION") + private fun apkSignerMatchesInstalled(path: String): Boolean { + return try { + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + PackageManager.GET_SIGNING_CERTIFICATES + } else { + PackageManager.GET_SIGNATURES + } + val archive = packageManager.getPackageArchiveInfo(path, flags) ?: return false + if (archive.packageName != packageName) return false + val installed = packageManager.getPackageInfo(packageName, flags) + val archiveSigners = signerDigests(archive) + archiveSigners.isNotEmpty() && archiveSigners == signerDigests(installed) + } catch (_: Exception) { + false + } + } + + @Suppress("DEPRECATION") + private fun signerDigests(info: android.content.pm.PackageInfo): Set { + val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + info.signingInfo?.apkContentsSigners.orEmpty() + } else { + info.signatures.orEmpty() + } + return signatures.map { signature -> + MessageDigest.getInstance("SHA-256") + .digest(signature.toByteArray()) + .joinToString("") { byte -> "%02x".format(byte) } + }.toSet() + } + + override fun onCreate(savedInstanceState: Bundle?) { + if (intent?.hasExtra(CallConst.EXTRA_CALL) == true) applyCallWindowFlags() + super.onCreate(savedInstanceState) + applyKeepAwake() + intent?.let { if (it.hasExtra(CallConst.EXTRA_CALL)) stashCall(it, emit = false) } + stashChatOpen(intent, emit = false) + stashShare(intent, emit = false) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + if (intent.hasExtra(CallConst.EXTRA_CALL)) { + applyCallWindowFlags() + stashCall(intent, emit = true) + } + stashChatOpen(intent, emit = true) + stashShare(intent, emit = true) + } + + private fun stashChatOpen(source: Intent?, emit: Boolean) { + val chatId = ChatNotifications.chatIdFrom(source) + if (chatId == 0L) return + source?.removeExtra(ChatNotifications.EXTRA_CHAT) + val fromRecents = + (source?.flags ?: 0) and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0 + if (fromRecents) return + val sink = ChatNotifications.sink + if (emit && sink != null) { + sink.success(chatId) + } else { + pendingChat = chatId + } + } + + private fun stashShare(source: Intent?, emit: Boolean) { + if (!ShareIntake.isShare(source)) return + val intent = source ?: return + val snapshot = ShareIntake.snapshot(intent) ?: return + intent.action = Intent.ACTION_MAIN + intent.removeExtra(Intent.EXTRA_STREAM) + intent.removeExtra(Intent.EXTRA_TEXT) + val task = shareExecutor.submit?> { + ShareIntake.materialize(applicationContext, snapshot) + } + if (!emit) { + pendingShareTask = task + return + } + shareExecutor.execute { + val payload = try { + task.get() + } catch (e: Exception) { + Log.w(SHARE_TAG, "materialize failed: $e") + null + } ?: return@execute + shareHandler.post { + val sink = ShareIntake.sink + if (sink != null) sink.success(payload) else pendingShare = payload + } + } + } + + private fun stashCall(intent: Intent, emit: Boolean) { + val json = intent.getStringExtra(CallConst.EXTRA_CALL) ?: return + val action = intent.getStringExtra(CallConst.EXTRA_ACTION) ?: CallConst.ACTION_RING + intent.removeExtra(CallConst.EXTRA_CALL) + intent.removeExtra(CallConst.EXTRA_ACTION) + intent.removeExtra(CallConst.EXTRA_CALLER) + if (action == CallConst.ACTION_ANSWER) CallRinger.stop() + val map = mapOf("data" to json, "action" to action) + val sink = CallEvents.sink + if (emit && sink != null) { + sink.success(map) + } else { + pendingCall = map + } + } + + private fun applyCallWindowFlags() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + } else { + @Suppress("DEPRECATION") + window.addFlags( + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON, + ) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val km = getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager + km?.requestDismissKeyguard(this, null) + } + } + + private fun clearCallWindowFlags() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(false) + setTurnScreenOn(false) + } else { + @Suppress("DEPRECATION") + window.clearFlags( + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON, + ) + } + } + + private fun setKeepAwake(enabled: Boolean) { + keepAwake = enabled + runOnUiThread { applyKeepAwake() } + } + + private fun applyKeepAwake() { + if (keepAwake) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + + // Центр-кроп видео в квадрат size×size (без искажений) через media3 + // Transformer: LAYOUT_SCALE_TO_FIT_WITH_CROP заполняет квадрат и обрезает + // лишнее по бокам. + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private fun cropSquare( + input: String, + output: String, + size: Int, + result: MethodChannel.Result, + ) { + try { + // Параметры энкодера как у официального клиента: H.264 ~1 Мбит/с CBR. + val videoSettings = VideoEncoderSettings.Builder() + .setBitrate(1_024_000) + .setBitrateMode(MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR) + .build() + val encoderFactory = DefaultEncoderFactory.Builder(this) + .setRequestedVideoEncoderSettings(videoSettings) + .build() + val transformer = Transformer.Builder(this) + .setEncoderFactory(encoderFactory) + .addListener(object : Transformer.Listener { + override fun onCompleted( + composition: Composition, + exportResult: ExportResult, + ) { + result.success(output) + } + + override fun onError( + composition: Composition, + exportResult: ExportResult, + exportException: ExportException, + ) { + result.error( + "TRANSCODE_FAILED", + exportException.message, + null, + ) + } + }) + .build() + // Аудио в моно (как у клиента). + val mono = ChannelMixingAudioProcessor() + mono.putChannelMixingMatrix(ChannelMixingMatrix.create(1, 1)) + mono.putChannelMixingMatrix(ChannelMixingMatrix.create(2, 1)) + val effects = Effects( + listOf(mono), + listOf( + Presentation.createForWidthAndHeight( + size, + size, + Presentation.LAYOUT_SCALE_TO_FIT_WITH_CROP, + ), + ), + ) + val edited = EditedMediaItem.Builder( + MediaItem.fromUri(Uri.fromFile(File(input))), + ).setEffects(effects).build() + transformer.start(edited, output) + } catch (e: Exception) { + result.error("TRANSCODE_FAILED", e.message, null) + } + } + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private fun probeVideo(input: String, result: MethodChannel.Result) = + VideoEditor.probe(input, result) + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private fun videoFrames( + input: String, + times: List, + size: Int, + precise: Boolean, + result: MethodChannel.Result, + ) = VideoEditor.frames(input, times, size, precise, result) + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private fun editVideo(call: MethodCall, result: MethodChannel.Result) = + VideoEditor.edit(this, call, result) + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private fun editVideoProgress(result: MethodChannel.Result) = + VideoEditor.progress(result) + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private fun editVideoCancel(result: MethodChannel.Result) = + VideoEditor.cancel(result) + + private fun nfcStatus(): Map { + val adapter = nfcAdapter + return mapOf( + "supported" to (adapter != null), + "enabled" to (adapter?.isEnabled == true), + ) + } + + private fun startNfcExchange(selfId: Long, selfPhone: Long) { + val session = "%08x".format(nfcJitter.nextInt()) + NfcExchange.selfId = selfId + NfcExchange.selfSession = session + NfcExchange.selfPhone = selfPhone + NfcExchange.active = true + NfcExchange.onServed = { onNfcServed() } + seenPeers.clear() + exchangingEmitted = false + pendingPeer = null + pendingSelfId = selfId + pendingSelfPhone = selfPhone + pendingSession = session + nfcCycling = true + nfcHandler.removeCallbacksAndMessages(null) + nfcReaderOn() + ensureBleStarted() + } + + private fun stopNfcExchange() { + nfcCycling = false + NfcExchange.active = false + NfcExchange.selfId = 0L + NfcExchange.selfSession = "" + NfcExchange.selfPhone = 0L + NfcExchange.onServed = null + pendingPeer = null + nfcHandler.removeCallbacksAndMessages(null) + nfcReaderDisable() + ble?.stop() + } + + private fun blePermissions(): Array = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + arrayOf( + Manifest.permission.BLUETOOTH_ADVERTISE, + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + ) + } else { + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) + } + + private fun hasBlePermissions(): Boolean = blePermissions().all { + ContextCompat.checkSelfPermission(this, it) == + PackageManager.PERMISSION_GRANTED + } + + private fun ensureBleStarted() { + if (hasBlePermissions()) { + startBle() + } else { + ActivityCompat.requestPermissions(this, blePermissions(), BLE_PERMS_REQUEST) + } + } + + private fun startBle() { + val exchange = ble ?: BleContactExchange(applicationContext).also { + it.onReceived = { id, phone -> revealPeer(id, phone) } + it.onSent = { _ -> pendingPeer?.let { p -> revealPeer(p.id, p.phone) } } + it.onError = { reason -> onBleError(reason) } + ble = it + } + exchange.start(pendingSelfId, pendingSession, pendingSelfPhone) + } + + private fun emitExchanging() { + if (exchangingEmitted) return + exchangingEmitted = true + nfcEvents?.success(mapOf("event" to "exchanging")) + } + + private fun revealPeer(id: Long, phone: Long) { + if (id == NfcExchange.selfId || !seenPeers.add(id)) return + nfcEvents?.success(mapOf("event" to "received", "id" to id, "phone" to phone)) + } + + private fun longArg(value: Any?): Long = when (value) { + is Int -> value.toLong() + is Long -> value + else -> 0L + } + + private fun onBleError(reason: String) { + nfcEvents?.success(mapOf("event" to "error", "reason" to reason)) + } + + private fun onNfcServed() { + nfcHandler.post { + if (!NfcExchange.active) return@post + nfcCycling = false + nfcReaderDisable() + emitExchanging() + } + } + + private var notePermResult: MethodChannel.Result? = null + + private fun isGranted(permission: String): Boolean = + ContextCompat.checkSelfPermission(this, permission) == + PackageManager.PERMISSION_GRANTED + + private fun notePermissionState(): Map = mapOf( + "camera" to isGranted(Manifest.permission.CAMERA), + "microphone" to isGranted(Manifest.permission.RECORD_AUDIO), + ) + + private fun requestNotePermissions(result: MethodChannel.Result) { + val state = notePermissionState() + if (state.values.all { it } || notePermResult != null) { + result.success(state); return + } + notePermResult = result + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO), + NOTE_PERMS_REQUEST, + ) + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == FkmChannel.NOTIF_PERMS_REQUEST) { + FkmChannel.onPermissionResult(grantResults) + return + } + if (requestCode == NOTE_PERMS_REQUEST) { + val pending = notePermResult + notePermResult = null + pending?.success(notePermissionState()) + return + } + if (requestCode != BLE_PERMS_REQUEST) return + if (!NfcExchange.active) return + val granted = grantResults.isNotEmpty() && + grantResults.all { it == PackageManager.PERMISSION_GRANTED } + if (granted) { + startBle() + } else { + onBleError("permission") + } + } + + private fun nfcReaderOn() { + if (!nfcCycling) return + nfcReaderEnable() + nfcHandler.postDelayed({ nfcReaderOff() }, nfcPhaseDuration()) + } + + private fun nfcReaderOff() { + if (!nfcCycling) return + nfcReaderDisable() + nfcHandler.postDelayed({ nfcReaderOn() }, nfcPhaseDuration()) + } + + private fun nfcPhaseDuration(): Long = + NFC_PHASE_MIN_MS + nfcJitter.nextInt(NFC_PHASE_JITTER_MS) + + private fun nfcReaderEnable() { + val adapter = nfcAdapter ?: return + try { + adapter.enableReaderMode(this, nfcReaderCallback, NFC_READER_FLAGS, null) + } catch (e: Exception) { + Log.w(NFC_TAG, "enableReaderMode failed: ${e.message}") + } + } + + private fun nfcReaderDisable() { + try { + nfcAdapter?.disableReaderMode(this) + } catch (e: Exception) { + Log.w(NFC_TAG, "disableReaderMode failed: ${e.message}") + } + } + + private fun onNfcTagDiscovered(tag: Tag) { + val isoDep = IsoDep.get(tag) ?: return + val peer = try { + isoDep.connect() + NfcExchange.parsePeer(isoDep.transceive(NfcExchange.buildSelectCommand())) + } catch (e: Exception) { + Log.w(NFC_TAG, "transceive failed: ${e.message}") + null + } finally { + try { + isoDep.close() + } catch (_: Exception) { + } + } + if (peer == null || peer.id <= 0L) return + nfcHandler.post { + if (peer.id == NfcExchange.selfId) return@post + nfcCycling = false + nfcReaderDisable() + emitExchanging() + pendingPeer = peer + ble?.connectTo(peer.session, peer.id) + nfcHandler.postDelayed({ revealPeer(peer.id, peer.phone) }, 3000L) + } + } + + // Движок переживает смерть активити, пока идёт звонок или включён FKM: + // в обоих случаях в фоне должно жить то же соединение, что и в UI. + private fun keepEngineAlive(): Boolean = CallState.inCall || FkmState.enabled + + // Движок общий с audio_service (AudioServiceFragmentActivity.provideFlutterEngine), и + // уничтожает его AudioServicePlugin.disposeFlutterEngine, когда останавливается + // медиа-сервис. Активити не должна рвать его из-под сервиса. + override fun shouldDestroyEngineWithHost(): Boolean = false + + override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) { + if (!keepEngineAlive()) { + FkmChannel.detach() + ChatNotifications.activeChatId = 0L + } + super.cleanUpFlutterEngine(flutterEngine) + } + + override fun onDestroy() { + LauncherBadge.detach() + shareExecutor.shutdown() + super.onDestroy() + } + + override fun onResume() { + super.onResume() + AppState.resumed = true + stashChatOpen(intent, emit = true) + } + + override fun onPause() { + super.onPause() + AppState.resumed = false + if (NfcExchange.active) { + stopNfcExchange() + nfcEvents?.success(mapOf("event" to "cancelled")) + } + } + + private fun connectivityManager(): ConnectivityManager = + getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + // Перечисляет активные интерфейсы: есть ли tun-туннель и какие прямые. + private fun detectInterfaces(): Map { + val tunNames = ArrayList() + val directNames = ArrayList() + val interfaces = try { + Collections.list(NetworkInterface.getNetworkInterfaces()) + } catch (_: Exception) { + emptyList() + } + for (nif in interfaces) { + val name = nif.name ?: continue + val up = try { + nif.isUp && !nif.isLoopback + } catch (_: Exception) { + false + } + if (!up) continue + when { + name.startsWith("tun") || name.startsWith("ppp") || + name.startsWith("ipsec") || name.startsWith("wg") -> + tunNames.add(name) + name.startsWith("wlan") || name.startsWith("rmnet") || + name.startsWith("eth") -> + directNames.add(name) + } + } + return mapOf( + "hasTun" to tunNames.isNotEmpty(), + "hasVpn" to hasVpnTransport(), + "tunNames" to tunNames, + "directInterfaces" to directNames, + ) + } + + // VPN активен, даже если tun-интерфейс не виден приложению (Android 10+). + private fun hasVpnTransport(): Boolean { + val cm = connectivityManager() + for (network in cm.allNetworks) { + val caps = cm.getNetworkCapabilities(network) ?: continue + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return true + if (!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) { + return true + } + } + return false + } + + private data class Candidate( + val network: Network, + val iface: String?, + val transport: String, + val score: Int, + ) + + // Привязка к не-VPN сети. Надёжный путь — попросить систему выдать + // подходящую сеть через NetworkCallback (валидный, привязываемый + // Network), и лишь при тайм-ауте — перебор getAllNetworks(). + private fun bindToNonVpnNetwork(result: MethodChannel.Result) { + val cm = connectivityManager() + val main = Handler(Looper.getMainLooper()) + val done = AtomicBoolean(false) + var callback: ConnectivityManager.NetworkCallback? = null + + fun finish(map: Map) { + if (!done.compareAndSet(false, true)) return + callback?.let { + try { + cm.unregisterNetworkCallback(it) + } catch (_: Exception) { + } + } + main.post { result.success(map) } + } + + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) + .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET) + .build() + + val cb = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + val caps = cm.getNetworkCapabilities(network) + val iface = cm.getLinkProperties(network)?.interfaceName + val transport = when { + caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + == true -> "wifi" + caps?.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + == true -> "ethernet" + else -> "cellular" + } + val ok = cm.bindProcessToNetwork(network) + Log.i(LOG_TAG, "onAvailable iface=$iface t=$transport bound=$ok") + finish( + mapOf( + "bound" to ok, + "interface" to iface, + "transport" to transport, + "reason" to if (ok) { + null + } else { + "bind_rejected_maybe_lockdown" + }, + ), + ) + } + } + + callback = cb + try { + cm.registerNetworkCallback(request, cb) + } catch (e: Exception) { + Log.w(LOG_TAG, "registerNetworkCallback failed: ${e.message}") + finish(bindByEnumeration()) + return + } + + main.postDelayed({ + if (done.get()) return@postDelayed + Log.w(LOG_TAG, "callback timeout — fallback to enumeration") + finish(bindByEnumeration()) + }, 4000L) + } + + // Запасной путь: перебор getAllNetworks(). Жёсткий фильтр — только + // исключение VPN-транспорта; INTERNET/NOT_VPN/VALIDATED лишь повышают + // приоритет (физическая сеть под VPN часто теряет эти capability). + private fun bindByEnumeration(): Map { + val cm = connectivityManager() + val networks = cm.allNetworks + val candidates = ArrayList() + + for (network in networks) { + val caps = cm.getNetworkCapabilities(network) + val iface = cm.getLinkProperties(network)?.interfaceName + Log.i(LOG_TAG, "net=$network iface=$iface caps=$caps") + if (caps == null) continue + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) continue + + val baseScore = when { + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 3 + caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 2 + caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 1 + else -> continue + } + val transport = when (baseScore) { + 3 -> "wifi" + 2 -> "ethernet" + else -> "cellular" + } + val internet = + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + val notVpn = + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + val validated = + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + val score = baseScore * 8 + + (if (internet) 4 else 0) + + (if (notVpn) 2 else 0) + + (if (validated) 1 else 0) + candidates.add(Candidate(network, iface, transport, score)) + } + + candidates.sortByDescending { it.score } + Log.i(LOG_TAG, "candidates=${candidates.map { "${it.iface}:${it.score}" }}") + + if (candidates.isEmpty()) { + return mapOf( + "bound" to false, + "reason" to "no_non_vpn_network(scanned=${networks.size})", + ) + } + + for (c in candidates) { + if (cm.bindProcessToNetwork(c.network)) { + Log.i(LOG_TAG, "bound to ${c.iface} (${c.transport})") + return mapOf( + "bound" to true, + "interface" to c.iface, + "transport" to c.transport, + "reason" to null, + ) + } + Log.w(LOG_TAG, "bindProcessToNetwork failed for ${c.iface}") + } + return mapOf("bound" to false, "reason" to "bind_blocked_maybe_lockdown") + } + + private fun unbindNetwork(): Map { + connectivityManager().bindProcessToNetwork(null) + return mapOf("bound" to false, "reason" to "unbound") + } +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/MediaExport.kt b/android/app/src/main/kotlin/ru/qlyra/app/MediaExport.kt new file mode 100644 index 0000000..f10b2cd --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/MediaExport.kt @@ -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("path"), + call.argument("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") + } + } +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/NfcExchange.kt b/android/app/src/main/kotlin/ru/qlyra/app/NfcExchange.kt new file mode 100644 index 0000000..3bb3af4 --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/NfcExchange.kt @@ -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 + } +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/NfcHostApduService.kt b/android/app/src/main/kotlin/ru/qlyra/app/NfcHostApduService.kt new file mode 100644 index 0000000..9f4a443 --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/NfcHostApduService.kt @@ -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 + } +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/NotifAvatars.kt b/android/app/src/main/kotlin/ru/qlyra/app/NotifAvatars.kt new file mode 100644 index 0000000..5a5db33 --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/NotifAvatars.kt @@ -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() + } + } +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/QlyraApplication.kt b/android/app/src/main/kotlin/ru/qlyra/app/QlyraApplication.kt new file mode 100644 index 0000000..71d3f62 --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/QlyraApplication.kt @@ -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" + } +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/QlyraFcmService.kt b/android/app/src/main/kotlin/ru/qlyra/app/QlyraFcmService.kt new file mode 100644 index 0000000..3f4afd3 --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/QlyraFcmService.kt @@ -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) { + 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) { + 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 { + 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) { + 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) { + 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, + alertOnce: Boolean, + ) { + if (history.isEmpty()) return + ensureChannel() + + val newest = history.last() + val avatarCache = HashMap() + val personCache = HashMap() + 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, + ) { + 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> { + val entries = ArrayList>() + 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>) { + 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 = 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 { + val arr = try { + JSONArray(pushPrefs().getString("hist_$chatId", "[]")) + } catch (e: Exception) { + JSONArray() + } + val out = ArrayList(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) { + 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 { + 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() + } + +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/ShareIntake.kt b/android/app/src/main/kotlin/ru/qlyra/app/ShareIntake.kt new file mode 100644 index 0000000..3eadeef --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/ShareIntake.kt @@ -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, + 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? { + val files = ArrayList>() + 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) { + 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 { + 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(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(Intent.EXTRA_STREAM) + } + return if (single != null) listOf(single) else emptyList() + } + + private fun copyToCache(context: Context, uri: Uri, intentType: String?): Map? { + 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) + } +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/UploadForegroundService.kt b/android/app/src/main/kotlin/ru/qlyra/app/UploadForegroundService.kt new file mode 100644 index 0000000..9111605 --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/UploadForegroundService.kt @@ -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() + } +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/VideoEditor.kt b/android/app/src/main/kotlin/ru/qlyra/app/VideoEditor.kt new file mode 100644 index 0000000..99da260 --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/VideoEditor.kt @@ -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? { + 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 { + 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, + size: Int, + precise: Boolean, + result: MethodChannel.Result, + ) { + Thread { + val out = ArrayList(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("input") + val output = call.argument("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("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("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("startMs")?.toLong() + val end = call.argument("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 { + val effects = mutableListOf() + val rotation = call.argument("rotationDegrees")?.toFloat() ?: 0f + val flipH = call.argument("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>("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("outWidth") ?: 0 + val height = call.argument("outHeight") ?: 0 + if (width > 0 && height > 0) { + effects.add( + Presentation.createForWidthAndHeight( + width, + height, + Presentation.LAYOUT_SCALE_TO_FIT_WITH_CROP, + ), + ) + } + val matrix = call.argument>("rgbMatrix") + if (matrix != null && matrix.size == 16) { + effects.add( + ColorMatrixEffect(FloatArray(16) { matrix[it].toFloat() }), + ) + } + val overlay = call.argument("overlay") + if (overlay != null) { + val bitmap = BitmapFactory.decodeFile(overlay) + if (bitmap != null) { + overlayBitmap = bitmap + effects.add( + OverlayEffect( + listOf( + 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 + } +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/VideoNoteRecorder.kt b/android/app/src/main/kotlin/ru/qlyra/app/VideoNoteRecorder.kt new file mode 100644 index 0000000..2e597d8 --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/VideoNoteRecorder.kt @@ -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? = 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? { + 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, + 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(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 + } +} diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8729deb Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_minimal_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_minimal_foreground.png new file mode 100644 index 0000000..5349f07 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_minimal_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ac7da43 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_minimal_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_minimal_foreground.png new file mode 100644 index 0000000..15423c3 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_minimal_foreground.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..2e79667 --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,2 @@ + + diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..dd946df Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_minimal_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_minimal_foreground.png new file mode 100644 index 0000000..9a2e964 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_minimal_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..e096210 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_minimal_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_minimal_foreground.png new file mode 100644 index 0000000..74e210b Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_minimal_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..e230b01 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_minimal_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_minimal_foreground.png new file mode 100644 index 0000000..9d6008b Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_minimal_foreground.png differ diff --git a/android/app/src/main/res/drawable/ic_notification.xml b/android/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..a878480 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..2e79667 --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,2 @@ + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c79c58a --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_minimal.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_minimal.xml new file mode 100644 index 0000000..7389348 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_minimal.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..1f1def6 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_minimal.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_minimal.png new file mode 100644 index 0000000..2c0aee9 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_minimal.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..8a2a551 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_minimal.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_minimal.png new file mode 100644 index 0000000..d2e0d20 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_minimal.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..7d31012 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_minimal.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_minimal.png new file mode 100644 index 0000000..52f60f4 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_minimal.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..22a3a0e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_minimal.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_minimal.png new file mode 100644 index 0000000..1074637 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_minimal.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..9250ea8 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_minimal.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_minimal.png new file mode 100644 index 0000000..54b9cde Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_minimal.png differ diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 0000000..f8e0b1d --- /dev/null +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/app/src/main/res/values-night/launch_colors.xml b/android/app/src/main/res/values-night/launch_colors.xml new file mode 100644 index 0000000..36d9212 --- /dev/null +++ b/android/app/src/main/res/values-night/launch_colors.xml @@ -0,0 +1,4 @@ + + + #141318 + diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..e8dba96 --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..519eb53 --- /dev/null +++ b/android/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,13 @@ + + Отправка медиа + Сервис уведомлений + Держит фоновое соединение с сервером + ИКАР · сервис уведомлений + Соединение активно + Соединение не активно + %1$s · принято %2$d + Это уведомление держит фоновое соединение с сервером, чтобы сообщения приходили без гугловых пушей. Убрать его можно, выключив FKM — кнопкой ниже или в Настройки → Уведомления → FKM. + Выключить + Удалено: + Отправить в чат + diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..1cb4d32 --- /dev/null +++ b/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..7d5be24 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #000000 + #000000 + \ No newline at end of file diff --git a/android/app/src/main/res/values/firebase.xml b/android/app/src/main/res/values/firebase.xml new file mode 100644 index 0000000..ff838c6 --- /dev/null +++ b/android/app/src/main/res/values/firebase.xml @@ -0,0 +1,8 @@ + + + 1:91097833269:android:c123132a32a690ba19b67c + AIzaSyCgjHUi4zFNRfwAwsiLBlRZCGup78N74ks + 91097833269 + ikar-3bdfb + ikar-3bdfb.firebasestorage.app + diff --git a/android/app/src/main/res/values/launch_colors.xml b/android/app/src/main/res/values/launch_colors.xml new file mode 100644 index 0000000..ffb646e --- /dev/null +++ b/android/app/src/main/res/values/launch_colors.xml @@ -0,0 +1,4 @@ + + + #F3F0FA + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..0fb1f1a --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,15 @@ + + ИКАР contact exchange + ИКАР contact exchange + Sending media + Notification service + Keeps the background connection to the server alive + ИКАР · notification service + Connection active + Connection inactive + %1$s · %2$d delivered + 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. + Turn off + Deleted: + Send to a chat + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..c5e56f9 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..45041eb --- /dev/null +++ b/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..bc9183e --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/xml/qlyra_nfc_apdu.xml b/android/app/src/main/res/xml/qlyra_nfc_apdu.xml new file mode 100644 index 0000000..f484ffd --- /dev/null +++ b/android/app/src/main/res/xml/qlyra_nfc_apdu.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/android/app/src/oneme/AndroidManifest.xml b/android/app/src/oneme/AndroidManifest.xml new file mode 100644 index 0000000..d8fc113 --- /dev/null +++ b/android/app/src/oneme/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/qlyra/AndroidManifest.xml b/android/app/src/qlyra/AndroidManifest.xml new file mode 100644 index 0000000..8af833d --- /dev/null +++ b/android/app/src/qlyra/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/store/AndroidManifest.xml b/android/app/src/store/AndroidManifest.xml new file mode 100644 index 0000000..04ed8e8 --- /dev/null +++ b/android/app/src/store/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -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("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..bab4e68 --- /dev/null +++ b/android/gradle.properties @@ -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 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..51a76cd --- /dev/null +++ b/android/settings.gradle.kts @@ -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") diff --git a/assets/certificates/README.md b/assets/certificates/README.md new file mode 100644 index 0000000..8ac6375 --- /dev/null +++ b/assets/certificates/README.md @@ -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. diff --git a/assets/certificates/russian_trusted_root_ca.pem b/assets/certificates/russian_trusted_root_ca.pem new file mode 100644 index 0000000..4c143a2 --- /dev/null +++ b/assets/certificates/russian_trusted_root_ca.pem @@ -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----- \ No newline at end of file diff --git a/assets/debug/fake_video_note.mp4 b/assets/debug/fake_video_note.mp4 new file mode 100644 index 0000000..9e1a713 Binary files /dev/null and b/assets/debug/fake_video_note.mp4 differ diff --git a/assets/emoji_keywords.json b/assets/emoji_keywords.json new file mode 100644 index 0000000..8da374d --- /dev/null +++ b/assets/emoji_keywords.json @@ -0,0 +1 @@ +{"😀":"широко улыбается лицо радость смех счастье улыбка grinning face cheerful cheery grin happy laugh nice smile smiling teeth","😃":"смеется весело лицо радость рот смех улыбка grinning face with big eyes awesome grin happy mouth open smile smiling teeth yay","😄":"смеется закрытыми глазами глаза закрытые лицо радость смех смеяться улыбка grinning face with smiling eyes eye grin happy laugh lol mouth open smile","😁":"светится от счастья весело глаза зубы лицо ржать смех счастье улыбка ухмылка широкая beaming face with smiling eyes eye grin grinning happy nice smile teeth","😆":"смеется прищуренными глазами глаза закрытые лол радость смех смешно угораю улыбка ха-ха хохот grinning squinting face closed eyes haha hahaha happy laugh lol mouth open rofl smile smiling","😅":"нервно смеется лол нервный облегчение открытый пот рот смех стресс улыбка широкая grinning face with sweat cold dejected excited mouth nervous open smile smiling stress stressed","🤣":"катается со смеху глаза катаюсь плач пол ржака ржач слезы умора ха-ха хохот rolling on the floor laughing crying face funny haha happy hehe hilarious joy laugh lmao lol rofl roflmao tear","😂":"смеется до слез глаза лол плач ржака ржач ржунимагу слезы смешно умора ха-ха face with tears of joy crying feels funny haha happy hehe hilarious laugh lmao lol rofl roflmao tear","🙂":"слегка улыбается легкая лицо радость смайлик улыбка slightly smiling face happy smile","🙃":"вверх тормашками ногами перевернутый смайлик улыбка upside-down face hehe smile","🫠":"тает грустная жара жарко лол нежность печальная плавится растаять таю улыбка melting face disappear dissolve embarrassed haha heat hot liquid lol melt sarcasm sarcastic","😉":"подмигивает глаз договорились ироничная легкая окей улыбка флирт хорошо шутка winking face flirt heartbreaker sexy slide tease wink winks","😊":"довольно улыбается глаза довольный закрытые краснеть легкая радость румянец улыбка щеки smiling face with eyes blush eye glad satisfied smile","😇":"нимбом ангел глаза добрый закрытые легкая невинная нимб улыбка smiling face with halo angel angelic angels blessed fairy fairytale fantasy happy innocent peaceful smile spirit tale","🥰":"улыбающееся лицо сердечками люблю любовь нежность обожаю радость романтика румянец сердечки улыбка чувства smiling face with hearts adore crush heart ily love romance smile you","😍":"влюбленное лицо без ума вау влюбленность восторг глаза люблю обожаю сердечки чувства smiling face with heart-eyes 143 bae eye feels hearts ily kisses love romance romantic smile xoxo","🤩":"звезды глазах вау глаза звезда звездный обалдеть радость супер улыбка широкая star-struck excited eyes face grinning smile star starry-eyed wow","😘":"воздушный поцелуй губы комплимент любовь подмигивание сердечко скучаю флирт чмок face blowing kiss adorbs bae flirt heart ily love lover miss muah romantic smooch xoxo you","😗":"целует люблю любовь нежность поцелуй романтика скучаю флирт чмок kissing face 143 date dating flirt ily kiss love smooch smooches xoxo you","☺":"улыбается довольный окей радость румянец смайлик счастье улыбка хорошо smiling face happy outlined relaxed smile","😚":"целует закрытыми глазами глаза закрытые поцелуй румянец флирт целовать чмок kissing face with closed eyes 143 bae blush date dating eye flirt ily kisses smooches xoxo","😙":"счастливый поцелуй глаза лицо любовь свидание улыбка флирт чмок kissing face with smiling eyes 143 closed date dating eye flirt ily kiss kisses love night smile","🥲":"улыбающееся лицо со слезой боль гордость облегчение признательность радость слеза трогательный улыбка умиление smiling face with tear glad grateful happy joy pain proud relieved smile smiley touched","😋":"вкусно вкус еда лицо объедение отлично смаковать язык face savoring food delicious eat full hungry savor smile smiling tasty um yum yummy","😛":"показывает язык вот высунуть забавно смешно улыбка ха шутка face with tongue awesome cool nice party stuck-out sweet","😜":"показывает язык подмигивает весело глаз забавно подмигнуть показать розыгрыш улыбка ха-ха шутка winking face with tongue crazy epic eye funny joke loopy nutty party stuck-out wacky weirdo wink yolo","🤪":"сумасшедшее лицо крейзи показать псих сдвиг сошел сумасшедший ума фазе чокнуться язык zany face crazy eye eyes goofy large small","😝":"морщится показывает язык бе гадость жмуриться омг отвращение показать противно ужас фу squinting face with tongue closed eye eyes gross horrible omg stuck-out taste whatever yolo","🤑":"деньги глазах баксы богатый выиграть джекпот доллары лотерея мани счастливчик язык money-mouth face money mouth paid","🤗":"обнимает встречи обнимашки объятия пока привет радость руки румянец увидимся улыбка smiling face with open hands hug hugging","🤭":"прикрывает рот рукой лишнее ляпнуть посмеиваться проговорился секрет смешок сюрприз тайна упс хи face with hand over mouth giggle giggling oops realization secret shock sudden surprise whoops","🫢":"прикрывает рот рукой открытыми глазами изумление испуг недоверие смущение страх удивление шок face with open eyes and hand over mouth amazement awe disbelief embarrass gasp omg quiet scared shock surprise","🫣":"подсматривает глаз интересно испуг рука румянец стесняюсь страх тайно украдкой face with peeking eye captivated embarrass hide hiding peek peep scared shy stare","🤫":"тс-с-с говори закрой никому секрет тайна тихо тсс узнает услышать шепот shushing face quiet shh shush","🤔":"думает думаю знаю интересно мозгами озадачен пораскинуть размышлять сложно трудно хм thinking face chin consider hmm ponder pondering wondering","🫡":"воинское приветствие да есть окей отдать приказ сделаю точно хорошо честь saluting face good luck ma am ok respect salute sir troops yes","🤐":"рот на замке говори могила молния никому секрет скажу тайна zipper-mouth face keep mouth quiet secret shut zip zipper","🤨":"не верит быть верю может недоверие подозрение правда скептик сомневаюсь хм face with raised eyebrow disapproval disbelief distrust emoji hmm mild skeptic skeptical skepticism surprise what","😐":"все равно безразличие незаинтересованный равнодушие скептик скучно усталость эмоций neutral face awkward blank deadpan expressionless fine jealous meh oh shade straight unamused unhappy unimpressed whatever","😑":"без эмоций безразличие надоело незаинтересованный нет ничего совсем спать усталость expressionless face awkward dead fine inexpressive jealous meh not oh omg straight uh unhappy unimpressed whatever","😶":"лицо без рта молчание немой нет рыба секрет слов тайна тихо тишина тсс face without mouth awkward blank expressionless mouthless mute quiet secret silence silent speechless","🫥":"лицо пунктиром грусть депрессия интроверт исчезать невидимка печаль пунктир угнетение усталость dotted line face depressed disappear hidden hide introvert invisible meh whatever wtv","😶‍🌫":"лицо облаках витающий голова тумане рассеянный face in clouds absentminded fog head","😏":"ухмыляется задумал лицо подмигнуть подозрение попался пошутил ухмылка хитрый шутка smirking face boss dapper flirt homie kidding leer shade slick sly smirk smug snicker suave suspicious swag","😒":"скучает гадость грустно надоело недовольный противно скука скучно тоска фу unamused face bored fine jealous jel jelly pissed smh ugh uhh unhappy weird whatever","🙄":"закатывает глаза блин закатить закончится когда надоело омг скука скучно тоска face with rolling eyes eyeroll shade ugh whatever","😬":"гримаса злой злость злюсь зубы кривляться показать рожа grimacing face awk awkward dentist grimace grinning smile smiling","😮‍💨":"выдыхает вздох выдох облегчение одышка свист шепот face exhaling blow blowing exhale exhausted gasp groan relief sigh smiley smoke whisper whistle","🤥":"лжец буратино врать врешь лицо ложь обман обманывать пиноккио lying face liar lie pinocchio","🫨":"трясущееся лицо бум вау головокружение обморок омг паника трясется удивление ужас шок shaking face crazy daze earthquake omg panic shock surprise vibrate whoa wow","🙂‍↔":"отрицательно качает головой несогласие нет отрицание head shaking horizontally no shake","🙂‍↕":"положительно качает головой да кивок подтверждение согласие head shaking vertically nod yes","😌":"испытывает облегчение богу довольный обошлось пронесло слава улыбка хорошо relieved face calm peace relief zen","😔":"размышляет грустный достало жаль задумчивый мысли плохо разочарован скучно устал pensive face awful bored dejected died disappointed losing lost sad sucks","😪":"хочет спать ночи плакать плач пора сон сплю спокойной устал sleepy face crying good night sad sleep sleeping tired","🤤":"пускает слюни лицо drooling face","😴":"спит боковую ночи пора сон спать спокойной уснул устал храп sleeping face bed bedtime good goodnight nap night sleep tired whatever yawn zzz","🫩":"лицо мешками под глазами без истощение мешки сил скучно сон спать устал face with bags under eyes bored exhausted fatigued late sleepy tired weary","😷":"медицинской маске болеть вирус врач заражение карантин маска медик микроб простуда санитар face with medical mask cold dentist dermatologist doctor dr germs medicine sick","🤒":"градусником во рту болеть больной вирус градусник грипп жар плохо простуда температура face with thermometer ill sick","🤕":"перевязанной головой бинт болеть больной гипс перевязка повязка пострадал ударился face with head-bandage bandage hurt injury ouch","🤢":"тошнит блевать болезнь выворачивать вырвет зеленый морская наизнанку отравиться плохо nauseated face gross nasty sick vomit","🤮":"рвота блевать болезнь вырвало гадость морская отравиться плохо тошнит фу face vomiting barf ew gross puke sick spew throw up vomit","🤧":"чихает апчхи лицо насморк пчхи sneezing face fever flu gesundheit sick sneeze","🥵":"жар болеть лихорадка плохо пот солнечный температура удар умираю язык hot face dying feverish heat panting red-faced stroke sweating tongue","🥶":"мерзнет болею дубак зубы мерзнуть мороз озноб синий снег стучат холодно cold face blue blue-faced freezing frostbite icicles subzero teeth","🥴":"чувствует головокружение глазах голова кружится мутит окосел плохо плывет пьяный woozy face dizzy drunk eyes intoxicated mouth tipsy uneven wavy","😵":"головокружение болею глаза голова крестики кружится мертвый плохо смерть умереть устал face with crossed-out eyes dead dizzy feels knocked out sick tired","😵‍💫":"ошеломление гипноз глаза голова замешательство кружится кучу омг сложно тупик шок face with spiral eyes confused dizzy hypnotized omg smiley trouble whoa woah woozy","🤯":"взрыв мозга быть голова может не омг шок exploding head blown explode mind mindblown no shocked way","🤠":"ковбойской шляпе веселый вестерн ковбой радость техас улыбка шапка шляпа cowboy hat face cowgirl","🥳":"на вечеринке вечеринка день днюха колпак конфетти отмечать поздравляю праздник рождения partying face bday birthday celebrate celebration excited happy hat hooray horn party","🥸":"маскировка внешность инкогнито нос очки профессор усы ученый шпион disguised face disguise eyebrow glasses incognito moustache mustache nose person spy tache tash","😎":"лицо темных очках защита клево круто очки свет солнечные солнце солнцезащитные улыбка smiling face with sunglasses awesome beach bright bro chilling cool rad relaxed shades slay smile style swag win","🤓":"очкарик ботан заучка интеллигент радость улыбка умник эврика nerd face brainy clever expert geek gifted glasses intelligent smart","🧐":"моноклем дедукция детектив доказательства изучение монокль расследование сомнение ученый face with monocle classy fancy rich stuffy wealthy","😕":"замешательстве замешательство недовольный непонятно огорчен плохо расстроен сбит сомнение толку confused face befuddled confusing dunno frown hm meh not sad sorry sure","🫤":"перекошенным ртом досадно думаю знаю не непонятно огорчен разочарован скептик уверен face with diagonal mouth confused confusion disappointed doubt doubtful frustrated frustration meh skeptical unsure whatever wtv","😟":"беспокоится беспокойство лицо расстройство тревога тревожность worried face anxious butterflies nerves nervous sad stress stressed surprised worry","🙁":"немного грустит грустный досада недовольный огорчен печаль плохо разочарован хмурый slightly frowning face frown sad","☹":"грустит грустный досада недовольный несчастный очень печаль плохо расстроен frowning face frown sad","😮":"удивление вау верю изумление невероятно ого омг ошеломление ужас шок face with open mouth believe forgot omg shocked surprised sympathy unbelievable unreal whoa wow you","😯":"неприятный сюрприз вау верю ого омг удивление hushed face epic omg stunned surprised whoa woah","😲":"шоке вау глаза изумление лоб ого омг ох потрясение удивление шок astonished face cost no omg shocked totally way","😳":"краснеет извини обидел прости румянец смущение стесняется стыдно хотел flushed face amazed awkward crazy dazed dead disbelief embarrassed geez heat hot impressed jeez what wow","🥺":"умоляет большие глаза грустно почему просить сжалься слезы умолять щенячьи pleading face begging big eyes mercy not please pretty puppy sad why","🥹":"еле сдерживает слезы благодарность восхищение гордость плакать радость счастья умиление face holding back tears admiration aww cry embarrassed feelings grateful gratitude joy please proud resist sad","😦":"изумлении изумление испуг неприятный ого омг страх сюрприз устал frowning face with open mouth caught frown guard scared scary surprise what wow","😧":"страдает боль забыл испуг неприятный омг страдаю стресс сюрприз удивление anguished face forgot scared scary stressed surprise unhappy what wow","😨":"боится беспокоится боюсь волнуюсь жуть испуг омг страх fearful face afraid anxious blame fear scared worried","😰":"сильно беспокоится беспокоюсь волнуюсь грусть замерз плакать плач пот страх холодно anxious face with sweat blue cold eek mouth nervous open rushed scared yikes","😥":"обошлось беспокоюсь грустно облегчение плач пот пронесло разочарован тревога фух sad but relieved face anxious call close complicated disappointed not sweat time whew","😢":"плачет грустный жаль обидно печаль плакать плач слеза crying face awful cry feels miss sad tear triste unhappy","😭":"слезы рекой за навзрыд несчастный плач почему реветь что loudly crying face bawling cry sad sob tear tears unhappy","😱":"ужасе испуг крик кричать нет омг страх ужас шок face screaming in fear epic fearful munch scared scream screamer shocked surprised woah","😖":"растерянности гадость кринж невкусно непонятно неприятно растерян сбит толку фу confounded face annoyed confused cringe distraught feels frustrated mad sad","😣":"упорство боль головная достало мигрень настойчивость упорный упрямый хватит persevering face concentrate concentration focus headache persevere","😞":"разочарование виноват грустно жаль лузер неудача обида обижен печаль разочарован устал disappointed face awful blame dejected fail losing sad unhappy","😓":"холодном поту беспокойство боль волнуюсь головная депресняк нервничаю уныние downcast face with sweat close cold feels headache nervous sad scared yikes","😩":"усталость гадость достало надоело нет плач сил устал фу хватит weary face crying fail feels hungry mad nooo sad sleepy tired unhappy","😫":"сильная усталость достало надоело очень сильно спать устал фу хватит tired face cost feels nap sad sneeze","🥱":"зевает боковую ночи поздно скука скучно спать спокойной устал yawning face bedtime bored goodnight nap night sleep sleepy tired whatever yawn zzz","😤":"ожидании успеха бешенстве дым зла злость зол пар пыхтеть раздражение face with steam from nose anger angry feels fume fuming furious fury mad triumph unhappy won","😡":"злится бешенство злой кипит красный ненавижу рак рассержен сердится ярость enraged face anger angry feels mad maddening pouting rage red shade unhappy upset","😠":"сердится бешенство злиться злость рассержен хмурится angry face anger blame feels frustrated mad maddening rage shade unhappy upset","🤬":"цензура браниться брань мат материться ругань ругаться face with symbols on mouth censor cursing cussing mad pissed swearing","😈":"улыбающийся чертенок дьявол зло злорадная рога рожки улыбка черт smiling face with horns demon devil evil fairy fairytale fantasy purple shade smile tale","👿":"злой чертенок грусть демон дьявол зло расстроен рога рожки хмурится черт angry face with horns demon devil evil fairy fairytale fantasy imp mischievous purple shade tale","💀":"череп кости мертвый могила монстр смерть умереть skull body dead death face fairy fairytale lmao monster tale yolo","☠":"череп кости конец метка опасно пират смерть умереть флаг skull and crossbones bone dead death face monster","💩":"какашка вонючка воняет говно кака куча улыбка фу шутка pile of poo bs comic doo dung face fml monster poop smelly smh stink stinks stinky turd","🤡":"клоун арлекин смех смешно улыбка цирк шут clown face","👹":"чудовище дракон змей карнавал лицо мифология монстр нечисть страх существо японский ogre creature devil face fairy fairytale fantasy mask monster scary tale","👺":"японский гоблин злой монстр сказка страшный тэнгу чудище goblin angry creature face fairy fairytale fantasy mask mean monster tale","👻":"привидение подмигнуть призрак смешной улыбка хэллоуин ghost boo creature excited face fairy fairytale fantasy halloween haunting monster scary silly tale","👽":"инопланетянин внеземная космос нло пришелец страшный улыбка цивилизация alien creature extraterrestrial face fairy fairytale fantasy monster space tale ufo","👾":"космический монстр аркада инопланетянин космос нло пиксель пришелец чудовище alien monster creature extraterrestrial face fairy fairytale fantasy game gamer games pixelated space tale ufo","🤖":"робот лицо монстр страх robot face monster","😺":"улыбающийся кот домашнее животное милашка питомец полосатый улыбка усы ушастик хорошо grinning cat animal face mouth open smile smiling","😸":"смеющийся кот глаза закрытые широкой улыбкой закрытыми глазами морда улыбка широкая grinning cat with smiling eyes animal eye face grin smile","😹":"смеющийся до слез кот веселье смеется морда радость слезы счастья счастье cat with tears of joy animal face laugh laughing lol tear","😻":"влюбленный кот влюбиться любовь морда сердечки счастье улыбка smiling cat with heart-eyes animal eye face heart love smile","😼":"ухмыляющийся кот ухмылкой сарказм улыбка ухмылка хитрость хитрый cat with wry smile animal face ironic","😽":"целующий кот глаза губы целует морда морды поцелуй kissing cat animal closed eye eyes face kiss","🙀":"кот шоке волнуется морда морды удивление усталость weary cat animal face oh surprised","😿":"плачущий кот грустно жаль плачет морда печаль плач слезы crying cat animal cry face sad tear","😾":"злой кот морда недовольный раздраженный pouting cat animal face","🙈":"ничего не вижу глаза запрещено зрение нельзя обезьяна see-no-evil monkey embarrassed evil face forbidden forgot gesture hide no omg prohibited scared secret smh watch","🙉":"ничего не слышу запрещено лапы нельзя обезьяна слух уши hear-no-evil monkey animal ears evil face forbidden gesture hear listen no not prohibited secret shh tmi","🙊":"ничего никому не скажу закрыт запрещено лапы молчание обезьяна рот speak-no-evil monkey animal evil face forbidden gesture no not oops prohibited quiet secret speak stealth","💌":"любовное письмо валентинка записка любовь почта тайна love letter heart mail romance valentine","💘":"сердце пронзенное стрелой купидон лук любовь стрела heart with arrow 143 adorbs cupid date emotion ily love romance valentine","💝":"сердце лентой валентин валентинка лента люблю любовь перевязано подарок heart with ribbon 143 anniversary emotion ily kisses valentine xoxo","💖":"сверкающее сердце блеск звездочки любовь радость sparkling heart 143 emotion excited good ily kisses morning night sparkle xoxo","💗":"растущее сердце биение больше влюбленность любовь размер ритм чувства growing heart 143 emotion excited heartpulse ily kisses muah nervous pulse xoxo","💓":"пульсирующее сердце бьется красное пульсирует розовое beating heart 143 cardio emotion heartbeat ily love pulsating pulse","💞":"вращающиеся сердца вращение два круги танец любви revolving hearts 143 adorbs anniversary emotion heart","💕":"два сердца влюблен влюблена влюбленные любовь отношения свидание сердечки чувства two hearts 143 anniversary date dating emotion heart ily kisses love loving xoxo","💟":"значок сердце белое квадрат любовь сиреневый фиолетовый чувства эмоция heart decoration 143 emotion hearth purple white","❣":"восклицательный знак виде сердца восклицание препинания сердце восклицательного знака heart exclamation heavy mark punctuation","💔":"разбитое сердце разочарование части broken heart break crushed emotion heartbroken lonely sad","❤‍🔥":"горящее сердце любовь огонь пламенное пламя страсть heart on fire burn love lust sacred","❤‍🩹":"заживающее сердце выздоровление излечение поправка улучшение хорошо mending heart healthier improving recovering recuperating well","❤":"алое сердце красное любовь свидание чувства эмоции red heart emotion love","🩷":"розовое сердце люблю любовь мило нравится pink heart 143 adorable cute emotion ily like love special sweet","🧡":"оранжевое сердце любовь свидание чувства эмоции orange heart 143","💛":"желтое сердце любовь чувство эмоции yellow heart 143 cardiac emotion ily love","💚":"зеленое сердце любовь чувства эмоции green heart 143 emotion ily love romantic","💙":"синее сердце голубое любовь чувства эмоции blue heart 143 emotion ily love romance","🩵":"голубое сердце бирюзовое люблю любовь милый нравится сердечко чувства light blue heart 143 cute cyan emotion ily like love sky special teal","💜":"фиолетовое сердце любовь сиреневое чувства эмоции purple heart 143 bestest emotion ily love","🤎":"коричневое сердце любовь чувства эмоции brown heart 143","🖤":"черное сердце злое black heart evil wicked","🩶":"серое сердце любовь серебряное grey heart 143 emotion gray ily love silver slate special","🤍":"белое сердце любовь чувства эмоции white heart 143","💋":"след от поцелуя губы любовь помада поцелуй романтика свидание целовать чувства эмоции kiss mark dating emotion heart kissing lips romance sexy","💯":"сто баллов 100 именно сотка сотня процентов пудов точно hundred points agree clearly definitely faithful fleek full keep perfect point score true truth yup","💢":"значок гнев злость ярость anger symbol angry comic mad upset","💥":"взрыв бомба бум вспышка искра столкновение collision bomb boom collide comic explode","💫":"кружащаяся звезда астероид головокружение звездочка звезды метеорит dizzy comic shining shooting star stars","💦":"капли пота брызги вода капать капельки мокрый пот тренировка sweat droplets comic drip droplet drops splashing squirt water wet work workout","💨":"исчезновение быстро дым облако облачко пукнуть пыли пыль убежать dashing away cloud comic dash fart fast go gone gotta running smoke","🕳":"дыра люк отверстие пропасть черная hole","💬":"облачко многоточием диалог идеи многоточие мысли мысль речь сообщение текст speech balloon bubble comic dialog message sms talk text typing","👁‍🗨":"значок свидетеля глаз подсматривать преследование свидетель символ eye in speech bubble balloon witness","🗨":"пустое облачко диалог мнение мысль речь слева left speech bubble balloon dialog","🗯":"гневное облачко бабл гнев комикс негатив справа ярость right anger bubble angry balloon mad","💭":"облачко мыслями идеи изобретения мечты мысли размышления thought balloon bubble cartoon cloud comic daydream decisions dream idea invent invention realize think thoughts wonder","💤":"сон ночи ночь спать спокойной храп zzz comic good goodnight night sleep sleeping sleepy tired","👋":"машет рукой взмах привет приветствие прощание рука waving hand bye cya g2g greetings gtg hello hey hi later outtie ttfn ttyl wave yo you","🤚":"поднятая рука ладонь сторона тыльная raised back of hand backhand","🖐":"раскрытая ладонь пальцы рука стоп hand with fingers splayed finger raised stop","✋":"поднятая ладонь вверх пальцы пять рука стоп raised hand five high stop","🖖":"вулканский салют жест пальцы приветствие рука спок vulcan salute finger hand hands","🫱":"рука вправо кисть направо рукопожатие rightwards hand handshake hold reach right rightward shake","🫲":"рука влево кисть налево рукопожатие leftwards hand handshake hold left leftward reach shake","🫳":"рука ладонью вниз бросить ладонь отпустить palm down hand dismiss drop dropped pick shoo up","🫴":"рука ладонью вверх жест ладонь манить подзывать поднимать подойди позвать предложить скажи palm up hand beckon catch come hold know lift me offer tell","🫷":"рука ладонью влево ждать нет отказ подождать подожди пять стоп толкать leftwards pushing hand block five halt high hold leftward pause push refuse slap stop wait","🫸":"рука ладонью вправо ждать нет отказ подождать подожди пять стоп толкать rightwards pushing hand block five halt high hold pause push refuse rightward slap stop wait","👌":"жест все хорошо ок окей отлично рука ясно ok hand awesome bet dope fleek fosho got gotcha legit okay pinch rad sure sweet three","🤌":"щепоть вопрос жест пальцы рега сарказм щепотка pinched fingers gesture hand hold huh interrogation patience relax sarcastic ugh what zip","🤏":"жест мало небольшое количество немного чуть-чуть pinching hand amount bit fingers little small sort","✌":"жест виктория мир пальцы победа рука victory hand peace","🤞":"скрещенные пальцы везение жест скрестить удача crossed fingers cross finger hand luck","🫰":"жест деньги дорого сердечко щелчок hand with index finger and thumb crossed expensive heart love money snap","🤟":"я-тебя-люблю жест люблю пальца пальцы рука три love-you gesture fingers hand ily love three you","🤘":"рокерский жест коза пальцы рок рука фанат sign of the horns finger hand rock-on","🤙":"жест позвони мне набери рука серферский шака call me hand hang loose shaka","👈":"указывающий налево палец влево жест направление рука тыльная указательный указывать backhand index pointing left finger hand point","👉":"указывающий направо палец вправо жест направление рука тыльная указательный указывать backhand index pointing right finger hand point","👆":"указывающий вверх палец выше жест наверх направление рука тыльная указательный указывать backhand index pointing up finger hand point","🖕":"средний палец жест рука фак middle finger hand","👇":"указывающий вниз палец жест направление ниже рука тыльная указательный указывать backhand index pointing down finger hand point","☝":"указательный палец вверх внимание направление послушайте рука index pointing up finger hand point this","🫵":"указательный палец на себя жест показать рука ты указывать index pointing at the viewer finger hand poke you","👍":"большой палец вверх договорились жест лайк нравится одобрение отлично рука супер хорошо thumbs up good hand like thumb yes","👎":"большой палец вниз бу недоволен недовольна нет плохо порицание рука фу thumbs down -1 bad dislike good hand no nope thumb","✊":"поднятый кулак пальцы рука удар raised fist clenched hand punch solidarity","👊":"кулак бокс боксировать бро кулачок опасность рука удар oncoming fist absolutely agree boom bro bruh bump clenched correct hand knuckle pound punch rock ttyl","🤛":"кулак влево рука left-facing fist leftwards","🤜":"кулак вправо рука right-facing fist rightwards","👏":"аплодисменты движение жест овация рука хлопать clapping hands applause approval awesome clap congrats congratulations excited good great hand homie job nice prayed well yay","🙌":"ура вверх жест класс отлично победа руки raising hands celebration gesture hand hooray praise raised","🫶":"жест сердце любовь руки heart hands love you","👐":"ладони стороны врозь открытые рука руки open hands hand hug jazz swerve","🤲":"ладони лодочкой вверх вместе горсть дуа желание молитва молиться руки palms up together cupped dua hands pray prayer wish","🤝":"рукопожатие встреча договорились руки согласие соглашение handshake agreement deal hand meeting shake","🙏":"сложенные руки молитва пожалуйста просьба прошу спасибо умолять folded hands appreciate ask beg blessed bow cmon five gesture hand high please pray thanks thx","✍":"пишущая рука бумага написать писать письмо пишет пишу ручка writing hand write","💅":"маникюр готово кисть косметика лак ногти пальцы руки скука уход nail polish bored care cosmetics done makeup manicure whatever","🤳":"селфи снимок телефон selfie camera phone","💪":"бицепс атлет зал качать качок мышцы сила спорт flexed biceps arm beast bench bodybuilder bro curls flex gains gym jacked muscle press ripped strong weightlift","🦾":"механическая рука возможности инвалид инвалидность ограниченные протез mechanical arm accessibility prosthetic","🦿":"механическая нога возможности инвалид инвалидность ограниченные протез mechanical leg accessibility prosthetic","🦵":"нога бедро голень колено конечность пинать пинок стопа leg bent foot kick knee limb","🦶":"щиколотка лодыжка нога стопа ступня foot ankle feet kick stomp","👂":"ухо звук подслушивать слушать слышать тело человек ear body ears hear hearing listen listening sound","🦻":"ухо со слуховым аппаратом аппарат возможности глухота ограниченные слабослышащий слух слуховой ear with hearing aid accessibility hard","👃":"нос тело человек nose body noses nosey odor smell smells","🧠":"мозг интеллект разум ум умная умный brain intelligent smart","🫀":"сердце человека анатомия кардиология красный орган пульс сердцебиение человеческое anatomical heart beat cardiology heartbeat organ pulse real red","🫁":"легкие вдох выдох дыхание дышать легкое орган lungs breath breathe exhalation inhalation lung organ respiration","🦷":"зуб белый дантист зубной зубы стоматолог tooth dentist pearly teeth white","🦴":"кость костлявый косточка скелет собака bone bones dog skeleton wishbone","👀":"глаза два наблюдать подглядывать смотреть тело человек eyes body eye face googly look looking omg peep see seeing","👁":"глаз зрачок тело человек eye body one","👅":"язык розовый рот tongue body lick slurp","👄":"рот алые губы зубы красные накрашенные помада mouth beauty body kiss kissing lips lipstick","🫦":"кусать губы беспокоиться беспокойство волнение переживания помада укус флирт biting lip anxious bite fear flirt flirting kiss lipstick nervous sexy uncomfortable worried worry","👶":"младенец дитя карапуз маленький ребенок baby babies children goo infant newborn pregnant young","🧒":"ребенок гендерно нейтральный дитя малыш child bright-eyed grandchild kid young younger","👦":"мальчик дитя маленький малыш ребенок сын boy bright-eyed child grandson kid son young younger","👧":"девочка дитя маленькая малышка ребенок girl bright-eyed child daughter granddaughter kid virgo young younger zodiac","🧑":"взрослый гендерно нейтральный совершеннолетний person adult","👱":"человек со светлыми волосами блондин волосы молодой мужское лицо мужчина парень светлые person blond hair blond-haired human","👨":"мужчина брат взрослый друг усы человек man adult bro","🧔":"бородатый человек борода бородач мужчина person beard bearded whiskers","🧔‍♂":"бородатый мужчина борода man beard bearded whiskers","🧔‍♀":"бородатая женщина борода woman beard bearded whiskers","👨‍🦰":"мужчина рыжие волосы брат взрослый друг усы человек man red hair adult bro","👨‍🦱":"мужчина кудрявые волосы брат взрослый друг усы человек man curly hair adult bro","👨‍🦳":"мужчина седые волосы брат взрослый друг усы человек man white hair adult bro","👨‍🦲":"мужчина без волос брат взрослый друг усы человек man bald adult bro","👩":"женщина взрослая волосы девушка длинные леди человек woman adult lady","👩‍🦰":"женщина рыжие волосы взрослая девушка длинные леди человек woman red hair adult lady","🧑‍🦰":"взрослый рыжие волосы гендерно нейтральный совершеннолетний person red hair adult","👩‍🦱":"женщина кудрявые волосы взрослая девушка длинные леди человек woman curly hair adult lady","🧑‍🦱":"взрослый кудрявые волосы гендерно нейтральный совершеннолетний person curly hair adult","👩‍🦳":"женщина седые волосы взрослая девушка длинные леди человек woman white hair adult lady","🧑‍🦳":"взрослый седые волосы гендерно нейтральный совершеннолетний person white hair adult","👩‍🦲":"женщина без волос взрослая волосы девушка длинные леди человек woman bald adult lady","🧑‍🦲":"взрослый без волос гендерно нейтральный совершеннолетний person bald adult","👱‍♀":"блондинка женщина со светлыми волосами светлые волосы человек woman blond hair blond-haired blonde","👱‍♂":"блондин мужчина со светлыми волосами светлые волосы человек man blond hair blond-haired","🧓":"пожилой человек возрасте гендерно нейтральный старый older person adult elderly grandparent old wise","👴":"пожилой мужчина возраст годы дедушка старик старый человек old man adult bald elderly gramps grandfather grandpa wise","👵":"пожилая женщина бабушка старая старуха старушка человек old woman adult elderly grandma grandmother granny lady wise","🙍":"нахмурившийся человек грустит грусть грущу лицо обида печаль расстроен хмурый person frowning annoyed disappointed disgruntled disturbed frown frustrated gesture irritated upset","🙍‍♂":"нахмурившийся мужчина расстроен хмурый человек man frowning annoyed disappointed disgruntled disturbed frown frustrated gesture irritated upset","🙍‍♀":"нахмурившаяся женщина расстроена хмурая человек woman frowning annoyed disappointed disgruntled disturbed frown frustrated gesture irritated upset","🙎":"обиженный человек губы зла зол надоело надуть обижен огорчен раздражение разочарование расстроен person pouting disappointed downtrodden frown grimace scowl sulk upset whine","🙎‍♂":"обиженный мужчина губы зол надуть обида обижен огорчен разочарован расстроен хмурый man pouting disappointed downtrodden frown grimace scowl sulk upset whine","🙎‍♀":"обиженная женщина недовольство обида woman pouting disappointed downtrodden frown grimace scowl sulk upset whine","🙅":"человек против возражаю жест запрет нельзя нет отказ хватит person gesturing no forbidden gesture hand not prohibit","🙅‍♂":"мужчина против возражение жест запрет нельзя нет отказ хватит man gesturing no forbidden gesture hand not prohibit","🙅‍♀":"женщина против жест запрещено нельзя руки woman gesturing no forbidden gesture hand not prohibit","🙆":"человек за вверх домик зарядка окей омг отлично руки хорошо person gesturing ok exercise gesture hand omg","🙆‍♂":"мужчина за вверх жест зарядка окей омг отлично руки хорошо man gesturing ok exercise gesture hand omg","🙆‍♀":"женщина за жест ок окей отлично руки woman gesturing ok exercise gesture hand omg","💁":"человек тянет руку выдать жест знаю информацию не себе секрет сплетни так person tipping hand fetch flick flip gossip sarcasm sarcastic sassy seriously whatever","💁‍♂":"мужчина тянет руку выдать жест знаю информацию не себе секрет сплетни так man tipping hand fetch flick flip gossip sarcasm sarcastic sassy seriously whatever","💁‍♀":"женщина тянет руку информация помощь рука woman tipping hand fetch flick flip gossip sarcasm sarcastic sassy seriously whatever","🙋":"человек поднятой рукой вопрос задать знаю ответить поднять руку сказать спросить хочу person raising hand gesture here know me pick question raise","🙋‍♂":"мужчина поднятой рукой вопрос задать знаю ответить поднять руку сказать спросить хочу man raising hand gesture here know me pick question raise","🙋‍♀":"женщина поднятой рукой жест поднятая рука woman raising hand gesture here know me pick question raise","🧏":"глухой человек жестов немой слышать сурдопереводчик ухо язык deaf person accessibility ear gesture hear","🧏‍♂":"глухой мужчина глухота deaf man accessibility ear gesture hear","🧏‍♀":"глухая женщина глухота deaf woman accessibility ear gesture hear","🙇":"человек кланяется виноват жаль извини пожалуйста поклон прости прошу прощения стыд person bowing apology ask beg bow favor forgive gesture meditate meditation pity regret sorry","🙇‍♂":"мужчина кланяется извинение поклон просьба прощение man bowing apology ask beg bow favor forgive gesture meditate meditation pity regret sorry","🙇‍♀":"женщина кланяется виновата жаль извини пожалуйста поклон прости прошу прощения стыд woman bowing apology ask beg bow favor forgive gesture meditate meditation pity regret sorry","🤦":"человек потерял дар речи надоело нет омг позор раздражение слов фейспалм хватит person facepalming again bewilder disbelief exasperation facepalm no not oh omg shock smh","🤦‍♂":"мужчина потерял дар речи надоело нет омг позор раздражение слов фейспалм хватит man facepalming again bewilder disbelief exasperation facepalm no not oh omg shock smh","🤦‍♀":"женщина потеряла дар речи надоело нет омг позор раздражение слов фейспалм хватит woman facepalming again bewilder disbelief exasperation facepalm no not oh omg shock smh","🤷":"человек пожимает плечами все может равно равнодушие сомнение person shrugging doubt dunno guess idk ignorance indifference knows maybe shrug whatever who","🤷‍♂":"мужчина пожимает плечами все может равно равнодушие сомнение man shrugging doubt dunno guess idk ignorance indifference knows maybe shrug whatever who","🤷‍♀":"женщина пожимает плечами все может равно равнодушие сомнение woman shrugging doubt dunno guess idk ignorance indifference knows maybe shrug whatever who","🧑‍⚕":"медицинский работник врач доктор медбрат медицина медсестра терапевт health worker doctor healthcare nurse therapist","👨‍⚕":"врач доктор медбрат мужчина терапевт man health worker doctor healthcare nurse therapist","👩‍⚕":"женщина-врач врач доктор докторка докторша женщина медсестра woman health worker doctor healthcare nurse therapist","🧑‍🎓":"учащийся бакалавр выпускник студент student graduate","👨‍🎓":"студент бакалавр вуз выпуск выпускник мужчина образование университет учеба man student graduate","👩‍🎓":"студентка аспирантка выпускница девушка женщина woman student graduate","🧑‍🏫":"педагог инструктор преподаватель профессор учитель teacher instructor lecturer professor","👨‍🏫":"учитель инструктор мужчина преподаватель профессор man teacher instructor lecturer professor","👩‍🏫":"учительница женщина инструктор лектор преподаватель профессор woman teacher instructor lecturer professor","🧑‍⚖":"судья весы правосудие фемида judge justice law scales","👨‍⚖":"мужчина-судья закон мужчина правосудие справедливый суд судья man judge justice law scales","👩‍⚖":"женщина-судья женщина закон правосудие справедливая справедливость суд судья woman judge justice law scales","🧑‍🌾":"фермер дачник садовник скотовод farmer gardener rancher","👨‍🌾":"мужчина-фермер вилы дачник мужчина огород садовник сено соломенная урожай шляпа man farmer gardener rancher","👩‍🌾":"женщина-фермер вилы дачница женщина огород садовница сено соломенная урожай шляпа woman farmer gardener rancher","🧑‍🍳":"повар шеф шеф-повар cook chef","👨‍🍳":"мужчина-повар готовить еда мужчина повар шеф man cook chef","👩‍🍳":"повариха готовить еда женщина кухарка повар шеф woman cook chef","🧑‍🔧":"механик сантехник специалист электрик mechanic electrician plumber tradesperson","👨‍🔧":"мужчина-механик механик мужчина сантехник электрик man mechanic electrician plumber tradesperson","👩‍🔧":"женщина-механик женщина механик сантехник электрик woman mechanic electrician plumber tradesperson","🧑‍🏭":"рабочий завод промышленность factory worker assembly industrial","👨‍🏭":"мужчина-рабочий завод мужчина промышленность рабочий man factory worker assembly industrial","👩‍🏭":"работница женщина завод инженер промышленность рабочий строитель woman factory worker assembly industrial","🧑‍💼":"офисный работник белый воротничок бизнес менеджер офис office worker architect business manager white-collar","👨‍💼":"офисный работник мужчина белый бизнесмен воротничок галстук менеджер man office worker architect business manager white-collar","👩‍💼":"офисная работница бизнес деловая женщина офис секретарь woman office worker architect business manager white-collar","🧑‍🔬":"ученый биолог инженер наука физик химик scientist biologist chemist engineer mathematician physicist","👨‍🔬":"мужчина-ученый биолог инженер мужчина наука ученый химик man scientist biologist chemist engineer mathematician physicist","👩‍🔬":"ученая биология женщина инженер математик наука химия woman scientist biologist chemist engineer mathematician physicist","🧑‍💻":"инженер ноутбук программист разработчик технарь technologist coder computer developer inventor software","👨‍💻":"мужчина-инженер инженер мужчина ноутбук программист разработчик технарь man technologist coder computer developer inventor software","👩‍💻":"женщина-инженер женщина инженер код компьютер ноутбук программистка разработчица технарь woman technologist coder computer developer inventor software","🧑‍🎤":"певец актер звезда пение развлечения рок singer actor entertainer rock rockstar star","👨‍🎤":"мужчина-певец жест звезда исполнитель коза мужчина музыка певец рок сцена man singer actor entertainer rock rockstar star","👩‍🎤":"певица женщина жест звезда исполнительница коза музыка рок сцена woman singer actor entertainer rock rockstar star","🧑‍🎨":"художник кисти палитра artist palette","👨‍🎨":"мужчина-художник кисти краски мужчина палитра художник man artist palette","👩‍🎨":"художница арт галерея женщина живопись картины краски рисование творчество woman artist palette","🧑‍✈":"пилот летчик самолет pilot plane","👨‍✈":"мужчина-пилот мужчина пилот самолет man pilot plane","👩‍✈":"женщина-пилот женщина пилот самолет woman pilot plane","🧑‍🚀":"космонавт астронавт ракета astronaut rocket space","👨‍🚀":"мужчина-космонавт астронавт космонавт космос мужчина man astronaut rocket space","👩‍🚀":"женщина-космонавт астронавт женщина корабль космонавт пилот скафандр woman astronaut rocket space","🧑‍🚒":"пожарный пожарная машина firefighter fire firetruck","👨‍🚒":"мужчина-пожарный мужчина огонь пожарный профессия спасатель тушить man firefighter fire firetruck","👩‍🚒":"женщина-пожарный женщина огонь пожарная профессия спасательница тушить woman firefighter fire firetruck","👮":"полицейский блюститель закон коп милиция органы порядка правоохранительные police officer apprehend arrest citation cop law over pulled undercover","👮‍♂":"мужчина-полицейский лицо мужчина охрана полицейский полиция человек man police officer apprehend arrest citation cop law over pulled undercover","👮‍♀":"женщина-полицейский блюстительница закон коп милиция органы полицейская порядка правоохранительные woman police officer apprehend arrest citation cop law over pulled undercover","🕵":"детектив ищейка расследование сыщик шпион detective sleuth spy","🕵‍♂":"мужчина-детектив детектив ищейка мужчина расследование сыщик шпион man detective sleuth spy","🕵‍♀":"женщина-детектив детектив женщина ищейка расследование сыщик шпионка woman detective sleuth spy","💂":"гвардеец гвардия караул охрана почетный солдат шапка шлем guard buckingham helmet london palace","💂‍♂":"мужчина-гвардеец гвардия мужчина охрана почетный караул солдат man guard buckingham helmet london palace","💂‍♀":"женщина-гвардеец букингемский гвардеец дворец женщина караул королева лондон почетный стража woman guard buckingham helmet london palace","🥷":"ниндзя воин диверсант синоби скрытный солдат убийца человек шпион ninja assassin fight fighter hidden person secret skills sly soldier stealth war","👷":"строитель инженер каска лицо рабочий ремонт ремонтник строить стройка construction worker build fix hardhat hat man person rebuild remodel repair work","👷‍♂":"мужчина-строитель инженер каска мужчина рабочий строитель стройка man construction worker build fix hardhat hat rebuild remodel repair work","👷‍♀":"женщина-строитель женщина инженер каска мастерская ремонтница служба строитель woman construction worker build fix hardhat hat man rebuild remodel repair work","🫅":"человек короной владыка власть князь король монарх правитель престол царь person with crown monarch noble regal royal royalty","🤴":"принц королевство король корона притча сказка фэнтези prince crown fairy fairytale fantasy king royal royalty tale","👸":"принцесса королева королевство корона победительница притча сказка тиара фэнтези princess crown fairy fairytale fantasy queen royal royalty tale","👳":"человек тюрбане индус повязка ранение головы религия сикх тюрбан чалма person wearing turban","👳‍♂":"мужчина тюрбане тюрбан man wearing turban","👳‍♀":"женщина тюрбане тюрбан woman wearing turban","👲":"мужчина китайской шапке китай китайский человек шапка person with skullcap cap chinese gua guapi hat mao pi","🧕":"женщина платке платком закрытые волосы мантилья монахиня монашка платок прикрытые тичел хиджаб woman with headscarf bandana head hijab kerchief mantilla tichel","🤵":"жених мужчина смокинге смокинг человек person in tuxedo formal wedding","🤵‍♂":"мужчина смокинге смокинг man in tuxedo formal groom wedding","🤵‍♀":"женщина смокинге смокинг woman in tuxedo formal wedding","👰":"невеста свадьба фата person with veil wedding","👰‍♂":"мужчина фате фата man with veil wedding","👰‍♀":"женщина фате невеста фата woman with veil bride wedding","🤰":"беременная женщина беременность pregnant woman","🫃":"беременный мужчина беременность вздутие животик переедание pregnant man belly bloated full overeat","🫄":"беременный человек беременная беременность вздутие животик переедание pregnant person belly bloated full overeat stuffed","🤱":"кормление грудью вскармливание грудь кормить кормящая лактация мать младенец молоко ребенок breast-feeding baby breast feeding mom mother nursing woman","👩‍🍼":"кормящая женщина забота кормление малыш мама мать новорожденный няня ребенок woman feeding baby feed mom mother nanny newborn nursing","👨‍🍼":"кормящий мужчина забота кормление малыш новорожденный няня отец папа ребенок man feeding baby dad father feed nanny newborn nursing","🧑‍🍼":"кормящий человек забота кормление малыш новорожденный няня ребенок родитель person feeding baby feed nanny newborn nursing parent","👼":"ангелочек ангел дитя крылья лик младенец нимб фэнтези baby angel church face fairy fairytale fantasy tale","🎅":"санта-клаус дед мороз дедушка клаус новый год рождество санта santa claus celebration christmas fairy fantasy father holiday merry tale xmas","🤶":"миссис клаус новый год рождество снегурочка mrs claus celebration christmas fairy fantasy holiday merry mother santa tale xmas","🧑‍🎄":"санта дед мороз клаус новый год рождество mx claus celebration christmas fairy fantasy holiday merry santa tale xmas","🦸":"супергерой бэтмен героиня герой сильная супервумен супермен суперсила superhero good hero superpower","🦸‍♂":"мужчина-супергерой герой супергерой супермен суперсила man superhero good hero superpower","🦸‍♀":"женщина-супергерой героиня супервумен супергероиня суперсила woman superhero good hero heroine superpower","🦹":"суперзлодей вампир дракула зло злодей плохой преступник суперсила supervillain bad criminal evil superpower villain","🦹‍♂":"мужчина-суперзлодей зло злодей суперзлодей man supervillain bad criminal evil superpower villain","🦹‍♀":"женщина-суперзлодей зло злодей суперзлодей woman supervillain bad criminal evil superpower villain","🧙":"маг волшебник заклинание колдун магия чародей mage fantasy magic play sorcerer sorceress sorcery spell summon witch wizard","🧙‍♂":"волшебник маг чародей man mage fantasy magic play sorcerer sorceress sorcery spell summon witch wizard","🧙‍♀":"волшебница ведьма чародейка woman mage fantasy magic play sorcerer sorceress sorcery spell summon witch wizard","🧚":"фея динь-динь крылышки крылья оберон пак сказка титания фэнтези fairy fairytale fantasy myth person pixie tale wings","🧚‍♂":"мужчина-фея крылья мужчина сказка фея фэнтези man fairy fairytale fantasy myth oberon person pixie puck tale wings","🧚‍♀":"женщина-фея динь-динь крылья сказка титания фея фэнтези woman fairy fairytale fantasy myth person pixie tale titania wings","🧛":"вампир граф дракула зубы кровопийца кровь упырь vampire blood dracula fangs halloween scary supernatural teeth undead","🧛‍♂":"мужчина-вампир вампир граф дракула зубы кровь мужчина man vampire blood fangs halloween scary supernatural teeth undead","🧛‍♀":"женщина-вампир вампирша женщина зубы кровь woman vampire blood fangs halloween scary supernatural teeth undead","🧜":"русалка ариэль морские существа полурыба русалочка сказка тритон ундина merperson creature fairytale folklore ocean sea siren trident","🧜‍♂":"тритон мужчина полурыба русалка merman creature fairytale folklore neptune ocean poseidon sea siren trident triton","🧜‍♀":"женщина-русалка женщина полурыба русалка mermaid creature fairytale folklore merwoman ocean sea siren trident","🧝":"эльф магия сказка уши фэнтези elf elves enchantment fantasy folklore magic magical myth","🧝‍♂":"мужчина-эльф магия мужчина сказка уши фэнтези эльф man elf elves enchantment fantasy folklore magic magical myth","🧝‍♀":"женщина-эльф магия фэнтези эльф эльфийка woman elf elves enchantment fantasy folklore magic magical myth","🧞":"джинн лампа потереть сказка genie djinn fantasy jinn lamp myth rub wishes","🧞‍♂":"мужчина-джинн джинн лампа мужчина потереть сказка man genie djinn fantasy jinn lamp myth rub wishes","🧞‍♀":"женщина-джинн джинн женщина лампа потереть сказка woman genie djinn fantasy jinn lamp myth rub wishes","🧟":"зомби апокалипсис живой мертвец страх страшилка ужас хэллоуин zombie apocalypse dead halloween horror scary undead walking","🧟‍♂":"мужчина-зомби живой метрвец зомби мертвец мужчина man zombie apocalypse dead halloween horror scary undead walking","🧟‍♀":"женщина-зомби женщина живой зомби мертвец woman zombie apocalypse dead halloween horror scary undead walking","🧌":"тролль монстр сказка троллинг чудище чудовище troll fairy fantasy monster tale trolling","💆":"человеку массируют лицо голова массаж головы лица процедура салон спа person getting massage face headache relax relaxing salon soothe spa tension therapy treatment","💆‍♂":"мужчине массируют лицо голова массаж головы лица мужчина процедура салон спа man getting massage face headache relax relaxing salon soothe spa tension therapy treatment","💆‍♀":"женщине массируют лицо массаж головы лица спа woman getting massage face headache relax relaxing salon soothe spa tension therapy treatment","💇":"человек парикмахера волосы косметология красота ножницы обрезать парикмахер прическа салон стрижка укоротить уход person getting haircut barber beauty chop cosmetology cut groom hair parlor shears style","💇‍♂":"мужчина парикмахера барбер барбершоп ножницы парикмахер прическа салон стилист стрижка тримминг man getting haircut barber beauty chop cosmetology cut groom hair parlor person shears style","💇‍♀":"женщина парикмахера ножницы парикмахер прическа салон стрижка woman getting haircut barber beauty chop cosmetology cut groom hair parlor person shears style","🚶":"человек идет гулять дорога прогулка тротуар ходьба person walking amble gait hike man pace pedestrian stride stroll walk","🚶‍♂":"мужчина идет дорога прогулка тротуар ходьба man walking amble gait hike pace pedestrian stride stroll walk","🚶‍♀":"женщина идет дорога прогулка тротуар ходьба woman walking amble gait hike man pace pedestrian stride stroll walk","🚶‍➡":"человек идет направленные вправо гулять дорога прогулка тротуар ходьба person walking facing right amble gait hike man pace pedestrian stride stroll walk","🚶‍♀‍➡":"женщина идет направленные вправо дорога прогулка тротуар ходьба woman walking facing right amble gait hike man pace pedestrian stride stroll walk","🚶‍♂‍➡":"мужчина идет направленные вправо дорога прогулка тротуар ходьба man walking facing right amble gait hike pace pedestrian stride stroll walk","🧍":"человек стоит стоя стоять person standing stand","🧍‍♂":"мужчина стоит мальчик парень стоя стоять man standing stand","🧍‍♀":"женщина стоит девушка стоя стоять woman standing stand","🧎":"человек на коленях йога колени сидеть person kneeling kneel knees","🧎‍♂":"мужчина на коленях колени мальчик парень man kneeling kneel knees","🧎‍♀":"женщина на коленях девочка девушка колени woman kneeling kneel knees","🧎‍➡":"человек на коленях направленные вправо йога колени сидеть person kneeling facing right kneel knees","🧎‍♀‍➡":"женщина на коленях направленные вправо девочка девушка колени woman kneeling facing right kneel knees","🧎‍♂‍➡":"мужчина на коленях направленные вправо колени мальчик парень man kneeling facing right kneel knees","🧑‍🦯":"человек белой тростью инвалид слепой person with white cane accessibility blind probing","🧑‍🦯‍➡":"человек белой тростью направленные вправо инвалид слепой person with white cane facing right accessibility blind probing","👨‍🦯":"мужчина тростью для ходьбы инвалид палочка слепой трость человек man with white cane accessibility blind probing","👨‍🦯‍➡":"мужчина тростью для ходьбы направленные вправо инвалид палочка слепой трость человек man with white cane facing right accessibility blind probing","👩‍🦯":"женщина тростью для ходьбы инвалид палочка слепая трость человек woman with white cane accessibility blind probing","👩‍🦯‍➡":"женщина тростью для ходьбы направленные вправо инвалид палочка слепая трость человек woman with white cane facing right accessibility blind probing","🧑‍🦼":"человек кресле-коляске мотором инвалид инвалидная коляска person in motorized wheelchair accessibility","🧑‍🦼‍➡":"человек кресле-коляске мотором направленные вправо инвалид инвалидная коляска person in motorized wheelchair facing right accessibility","👨‍🦼":"мужчина электрическом кресле-коляске кресле инвалид инвалидное кресло man in motorized wheelchair accessibility","👨‍🦼‍➡":"мужчина электрическом кресле-коляске направленные вправо кресле инвалид инвалидное кресло man in motorized wheelchair facing right accessibility","👩‍🦼":"женщина электрическом кресле-коляске кресле инвалид инвалидное кресло человек woman in motorized wheelchair accessibility","👩‍🦼‍➡":"женщина электрическом кресле-коляске направленные вправо кресле инвалид инвалидное кресло человек woman in motorized wheelchair facing right accessibility","🧑‍🦽":"человек кресле-коляске ручным приводом инвалид инвалидная коляска person in manual wheelchair accessibility","🧑‍🦽‍➡":"человек кресле-коляске ручным приводом направленные вправо инвалид инвалидная коляска person in manual wheelchair facing right accessibility","👨‍🦽":"мужчина ручном кресле-коляске кресле инвалид инвалидное кресло человек man in manual wheelchair accessibility","👨‍🦽‍➡":"мужчина ручном кресле-коляске направленные вправо кресле инвалид инвалидное кресло человек man in manual wheelchair facing right accessibility","👩‍🦽":"женщина ручном кресле-коляске кресле инвалид инвалидное кресло woman in manual wheelchair accessibility","👩‍🦽‍➡":"женщина ручном кресле-коляске направленные вправо кресле инвалид инвалидное кресло woman in manual wheelchair facing right accessibility","🏃":"человек бежит бег марафон спорт трусцой person running fast hurry marathon move quick race racing run rush speed","🏃‍♂":"мужчина бежит бег марафон спорт трусцой man running fast hurry marathon move quick race racing run rush speed","🏃‍♀":"женщина бежит бег марафон спешу спорт трусцой убегаю woman running fast hurry marathon move quick race racing run rush speed","🏃‍➡":"человек бежит направленные вправо бег марафон спорт трусцой person running facing right fast hurry marathon move quick race racing run rush speed","🏃‍♀‍➡":"женщина бежит направленные вправо бег марафон спешу спорт трусцой убегаю woman running facing right fast hurry marathon move quick race racing run rush speed","🏃‍♂‍➡":"мужчина бежит направленные вправо бег марафон спорт трусцой man running facing right fast hurry marathon move quick race racing run rush speed","💃":"танцующая женщина движения дискотека красное платье красота танцовщица танцы человек woman dancing dance dancer elegant festive flair flamenco groove let salsa tango","🕺":"танцующий мужчина движения красота танец танцы человек man dancing dance dancer elegant festive flair flamenco groove let salsa tango","🕴":"бизнесмен воздухе бизнес костюм полет человек person in suit levitating business","👯":"люди ушами кролика близкие вечеринка двое двойняшки кордебалет кролик парочка подруги танец уши people with bunny ears bestie bff counterpart dancer double ear identical pair party partying soulmate twin twinsies","👯‍♂":"мужчины ушами кролика вечеринка кордебалет кролик танец танцор уши men with bunny ears bestie bff counterpart dancer double ear identical pair party partying people soulmate twin twinsies","👯‍♀":"женщины ушами кролика вечеринка кордебалет кролик танец уши women with bunny ears bestie bff counterpart dancer double ear identical pair party partying people soulmate twin twinsies","🧖":"человек бане баня париться парная паровая паром пропотеть сауна хамам person in steamy room day luxurious pamper relax sauna spa steam steambath unwind","🧖‍♂":"мужчина бане парная сауна man in steamy room day luxurious pamper relax sauna spa steam steambath unwind","🧖‍♀":"женщина бане парная сауна woman in steamy room day luxurious pamper relax sauna spa steam steambath unwind","🧗":"человек взбирается по скале альпинистка взбираться восхождение гора подъем скала скалолазание person climbing climb climber mountain rock scale up","🧗‍♂":"мужчина взбирается по скале скала скалолаз man climbing climb climber mountain rock scale up","🧗‍♀":"женщина взбирается по скале скала скалолаз woman climbing climb climber mountain rock scale up","🤺":"фехтовальщик рапира спорт фехтование человек шпага person fencing fencer sword","🏇":"скачки бега жокей ипподром конный конь лошади спорт ставки тотализатор horse racing jockey racehorse riding sport","⛷":"горные лыжи горнолыжный склон скорость спортсмен человек skier ski snow","🏂":"сноубордист горы склон снег сноуборд спорт спортсмен snowboarder ski snow snowboard sport","🏌":"гольфист гольф игрок клюшка удар person golfing ball birdie caddy driving golf green pga putt range tee","🏌‍♂":"мужчина играет гольф гольфист игрок клюшка man golfing ball birdie caddy driving golf green pga putt range tee","🏌‍♀":"женщина играет гольф гольфистка клюшка спорт спортсменка удар woman golfing ball birdie caddy driving golf green pga putt range tee","🏄":"серфинг вода волны доска спорт person surfing beach ocean sport surf surfer swell waves","🏄‍♂":"серфингист вода волны доска серфинг спорт спортсмен man surfing beach ocean sport surf surfer swell waves","🏄‍♀":"серфингистка вода волны доска серфинг спорт спортсменка woman surfing beach ocean person sport surf surfer swell waves","🚣":"гребля весла вода лодка море person rowing boat canoe cruise fishing lake oar paddle raft river row rowboat","🚣‍♂":"гребец весла вода гребля лодка man rowing boat canoe cruise fishing lake oar paddle raft river row rowboat","🚣‍♀":"гребчиха весла вода гребля лодка woman rowing boat canoe cruise fishing lake oar paddle raft river row rowboat","🏊":"плавание бассейн вода водный спорт море пловец плыть спортсмен person swimming freestyle sport swim swimmer triathlon","🏊‍♂":"пловец мужчина плавание спорт man swimming freestyle sport swim swimmer triathlon","🏊‍♀":"пловчиха женщина плавание спорт woman swimming freestyle man sport swim swimmer triathlon","⛹":"баскетболист баскетбол ведение игра мяч person bouncing ball athletic basketball championship dribble net player throw","⛹‍♂":"мужчина мячом баскетбол мяч спорт man bouncing ball athletic basketball championship dribble net player throw","⛹‍♀":"женщина мячом баскетбол баскетболистка мяч спорт woman bouncing ball athletic basketball championship dribble net player throw","🏋":"тяжелоатлет атлет атлетика вес помост тяжелая штанга person lifting weights barbell bodybuilder deadlift lifter powerlifting weight weightlifter workout","🏋‍♂":"мужчина со штангой пауэрлифтинг спорт тяжелая атлетика man lifting weights barbell bodybuilder deadlift lifter powerlifting weight weightlifter workout","🏋‍♀":"женщина со штангой пауэрлифтинг спорт тяжелая атлетика тяжеловеска woman lifting weights barbell bodybuilder deadlift lifter powerlifting weight weightlifter workout","🚴":"велосипедист велосипед велоспорт кататься педали спорт человек person biking bicycle bicyclist bike cycle cyclist riding sport","🚴‍♂":"мужчина на велосипеде велосипед спорт man biking bicycle bicyclist bike cycle cyclist riding sport","🚴‍♀":"женщина на велосипеде велосипед велосипедистка кататься педали спорт woman biking bicycle bicyclist bike cycle cyclist riding sport","🚵":"человек на горном велосипеде байк велик велосипед горный горы педали соревнование спорт person mountain biking bicycle bicyclist bike cycle cyclist riding sport","🚵‍♂":"мужчина на горном велосипеде велосипед горный спорт man mountain biking bicycle bicyclist bike cycle cyclist riding sport","🚵‍♀":"женщина на горном велосипеде велосипед велосипедистка горный спорт woman mountain biking bicycle bicyclist bike cycle cyclist riding sport","🤸":"акробатический трюк акробатика активный вверх ногами гимнастика колесо спорт счастливый человек person cartwheeling active cartwheel excited flip gymnastics happy somersault","🤸‍♂":"акробат акробатика гимнастика мужчина спорт трюк man cartwheeling active cartwheel excited flip gymnastics happy somersault","🤸‍♀":"женщина-акробат акробат акробатика гимнастика женщина спорт трюк woman cartwheeling active cartwheel excited flip gymnastics happy somersault","🤼":"борцы бороться борьба драка рестлинг ринг спаринг спарринг спорт человек people wrestling combat duel grapple ring tournament wrestle","🤼‍♂":"мужчины-борцы борьба мужчина спорт men wrestling combat duel grapple ring tournament wrestle","🤼‍♀":"женщины-борцы борцы борчихи борьба женщины спарринг спорт women wrestling combat duel grapple ring tournament wrestle","🤽":"водное поло вода спорт человек person playing water polo sport swimming waterpolo","🤽‍♂":"мужчина играет водное поло вода мяч спорт man playing water polo sport swimming waterpolo","🤽‍♀":"женщина играет водное поло вода мяч спорт woman playing water polo sport swimming waterpolo","🤾":"гандбол бросок гандболист мяч спорт спортсмен человек person playing handball athletics ball catch chuck hurl lob pitch sport throw toss","🤾‍♂":"гандболист гандбол мужчина спорт man playing handball athletics ball catch chuck hurl lob pitch sport throw toss","🤾‍♀":"гандболистка бросок гандбол женщина мяч спорт спортсменка woman playing handball athletics ball catch chuck hurl lob pitch sport throw toss","🤹":"жонглер жонглирование многозадачность навык равновесие person juggling act balance balancing handle juggle manage multitask skill","🤹‍♂":"мужчина-жонглер жонглер многозадачность мужчина man juggling act balance balancing handle juggle manage multitask skill","🤹‍♀":"женщина-жонглер женщина жонглер многозадачность woman juggling act balance balancing handle juggle manage multitask skill","🧘":"человек позе лотоса йога лотос медитация поза расслабление person in lotus position cross legged legs meditation peace relax serenity yoga yogi zen","🧘‍♂":"мужчина позе лотоса йога медитация расслабление man in lotus position cross legged legs meditation peace relax serenity yoga yogi zen","🧘‍♀":"женщина позе лотоса йога медитация расслабление woman in lotus position cross legged legs meditation peace relax serenity yoga yogi zen","🛀":"человек ванне ванна ванная вода person taking bath bathtub tub","🛌":"человек кровати гостиница кровать ночь отель сон спит person in bed bedtime good goodnight hotel nap night sleep tired zzz","🧑‍🤝‍🧑":"люди держащиеся за руки держаться друзья лучшие пара семья people holding hands bae bestie bff couple dating flirt friends hand hold twins","👭":"две женщины держаться за руки лучшие подруги пара подружки сестры women holding hands bae bestie bff couple dating flirt friends girls hand hold sisters twins","👫":"мужчина женщина держаться за руки друзья пара свидание флирт woman and man holding hands bae bestie bff couple dating flirt friends hand hold twins","👬":"двое мужчин близнецы братья двойняшки дружба друзья мужчины пара men holding hands bae bestie bff boys brothers couple dating flirt friends hand hold twins","💏":"поцелуй любовь пара романтика чувства kiss anniversary babe bae couple date dating heart love mwah person romance together xoxo","👩‍❤‍💋‍👨":"поцелуй женщина мужчина любовь пара романтика чувства kiss woman man anniversary babe bae couple date dating heart love mwah person romance together xoxo","👨‍❤‍💋‍👨":"поцелуй мужчина любовь пара романтика чувства kiss man anniversary babe bae couple date dating heart love mwah person romance together xoxo","👩‍❤‍💋‍👩":"поцелуй женщина любовь пара романтика чувства kiss woman anniversary babe bae couple date dating heart love mwah person romance together xoxo","💑":"влюбленная пара влюбленные любовь люди свидание сердце чувства couple with heart anniversary babe bae dating kiss love person relationship romance together you","👩‍❤‍👨":"влюбленная пара женщина мужчина влюбленные любовь люди свидание сердце чувства couple with heart woman man anniversary babe bae dating kiss love person relationship romance together you","👨‍❤‍👨":"влюбленная пара мужчина влюбленные любовь люди свидание сердце чувства couple with heart man anniversary babe bae dating kiss love person relationship romance together you","👩‍❤‍👩":"влюбленная пара женщина влюбленные любовь люди свидание сердце чувства couple with heart woman anniversary babe bae dating kiss love person relationship romance together you","👨‍👩‍👦":"семья мужчина женщина мальчик портрет ребенок родители семейный family man woman boy child","👨‍👩‍👧":"семья мужчина женщина девочка портрет ребенок родители семейный family man woman girl child","👨‍👩‍👧‍👦":"семья мужчина женщина девочка мальчик портрет ребенок родители семейный family man woman girl boy child","👨‍👩‍👦‍👦":"семья мужчина женщина мальчик портрет ребенок родители семейный family man woman boy child","👨‍👩‍👧‍👧":"семья мужчина женщина девочка портрет ребенок родители семейный family man woman girl child","👨‍👨‍👦":"семья мужчина мальчик портрет ребенок родители семейный family man boy child","👨‍👨‍👧":"семья мужчина девочка портрет ребенок родители семейный family man girl child","👨‍👨‍👧‍👦":"семья мужчина девочка мальчик портрет ребенок родители семейный family man girl boy child","👨‍👨‍👦‍👦":"семья мужчина мальчик портрет ребенок родители семейный family man boy child","👨‍👨‍👧‍👧":"семья мужчина девочка портрет ребенок родители семейный family man girl child","👩‍👩‍👦":"семья женщина мальчик портрет ребенок родители семейный family woman boy child","👩‍👩‍👧":"семья женщина девочка портрет ребенок родители семейный family woman girl child","👩‍👩‍👧‍👦":"семья женщина девочка мальчик портрет ребенок родители семейный family woman girl boy child","👩‍👩‍👦‍👦":"семья женщина мальчик портрет ребенок родители семейный family woman boy child","👩‍👩‍👧‍👧":"семья женщина девочка портрет ребенок родители семейный family woman girl child","👨‍👦":"семья мужчина мальчик портрет ребенок родители семейный family man boy child","👨‍👦‍👦":"семья мужчина мальчик портрет ребенок родители семейный family man boy child","👨‍👧":"семья мужчина девочка портрет ребенок родители семейный family man girl child","👨‍👧‍👦":"семья мужчина девочка мальчик портрет ребенок родители семейный family man girl boy child","👨‍👧‍👧":"семья мужчина девочка портрет ребенок родители семейный family man girl child","👩‍👦":"семья женщина мальчик портрет ребенок родители семейный family woman boy child","👩‍👦‍👦":"семья женщина мальчик портрет ребенок родители семейный family woman boy child","👩‍👧":"семья женщина девочка портрет ребенок родители семейный family woman girl child","👩‍👧‍👦":"семья женщина девочка мальчик портрет ребенок родители семейный family woman girl boy child","👩‍👧‍👧":"семья женщина девочка портрет ребенок родители семейный family woman girl child","🗣":"говорящая голова говорить кричать речь силуэт speaking head face silhouette speak","👤":"силуэт человека бюст лицо таинственный тайна тень человек bust in silhouette mysterious shadow","👥":"два силуэта бюст головы двое лицо люди силуэт темный тень busts in silhouette bff bust everyone friend friends people","🫂":"обнимающиеся люди дружба любовь обнимашки объятия пока привет прощай спасибо успокоить people hugging comfort embrace farewell friendship goodbye hello hug love thanks","👪":"семья портрет ребенок родители семейный family child","🧑‍🧑‍🧒":"семья двое взрослых ребенок одним ребенком family adult child","🧑‍🧑‍🧒‍🧒":"семья двое взрослых детей двумя детьми family adult child","🧑‍🧒":"семья взрослый ребенок неполная одним ребенком родитель-одиночка family adult child","🧑‍🧒‍🧒":"семья взрослый двое детей неполная двумя детьми родитель-одиночка family adult child","👣":"следы нога подошва рельеф след человек footprints barefoot clothing footprint omw print walk","🫆":"отпечаток пальца безопасность биометрия идентификация криминалистика fingerprint clue crime detective forensics identity mystery print safety trace","🐵":"морда обезьяны животное обезьяна обезьянья monkey face animal banana","🐒":"обезьяна животное monkey animal banana","🦍":"горилла животное обезьяна gorilla animal","🦧":"орангутан обезьяна орангутанг примат человекоподобная человекоподобный orangutan animal ape monkey","🐶":"морда собаки животное пес собака собачья dog face adorbs animal pet puppies puppy","🐕":"собака животное пес питомец щенок dog animal animals dogs pet","🦮":"собака-поводырь инвалид поводырь помощь слепой собака guide dog accessibility animal blind","🐕‍🦺":"служебная собака инвалид поиск поисковая помощь service dog accessibility animal assistance","🐩":"пудель домашнее животное порода собака poodle animal dog fluffy","🐺":"морда волка волк волчья животное собака wolf animal face","🦊":"морда лисицы животное лис лиса лисица лисья fox animal face","🦝":"морда енота енот животное любопытный полоскун raccoon animal curious sly","🐱":"морда кошки животное кот кошачья кошка питомец cat face animal kitten kitty pet","🐈":"кошка животное кот котенок питомец cat animal animals cats kitten pet","🐈‍⬛":"черная кошка кот мяу несчастье неудача плохая примета черный black cat animal feline halloween meow unlucky","🦁":"морда льва гороскоп грива животное знак зодиака зодиак лев lion alpha animal face leo mane order rawr roar safari strong zodiac","🐯":"морда тигра животное зверь тигр тигренок тигриная tiger face animal big cat predator","🐅":"тигр животное зверь зоопарк кошка хищник tiger animal big cat predator zoo","🐆":"леопард гепард животное зверь зоопарк кошка хищник leopard animal big cat predator zoo","🐴":"морда лошади конь лошадиная лошадь horse face animal dressage equine farm horses","🫎":"лось животное млекопитающее рога рогатый сохатый moose alces animal antlers elk mammal","🫏":"осел животное ишак млекопитающее мул ослик упрямый donkey animal ass burro hinny mammal mule stubborn","🐎":"лошадь жеребец животное конь скаковая скакун скачки horse animal equestrian farm racehorse racing","🦄":"голова единорога единорог животное морда рог сказочное фэнтези unicorn face","🦓":"зебра зебры полосатая полоски черно-белая zebra animal stripe","🦌":"олень грация животное зверь рога deer animal","🦬":"бизон буйвол животное зубр стадо як bison animal buffalo herd wisent","🐮":"морда коровы животное корова коровья му рога cow face animal farm milk moo","🐂":"бык гороскоп животное знак зодиак телец ox animal animals bull farm taurus zodiac","🐃":"буйвол животное зверь зоопарк рога water buffalo animal zoo","🐄":"корова животное молоко рога ферма cow animal animals farm milk moo","🐷":"морда свиньи бекон животное поросенок пятачок сало свинина свинья хрюшка pig face animal bacon farm pork","🐖":"свинья бекон животное ферма хрюшка pig animal bacon farm pork sow","🐗":"кабан голова дикий животное клыки boar animal pig","🐽":"свиной пятачок морда поросенок пятак свинья хрю хрюшка pig nose animal face farm smell snout","🐏":"баран гороскоп животное знак зодиак овен рога ram animal aries horns male sheep zodiac zoo","🐑":"овца баран жвачное животное парнокопытное рога ферма шерсть ягненок ягнятина ewe animal baa farm female fluffy lamb sheep wool","🐐":"коза животное знак зодиака зодиак козел козерог goat animal capricorn farm milk zodiac","🐪":"одногорбый верблюд горб дромадер один пустыня camel animal desert dromedary hump one","🐫":"двугорбый верблюд бактриан горб жвачное животное один парнокопытное пустыня two-hump camel animal bactrian desert hump two","🦙":"лама альпака викунья гуанако животное копытное руно шерсть llama alpaca animal guanaco vicu wool","🦒":"жираф длинная шея животное пятна пятнистый giraffe animal spots","🐘":"слон животное хобот elephant animal","🦣":"мамонт бивень большой вымирание животное мохнатый предок mammoth animal extinction large tusk wooly","🦏":"носорог животное рог rhinoceros animal","🦛":"гиппопотам бегемот животное hippopotamus animal hippo","🐭":"морда мыши грызун мышка мышь mouse face animal","🐁":"мышь белая грызун крыса мышка мышонок хвост mouse animal animals","🐀":"крыса грызун rat animal","🐹":"морда хомяка домашнее животное мордочка мышь хомяк hamster animal face pet","🐰":"морда кролика домашнее животное зайчик кролик rabbit face animal bunny pet","🐇":"кролик белый домашнее животное зайчик заяц rabbit animal bunny pet","🐿":"бурундук белочка животное орех chipmunk animal squirrel","🦫":"бобр бобер грызть животное зубы плотина beaver animal dam teeth","🦔":"еж ежик животное иголка иголки колючий колючки hedgehog animal spiny","🦇":"летучая мышь вампир животное bat animal vampire","🐻":"морда медведя бурый животное медведь bear animal face grizzly growl honey","🐻‍❄":"морда белого медведя белый медведь животное полярный северный полюс polar bear animal arctic white","🐨":"морда коалы австралия животное коала медведь эвкалипт koala animal australia bear down face marsupial under","🐼":"морда панды животное медведь панда panda animal bamboo face","🦥":"ленивец животное лень медленно медленный спать sloth lazy slow","🦦":"выдра вода животное играть ловить рыбалка otter animal fishing playful","🦨":"скунс вонь вонючка вонять животное фу skunk animal stink","🦘":"кенгуру австралия животное скакать сумка сумчатое сумчатые kangaroo animal joey jump marsupial","🦡":"барсук животное носуха badger animal honey pester","🐾":"следы животного животное лапы отпечатки paw prints feet paws print","🦃":"индейка благодарение благодарения индюк крылья птица turkey bird gobble thanksgiving","🐔":"курица голова петух птица цыпленок chicken animal bird ornithology","🐓":"петух гребешок петушок птица rooster animal bird ornithology","🐣":"цыпленок яйце вылупился желтый яйцо hatching chick animal baby bird egg","🐤":"цыпленок боком голова цыпленка клюв маленький baby chick animal bird ornithology","🐥":"цыпленок крылья курица маленький front-facing baby chick animal bird newborn ornithology","🐦":"птица голова голубь bird animal ornithology","🐧":"пингвин антарктика арктика голова животное зоопарк птица penguin animal antarctica bird ornithology","🕊":"голубь белый мир полет преданность чистота dove bird fly ornithology peace","🦅":"орел птица хищник eagle animal bird ornithology","🦆":"утка птица селезень duck animal bird ornithology","🦢":"лебедь белый водоплавающее гадкий утенок птица swan animal bird cygnet duckling ornithology ugly","🦉":"сова мудрость птица филин owl animal bird ornithology wise","🦤":"дронт большой вымерший додо маврикий птица dodo animal bird extinction large ornithology","🪶":"перо лёгкий оперение перышко писать полёт птица feather bird flight light plumage","🦩":"фламинго птица розовый тропики тропическая тропический flamingo animal bird flamboyant ornithology tropical","🦚":"павлин веер красивый перья птица хвост цветной peacock animal bird colorful ornithology ostentatious peahen pretty proud","🦜":"попугай ара говорить говорящий пират птица птица-говорун parrot animal bird ornithology pirate talk","🪽":"крыло авиация ангельский летать мифология полет птица wing angelic ascend aviation bird fly flying heavenly mythology soar","🐦‍⬛":"черная птица ворон ворона врановый грач каркать клюв черный чёрный black bird animal beak caw corvid crow ornithology raven rook","🪿":"гусь глупый гогот гоготать гуси дичь птица утка goose animal bird duck flock fowl gaggle gander geese honk ornithology silly","🐦‍🔥":"феникс возрождение восстать заново огненная птица реинкарнация сказка сказочная phoenix ascend ascension emerge fantasy firebird glory immortal rebirth reincarnation reinvent renewal revival revive rise transform","🐸":"голова лягушки жаба зеленая лягушка морда frog animal face","🐊":"крокодил аллигатор зеленый crocodile animal zoo","🐢":"черепаха зеленая turtle animal terrapin tortoise","🦎":"ящерица зеленая рептилия lizard animal reptile","🐍":"змея гадюка змееносец знак зодиак опасность яд snake animal bearer ophiuchus serpent zodiac","🐲":"голова дракона дракон морда сказка фентези фэнтези dragon face animal fairy fairytale tale","🐉":"дракон голова зеленый змея морда сказка фентези фэнтези dragon animal fairy fairytale knights tale","🦕":"зауропод брахиозавр бронтозавр динозавр диплодок sauropod brachiosaurus brontosaurus dinosaur diplodocus","🦖":"ти-рекс динозавр рекс тираннозавр t-rex dinosaur rex tyrannosaurus","🐳":"кит фонтанчиком брызги вода spouting whale animal beach face ocean","🐋":"кит животное океан whale animal beach ocean","🐬":"дельфин вода море океан пляж dolphin animal beach flipper ocean","🦭":"тюлень животное морской котик лев нерпа океан seal animal lion ocean sea","🐟":"рыба гороскоп знак зодиака зодиак рыбалка рыбы fish animal dinner fishes fishing pisces zodiac","🐠":"тропическая рыба тропики экзотическая tropical fish animal fishes","🐡":"рыба-еж еж иголки рыба blowfish animal fish","🦈":"акула рыба хищник shark animal fish","🐙":"осьминог океан спрут щупальца octopus animal creature ocean","🐚":"раковина моллюск ракушка спираль улитка spiral shell animal beach conch sea","🪸":"коралл изменение климата море океан природа риф coral change climate ocean reef sea","🪼":"медуза беспозвоночное морское ожог ой ужалить jellyfish animal aquarium burn invertebrate jelly life marine ocean ouch plankton sea sting stinger tentacles","🦀":"краб знак зодиак клешни рак crab cancer zodiac","🦞":"омар животное клешни лангуст лобстер морепродукты рак lobster animal bisque claws seafood","🦐":"креветка еда маленькая морепродукты ракообразные рачок shrimp food shellfish small","🦑":"кальмар еда моллюск щупальца squid animal food mollusk","🦪":"устрица жемчуг жемчужина нырять раковина ракушка устричный oyster diving pearl","🐌":"улитка медленно ползти раковина snail animal escargot garden nature slug","🦋":"бабочка красота мотылек насекомое butterfly insect pretty","🐛":"гусеница жук многоножка насекомое сороконожка bug animal garden insect","🐜":"муравей насекомое ant animal garden insect","🐝":"пчела жало желто-черная мед насекомое природа honeybee animal bee bumblebee honey insect nature spring","🪲":"жук букашка жучок клоп насекомое beetle animal bug insect","🐞":"божья коровка жук жучок насекомое природа сад lady beetle animal garden insect ladybird ladybug nature","🦗":"сверчок кузнечик насекомое цикада cricket animal bug grasshopper insect orthoptera","🪳":"таракан вредитель мерзкий насекомое прусак cockroach animal insect pest roach","🕷":"паук насекомое черная вдова spider animal insect","🕸":"паутина паук сети сеть spider web","🦂":"скорпион гороскоп знак зодиака зодиак scorpion scorpio scorpius zodiac","🦟":"комар вирус кровосос лихорадка малярия москит насекомое укус mosquito bite disease fever insect malaria pest virus","🪰":"муха вредитель зараза личинка насекомое fly animal disease insect maggot pest rotting","🪱":"червяк дождевой червь животное земляной круглый паразит worm animal annelid earthworm parasite","🦠":"микроб амеба бактерия вирус наука microbe amoeba bacteria science virus","💐":"букет любовь подарок поздравление праздник розы романтика свидание тюльпаны цветы bouquet anniversary birthday date flower love plant romance","🌸":"цветок вишни мальва сакура цвести cherry blossom flower plant spring springtime","💮":"белый цветок white flower","🪷":"лотос буддизм индуизм красота медитация спокойствие умиротворение цветок чистота lotus beauty buddhism calm flower hinduism peace purity serenity","🏵":"розетка растение розочка цветок rosette plant","🌹":"роза бутон валентинка красная красота любовь растение цветок элегантность rose beauty elegant flower love plant red valentine","🥀":"увядший цветок завяла лепесток роза wilted flower dying","🌺":"гибискус китайская роза цветок hibiscus flower plant","🌻":"подсолнух солнце цветок sunflower flower outdoors plant sun","🌼":"цветок растение blossom buttercup dandelion flower plant","🌷":"тюльпан цвести цветок tulip blossom flower growth plant","🪻":"гиацинт лаванда львиный зев люпин цветок hyacinth bloom bluebonnet flower indigo lavender lilac lupine plant purple shrub snapdragon spring violet","🌱":"росток молодое растение молодой новое новый seedling plant sapling sprout young","🪴":"растение горшке горшок домашнее доме цветок potted plant decor grow house nurturing pot","🌲":"елка вечнозеленое дерево ель лес новый год evergreen tree christmas forest pine","🌳":"дерево зеленое листва растение deciduous tree forest green habitat shedding","🌴":"пальма дерево тропики palm tree beach plant tropical","🌵":"кактус зеленое колючки мексика растение текила cactus desert drought nature plant","🌾":"колос зерно пшеница рис sheaf of rice ear grain grains plant","🌿":"веточка ветка листья трава herb leaf plant","☘":"трилистник ирландия клевер растение shamrock irish plant","🍀":"четырехлистный клевер удача четыре four leaf clover four-leaf irish lucky plant","🍁":"кленовый лист клен красный осень maple leaf falling","🍂":"падающие листья листопад осень fallen leaf autumn fall falling","🍃":"листья на ветру ветер листопад leaf fluttering in wind blow flutter","🪹":"пустое гнездо гнездование домой природа птицы пусто empty nest branch home nesting","🪺":"гнездо яйцами гнездование кладка яиц природа птицы яйца nest with eggs bird branch egg nesting","🍄":"гриб мухомор несъедобный поганка ядовитый mushroom fungus toadstool","🪾":"дерево без листвы листьев засуха зима leafless tree bare barren branches dead drought trunk winter wood","🍇":"виноград ветвь винограда виноградина сок фрукт grapes dionysus fruit grape","🍈":"дыня растение фрукт ягода melon cantaloupe fruit","🍉":"арбуз растение фрукт ягода watermelon fruit","🍊":"мандарин апельсин клементин фрукт цитрус цитрусовый tangerine citrus fruit nectarine orange vitamin","🍋":"лимон кислый фрукт цитрус цитрусовый lemon citrus fruit sour","🍋‍🟩":"лайм зеленый кислота коктейль маргарита мохито сок текила тропический фрукт цитрус цитрусовый lime acidity citrus cocktail fruit garnish key margarita mojito refreshing salsa sour tangy tequila tropical zest","🍌":"банан фрукт banana fruit potassium","🍍":"ананас фрукт pineapple colada fruit pina tropical","🥭":"манго еда сладкий тропики тропический фрукт mango food fruit tropical","🍎":"красное яблоко зрелое фрукт red apple diet food fruit health ripe","🍏":"зеленое яблоко фрукт green apple fruit","🍐":"груша фрукт pear fruit","🍑":"персик нектарин фрукт peach fruit","🍒":"вишня вишни дерево плоды фрукт черешни черешня cherries berries cherry fruit red","🍓":"клубника земляника ягода strawberry berry fruit","🫐":"голубика голубой черника ягода blueberries berries berry bilberry blue blueberry food fruit","🥝":"киви еда фрукт kiwi fruit food","🍅":"помидор овощ томат ягода tomato food fruit vegetable","🫒":"оливка еда маслина олива olive food","🥥":"кокос кокосы орех пальма пина колада coconut colada palm pi","🥑":"авокадо еда фрукт avocado food fruit","🍆":"баклажан овощ синенькие синенький eggplant aubergine vegetable","🥔":"картофель еда картошка овощ potato food vegetable","🥕":"морковь еда морковка овощ carrot food vegetable","🌽":"кукурузный початок кукуруза ear of corn crops farm maize maze","🌶":"острый перец красный растение чили hot pepper","🫑":"болгарский перец еда овощ растение сладкий bell pepper capsicum food vegetable","🥒":"огурец еда зелень овощ огурчик соленье cucumber food pickle vegetable","🥬":"зеленый салат ботва капуста латук листовой листья leafy green bok burgers cabbage choy kale lettuce salad","🥦":"брокколи капуста broccoli cabbage wild","🧄":"чеснок вкус головка готовить еда запах приправа специя чесночный garlic flavoring","🧅":"лук головка готовить еда луковица луковый плакать репчатый onion flavoring","🥜":"арахис еда овощ орех peanuts food nut peanut vegetable","🫘":"фасоль бобовые бобы еда кидни beans food kidney legume small","🌰":"каштан растение chestnut almond plant","🫚":"корень имбиря имбирь специя ginger root beer health herb natural spice","🫛":"стручок гороха бобовые бобы горох овощ эдамамэ pea pod beans beanstalk edamame legume soybean vegetable veggie","🍄‍🟫":"бурый гриб вегетарианство веги грибок грибочек грибы еда овощ природа brown mushroom food fungi fungus nature pizza portobello shiitake shroom spore sprout toppings truffle vegetable vegetarian veggie","🫜":"корнеплод корень овощ огород репа свекла root vegetable beet food garden radish salad turnip vegetarian","🍞":"хлеб батон буханка bread carbs food grain loaf restaurant toast wheat","🥐":"круассан еда рогалик французский хлеб croissant bread breakfast crescent food french roll","🥖":"багет батон булка еда французский хлеб baguette bread food french","🫓":"лепешка еда лаваш наан пита тортилья хлеб flatbread arepa bread food gordita lavash naan pita","🥨":"крендель брецель бублик закрученный претцель pretzel convoluted twisted","🥯":"бублик баранка бейгель бейгл выпечка еда завтрак намазать bagel bakery bread breakfast schmear","🥞":"блины блинчики еда масленица оладьи оладья pancakes breakfast cr pe food hotcake pancake","🧇":"вафля вафельный вафли десерт еда завтрак waffle breakfast indecisive iron","🧀":"сыр дырки желтый кусок cheese wedge","🍖":"мясо на косточке кость рулька meat on bone","🍗":"куриная ножка еда кость курица нога окорочок птица poultry leg bone chicken drumstick hungry turkey","🥩":"кусок мяса вырезка красное мясо отбивная стейк cut of meat chop lambchop porkchop red steak","🥓":"бекон еда мясо bacon breakfast food meat","🍔":"гамбургер бургер бутерброд сэндвич фастфуд hamburger burger eat fast food hungry","🍟":"картофель фри картошка фастфуд french fries fast food","🍕":"пицца кусок пиццы перекус сыр pizza cheese food hungry pepperoni slice","🌭":"хот-дог тесте горчица перекус сосиска тесто hot dog frankfurter hotdog sausage","🥪":"сэндвич бутерброд сендвич хлеб sandwich bread","🌮":"тако кухня мексика мексиканская перекус taco mexican","🌯":"буррито бурритос кухня мексика мексиканская ролл тортилья burrito mexican wrap","🫔":"тамале еда завернутый мексиканский памонья тамал tamale food mexican pamonha wrapped","🥙":"шаурма еда кебаб лаваш перекус пита фалафель шаверма stuffed flatbread falafel food gyro kebab","🧆":"фалафель бобовый бобы вегетарианский горох гороховый еда нут шарик falafel chickpea meatball","🥚":"яйцо еда завтрак скорлупа egg breakfast food","🍳":"яичница на сковороде глазунья жарить желток завтрак сковорода яйцо cooking breakfast easy egg fry frying over pan restaurant side sunny up","🥘":"рагу сковороде еда паэлья плов сковорода shallow pan of food casserole paella","🍲":"кастрюля горячей едой горячая еда горячее обед первое суп тарелка pot of food soup stew","🫕":"фондю еда какелон плавленый сыр швейцарский шоколад fondue cheese chocolate food melted pot ski","🥣":"миска ложкой глубокая гранола завтрак каша мюсли пиала тарелка хлопья bowl with spoon breakfast cereal congee oatmeal porridge","🥗":"овощной салат еда овощи овощная нарезка огород урожай green salad food","🍿":"попкорн кукуруза стакан popcorn corn movie pop","🧈":"масло готовить еда масленка масляный молокопродукты молочный сливки сливочное butter dairy","🧂":"солонка вкус еда морская соль приправа соленый salt condiment flavor mad salty shaker taste upset","🥫":"консервы банка сгущенки жестянка консервированная еда консервная canned food can","🍱":"бэнто бенто бизнес ланч коробка суши рисом обед bento box food","🍘":"рисовый крекер рис rice cracker food","🍙":"онигири рис рисовый шар шарик япония rice ball food japanese","🍚":"рис вареный гарнир еда миска обед плошка тарелка чашка cooked rice food","🍛":"рис карри обед curry rice food","🍜":"лапша доширак локшина миска обед пар чашка steaming bowl chopsticks food noodle pho ramen soup","🍝":"спагетти вермишель лапша макаронные изделия макароны паста spaghetti food meatballs pasta restaurant","🍠":"печеный сладкий картофель батат картошка roasted sweet potato food","🍢":"одэн закуска на палочке шашлык шашлычок из морепродуктов oden food kebab restaurant seafood skewer stick","🍣":"суши кухня япония sushi food","🍤":"жареная креветка креветки темпура fried shrimp prawn tempura","🍥":"камабоко начинка рыба рыбная котлета сурими юла fish cake with swirl food pastry restaurant","🥮":"лунный пирог пряник осень фестиваль юэбин moon cake autumn festival yu ng","🍡":"данго десерт конфета леденец мороженое на палочке сладости сладость японский dango dessert japanese skewer stick sweet","🥟":"вареник вареники гёдза колдуны пельмени равиоли ушки цзяоцзы dumpling empanada gy za jiaozi pierogi potsticker","🥠":"печенье предсказанием гадание оракул предсказание fortune cookie prophecy","🥡":"коробка для еды на вынос доставка еда собой едой палочки takeout box chopsticks delivery food oyster pail","🍦":"мороженое стаканчике ванильное вафельный стаканчик вафля десерт мягкое рожок soft ice cream dessert food icecream restaurant serve sweet","🍧":"мороженое креманке десерт креманка строганый лед shaved ice dessert restaurant sweet","🍨":"мороженое десерт сладкое холодное ice cream dessert food restaurant sweet","🍩":"пончик глазурь пирожок сладкое doughnut breakfast dessert donut food sweet","🍪":"печенье выпечка десерт сладкое сладость cookie chip chocolate dessert sweet","🎂":"торт ко дню рождения выпечка день отмечать пирог праздник днем свечи свечки сладкое birthday cake bday celebration dessert happy pastry sweet","🍰":"кусочек торта выпечка десерт крем сладкая торт shortcake cake dessert pastry slice sweet","🧁":"капкейк выпечка десерт еда кекс корзиночка кремом пирожное сахар сладости cupcake bakery dessert sprinkles sugar sweet treat","🥧":"пирог выпечка кусок пирога мясной сдоба торт фруктовый шарлотка pie apple filling fruit meat pastry pumpkin slice","🍫":"шоколад десерт какао плитка шоколада сладость шоколадка chocolate bar candy dessert halloween sweet tooth","🍬":"конфета леденец обертка сладкая сладость фантик candy cavities dessert halloween restaurant sweet tooth wrapper","🍭":"леденец конфета на палочке палочка радуга сладкий сладость lollipop candy dessert food restaurant sweet","🍮":"пудинг десерт сладкий custard dessert pudding sweet","🍯":"мед бочонок меда горшок медом honey pot barrel bear food honeypot jar sweet","🍼":"бутылочка соской бутылка дети кормить молоко новорожденный прикорм сосать соска baby bottle babies birth born drink infant milk newborn","🥛":"стакан молока молоко напиток полный glass of milk drink","☕":"горячий напиток кипяток кофе пар чаепитие чай hot beverage cafe caffeine chai coffee drink morning steaming tea","🫖":"чайник заварка заварочный напиток чай teapot brew drink food pot tea","🍵":"чашка без ручки зеленый чай зеленого чая teacup without handle beverage cup drink oolong tea","🍶":"бутылка саке водка выпивать горячее япония sake bar beverage bottle cup drink restaurant","🍾":"шампанское бутылка вино игристое отмечать праздновать пробка bottle with popping cork bar drink","🍷":"бокал вина алкоголь вино красное wine glass alcohol bar beverage booze club drink drinking drinks restaurant","🍸":"коктейль бар бокал напиток cocktail glass alcohol bar booze club drink drinking drinks mad martini men","🍹":"тропический коктейль зонтик напиток прохладительный соломинка тропики tropical drink alcohol bar booze club cocktail drinking drinks drunk mai party tai tropics","🍺":"кружка пива пена пенное пивная пиво beer mug alcohol ale bar booze drink drinking drinks octoberfest oktoberfest pint stein summer","🍻":"чокающиеся кружки бар выпивать пива пенное пивные пиво тост чокнуться clinking beer mugs alcohol bar booze bottoms cheers clink drinking drinks","🥂":"чокающиеся бокалы бокал годовщина звон напиток отмечать праздник праздновать шампанское clinking glasses celebrate clink drink glass","🥃":"стакан виски бурбон вино коктейль коньяк ликер для tumbler glass liquor scotch shot whiskey whisky","🫗":"стакан жидкостью вода выливать вылить наливать напиток оплошность опустошить пролить пустой случайность pouring liquid accident drink empty glass oops pour spill water","🥤":"стакан соломинкой вода газировка кола минеральная напиток питье сок газировкой стаканчик трубочкой cup with straw drink juice malt soda soft water","🧋":"чай шариками баббл бабл боба еда жемчужный молоко напиток шарик bubble tea boba food milk pearl","🧃":"пакетик сока картон напиток пакет пить сок трубочка beverage box juice straw sweet","🧉":"мате матэ напиток пить трубочка чай mate drink","🧊":"кубик льда лед ледяной холод холодный ice cold cube iceberg","🥢":"палочки для еды суши приборы столовые японские chopsticks hashi jeotgarak kuaizi","🍽":"тарелка приборами вилка нож прибор сервировка стол столовый fork and knife with plate cooking dinner eat","🍴":"вилка нож еда завтрак обед перекус прибор ресторан ужин fork and knife breakfast breaky cooking cutlery delicious dinner eat feed food hungry lunch restaurant yum yummy","🥄":"ложка ложечка прибор приборы стол столовые столовый чайная spoon eat tableware","🔪":"нож кухонный оружие острие резать холодное kitchen knife chef cooking hocho tool weapon","🫙":"банка варенье мариновать пустая соленья хранить jar condiment container empty nothing sauce store","🏺":"амфора кувшин сосуд amphora aquarius cooking drink jug tool weapon zodiac","🌍":"европа африка на земном шаре глобус земля земной шар globe showing europe-africa africa earth europe world","🌎":"америка на земном шаре глобус земной мир шар globe showing americas earth world","🌏":"азия австралия на земном шаре глобус земля земной шар globe showing asia-australia asia australia earth world","🌐":"меридианы глобус земля земной меридиан мир шар globe with meridians earth internet web world worldwide","🗺":"карта мира атлас мир страны world map","🗾":"карта японии атлас япония map of japan","🧭":"компас магнит навигация направление ориентирование поход путешествие путь света стороны турист compass direction magnetic navigation orienteering","🏔":"гора со снежной шапкой вершина снег snow-capped mountain cold snow","⛰":"гора вершина пик mountain","🌋":"вулкан извержение вулкана кратер лава volcano eruption mountain nature","🗻":"фудзияма вулкан гора mount fuji mountain nature","🏕":"кемпинг лагерь палатка поход camping","🏖":"пляж зонт лето море остров отпуск пальма песок пляжный солнце beach with umbrella","🏜":"пустыня кактус песок солнце desert","🏝":"остров кокос лето море необитаемый отпуск пальма пустыня desert island","🏞":"национальный парк заповедник природа national park","🏟":"стадион игры матч олимпийские соревнования stadium","🏛":"античное здание античность классический колонны стиль храм classical building","🏗":"стройка кран строительство building construction crane","🧱":"кирпичи глина кирпич кирпичная кирпичный кладка стена brick bricks clay mortar wall","🪨":"камень булыжник жесткий скала твердый тяжелый rock boulder heavy solid stone tough","🪵":"древесина бревно дрова лес пень полено wood log lumber timber","🛖":"хижина дом юрта hut home house roundhouse shelter yurt","🏘":"дома городок дача здания поселок houses house","🏚":"заброшенный дом развалины упадок derelict house home","🏠":"дом дача домашний здание родной стены house building country heart home ranch settle simple suburban suburbia where","🏡":"дом садом дача загородный крыша сад стены усадьба house with garden building country heart home ranch settle simple suburban suburbia where","🏢":"офисное здание бизнес-центр офис работа office building city cubical job","🏣":"японская почта здание япония японское japanese post office building","🏤":"почта европа здание post office building european","🏥":"больница госпиталь здание лечение поликлиника помощь скорая стационар хоспис hospital building doctor medicine","🏦":"банк деньги здание bank building","🏨":"гостиница здание отель хостел hotel building","🏩":"гостиница для свиданий дом здание отель публичный свидания love hotel building","🏪":"круглосуточный магазин 24 часа здание покупки сутки convenience store building hours","🏫":"школа здание колледж обучение учебное school building","🏬":"универмаг здание магазин супермаркет торговый универсам центр department store building","🏭":"завод предприятие промышленность фабрика factory building","🏯":"японский замок здание япония japanese castle building","🏰":"замок башни крепость укрепение форт castle building european","💒":"свадьба венчание сердечки собор церемония церковь wedding chapel hitched nuptials romance","🗼":"токийская башня архитектура вышка телебашня телевышка токио tokyo tower","🗽":"статуя свободы нью-йорк свобода факел statue of liberty new ny nyc york","⛪":"церковь здание монастырь религия собор храм христианство church bless chapel christian cross religion","🕌":"мечеть здание ислам мусульмане религия mosque islam masjid muslim religion","🛕":"индуистский храм ашрам индийский индуизм церковь hindu temple","🕍":"синагога евреи здание иудаизм религия церковь synagogue jew jewish judaism religion temple","⛩":"синтоистский храм религия святилище синто синтоизм япония shinto shrine religion","🕋":"кааба ислам мусульмане религия святыня хадж kaaba hajj islam muslim religion umrah","⛲":"фонтан брызги вода источник родник fountain","⛺":"палатка кемпинг лагерь поход тент tent camping","🌁":"туман дымка облака туманно foggy fog","🌃":"звездная ночь город звезды месяц ночной night with stars star","🏙":"вид на город городской многоэтажки ночь пейзаж cityscape city","🌄":"восход солнца над горами горы закат заря пейзаж рассвет солнце утро sunrise over mountains morning sun","🌅":"восход вечер заря лучи пейзаж рассвет солнце утро sunrise morning nature sun","🌆":"закат городе вечер вечерний высотки город заход многоэтажки небоскреб солнце cityscape at dusk building city evening landscape sun sunset","🌇":"закат вечер высотные город дома заход небоскреб романтика солнце sunset building dusk sun","🌉":"мост ночью ночь bridge at night","♨":"горячие источники гейзер пар поток hot springs hotsprings steaming","🎠":"лошадь на карусели аттракцион карусель лошадка carousel horse entertainment","🛝":"детская горка играть игровая кататься парк площадка развлечений playground slide amusement park play playing sliding theme","🎡":"колесо обозрения аттракцион веселье чертово ferris wheel amusement park theme","🎢":"американские горки аттракцион горка roller coaster amusement park theme","💈":"парикмахерская барбершоп волосы салон стрижка цирюльник barber pole cut fresh haircut shave","🎪":"цирковой шатер цирк шапито circus tent","🚂":"паровоз вагон локомотив поезд locomotive caboose engine railway steam train trains travel","🚃":"вагон общественный поезд рельсы трамвай транспорт фуникулер railway car electric train tram travel trolleybus","🚄":"скоростной поезд дорога железная пуля синкансэн скорость high-speed train railway shinkansen speed","🚅":"поезд-пуля поезд пуля синкансэн скорость bullet train high-speed nose railway shinkansen speed travel","🚆":"поезд дорога железная транспорт train arrived choo railway","🚇":"метро подземка поезд станция тоннель транспорт metro subway travel","🚈":"легкий рельсовый транспорт метро монорельс трамвай электричка light rail arrived monorail railway","🚉":"железнодорожный вокзал станция железнодорожная рельсы электричка station railway train","🚊":"трамвай рельсы транспорт tram trolleybus","🚝":"монорельс легкое метро рельсы транспорт monorail vehicle","🚞":"поезд горах горы дорога железная рельсы транспорт электричка mountain railway car trip","🚋":"трамвайный вагон рельсы трамвай электричество tram car bus trolley trolleybus","🚌":"автобус маршрутка мини общественный транспорт школьный bus school vehicle","🚍":"автобус спереди лобовое общественный транспорт приближающийся стекло oncoming bus cars","🚎":"троллейбус дорога общественный провода транспорт электричество trolleybus bus tram trolley","🚐":"микроавтобус автобус бусик газель маршрутка поездка minibus bus drive van vehicle","🚑":"машина скорой помощи больница врачи помощь скорая ambulance emergency vehicle","🚒":"пожарная машина огонь пожар пожарные спасатели fire engine truck","🚓":"полицейская машина милиция патруль полицейские полиция police car cops patrol","🚔":"полицейская машина спереди лобовое стекло полицейские полиция приближающаяся проблесковый маячок сирена oncoming police car","🚕":"такси водитель желтое машина транспорт убер шашечки taxi cab cabbie car drive vehicle yellow","🚖":"такси спереди машина приближающееся транспорт oncoming taxi cab cabbie cars drove hail yellow","🚗":"автомобиль машина транспорт automobile car driving vehicle","🚘":"автомобиль спереди машина приближающаяся приближающийся транспорт oncoming automobile car cars drove vehicle","🚙":"джип suv автомобиль жилой легковой машина отдых фургон sport utility vehicle car drive recreational sportutility","🛻":"пикап автомобиль грузовик машина перевозка транспорт pickup truck automobile car flatbed pick-up transportation","🚚":"грузовик газель груз грузовой доставка кузов перевозки транспорт delivery truck car drive vehicle","🚛":"фура газель грузовик кузов перевозка переезд транспорт articulated lorry car drive move semi truck vehicle","🚜":"трактор сельское техника транспорт хозяйство tractor vehicle","🏎":"гоночный автомобиль гонки скорость спортивный racing car zoom","🏍":"мотоцикл байк гонки опасность скорость motorcycle racing","🛵":"скутер мопед мотороллер транспорт motor scooter","🦽":"кресло-коляска ручным приводом инвалид коляска кресло ручной транспорт manual wheelchair accessibility","🦼":"кресло-коляска мотором инвалид кресло транспорт электропривод motorized wheelchair accessibility","🛺":"авторикша мопед моторикша рикша такси транспорт тук-тук туктук auto rickshaw tuk","🚲":"велосипед велик колеса крутить педали прокатиться руль спорт транспорт bicycle bike class cycle cycling cyclist gang ride spin spinning","🛴":"самокат колеса руль транспорт kick scooter","🛹":"скейтборд доска колесиками роликами колеса роликовая скейт скейтер skateboard board skate skater wheels","🛼":"роликовые коньки ролики спорт roller skate blades skates sport","🚏":"автобусная остановка автобус знак bus stop busstop","🛣":"шоссе автомагистраль автострада дорога магистраль путь разметка motorway highway road","🛤":"железная дорога путь рельсы шпалы railway track train","🛢":"бочка емкость нефть oil drum","⛽":"заправка автозаправка автозаправочная станция азс бензин дизель топливо fuel pump diesel fuelpump gas gasoline station","🛞":"автомобильное колесо диск колпак круг поворот шина wheel car circle tire turn vehicle","🚨":"мигалка красная полиция проблесковый маячок сигнальные огни сирена экстренная ситуация police car light alarm alert beacon emergency revolving siren","🚥":"горизонтальный светофор свет horizontal traffic light intersection signal stop stoplight","🚦":"вертикальный светофор перекресток свет vertical traffic light drove intersection signal stop stoplight","🛑":"знак стоп восьмиугольник красный остановка stop sign octagonal","🚧":"строительные работы барьер заграждение опасность преграда препятствие строительство стройка construction barrier","⚓":"якорь корабль anchor ship tool","🛟":"спасательный круг безопасность надувной помощь спасение ring buoy float life lifesaver preserver rescue safety save saver swim","⛵":"парусник кораблик лодка отдых парус яхта sailboat boat resort sailing sea yacht","🛶":"каноэ байдарка весло индейцы каяк лодка canoe boat","🚤":"катер вода двигатель круиз лодка море мотор яхта speedboat billionaire boat lake luxury millionaire summer travel","🛳":"пассажирский лайнер круиз пассажиры перевозки судно passenger ship","⛴":"паром перевозки транспорт ferry boat passenger","🛥":"моторная лодка вода двигатель катер корабль мотор моторка яхта motor boat motorboat","🚢":"корабль груз круиз лайнер морской пароход перевозки судно титаник ship boat passenger travel","✈":"самолет воздух крылья перевозки airplane aeroplane fly flying jet plane travel","🛩":"маленький самолет воздух крылья перевозки small airplane aeroplane plane","🛫":"взлет взлетаю отправление посадка регистрация самолет улетать airplane departure aeroplane check-in departures plane","🛬":"посадка прибытие приземляется садится самолет airplane arrival aeroplane arrivals arriving landing plane","🪂":"парашют крыло параглайд парасейл парашютист скайдайв parachute hang-glide parasail skydive","💺":"кресло самолете место пассажирское сиденье стул seat chair","🚁":"вертолет винт воздух лопасти транспорт helicopter copter roflcopter travel vehicle","🚟":"подвесная железная дорога вагон suspension railway","🚠":"вагон фуникулера горы кабинка подъем подъемник тросы фуникулер mountain cableway cable gondola lift ski","🚡":"канатная дорога канат подвесная подъемник трамвай тросы фуникулер aerial tramway cable car gondola ropeway","🛰":"спутник космос satellite space","🚀":"ракета быстро взлет космос скорость rocket launch rockets space travel","🛸":"летающая тарелка инопланетный гость инопланетяне марсианин нло пришелец пришельцы flying saucer aliens extra terrestrial ufo","🛎":"гостиничный звонок вызов гостиница звоночек звук отель портье bellhop bell hotel","🧳":"багаж путешествие чемодан luggage bag packing roller suitcase travel","⌛":"песочные часы время песок hourglass done sand time timer","⏳":"время пошло песок песочные часы hourglass not done flowing hours sand timer waiting yolo","⌚":"наручные часы время запястье рука watch clock time","⏰":"будильник время заводить звенит напоминание подъем сигнал часы alarm clock hours hrs late time waiting","⏱":"секундомер время кнопка стрелки таймер точность часы stopwatch clock time","⏲":"таймер время отсчет сигнал часы timer clock","🕰":"каминные часы винтажные время камин старинные украшение mantelpiece clock time","🕛":"двенадцать часов 00 12 дня ночи полдень полночь twelve clock time","🕧":"полпервого 00 30 12 двенадцать первого половина тридцать twelve-thirty clock thirty time twelve","🕐":"час 13 00 время дня ночи один часы one clock time","🕜":"полвторого 30 время половина тик так тик-так час тридцать часы one-thirty clock one thirty time","🕑":"два часа время час часы two clock 00 time","🕝":"полтретьего 14 30 время два тридцать час часы two-thirty clock thirty time two","🕒":"три часа 15 00 время ровно час часы three clock time","🕞":"полчетвертого 15 30 время половина три тридцать час часы three-thirty clock thirty three time","🕓":"четыре часа 16 00 время ровно час часы four clock time","🕟":"полпятого 16 30 время половина тридцать час часы четыре four-thirty clock four thirty time","🕔":"пять часов 17 00 время ровно час часы five clock time","🕠":"полшестого 17 30 время половина пять тридцать час часы five-thirty clock five thirty time","🕕":"шесть часов 18 00 время ровно час часы six clock time","🕡":"полседьмого 18 30 время половина тридцать час часы шесть six-thirty clock six thirty","🕖":"семь часов 19 00 время ровно час часы seven clock","🕢":"полвосьмого 19 30 время половина семь тридцать час часы seven-thirty clock seven thirty","🕗":"восемь часов 20 00 время ровно час часы eight clock time","🕣":"полдевятого 20 30 восемь тридцать время половина час часы eight-thirty clock eight thirty time","🕘":"девять часов 21 00 время ровно час часы nine clock time","🕤":"полдесятого 21 30 время девять тридцать половина час часы nine-thirty clock nine thirty time","🕙":"десять часов 10 00 22 время ровно час часы ten clock","🕥":"пол-одиннадцатого 10 30 22 время десять половина тридцать час часы ten-thirty clock ten thirty time","🕚":"одиннадцать часов 11 00 23 время ровно час часы eleven clock time","🕦":"полдвенадцатого 11 30 23 время одиннадцать половина тридцать час часы eleven-thirty clock eleven thirty time","🌑":"новолуние луна месяц новая ночь темная темно new moon dark space","🌒":"молодая луна месяц молодой waxing crescent moon dreams space","🌓":"луна первой четверти месяц небо четверть first quarter moon space","🌔":"прибывающая луна месяц небо waxing gibbous moon space","🌕":"полнолуние луна месяц небо полная полный full moon space","🌖":"убывающая луна месяц небо планета waning gibbous moon space","🌗":"луна последней четверти месяц небо четверть last quarter moon space","🌘":"старая луна затмение месяц небо убывающая waning crescent moon space","🌙":"полумесяц луна месяц crescent moon ramadan space","🌚":"новая луна лицом лицо молодая новолуние темная черная new moon face space","🌛":"луна первой четверти лицом лицо месяц небо половинка луны четвертинка четверть first quarter moon face space","🌜":"луна последней четверти лицом лицо месяц небо полу полумесяц четверть last quarter moon face dreams","🌡":"термометр градусник измерение температура thermometer weather","☀":"солнце жарко лучи планета погода sun bright rays space sunny weather","🌝":"полная луна лицом круглая лицо месяц небо full moon face bright","🌞":"солнце лицом лицо небо sun with face beach bright day heat shine sunny sunshine weather","🪐":"планета кольцом кольца кольцо космос сатурн ringed planet saturn saturnine","⭐":"желтая звезда астрономия звезды star astronomy medium stars white","🌟":"сияющая звезда свет сияние glowing star glittery glow night shining sparkle win","🌠":"падающая звезда желание загадать звездопад небо shooting star falling night space","🌌":"млечный путь звезды космос молоко созвездие milky way space","☁":"облако небо туча cloud weather","⛅":"солнце за облаками непогода облака облако облачно погода sun behind cloud cloudy weather","⛈":"гроза гром дождь молния облако туча cloud with lightning and rain thunder thunderstorm","🌤":"солнце за облачком облако облачко пасмурно туча sun behind small cloud weather","🌥":"солнце за облаком облако облачно туча sun behind large cloud weather","🌦":"слепой дождь грибной облако осадки солнце за тучей туча sun behind rain cloud weather","🌧":"дождь осадки туча cloud with rain weather","🌨":"снег облако осадки погода снегопад туча cloud with snow cold weather","🌩":"молния гром облако погода туча cloud with lightning weather","🌪":"торнадо ветер вихрь катастрофа смерч ураган tornado cloud weather whirlwind","🌫":"густой туман видимость низкая облачность плохая fog cloud weather","🌬":"дует ветер вьюга лицо пурга снежная королева wind face blow cloud","🌀":"циклон завихрение тайфун юла cyclone dizzy hurricane twister typhoon weather","🌈":"радуга дождь осадки погода природа цвета rainbow gay genderqueer glbt glbtq lesbian lgbt lgbtq lgbtqia nature pride queer rain trans transgender weather","🌂":"закрытый зонт дождь зонтик осадки closed umbrella clothing rain","☂":"зонт вода дождь зонтик осадки открытый погода umbrella clothing rain","☔":"зонт под дождем зонтик капли капля ливень погода umbrella with rain drops clothing drop weather","⛱":"пляжный зонт аксессуар дождь защита зонтик непогода пляж солнце umbrella on ground rain sun","⚡":"высокое напряжение буря высоковольтное гром молниеносный молния опасность электричество high voltage danger electric electricity lightning nature thunder thunderbolt zap","❄":"снежинка зима небо рисунок снег снежок холод холодно snowflake cold snow weather","☃":"снеговик под снегом снег фигура человек человечек snowman cold man snow","⛄":"снеговик снег снеговичок снежная баба скульптура фигура человек человечек snowman without snow cold man","☄":"комета космос метеор метеорит метеоритный дождь небо планета comet space","🔥":"огонь костер очаг пламя fire af burn flame hot lit litaf tool","💧":"капля вода дождь капелька пот слеза droplet cold comic drop nature sad sweat tear water weather","🌊":"волна вода море морская пена океан серфинг water wave nature ocean surf surfer surfing","🎃":"тыква-фонарь праздник тыква фонарь из тыквы хеллоуин хэллоуин jack-o-lantern celebration halloween jack lantern pumpkin","🎄":"новогодняя елка новый год праздник рождество christmas tree celebration","🎆":"фейерверк пиротехника праздник салют свет шум fireworks boom celebration entertainment yolo","🎇":"бенгальский огонь искры новый год праздник салют sparkler boom celebration fireworks sparkle","🧨":"динамитная шашка взрыв взрывчатка динамит искра петарда фейерверк хлопушка firecracker dynamite explosive fire fireworks light pop popping spark","✨":"блестки блестка звезда звезды искры мерцание свет sparkles magic sparkle star","🎈":"воздушный шарик веревочка воздух праздник balloon birthday celebrate celebration","🎉":"хлопушка бабах веселье вечеринка день рождения мишура поздравление праздник party popper awesome birthday celebrate celebration excited hooray tada woohoo","🎊":"конфетти вечеринка праздник confetti ball celebrate celebration party woohoo","🎋":"бамбук бумажными лентами бумага дерево лента танабата традиция япония tanabata tree banner celebration japanese","🎍":"украшенный бамбук новый год растение рождество сосна япония pine decoration bamboo celebration japanese plant","🎎":"японские куклы две кукла праздник фестиваль japanese dolls celebration doll festival","🎏":"вымпелы виде карпов карпы палочка рыбки рыбы carp streamer celebration","🎐":"музыка ветра бубенчик ветер звон wind chime bell celebration","🎑":"праздник луны луна церемония moon viewing ceremony celebration","🧧":"красный конверт деньги китай подарок традиция удача red envelope gift good ngb lai luck money see","🎀":"бантик бант волосы лента одежда праздник ribbon celebration","🎁":"подарок бант бантик год день коробка новый праздник рождения рождество wrapped gift birthday bow box celebration christmas present surprise","🎗":"памятная лента память reminder ribbon celebration","🎟":"билеты билет вход квиток концерт пропуск самолет театр транспорт услуги admission tickets ticket","🎫":"билет билетик вход квиток концерт пропуск самолет театр транспорт услуги ticket admission stub","🎖":"военная медаль война заслуги награда одежда орден military medal award celebration","🏆":"кубок победа приз трофей чемпион trophy champion champs prize slay sport victory win winning","🏅":"спортивная медаль достижение место победа спорт sports medal award gold winner","🥇":"золотая медаль золото место первое победа победители победитель чемпион чемпионы 1st place medal first gold","🥈":"серебряная медаль второе место серебро 2nd place medal second silver","🥉":"бронзовая медаль бронза третье место 3rd place medal bronze third","⚽":"футбол игра мяч спорт футбольный soccer ball football futbol sport","⚾":"бейсбол бейсбольный игра мяч спорт baseball ball sport","🥎":"софтбол бита зажать игра мягкий мяч нести перчатка спорт softball ball glove sports underarm","🏀":"баскетбол баскетбольный игра кольцо мяч спорт basketball ball hoop sport","🏐":"волейбол волейбольный игра мяч спорт volleyball ball game","🏈":"американский футбол игра мяч спорт american football ball bowl sport super","🏉":"регби игра мяч спорт rugby football ball sport","🎾":"теннис игра мяч мячик ракетка спорт теннисная теннисный tennis ball racquet sport","🥏":"летающий диск бросать досуг игра летающая тарелка спорт фрисби flying disc ultimate","🎳":"боулинг игра кегли спорт страйк шар шары bowling ball game sport strike","🏏":"крикет бита игра мяч спорт cricket game ball bat","🏑":"хоккей на траве бег игра клюшка трава field hockey ball game stick","🏒":"хоккей игра клюшка лед спорт шайба ice hockey game puck stick","🥍":"лакросс гол забить клюшка мяч ракетка lacrosse ball goal sports stick","🏓":"настольный теннис мяч мячик пинг-понг ракетка стол шарик ping pong ball bat game paddle pingpong table tennis","🏸":"бадминтон волан воланчик игра ракетка спорт badminton birdie game racquet shuttlecock","🥊":"боксерская перчатка бокс спорт boxing glove","🥋":"спортивное кимоно боевые искусства дзюдо карате тхэквондо форма martial arts uniform judo karate taekwondo","🥅":"спортивные ворота гол сетка спорт goal net","⛳":"гольф игра лунка поле спорт флаг флажок flag in hole golf sport","⛸":"коньки ботинок катание каток конек лед лезвие скорость фигурное ice skate skating","🎣":"удочка рыбой крючок леска наживка рыба рыбалка спининг улов fishing pole entertainment fish sport","🤿":"маска трубкой дайвинг нырять подводная подводное плавание подводный спорт трубка diving mask scuba snorkeling","🎽":"футболка для бега атлетика бег спорт running shirt athletics sash","🎿":"лыжи горные горы склон снег спорт трасса skis ski snow sport","🛷":"санки гора катание сани снег спуск sled luge sledge sleigh snow toboggan","🥌":"керлинг игра камень лед спорт щетка curling stone game rock","🎯":"мишень дартс доска дротик попадание bullseye bull dart direct entertainment game hit target","🪀":"йо-йо игрушка йойо yo-yo fluctuate toy","🪁":"воздушный змей ветер игра летать парить kite fly soar","🔫":"водяной пистолет водный игрушка огнестрельное оружие револьвер water pistol gun handgun revolver tool weapon","🎱":"бильярд восьмерка игра пул черный шар pool ball 8ball billiard eight game","🔮":"хрустальный шар будущее волшебство гадание магия предсказание хрусталь crystal ball fairy fairytale fantasy fortune future magic tale tool","🪄":"волшебная палочка волшебник волшебство колдунья магия фокус magic wand magician witch wizard","🎮":"видеоигры play playstation station игры компьютер компьютерные приставка video game controller entertainment","🕹":"джойстик видеоигра видеоигры игра игровая приставка игры компьютер компьютерная joystick game video videogame","🎰":"игровой автомат азарт деньги игры казино slot machine casino gamble gambling game slots","🎲":"игральная кость игра кости кубик кубики game die dice entertainment","🧩":"пазл головоломка досуг игра кусочек пазла мозаика собирать puzzle piece clue interlocking jigsaw","🧸":"плюшевый мишка игрушка медвежонок мягкая плюшевая teddy bear plaything plush stuffed toy","🪅":"пиньята вечеринка мексиканский праздник праздновать сладости pi ata candy celebrate celebration cinco de festive mayo party pinada pinata","🪩":"диско-шар блестеть вечеринка диско дискотека зеркальный шар танцы mirror ball dance disco glitter party","🪆":"матрешка игрушка кукла матрешки россия русская nesting dolls babooshka baboushka babushka doll matryoshka russia","♠":"пики игра карты масть spade suit card game","♥":"червы игра карты масть heart suit card emotion game hearts","♦":"бубны игра карты масть diamond suit card game","♣":"трефы игра карты масть club suit card clubs game","♟":"пешка игра фигура шахматная шахматы chess pawn dupe expendable","🃏":"джокер игра карты joker card game wildcard","🀄":"маджонг игра игральные китай кости кубики mahjong red dragon game","🎴":"цветочная карта игра карты ханафуда цветы япония flower playing cards card game japanese","🎭":"маски драма искусство комедия постановка премьера театр шекспир performing arts actor actress art entertainment mask theater theatre thespian","🖼":"картина раме рамке пейзаж природа рама рамка framed picture art frame museum painting","🎨":"палитра красками краски рисовать творить художник artist palette art artsy arty colorful creative entertainment museum painter painting","🧵":"катушка ниток вышивка иголка нитки нить рукоделие шить шитье штопать thread needle sewing spool string","🪡":"швейная игла вышивка иголка стежки швы шитье sewing needle embroidery sew stitches sutures tailoring thread","🧶":"клубок вязание ниток нитки пряжа yarn ball crochet knit","🪢":"узел веревка канат шнур шнурок knot cord rope tangled tie twine twist","👓":"очки аксессуар глаза защита зрение одежда glasses clothing eye eyeglasses eyewear","🕶":"солнечные очки аксессуар защита солнце солнцезащитные темные sunglasses dark eye eyewear glasses","🥽":"защитные очки защита лыжная маска нырять плавание сварка goggles dive eye protection scuba swimming welding","🥼":"лабораторный халат белый лаборатория форма lab coat clothes doctor dr experiment jacket scientist white","🦺":"спасательный жилет защитный спасать экстренный safety vest emergency","👔":"галстук одежда офис работа рубашка серьезный сотрудник necktie clothing employed serious shirt tie","👕":"футболка короткие одежда рубашка рукава t-shirt blue casual clothes clothing collar dressed shirt shopping tshirt weekend","👖":"джинсы брюки штаны jeans blue casual clothes clothing denim dressed pants shopping trousers weekend","🧣":"шарф обмотать повязать шея scarf bundle cold neck up","🧤":"перчатки аксессуар одежда руки gloves hand","🧥":"пальто верхняя куртка одежда пиджак coat brr bundle cold jacket up","🧦":"носки ноги чулки socks stocking","👗":"платье женская одежда женское dress clothes clothing dressed fancy shopping","👘":"кимоно одежда традиции япония kimono clothing comfortable","🥻":"сари женская индия одежда платье традиционная sari clothing dress","🩱":"цельный купальник закрытый одежда плавать слитный спорт one-piece swimsuit bathing suit","🩲":"плавки купальник одежда плавать трусы шорты briefs bathing one-piece suit swimsuit underwear","🩳":"шорты белье купальник одежда плавать плавки спорт трусы shorts bathing pants suit swimsuit underwear","👙":"бикини бассейн купальник море отпуск плавать bikini bathing beach clothing pool suit swim","👚":"блузка женщина одежда платье розовая футболка woman clothes blouse clothing collar dress dressed lady shirt shopping","🪭":"веер жара жарко застенчивый опахало охлаждение танец folding hand fan clack clap cool cooling dance flirt flutter hot shy","👛":"кошелек аксессуар деньги мелочь сумка сумочка purse clothes clothing coin dress fancy handbag shopping","👜":"сумка женская сумочка handbag bag clothes clothing dress lady purse shopping","👝":"клатч аксессуар багаж вещи женское мешок саквояж сумочка театр чедоман clutch bag clothes clothing dress handbag pouch purse","🛍":"пакеты магазины новое обновки покупки шопинг shopping bags bag hotel","🎒":"ранец портфель рюкзак школа backpack backpacking bag bookbag education rucksack satchel school","🩴":"вьетнамки обувь пляж сланцы шлепанцы шлепки thong sandal beach flip flop sandals shoe thongs ri","👞":"ботинок ботинки мужская мужчина обувь туфли туфля man shoe brown clothes clothing feet foot kick shoes shopping","👟":"кроссовки бег кеды обувь побежать пробежка спорт running shoe athletic clothes clothing fast kick shoes shopping sneaker tennis","🥾":"треккинговый ботинок ботинки идти кемпинг палатка поход рюкзак треккинговая обувь треккинговые туристическая туристические hiking boot backpacking brown camping outdoors shoe","🥿":"балетки без каблука обувь на плоской подошве слиперы туфли удобно flat shoe ballet comfy flats slip-on slipper","👠":"туфли на каблуке женщина каблук обувь стиль high-heeled shoe clothes clothing dress fashion heel heels shoes shopping stiletto woman","👡":"босоножки женская женщина каблук лето обувь сабо сандалии woman sandal clothing shoe","🩰":"пуанты балерина балет обувь танец туфли ballet shoes dance","👢":"сапог высокая женская каблук обувь одежда сапоги холодно шоппинг woman boot clothes clothing dress shoe shoes shopping","🪮":"афро-гребень афро волосы гребень расческа hair pick afro comb groom","👑":"корона голова королева король монарх царица царство царь crown clothing family king medieval queen royal royalty win","👒":"шляпка женская женщина летняя одежда шляпа woman hat clothes clothing garden hats party","🎩":"цилиндр магия мужчина одежда фокус черная черный шляпа top hat clothes clothing fancy formal magic tophat","🎓":"шапочка выпускника бонет выпускник выпускной конфедератка graduation cap celebration clothing education hat scholar","🧢":"бейсболка головной убор кепка шапка billed cap baseball bent dad hat","🪖":"армейская каска армия воин солдат military helmet army soldier war warrior","⛑":"каска белым крестом головной убор крест лицо спасатель шлем rescue worker helmet aid cross face hat","📿":"четки бусы колье молитва ожерелье религия prayer beads clothing necklace religion","💄":"помада губы косметика макияж lipstick cosmetics date makeup","💍":"кольцо бриллиант драгоценный камень обручальное помолвка свадьба ring diamond engaged engagement married romance shiny sparkling wedding","💎":"драгоценный камень алмаз драгоценность цена gem stone diamond engagement jewel money romance wedding","🔇":"без звука громкость звук молчание тихо тишина muted speaker mute quiet silent sound","🔈":"низкая громкость динамик звук уровень speaker low volume soft sound","🔉":"средняя громкость громкоговоритель динамик звук рупор спикер уровень speaker medium volume sound","🔊":"высокая громкость динамик звук уровень speaker high volume loud music sound","📢":"громкоговоритель внимание звук обращение объявление рупор loudspeaker address communication loud public sound","📣":"мегафон громкоговоритель звук обращение объявление речь рупор megaphone cheering sound","📯":"почтовый рожок почта труба postal horn post","🔔":"колокольчик будильник звонок колокол bell break church sound","🔕":"перечеркнутый колокольчик будильник отключен колокол отключить перечеркнуть тихо тишина bell with slash forbidden mute no not prohibited quiet silent sound","🎼":"нотный стан ключ музыка ноты скрипичный musical score music note","🎵":"нота звук мелодия музыка musical note music sound","🎶":"ноты звуки мелодия музыка musical notes music note sound","🎙":"студийный микрофон вокал звук музыка пение studio microphone mic music","🎚":"слайдер звук музыка уровень level slider music","🎛":"регуляторы звук музыка регулятор ручка уровень control knobs music","🎤":"микрофон звук караоке пение microphone karaoke mic music sing sound","🎧":"наушники вкладыши гарнитура затычки звук капли музыка headphone earbud sound","📻":"радио передача приемник сигнал radio entertainment tbt video","🎷":"саксофон инструмент музыка музыкальный saxophone instrument music sax","🪗":"аккордеон баян гармонь гармошка концертина accordion box concertina instrument music squeeze squeezebox","🎸":"гитара гитарист инструмент концерт музыка музыкальный рок guitar instrument music strat","🎹":"клавишные инструмент клавиши музыка музыкальный пианино рояль синтезатор musical keyboard instrument music piano","🎺":"труба духовой инструмент музыка музыкальный trumpet instrument music","🎻":"скрипка инструмент музыка музыкальный смычковый смычок струнный струны violin instrument music","🪕":"банджо гитара играть инструмент музыка струнный banjo music stringed","🥁":"барабан инструмент музыка музыкальный палочки ударные drum drumsticks music","🪘":"конга барабан бит музыкальный инструмент ритм тамтам long drum beat conga instrument rhythm","🪇":"маракасы вечеринка инструмент музыка музыкальные инструменты перкуссия погремушки ритм танец танцы трясти ча-ча-ча maracas cha dance instrument music party percussion rattle shake shaker","🪈":"флейта группа деревянный духовой инструмент дудочка музыка оркестр продольная сопилка труба пикколо флейтист flute band fife flautist instrument marching music orchestra piccolo pipe recorder woodwind","🪉":"арфа ангелочек инструмент купидон любовь музыка оркестр harp cupid instrument love music orchestra","📱":"мобильный телефон мобильник смартфон сотовый mobile phone cell communication telephone","📲":"входящий вызов мобильник мобильный смартфон сотовый телефон mobile phone with arrow build call cell communication receive telephone","☎":"телефон аппарат линия трубка telephone phone","📞":"телефонная трубка телефон telephone receiver communication phone voip","📟":"пейджер сообщение строка экран pager communication","📠":"факс бумага сообщение fax machine communication","🔋":"батарейка аккумулятор батарея заряд уровень заряда battery","🪫":"низкий уровень заряда аккумулятор батарейка батарея заряд мало зарядки предмет разрядилась разряжено low battery drained electronic energy power","🔌":"вилка розетка штекер штепсель электричество electric plug electricity","💻":"ноутбук компьютер портативный laptop computer office pc personal","🖥":"компьютер монитор настольный экран desktop computer monitor","🖨":"принтер бумага печать printer computer","⌨":"клавиатура клавиши периферия keyboard computer","🖱":"мышка кнопки мышь периферия устройство computer mouse","🖲":"трекбол ввод компьютер шар trackball computer","💽":"диск cd компьютер компьютерный мини-диск оптический си ди си-ди сиди computer disk minidisk optical","💾":"дискета данные компьютер носитель floppy disk computer","💿":"cd диск компакт компакт-диск си ди си-ди сиди формат optical disk blu-ray computer dvd","📀":"dvd cd двд двд-диск дивиди диск формат blu-ray computer disk optical","🧮":"счеты вычисления подсчет abacus calculation calculator","🎥":"кинокамера видеокамера камера кино снять съемка фильм movie camera bollywood cinema film hollywood record","🎞":"кинопленка кадр кинематограф кино пленка фильм film frames cinema movie","📽":"кинопроектор видео кино показ проектор film projector cinema movie video","🎬":"хлопушка-нумератор дубль кино киносъемки нумератор хлопушкой снято сцена хлопушка clapper board action movie","📺":"телевизор тв телевидение television tv video","📷":"фотоаппарат съемка фото фотокамера camera photo selfie snap tbt trip video","📸":"фотоаппарат со вспышкой вспышка съемка фото фотокамера camera with flash video","📹":"видеокамера видео запись съемка video camera camcorder tbt","📼":"видеокассета видео кассета пленка videocassette old school tape vcr vhs video","🔍":"лупа наклоненная влево линза стекло увеличительное magnifying glass tilted left lab left-pointing science search tool","🔎":"лупа наклоненная вправо исследовать лаборатория стекло увеличительное magnifying glass tilted right contact lab right-pointing science search tool","🕯":"свеча воск огонь пламя свет свечка тепло candle light","💡":"лампочка идея лампа свет эврика электричество light bulb comic electric idea","🔦":"фонарик свет фонарь электричество flashlight electric light tool torch","🏮":"красный бумажный фонарик бумага китай китайский огонь свет фонарь red paper lantern bar light restaurant","🪔":"масляная лампа гореть дия масло свет diya lamp light oil","📔":"записная книжка блокнот дневник ежедневник записи красивая обложка notebook with decorative cover book decorated education school writing","📕":"закрытая книга закрыть захлопнутая захлопнуть книжка красивая обложка closed book education","📖":"открытая книга книжка открыть раскрытая раскрыть open book education fantasy knowledge library novels reading","📗":"зеленая книга библиотека книжка учеба учиться читать чтение green book education fantasy library reading","📘":"синяя книга голубая голубой книжка синий blue book education fantasy library reading","📙":"оранжевая книга книжка оранжевый orange book education fantasy library reading","📚":"книги библиотека книжки много книг сессия стопка учить читать экзамен books book education fantasy knowledge library novels reading school study","📓":"тетрадь блокнот записная книжка тетрадка notebook","📒":"блокнот блокнотик записная книжка тетрадка тетрадь ledger notebook","📃":"страница загибом документ загиб загнутая загнутый лист page with curl document paper","📜":"свиток бумага пергамент scroll paper","📄":"страница вертикально лист page facing up document paper","📰":"газета известия издание новости печать newspaper communication news paper","🗞":"свернутая газета бумага новости rolled-up newspaper news paper rolled","📑":"стикеры-закладки закладки клеящиеся наклейки отметки стикеры bookmark tabs mark marker","🔖":"закладка книга книжка отметка bookmark mark","🏷":"бирка сведения этикетка ярлык label tag","💰":"мешок денег банкноты деньги доллар доллары миллион много money bag bank bet billion cash cost dollar gold million moneybag paid paying pot rich win","🪙":"монета деньги доллар евро золото казна металл рубль серебро coin dollar euro gold metal money rich silver treasure","💴":"банкнота иены валюта деньги иена япония yen banknote bank bill currency money note","💵":"банкнота доллара австралия бакс валюта деньги доллар канада купюра сша dollar banknote bank bill currency money note","💶":"банкнота евро валюта деньги евросоюз euro banknote 100 bank bill currency money note rich","💷":"банкнота фунта англия банк валюта великобритания деньги стерлинг стерлингов фунт pound banknote bank bill billion cash currency money note pounds","💸":"банкнота крылышками бакс деньги доллар крылья летающая money with wings bank banknote bill billion cash dollar fly million note pay","💳":"кредитная карта банковская деньги заплатить карточка кредитка оплатить credit card bank cash charge money pay","🧾":"чек бухгалтерия доказательство квитанция счет фактура receipt accounting bookkeeping evidence invoice proof","💹":"биржа валюта деньги курс рост рынок тенденция chart increasing with yen bank currency graph growth market money rise trend upward","✉":"конверт письмо почта сообщение электронное envelope e-mail email letter","📧":"электронная почта конверт письмо сообщение электронное e-mail email letter mail","📨":"входящее письмо конверт получение почта сообщение электронное incoming envelope delivering e-mail email letter mail receive sent","📩":"исходящее письмо конверт отправка сообщение стрелка электронное envelope with arrow communication down e-mail email letter mail outgoing send sent","📤":"исходящие исходящая отправленная отправленные письма почта почтовый сообщения ящик outbox tray box email letter mail sent","📥":"входящие письма полученная полученные почта почтовый ящик inbox tray box email letter mail receive zero","📦":"посылка доставка коробка почта package box communication delivery parcel shipping","📫":"закрытый почтовый ящик поднятым флажком закрыт поднятый почта флаг closed mailbox with raised flag communication mail postbox","📪":"закрытый почтовый ящик опущенным флажком закрыт опущенный почта флаг closed mailbox with lowered flag mail postbox","📬":"открытый почтовый ящик поднятым флажком открыт флажкгом письмо поднятый почта флаг open mailbox with raised flag mail postbox","📭":"открытый почтовый ящик опущенным флажком опущенный открыт письмо почта флаг open mailbox with lowered flag mail postbox","📮":"почтовый ящик корреспонденция почта postbox mail mailbox","🗳":"избирательная урна бюллетень выборы голосовать избирательный ящик ballot box with","✏":"карандаш грифель простой рисовать pencil","✒":"перьевой наконечник кончик пера перо перьевая ручка письмо black nib pen","🖋":"перьевая ручка перо письмо чернила чернильная fountain pen","🖊":"шариковая ручка чернила шарик pen ballpoint","🖌":"кисточка кисть рисование щетина paintbrush painting","🖍":"восковой мелок выделение карандаш красный маркер мелки фломастер цвет crayon","📝":"заметка запись карандаш писать письмо memo communication media notes pencil","💼":"портфель бумаги дипломат документы офис работа briefcase office","📁":"папка бумаги документы файл file folder","📂":"открытая папка бумаги документы файл open file folder","🗂":"маркированные папки картотека карточки оглавление папка папочка разделители файлы card index dividers","📅":"календарь даты датой назначенная дата отрывной числа calendar date","📆":"отрывной календарь 17 число дата день листы tear-off calendar","🗒":"перекидной блокнот записная книга листы откидной пружина spiral notepad note pad","🗓":"перекидной календарь блокнот листы пружина расписание spiral calendar pad","📇":"органайзер визитка визитница картотека карточки указатели card index old rolodex school","📈":"диаграмма роста вверх рост скачок схема тенденция улучшение chart increasing data graph growth right trend up upward","📉":"диаграмма падения вниз падение схема тенденция ухудшение chart decreasing data down downward graph negative trend","📊":"гистограмма граф диаграмма столбчатая bar chart data graph","📋":"планшет зажимом бумага зажим лист список дел clipboard do list notes","📌":"канцелярская кнопка бумага pushpin collage pin","📍":"канцелярская кнопка шариком бумага место местоположение шарик round pushpin location map pin","📎":"скрепка бумаги канцелярия скрепить paperclip","🖇":"скрепки бумаги две канцелярия несколько скрепок присоединить linked paperclips link paperclip","📏":"линейка измерение прямая straight ruler angle edge math straightedge","📐":"линейка-треугольник измерение измерительный инструмент линейка треугольная треугольник чертежный набор triangular ruler angle math rule set slide triangle","✂":"ножницы инструмент резать scissors cut cutting paper tool","🗃":"картотека карточки коробка сведения учет файлы ящик card file box","🗄":"архивный шкаф архив данные кабинет сведения хранилище шкафчик file cabinet filing paper","🗑":"корзина для мусора бумаги мусор мусорка мусорная урна wastebasket can garbage trash waste","🔒":"закрытый замок затвор навесной locked closed lock private","🔓":"открытый замок закрыть затвор unlocked cracked lock open unlock","🔏":"закрытый замок перьевой ручкой защита конфиденциальность перо перьевая ручка наконечник locked with pen ink lock nib privacy","🔐":"закрытый замок ключом безопасность ключ locked with key bike closed lock secure","🔑":"ключ закрыть открыть пароль key keys lock major password unlock","🗝":"старинный ключ винтажный замок старый old key clue lock","🔨":"молоток забить инструмент молот ремонт удар hammer home improvement repairs tool","🪓":"топор дрова дровосек рубить рубка axe ax chop hatchet split wood","⛏":"кирка добыча инструмент шахта pick hammer mining tool","⚒":"кирка молот инструмент молоток серп строитель строить шахта hammer and pick tool","🛠":"молот гаечный ключ инструмент молоток hammer and wrench spanner tool","🗡":"кинжал клинок лезвие нож ножик оружие острый холодное dagger knife weapon","⚔":"скрещенные мечи битва два меча крестоносец крестоносцы оружие рыцарь скрещение мечей холодное crossed swords weapon","💣":"бомба взрыв ложись опасность фитиль черная bomb boom comic dangerous explosion hot","🪃":"бумеранг абориген австралия возврат оружие последствия boomerang rebound repercussion weapon","🏹":"лук со стрелой знак зодиака оружие стрелец стрелы bow and arrow archer archery sagittarius tool weapon zodiac","🛡":"щит безопасность доспехи защита снаряжение shield weapon","🪚":"пила по дереву древесина инструмент пилить плотник резать столяр carpentry saw carpenter cut lumber tool trim","🔧":"гаечный ключ инструмент ремонт wrench home improvement spanner tool","🪛":"отвертка винт инструмент мастер умелец screwdriver flathead handy screw tool","🔩":"болт гайка винт закрутить инструмент ремонт шуруп nut and bolt home improvement tool","⚙":"шестеренка деталь зубчатое колесо шестерня gear cog cogwheel tool","🗜":"компрессор зажим тиски clamp compress tool vice","⚖":"весы взвешивание знак зодиак правосудие справедливость balance scale justice libra scales tool weight zodiac","🦯":"белая трость инвалид палка слепой ходьба white cane accessibility blind probing","🔗":"гиперссылка звенья связывание связь ссылка цепочка цепь link links","⛓‍💥":"разорванная цепь оковы разорвать разрыв свобода broken chain break breaking cuffs freedom","⛓":"цепь звено звенья цепи цепочка chains chain","🪝":"крюк захват крючок подвес уловка hook catch crook curve ensnare point selling","🧰":"ящик для инструментов инструменты комплект коробка красный мастер механик набор ремонт слесарь toolbox box chest mechanic red tool","🧲":"магнит магнетический магнит-подкова отрицательный подкова положительный притяжение magnet attraction horseshoe magnetic negative positive shape","🪜":"лестница залезать лезть перекладина стремянка ступень ladder climb rung step","🪏":"лопата ковш копать совок яма shovel bury dig garden hole plant scoop snow spade","⚗":"перегонный сосуд аппарат куб химия alembic chemistry tool","🧪":"пробирка лаборатория наука опыты химик химия эксперимент эксперименты test tube chemist chemistry experiment lab science","🧫":"чашка петри анализы бактерии биолог биология лаборатория эксперимент petri dish bacteria biologist biology culture lab","🧬":"днк биология генетика гены жизнь молекула эволюция dna biologist evolution gene genetics life","🔬":"микроскоп биология изучать инструмент исследовать клетки лупа микробы прибор увеличение microscope experiment lab science tool","🔭":"телескоп астрономия звезды инструмент линза лупа планетарий планеты прибор увеличительное стекло telescope contact extraterrestrial science tool","📡":"спутниковая антенна спутник satellite antenna aliens contact dish science","💉":"шприц инъекция кровь лечение медицина препарат прививка укол syringe doctor flu medicine needle shot sick tool vaccination","🩸":"капля крови донор красный кровить кровоточить кровь медицина менструация drop of blood bleed donation injury medicine menstruation","💊":"пилюля барбитураты капсула лекарство лечиться медикаменты препарат рецепт таблетка pill doctor drugs medicated medicine pills sick vitamin","🩹":"пластырь заклеить лейкопластырь лечить медицина порез рана adhesive bandage","🩼":"костыль инвалид инвалидность костыли опора перелом повреждение помощь предмет травма трость crutch aid cane disability help hurt injured mobility stick","🩺":"стетоскоп врач доктор инструмент медицина сердце слушать stethoscope doctor heart medicine","🩻":"рентген врач кости медицина предмет просветить скелет снимок x-ray bones doctor medical skeleton skull xray","🚪":"дверь вход входная выход двери дверная ручка закрытая проем door back closet front","🛗":"лифт грузоподъемник подъемник специальные возможности elevator accessibility hoist lift","🪞":"зеркало краситься макияж отражатель отражение mirror makeup reflection reflector speculum","🪟":"окно вид окошко прозрачный рама свежий воздух стекло window air frame fresh opening transparent view","🛏":"кровать гостиница дом застеленная постель отель пустая спальня спать bed hotel sleep","🛋":"диван торшер вечер лампа лень отдых дома уют couch and lamp hotel","🪑":"стул мебель сидение сидеть сидя стулья chair seat sit","🚽":"унитаз бачок смывать туалет toilet bathroom","🪠":"вантуз засор прочистка сантехник слив сортир туалет plunger cup force plumber poop suction toilet","🚿":"душ вода мыться принимать струя воды чистота shower water","🛁":"ванна ванная вода купаться мыться принимать ванную чистота bathtub bath","🪤":"мышеловка заманить западня капкан ловушка приманка сыр mouse trap bait cheese lure mousetrap snare","🪒":"бритва бритье бриться лезвие лицо опасная острый razor sharp shave","🧴":"бутылка лосьона бальзам гель для душа кондиционер крем лосьон увлажнение флакон шампунь lotion bottle moisturizer shampoo sunscreen","🧷":"английская булавка приколоть пристегнуть safety pin diaper punk rock","🧹":"метла ведьма веник подметать уборка broom cleaning sweeping witch","🧺":"корзина грибы бельем пикник собирать стирка ягоды basket farming laundry picnic","🧻":"рулон бумаги бумага бумажное полотенце бумажные полотенца салфетка туалетная roll of paper toilet towels","🪣":"ведро бадья ведерко кадка bucket cask pail vat","🧼":"мыло ванна душ купание кусок мыла мало мыльная пена мыть пузыри soap bar bathing clean cleaning lather soapdish","🫧":"пузыри вода мыло мыльные мыть предмет пузырь чистый bubbles bubble burp clean floating pearl soap underwater","🪥":"зубная щетка ванная гигиена зубы умываться чистить чистый toothbrush bathroom brush clean dental hygiene teeth toiletry","🧽":"губка ванна впитывать мочалка мыло мыть мытье посуды пена посуда спонж чистить sponge absorbing cleaning porous soak","🧯":"огнетушитель красный огонь пожар тушение пожара тушить fire extinguisher extinguish quench","🛒":"тележка для покупок магазин покупки shopping cart trolley","🚬":"сигарета дым курение курить курю можно окурок пепел разрешено cigarette smoking","⚰":"гроб гибель погребение похороны смерть coffin dead death vampire","🪦":"надгробие гробница кладбище могила могильный камень headstone cemetery dead grave graveyard memorial rip tomb tombstone","⚱":"урна прахом останки пепел похороны прах смерть funeral urn ashes death","🧿":"назар бонджук амулет око сглаз синий талисман nazar amulet bead blue charm evil-eye talisman","🪬":"хамса амулет защита защитить кисть ладонь предмет рука марии мириам фатимы hamsa amulet fatima fortune guide hand mary miriam palm protect protection","🗿":"истукан голова лицо остров пасхи статуя moai face moyai statue stoneface travel","🪧":"плакат высказаться демонстрация заявление знак пикет протест placard card demonstration notice picket plaque protest sign","🪪":"удостоверение личности водительские права документ паспорт identification card credentials document id license security","🏧":"значок банкомата банкомат выдача деньги наличные терминал atm sign automated bank cash money teller","🚮":"бросайте мусор урну мусорить мусорка урна litter in bin sign litterbin","🚰":"питьевая вода кран пить potable water drinking","♿":"значок для инвалидов возможности доступ инвалид инвалидная коляска wheelchair symbol access handicap","🚹":"мужской туалет кабинки мужчины уборная men room bathroom lavatory man restroom toilet wc","🚺":"женский туалет женщины кабинки уборная women room bathroom lavatory restroom toilet wc woman","🚻":"уборная wc туалет restroom bathroom lavatory toilet","🚼":"пеленальная дети младенцы пеленки стол baby symbol changing","🚾":"туалет wc общественный уборная унитаз water closet bathroom lavatory restroom toilet","🛂":"паспортный контроль печать пограничник проверка passport control","🛃":"таможня багаж граница проверка таможенник customs packing","🛄":"получение багажа багаж лента прибытие чемодан baggage claim arrived bags case checked journey packing plane ready travel trip","🛅":"забытый багаж замок кабинка камера ключ хранения чемодан left luggage baggage case locker","⚠":"значок предупреждения восклицательный желтый знак оповещение осторожно предупреждение треугольный warning caution","🚸":"знак дети движение дорога переход пешеход пешеходы children crossing child pedestrian traffic","⛔":"въезд запрещен вход запрещено кирпич красный круг нет стоп no entry do fail forbidden not pass prohibited traffic","🚫":"запрещено вход входа зачеркнутый красный круг нельзя нет prohibited entry forbidden no not smoke","🚳":"перечеркнутый велосипед въезд въезда запрещено нельзя нет no bicycles bicycle bike forbidden not prohibited","🚭":"курение запрещено запрет красный круг курить не нельзя черный no smoking forbidden not prohibited smoke","🚯":"не мусорьте запрещено красный мусор мусорить нельзя перечеркнутый черный no littering forbidden litter not prohibited","🚱":"непитьевая вода вредно запрещено кран круг не нельзя опасно перечеркнутый пить non-potable water dry non-drinking prohibited","🚷":"переход запрещен входить круг не опасно пешеход пешеходный no pedestrians forbidden not pedestrian prohibited","📵":"мобильные телефоны запрещены запрещен запрещено звонить звонки мобильный перечеркнутый смартфон сотовый телефон no mobile phones cell forbidden not phone prohibited telephone","🔞":"для людей старше восемнадцати лет 18 запрет нельзя несовершеннолетним запрещено no one under eighteen age forbidden not prohibited restriction underage","☢":"радиация знак опасность радиоактивность символ угроза radioactive sign","☣":"биологическая угроза знак опасность химическая biohazard sign","⬆":"стрелка вверх направление север up arrow cardinal direction north","↗":"стрелка вправо-вверх направление северо-восток up-right arrow direction intercardinal northeast","➡":"стрелка вправо восток направление right arrow cardinal direction east","↘":"стрелка вправо-вниз направление юго-восток down-right arrow direction intercardinal southeast","⬇":"стрелка вниз направление юг down arrow cardinal direction south","↙":"стрелка влево-вниз направление юго-запад down-left arrow direction intercardinal southwest","⬅":"стрелка влево запад направление left arrow cardinal direction west","↖":"стрелка влево-вверх направление северо-запад up-left arrow direction intercardinal northwest","↕":"стрелка вверх-вниз вверх вниз двойная up-down arrow","↔":"стрелка влево-вправо влево вправо двойная left-right arrow","↩":"стрелка вправо поворотом влево изгиб изогнутая поворот right arrow curving left","↪":"стрелка влево поворотом вправо изгиб изогнутая поворот left arrow curving right","⤴":"стрелка вправо поворотом вверх изгиб изогнутая поворот right arrow curving up","⤵":"стрелка вправо поворотом вниз изгиб изогнутая поворот right arrow curving down","🔃":"по часовой стрелке вертикальные зависание перезагрузка стрелки clockwise vertical arrows arrow refresh reload","🔄":"против часовой стрелки горизонтальные кнопка обновить обновление counterclockwise arrows button again anticlockwise arrow deja refresh rewindershins vu","🔙":"стрелка назад вернуться back arrow","🔚":"стрелка конец end arrow","🔛":"стрелка активно включено on arrow mark","🔜":"стрелка скоро вправо далее продолжение следует soon arrow brb omw","🔝":"стрелка верх вверх наверх топ top arrow homie up","🛐":"религия место молитва молиться поклонение религиозное святое святыня place of worship pray religion","⚛":"символ атома атеизм атеист атом атомный частица atom symbol atheist","🕉":"ом аум мантра религия om hindu religion","✡":"звезда давида вера давид еврей иудаизм религия шестиконечная star of david jew jewish judaism religion","☸":"колесо дхармы буддизм буддийский дхарма религия wheel of dharma buddhist religion","☯":"инь-ян вера инь инь-янь религия символ тао таоизм ян янь yin yang difficult lives religion tao taoist total yinyang","✝":"латинский крест религия христианский христианство latin cross christ christian religion","☦":"православный крест вера православие религия христианин христианство orthodox cross christian religion","☪":"звезда полумесяц вера ислам мечеть мусульманин мусульманство рамадан религия символ star and crescent islam muslim ramadan religion","☮":"пацифик значок мир пацифизм символ peace symbol healing peaceful","🕎":"менора подсвечник религия свечи ханука ханукия menorah candelabrum candlestick hanukkah jewish judaism religion","🔯":"шестиконечная звезда давида dotted six-pointed star fortune jewish judaism","🪯":"кханда дег религия сикх сикхизм символ тех фатех хальса khanda deg fateh khalsa religion sikh sikhism tegh","♈":"знак зодиака овен баран гороскоп зодиак овца aries horoscope ram zodiac","♉":"знак зодиака телец бык гороскоп зодиак теленок taurus bull horoscope ox zodiac","♊":"знак зодиака близнецы гороскоп зодиак gemini horoscope twins zodiac","♋":"знак зодиака рак гороскоп зодиак cancer crab horoscope zodiac","♌":"знак зодиака лев гороскоп зодиак leo horoscope lion zodiac","♍":"знак зодиака дева гороскоп зодиак virgo horoscope zodiac","♎":"знак зодиака весы гороскоп зодиак libra balance horoscope justice scales zodiac","♏":"знак зодиака скорпион гороскоп зодиак scorpio horoscope scorpion scorpius zodiac","♐":"знак зодиака стрелец гороскоп зодиак sagittarius archer horoscope zodiac","♑":"знак зодиака козерог гороскоп зодиак capricorn goat horoscope zodiac","♒":"знак зодиака водолей гороскоп зодиак aquarius bearer horoscope water zodiac","♓":"знак зодиака рыбы гороскоп зодиак рыба pisces fish horoscope zodiac","⛎":"знак зодиака змееносец гороскоп зодиак ophiuchus bearer serpent snake zodiac","🔀":"случайный выбор перемешивание пересечение скрещенные случайно стрелки shuffle tracks button arrow crossed","🔁":"повтор горизонтальные замкнутые по повторить стрелке стрелки часовой repeat button arrow clockwise","🔂":"повтор одной песни две стрелки кнопка одна песня repeat single button arrow clockwise once","▶":"воспроизведение вперед вправо геометрия кнопка перемотать перемотка проигрывание треугольник фигура play button arrow right triangle","⏩":"перемотка вперед вправо два кнопка перемотать треугольника fast-forward button arrow double fast forward","⏭":"следующий трек вперед вправо далее два треугольника дорожка кнопка композиция перемотать перемотка песня следующая черта next track button arrow scene triangle","⏯":"воспроизведение пауза воспроизвести воспроизвещение две черты играть кнопка приостановить треугольник play or pause button arrow right triangle","◀":"реверс влево кнопка назад обратный стрелка reverse button arrow left triangle","⏪":"перемотка назад влево два треугольника кнопка перемотать стрелка fast reverse button arrow double rewind","⏮":"предыдущий трек влево два треугольника перемотать назад перемотка предыдущая дорожка композиция last track button arrow previous scene triangle","🔼":"треугольник острием вверх кнопка пирамида upwards button arrow red up","⏫":"два треугольника острием вверх кнопка стрелка стрелки fast up button arrow double","🔽":"треугольник острием вниз кнопка downwards button arrow down red","⏬":"два треугольника острием вниз кнопка стрелка стрелки fast down button arrow double","⏸":"пауза две черты кнопка паузы остановить стоп pause button bar double vertical","⏹":"стоп кнопка остановить остановка stop button square","⏺":"запись записать кнопка круг начать record button circle","⏏":"выброс кнопка eject button","🎦":"видеозапись видео видеокамера дубль запись камера кино кинокамера телевидение фильм cinema camera film movie","🔅":"низкая яркость низкий солнышко темно уровень dim button brightness low","🔆":"высокая яркость высокий светло уровень bright button brightness light","📶":"сигнал сети антенна подключение связь телефон уровень сигнала antenna bars bar cell communication mobile phone signal telephone","🛜":"беспроводная сеть wi-fi wifi wlan вайфай интернет компьютер подключение роутер смартфон точка доступа широкополосный wireless broadband computer connectivity hotspot internet network router smartphone","📳":"режим вибрации вибрация мобильник мобильный смартфон телефон вибрирует vibration mode cell communication mobile phone telephone","📴":"выключенный телефон выключен выключено мобильник мобильный смартфон сотовый mobile phone off cell telephone","♀":"женский символ венера женщина female sign woman","♂":"мужской символ марс мужчина male sign man","⚧":"трансгендер символ трансгендерная трансгендерное трансгендерные трансгендерный transgender symbol","✖":"знак умножения отмена отмены умножение multiply cancel multiplication sign","➕":"знак сложения добавить плюс прибавить сложение plus","➖":"знак вычитания вычесть вычитание минус отнять minus heavy math sign","➗":"знак деления деление делить разделить divide division heavy math sign","🟰":"жирный знак равенства математика равенство равно heavy equals sign answer equal equality math","♾":"бесконечность знак бесконечности навсегда постоянно infinity forever unbounded universal","‼":"два восклицательных знака восклицание восклицательные восклицательный знак знаки пунктуация double exclamation mark bangbang punctuation","⁉":"восклицательный вопросительный знаки вопрос восклицание знак пунктуация exclamation question mark interrobang punctuation","❓":"красный вопросительный знак вопрос вопроса пунктуация red question mark punctuation","❔":"белый вопросительный знак вопрос пунктуация white question mark outlined punctuation","❕":"белый восклицательный знак восклицание восклицать пунктуация white exclamation mark outlined punctuation","❗":"красный восклицательный знак восклицание пунктуация red exclamation mark punctuation","〰":"волнистая линия волна знак пунктуация wavy dash punctuation","💱":"обмен валюты банк валюта деньги касса курс currency exchange bank money","💲":"жирный знак доллара валюта деньги доллар сша heavy dollar sign billion cash charge currency million money pay","⚕":"медицинский символ аптека асклепий врачевание жезл лекарства лекарство лечение медицина посох со змеей medical symbol aesculapius medicine staff","♻":"утилизация мусор мусорный отходы переработка отходов recycling symbol recycle","⚜":"геральдическая лилия геральдика герб фигура флер-де-лис fleur-de-lis knights","🔱":"трезубец вилы посейдон символ эмблема trident emblem anchor poseidon ship tool","📛":"бейдж бейджик значок именем имя огонь предупреждение name badge","🔰":"символ новичка новичок япония японский japanese symbol for beginner chevron green leaf tool yellow","⭕":"красное кольцо большое большой круг красный синий hollow red circle heavy large","✅":"белая галочка готово знак проверено проверка сделано check mark button checked checkmark complete completed done fixed tick","☑":"галочка квадрате бюллетень галочкой выполнено голосование готово проверено проверка сделано check box with ballot checked done off tick","✔":"галочка галка готово знак значок отметка отмечено помечено черная черный check mark checked checkmark done heavy tick","❌":"крестик закрытие значок отмена умножение cross mark cancel multiplication multiply","❎":"кнопка крестик закрытие квадрат умножение cross mark button multiplication multiply square","➰":"петля значок петелька curly loop curl","➿":"двойная петля две петелька петельки петли double curly loop curl","〽":"вступает другой голос знак певцы япония part alternation mark","✳":"восьмиконечная звездочка звезда eight-spoked asterisk","✴":"восьмиконечная звезда eight-pointed star","❇":"искра мерцание sparkle","©":"значок защиты авторского права автор авторские авторское защита копирайт право символ copyright","®":"зарегистрированный товарный знак зарегистрирован зарегистрировано registered","™":"товарный знак тм товарная марка торговая торговый trade mark tm trademark","🫟":"клякса краска пролить пятно холи splatter drip holi ink liquid mess paint spill stain","#⃣":"клавиши keycap","*⃣":"клавиши keycap","0⃣":"клавиши keycap","1⃣":"клавиши keycap","2⃣":"клавиши keycap","3⃣":"клавиши keycap","4⃣":"клавиши keycap","5⃣":"клавиши keycap","6⃣":"клавиши keycap","7⃣":"клавиши keycap","8⃣":"клавиши keycap","9⃣":"клавиши keycap","🔟":"клавиши 10 keycap","🔠":"ввод прописными алфавит верхний регистр латиница латинские буквы прописные символы input latin uppercase abcd letters","🔡":"ввод строчными алфавит буквы кнопка латинские нижний регистр строчные символы input latin lowercase abcd letters","🔢":"ввод цифр цифры input numbers 1234","🔣":"ввод символов символы input symbols","🔤":"ввод латиницей алфавит буквы латиница латинские латинский символы input latin letters abc alphabet","🅰":"ii группа крови буква вторая кровь button blood type","🆎":"iv группа крови буквы ав ab кровь четвертая button blood type","🅱":"iii группа крови буква кровь третья button blood type","🆑":"кнопка очистить cl значок понятно стереть ясно button","🆒":"значок круто здорово кнопка cool button","🆓":"значок бесплатно кнопка free button","ℹ":"значок информации инфо информация подробнее сведения справка справки information","🆔":"значок идентификация идентификатор кнопка id button identity","Ⓜ":"буква кружке кружок circled circle","🆕":"значок новинка новое новый new button","🆖":"значок не годится нехорошо плохо ng button","🅾":"группа крови буква кнопка кровь первая button blood type","🆗":"значок ок окей кей хорошо ok button okay","🅿":"парковка автостоянка машины парковаться припарковаться стоянка button parking","🆘":"значок sos помогите помощь сос спасите button help","🆙":"значок обновление вверх up button mark","🆚":"значок против кнопка сравнение vs button versus","🈁":"значок здесь кнопка япония japanese here button katakana","🈂":"цена услуги значок кнопка стоимость япония japanese service charge button katakana","🈷":"месяц значок иероглиф кнопка сумма япония japanese monthly amount button ideograph","🈶":"платно значок иероглиф кнопка наличие япония japanese not free of charge button ideograph","🈯":"забронировано занято значок кнопка япония japanese reserved button ideograph","🉐":"удачная сделка выгода значок кнопка япония japanese bargain button ideograph","🈹":"скидка деление значок иероглиф кнопка япония japanese discount button ideograph","🈚":"бесплатно значок иероглиф кнопка нет наличии отсутствие япония japanese free of charge button ideograph","🈲":"запрет воспрещен вход запрещен запрещено значок иероглиф кнопка нельзя япония japanese prohibited button ideograph","🉑":"допустимость допустимо значок иероглиф китай кнопка можно приемлемо принятие japanese acceptable button ideograph","🈸":"заявка запрос значок иероглиф китай кнопка подача япония japanese application button ideograph","🈴":"проходной балл значок иероглиф китай кнопка пропускной согласие япония japanese passing grade button ideograph","🈳":"есть места доступно доступность значок иероглиф китай кнопка пусто пустота свободно japanese vacancy button ideograph","㊗":"поздравление значок кнопка праздник япония japanese congratulations button ideograph","㊙":"секрет значок кнопка тайна япония japanese secret button ideograph","🈺":"открыто значок иероглиф китай кнопка работа работает япония japanese open for business button ideograph","🈵":"нет мест занято заполнено значок иероглиф кнопка япония japanese no vacancy button ideograph","🔴":"красный круг геометрия фигура шар red circle geometric","🟠":"оранжевый круг геометрия фигура шар orange circle","🟡":"желтый круг геометрия фигура шар yellow circle","🟢":"зеленый круг геометрия фигура шар green circle","🔵":"голубой круг геометрия синий фигура шар blue circle geometric","🟣":"фиолетовый круг геометрия фигура шар purple circle","🟤":"коричневый круг геометрия фигура шар brown circle","⚫":"черный круг геометрия фигура шар black circle geometric","⚪":"белый круг геометрия фигура шар white circle geometric","🟥":"красный квадрат геометрия прямоугольник фигура red square card penalty","🟧":"оранжевый квадрат геометрия прямоугольник фигура orange square","🟨":"желтый квадрат геометрия желтая карточка предупреждение фигура yellow square card penalty","🟩":"зеленый квадрат геометрия фигура green square","🟦":"синий квадрат геометрия голубой фигура blue square","🟪":"фиолетовый квадрат геометрия прямоугольник фигура purple square","🟫":"коричневый квадрат геометрия прямоугольник фигура brown square","⬛":"большой черный квадрат геометрия фигура black large square geometric","⬜":"большой белый квадрат геометрия фигура white large square geometric","◼":"черный квадрат геометрия средний фигура black medium square geometric","◻":"белый квадрат геометрия фигура white medium square geometric","◾":"небольшой черный квадрат геометрия фигура black medium-small square geometric","◽":"небольшой белый квадрат геометрия фигура white medium-small square geometric","▪":"черный квадратик геометрия квадрат маленький фигура black small square geometric","▫":"белый квадратик геометрия квадрат маленький фигура white small square geometric","🔶":"большой оранжевый ромб геометрия фигура large orange diamond geometric","🔷":"большой голубой ромб геометрия синий фигура large blue diamond geometric","🔸":"небольшой оранжевый ромб геометрия маленький фигура small orange diamond geometric","🔹":"небольшой голубой ромб геометрия фигура small blue diamond geometric","🔺":"треугольник вершиной вверх геометрия красный пирамида фигура red triangle pointed up geometric","🔻":"треугольник вершиной вниз геометрия красный пирамида фигура red triangle pointed down geometric","💠":"ромб точкой алмаз бриллиант геометрия драгоценный камень украшение фигура цветок diamond with dot comic geometric","🔘":"круглая кнопка серая radio button geometric","🔳":"кнопка белой рамкой белая квадрат white square button geometric outlined","🔲":"кнопка черной рамкой квадрат черная black square button geometric"} \ No newline at end of file diff --git a/assets/fonts/Inter.ttf b/assets/fonts/Inter.ttf new file mode 100644 index 0000000..047c92f Binary files /dev/null and b/assets/fonts/Inter.ttf differ diff --git a/assets/fonts/Outfit.ttf b/assets/fonts/Outfit.ttf new file mode 100644 index 0000000..466d624 Binary files /dev/null and b/assets/fonts/Outfit.ttf differ diff --git a/assets/fonts/Unbounded.ttf b/assets/fonts/Unbounded.ttf new file mode 100644 index 0000000..e3c064d Binary files /dev/null and b/assets/fonts/Unbounded.ttf differ diff --git a/assets/ikar.png b/assets/ikar.png new file mode 100644 index 0000000..c386a28 Binary files /dev/null and b/assets/ikar.png differ diff --git a/assets/ikar_icon.png b/assets/ikar_icon.png new file mode 100644 index 0000000..c27464b Binary files /dev/null and b/assets/ikar_icon.png differ diff --git a/assets/lottie/ic_call.json b/assets/lottie/ic_call.json new file mode 100644 index 0000000..d38198a --- /dev/null +++ b/assets/lottie/ic_call.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":30,"w":500,"h":500,"nm":"ic_call","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"ic_call","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"t":0,"s":[0],"i":{"x":[0.42],"y":[1]},"o":{"x":[0.58],"y":[0]}},{"t":4,"s":[-17],"i":{"x":[0.42],"y":[1]},"o":{"x":[0.58],"y":[0]}},{"t":9,"s":[14],"i":{"x":[0.42],"y":[1]},"o":{"x":[0.58],"y":[0]}},{"t":14,"s":[-10],"i":{"x":[0.42],"y":[1]},"o":{"x":[0.58],"y":[0]}},{"t":19,"s":[6],"i":{"x":[0.42],"y":[1]},"o":{"x":[0.58],"y":[0]}},{"t":24,"s":[-3],"i":{"x":[0.42],"y":[1]},"o":{"x":[0.58],"y":[0]}},{"t":30,"s":[0]}],"ix":10},"p":{"a":0,"k":[250.0,250.0,0],"ix":2,"l":2},"a":{"a":0,"k":[250.0,250.0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[13.393,-0.0],[71.528,71.528],[0.0,80.556],[-13.393,-0.0],[0.0,0.0],[-2.778,-10.417],[0.0,0.0],[5.714,-5.713],[0.0,0.0],[-24.306,-23.611],[-31.25,-16.667],[0.0,0.0],[-8.818,-1.389],[0.0,0.0],[0.0,-10.355],[0.0,0.0]],"o":[[-80.556,-0.0],[-71.528,-71.528],[0.0,-13.393],[0.0,0.0],[9.722,-0.0],[0.0,0.0],[1.438,9.972],[0.0,0.0],[18.056,30.556],[25.694,26.389],[0.0,0.0],[6.944,-7.639],[0.0,0.0],[10.417,2.778],[0.0,0.0],[0.0,13.393]],"v":[[414.063,437.5],[174.479,325.521],[62.5,85.937],[85.938,62.5],[158.854,62.5],[178.646,80.729],[192.671,146.167],[186.86,169.91],[134.896,222.396],[196.875,302.604],[282.292,367.708],[331.771,316.667],[357.292,307.812],[419.271,321.354],[437.5,343.75],[437.5,414.062]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[-17.361,-41.667],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0]],"o":[[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,27.083]],"v":[[119.271,193.75],[161.458,151.042],[161.458,151.042],[161.458,151.042],[149.479,93.75],[149.479,93.75],[149.479,93.75],[93.75,93.75],[93.75,93.75],[93.75,93.75]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0.0,0.0],[-31.25,-1.389],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0]],"o":[[28.472,13.194],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0]],"v":[[311.458,382.812],[406.25,406.25],[406.25,406.25],[406.25,406.25],[406.25,350.521],[406.25,350.521],[406.25,350.521],[352.604,339.583],[352.604,339.583],[352.604,339.583]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,27.083],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0]],"o":[[-17.361,-41.667],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0]],"v":[[119.271,193.75],[93.75,93.75],[93.75,93.75],[93.75,93.75],[149.479,93.75],[149.479,93.75],[149.479,93.75],[161.458,151.042],[161.458,151.042],[161.458,151.042]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[28.472,13.194],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0]],"o":[[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[-31.25,-1.389]],"v":[[311.458,382.812],[352.604,339.583],[352.604,339.583],[352.604,339.583],[406.25,350.521],[406.25,350.521],[406.25,350.521],[406.25,406.25],[406.25,406.25],[406.25,406.25]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":6,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":30,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lottie/ic_chat.json b/assets/lottie/ic_chat.json new file mode 100644 index 0000000..cd04262 --- /dev/null +++ b/assets/lottie/ic_chat.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":30,"w":600,"h":600,"nm":"ic_chat","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"ic_chat","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"ix":10,"k":[{"t":0,"s":[0],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":6,"s":[-22],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":14,"s":[9],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":20,"s":[-4],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":25,"s":[1.5],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":30,"s":[0]}]},"p":{"a":1,"ix":2,"l":2,"k":[{"t":0,"s":[300,300,0],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":6,"s":[284,300,0],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":14,"s":[306,300,0],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":20,"s":[298,300,0],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":30,"s":[300,300,0]}]},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,0.0],[-16.667,-0.0],[0.0,0.0],[0.0,-16.667],[0.0,0.0],[16.667,-0.0],[0.0,0.0]],"o":[[0.0,0.0],[0.0,-16.667],[0.0,0.0],[16.667,-0.0],[0.0,0.0],[0.0,16.667],[0.0,0.0],[0.0,0.0]],"v":[[41.667,458.333],[41.667,72.917],[72.917,41.667],[427.083,41.667],[458.333,72.917],[458.333,343.75],[427.083,375.0],[125.0,375.0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0]],"o":[[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,0.0]],"v":[[111.458,343.75],[427.083,343.75],[427.083,343.75],[427.083,343.75],[427.083,72.917],[427.083,72.917],[427.083,72.917],[72.917,72.917],[72.917,72.917],[72.917,72.917],[72.917,385.417]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0]],"o":[[0.0,0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[0.0,0.0]],"v":[[111.458,343.75],[72.917,385.417],[72.917,72.917],[72.917,72.917],[72.917,72.917],[427.083,72.917],[427.083,72.917],[427.083,72.917],[427.083,343.75],[427.083,343.75],[427.083,343.75]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":30,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lottie/ic_clock.json b/assets/lottie/ic_clock.json new file mode 100644 index 0000000..606ba87 --- /dev/null +++ b/assets/lottie/ic_clock.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":60,"w":500,"h":500,"nm":"system-regular-67-clock","ddd":0,"assets":[{"id":"comp_2","nm":"loop-clock","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[294.295,250.472,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[8.644,0],[0,-8.643],[0,0],[-2.919,-2.843],[0,0],[-4.006,0],[-3.056,3.056],[6.111,6.112]],"o":[[0,0],[0,-8.643],[-8.644,0],[0,0],[0,4.396],[0,0],[3.056,3.056],[4.005,0],[6.111,-6.111],[0,0]],"v":[[-28.62,-6.901],[-28.62,-88.127],[-44.271,-103.777],[-59.921,-88.127],[-59.921,-0.406],[-55.184,10.799],[33.205,99.193],[44.271,103.777],[55.338,99.194],[55.338,77.061]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.071,0.075,0.192,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-67-clock').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":60,"op":300,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[33.442,-33.442],[69.036,69.037],[0,47.294],[-33.442,33.442],[-45.341,0],[-34.518,-34.518],[0,-47.294]],"o":[[-69.034,69.036],[-33.442,-33.442],[0,-47.294],[34.518,-34.518],[45.342,0],[33.442,33.442],[0,47.294]],"v":[[125.197,125.205],[-125.202,125.205],[-177.062,0.006],[-125.202,-125.194],[-0.003,-176.971],[125.197,-125.194],[177.057,0.006]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[39.354,39.354],[81.239,-81.238],[0,-55.655],[-39.354,-39.354],[-53.356,0],[-40.62,40.62],[0,55.655]],"o":[[-81.241,-81.239],[-39.354,39.354],[0,55.655],[40.62,40.62],[53.355,0],[39.354,-39.354],[0,-55.655]],"v":[[147.33,-147.327],[-147.335,-147.327],[-208.362,0.006],[-147.335,147.338],[-0.002,208.267],[147.33,147.338],[208.357,0.006]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.071,0.075,0.192,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-67-clock').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":60,"op":300,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[294.295,250.472,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[8.644,0],[0,-8.643],[0,0],[-2.919,-2.843],[0,0],[-4.006,0],[-3.056,3.056],[6.111,6.112]],"o":[[0,0],[0,-8.643],[-8.644,0],[0,0],[0,4.396],[0,0],[3.056,3.056],[4.005,0],[6.111,-6.111],[0,0]],"v":[[-28.62,-6.901],[-28.62,-88.127],[-44.271,-103.777],[-59.921,-88.127],[-59.921,-0.406],[-55.184,10.799],[33.205,99.193],[44.271,103.777],[55.338,99.194],[55.338,77.061]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.071,0.075,0.192,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-67-clock').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[33.442,-33.442],[69.036,69.037],[0,47.294],[-33.442,33.442],[-45.341,0],[-34.518,-34.518],[0,-47.294]],"o":[[-69.034,69.036],[-33.442,-33.442],[0,-47.294],[34.518,-34.518],[45.342,0],[33.442,33.442],[0,47.294]],"v":[[125.197,125.205],[-125.202,125.205],[-177.062,0.006],[-125.202,-125.194],[-0.003,-176.971],[125.197,-125.194],[177.057,0.006]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[39.354,39.354],[81.239,-81.238],[0,-55.655],[-39.354,-39.354],[-53.356,0],[-40.62,40.62],[0,55.655]],"o":[[-81.241,-81.239],[-39.354,39.354],[0,55.655],[40.62,40.62],[53.355,0],[39.354,-39.354],[0,-55.655]],"v":[[147.33,-147.327],[-147.335,-147.327],[-208.362,0.006],[-147.335,147.338],[-0.002,208.267],[147.33,147.338],[208.357,0.006]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.071,0.075,0.192,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-67-clock').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":60,"s":[360]}],"ix":10},"p":{"a":0,"k":[250.038,250.066,0],"ix":2,"l":2},"a":{"a":0,"k":[250.038,250.066,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[44.264,44.267],[-44.264,-44.267]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.071,0.075,0.192,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-67-clock').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[294.302,294.333],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.407],"y":[1]},"o":{"x":[0.484],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.425],"y":[0]},"t":21,"s":[23]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":46,"s":[-5]},{"t":61,"s":[0]}],"ix":10},"p":{"a":0,"k":[250.024,250.066,0],"ix":2,"l":2},"a":{"a":0,"k":[250.024,250.066,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-43.86],[0,43.86]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.071,0.075,0.192,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-67-clock').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[250.024,206.206],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[249.998,250.006,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[75.258,-75.258],[75.258,75.258],[-75.258,75.258],[-75.258,-75.258]],"o":[[-75.258,75.258],[-75.258,-75.258],[75.258,-75.258],[75.258,75.258]],"v":[[136.266,136.266],[-136.266,136.266],[-136.266,-136.266],[136.266,-136.266]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.071,0.075,0.192,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-67-clock').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"control","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"primary","np":3,"mn":"ADBE Color Control","ix":1,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[0.071,0.075,0.192],"ix":1}}]}],"ip":0,"op":201,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"loop-clock","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":500,"h":500,"ip":0,"op":70,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"default:loop-clock","dr":60}],"props":{}} \ No newline at end of file diff --git a/assets/lottie/ic_contacts.json b/assets/lottie/ic_contacts.json new file mode 100644 index 0000000..dbce99c --- /dev/null +++ b/assets/lottie/ic_contacts.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":31,"w":600,"h":600,"nm":"ic_contacts","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"ic_contacts","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"ix":10,"k":[{"t":0,"s":[0],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":9,"s":[22],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":15,"s":[18],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":22,"s":[-5],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":27,"s":[2],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":31,"s":[0]}]},"p":{"a":0,"k":[248,532,0],"ix":2,"l":2},"a":{"a":0,"k":[250,479,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,16.667],[0.0,0.0],[-16.667,0.0],[0.0,0.0],[0.0,-17.187],[0.0,0.0],[16.667,-0.0],[0.0,0.0]],"o":[[0.0,0.0],[0.0,0.0],[-16.667,-0.0],[0.0,0.0],[0.0,-17.187],[0.0,0.0],[16.667,0.0],[0.0,0.0],[0.0,16.667],[0.0,0.0],[0.0,0.0]],"v":[[250.0,479.167],[186.979,416.146],[93.75,416.146],[62.5,384.896],[62.5,72.396],[93.75,41.146],[406.25,41.146],[437.5,72.396],[437.5,384.896],[406.25,416.146],[313.021,416.146]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.0,0.0],[-61.183,-0.0],[-41.667,-38.889],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"o":[[41.667,-38.889],[61.183,-0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"v":[[93.75,365.625],[249.946,300.521],[406.25,365.625],[406.25,72.396],[406.25,72.396],[93.75,72.396],[93.75,72.396]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[-20.139,0.0],[-13.889,13.889],[0.0,20.139],[13.889,13.889],[20.139,-0.0],[13.889,-13.889],[0.0,-20.139],[-13.889,-13.889]],"o":[[20.139,0.0],[13.889,-13.889],[0.0,-20.139],[-13.889,-13.889],[-20.139,-0.0],[-13.889,13.889],[0.0,20.139],[13.889,13.889]],"v":[[251.042,269.792],[302.083,248.958],[322.917,197.917],[302.083,146.875],[251.042,126.042],[200.0,146.875],[179.167,197.917],[200.0,248.958]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0.0,-7.639],[0.0,0.0],[0.0,7.341],[61.111,-0.0],[41.667,-38.889]],"o":[[0.0,0.0],[0.0,-7.639],[-41.667,-38.889],[-61.111,-0.0],[0.0,7.341]],"v":[[93.75,384.896],[406.25,384.896],[406.25,365.625],[250.0,300.521],[93.75,365.625]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[0.0,-0.0],[0.0,-0.0],[0.0,-0.0],[0.0,-0.0],[0.0,-0.0],[0.0,-0.0],[0.0,-0.0],[0.0,-0.0]],"o":[[0.0,-0.0],[0.0,-0.0],[0.0,-0.0],[0.0,-0.0],[0.0,-0.0],[0.0,-0.0],[0.0,-0.0],[0.0,-0.0]],"v":[[251.563,197.917],[251.563,197.917],[251.563,197.917],[251.563,197.917],[251.563,197.917],[251.563,197.917],[251.563,197.917],[251.563,197.917]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ind":5,"ty":"sh","ix":6,"ks":{"a":0,"k":{"i":[[41.667,-38.889],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[61.111,-0.0]],"o":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-41.667,-38.889],[-61.111,-0.0]],"v":[[93.75,365.625],[93.75,72.396],[93.75,72.396],[406.25,72.396],[406.25,72.396],[406.25,365.625],[250.0,300.521]],"c":true},"ix":2},"nm":"Path 6","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":31,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lottie/ic_flash_on_to_off.json b/assets/lottie/ic_flash_on_to_off.json new file mode 100644 index 0000000..0506aa6 --- /dev/null +++ b/assets/lottie/ic_flash_on_to_off.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_flash_on_to_off","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"slashed","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[300.0,300.0,0],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":11,"s":[92,92,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[694.4,-578.4],[-578.4,694.4],[-1851.19,-578.4],[-578.4,-1851.19]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1178.4,-94.4],[-94.4,1178.4],[-1367.19,-94.4],[-94.4,-1367.19]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Wipe"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.0,0.0],[-14.07,0.0],[-8.52,0.0],[-8.04,0.0],[-7.81,0.0],[-7.67,0.0],[-7.56,0.0],[-7.44,0.0],[-7.29,0.0],[-7.06,0.0],[-6.52,0.0],[0.0,0.0],[3.81,-13.32],[2.32,-8.1],[2.17,-7.61],[2.1,-7.36],[2.04,-7.15],[1.97,-6.89],[1.81,-6.35],[0.0,0.0],[-14.35,0.0],[-8.68,0.0],[-7.64,0.0],[0.0,0.0],[7.27,-10.51],[4.44,-6.41],[4.13,-5.97],[3.92,-5.67],[3.59,-5.19],[0.0,0.0],[8.91,8.91],[0.0,0.0],[0.0,0.0],[8.44,0.0],[0.0,0.0],[7.34,7.34],[0.0,0.0],[-3.61,12.56],[-2.2,7.65],[-2.06,7.17],[-1.98,6.89],[-1.9,6.61],[-1.75,6.07],[0.0,0.0],[13.3,0.0],[8.11,0.0],[7.56,0.0],[7.17,0.0],[6.57,0.0],[0.0,0.0],[0.0,-13.44],[0.0,0.0],[9.18,9.18],[5.36,5.36],[0.0,0.0],[0.0,13.44]],"o":[[0.0,0.0],[6.52,0.0],[7.06,0.0],[7.29,0.0],[7.44,0.0],[7.56,0.0],[7.67,0.0],[7.81,0.0],[8.04,0.0],[8.52,0.0],[14.07,0.0],[0.0,0.0],[-1.81,6.35],[-1.97,6.89],[-2.04,7.15],[-2.1,7.36],[-2.17,7.61],[-2.32,8.1],[-3.81,13.32],[0.0,0.0],[7.64,0.0],[8.68,0.0],[14.35,0.0],[0.0,0.0],[-3.59,5.19],[-3.92,5.67],[-4.13,5.97],[-4.44,6.41],[-7.27,10.51],[0.0,0.0],[-8.91,-8.91],[0.0,0.0],[0.0,0.0],[-8.44,0.0],[0.0,0.0],[-7.34,-7.34],[0.0,0.0],[1.75,-6.07],[1.9,-6.61],[1.98,-6.89],[2.06,-7.17],[2.2,-7.65],[3.61,-12.56],[0.0,0.0],[-6.57,0.0],[-7.17,0.0],[-7.56,0.0],[-8.11,0.0],[-13.3,0.0],[0.0,0.0],[0.0,13.44],[0.0,0.0],[-5.36,-5.36],[-9.18,-9.18],[0.0,0.0],[0.0,-13.44]],"v":[[175.0,50.0],[197.72,50.0],[220.43,50.0],[243.18,50.0],[265.91,50.0],[288.64,50.0],[311.36,50.0],[334.09,50.0],[356.82,50.0],[379.57,50.0],[402.28,50.0],[425.0,50.0],[418.75,71.87],[412.5,93.75],[406.25,115.62],[400.0,137.5],[393.75,159.38],[387.5,181.25],[381.25,203.13],[375.0,225.0],[400.0,225.0],[425.0,225.0],[450.0,225.0],[475.0,225.0],[462.82,242.6],[450.63,260.21],[438.44,277.81],[426.25,295.42],[414.06,313.03],[401.88,330.62],[384.06,312.81],[366.25,295.0],[380.0,275.0],[363.12,275.0],[346.25,275.0],[331.56,260.31],[316.88,245.63],[322.85,224.83],[328.84,204.03],[334.82,183.21],[340.8,162.41],[346.79,141.6],[352.77,120.79],[358.75,100.0],[336.47,100.0],[314.17,100.0],[291.88,100.0],[269.58,100.0],[247.28,100.0],[225.0,100.0],[225.0,126.87],[225.0,153.75],[208.33,137.08],[191.67,120.42],[175.0,103.75],[175.0,76.88]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,17.25],[0.0,10.51],[0.0,9.85],[0.0,9.47],[0.0,9.08],[0.0,8.34],[0.0,0.0],[18.75,0.0],[0.0,0.0],[0.0,19.05],[0.0,11.13],[0.0,0.0],[12.13,12.13],[7.39,7.39],[6.92,6.92],[6.66,6.66],[6.38,6.38],[5.86,5.86],[0.0,0.0],[-8.91,8.91],[0.0,0.0],[-13.86,-13.86],[-8.36,-8.36],[-7.87,-7.87],[-7.68,-7.68],[-7.56,-7.56],[-7.49,-7.49],[-7.44,-7.44],[-7.4,-7.4],[-7.36,-7.36],[-7.33,-7.33],[-7.3,-7.3],[-7.27,-7.27],[-7.24,-7.24],[-7.2,-7.2],[-7.16,-7.16],[-7.1,-7.1],[-7.02,-7.02],[-6.91,-6.91],[-6.7,-6.7],[-6.2,-6.2],[0.0,0.0],[8.91,-8.91],[0.0,0.0],[12.99,12.99],[7.91,7.91],[7.42,7.42],[7.13,7.13],[6.84,6.84],[6.28,6.28],[0.0,0.0],[11.02,-15.86],[6.71,-9.67],[6.18,-8.89],[5.6,-8.06]],"o":[[0.0,0.0],[0.0,-8.34],[0.0,-9.08],[0.0,-9.47],[0.0,-9.85],[0.0,-10.51],[0.0,-17.25],[0.0,0.0],[-18.75,0.0],[0.0,0.0],[0.0,-11.13],[0.0,-19.05],[0.0,0.0],[-5.86,-5.86],[-6.38,-6.38],[-6.66,-6.66],[-6.92,-6.92],[-7.39,-7.39],[-12.13,-12.13],[0.0,0.0],[8.91,-8.91],[0.0,0.0],[6.2,6.2],[6.7,6.7],[6.91,6.91],[7.02,7.02],[7.1,7.1],[7.16,7.16],[7.2,7.2],[7.24,7.24],[7.27,7.27],[7.3,7.3],[7.33,7.33],[7.36,7.36],[7.4,7.4],[7.44,7.44],[7.49,7.49],[7.56,7.56],[7.68,7.68],[7.87,7.87],[8.36,8.36],[13.86,13.86],[0.0,0.0],[-8.91,8.91],[0.0,0.0],[-6.28,-6.28],[-6.84,-6.84],[-7.13,-7.13],[-7.42,-7.42],[-7.91,-7.91],[-12.99,-12.99],[0.0,0.0],[-5.6,8.06],[-6.18,8.89],[-6.71,9.67],[-11.02,15.86]],"v":[[250.0,550.0],[250.0,521.44],[250.0,492.87],[250.0,464.29],[250.0,435.71],[250.0,407.13],[250.0,378.56],[250.0,350.0],[212.5,350.0],[175.0,350.0],[175.0,315.42],[175.0,280.83],[175.0,246.25],[154.92,226.17],[134.83,206.08],[114.73,185.98],[94.64,165.89],[74.55,145.8],[54.46,125.71],[34.38,105.62],[52.19,87.81],[70.0,70.0],[91.84,91.84],[113.78,113.78],[135.68,135.68],[157.6,157.6],[179.5,179.5],[201.4,201.4],[223.32,223.32],[245.23,245.23],[267.14,267.14],[289.05,289.05],[310.95,310.95],[332.86,332.86],[354.77,354.77],[376.68,376.68],[398.6,398.6],[420.5,420.5],[442.4,442.4],[464.32,464.32],[486.22,486.22],[508.16,508.16],[530.0,530.0],[512.19,547.81],[494.38,565.62],[472.87,544.12],[451.35,522.6],[429.82,501.07],[408.3,479.55],[386.78,458.03],[365.26,436.51],[343.75,415.0],[325.01,441.99],[306.25,469.0],[287.5,496.0],[268.74,523.01]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,6.32],[0.0,3.85],[0.0,3.54],[0.0,3.21],[0.0,0.0],[-6.92,0.0],[-4.19,0.0],[-3.96,0.0],[-3.85,0.0],[-3.78,0.0],[-3.73,0.0],[-3.69,0.0],[-3.63,0.0],[-3.56,0.0],[-3.45,0.0],[-3.19,0.0],[0.0,0.0],[2.01,-6.99],[1.21,-4.22],[1.15,-3.98],[1.11,-3.88],[1.1,-3.82],[1.08,-3.77],[1.07,-3.73],[1.06,-3.69],[1.05,-3.64],[1.03,-3.57],[1.0,-3.46],[0.92,-3.2],[0.0,0.0],[-4.21,-4.21],[-2.55,-2.55],[-2.24,-2.24],[0.0,0.0],[-6.2,0.0],[-3.62,0.0],[0.0,0.0],[3.44,-5.0],[0.0,0.0],[5.23,5.23],[3.16,3.16],[2.98,2.98],[2.9,2.9],[2.86,2.86],[2.82,2.82],[2.8,2.8],[2.78,2.78],[2.77,2.77],[2.75,2.75],[2.73,2.73],[2.71,2.71],[2.68,2.68],[2.63,2.63],[2.55,2.55],[2.36,2.36]],"o":[[0.0,0.0],[0.0,-3.21],[0.0,-3.54],[0.0,-3.85],[0.0,-6.32],[0.0,0.0],[3.19,0.0],[3.45,0.0],[3.56,0.0],[3.63,0.0],[3.69,0.0],[3.73,0.0],[3.78,0.0],[3.85,0.0],[3.96,0.0],[4.19,0.0],[6.92,0.0],[0.0,0.0],[-0.92,3.2],[-1.0,3.46],[-1.03,3.57],[-1.05,3.64],[-1.06,3.69],[-1.07,3.73],[-1.08,3.77],[-1.1,3.82],[-1.11,3.88],[-1.15,3.98],[-1.21,4.22],[-2.01,6.99],[0.0,0.0],[2.24,2.24],[2.55,2.55],[4.21,4.21],[0.0,0.0],[3.62,0.0],[6.2,0.0],[0.0,0.0],[-3.44,5.0],[0.0,0.0],[-2.36,-2.36],[-2.55,-2.55],[-2.63,-2.63],[-2.68,-2.68],[-2.71,-2.71],[-2.73,-2.73],[-2.75,-2.75],[-2.77,-2.77],[-2.78,-2.78],[-2.8,-2.8],[-2.82,-2.82],[-2.86,-2.86],[-2.9,-2.9],[-2.98,-2.98],[-3.16,-3.16],[-5.23,-5.23]],"v":[[225.0,153.75],[225.0,143.0],[225.0,132.25],[225.0,121.5],[225.0,110.75],[225.0,100.0],[236.13,100.0],[247.28,100.0],[258.43,100.0],[269.58,100.0],[280.73,100.0],[291.88,100.0],[303.02,100.0],[314.17,100.0],[325.32,100.0],[336.47,100.0],[347.62,100.0],[358.75,100.0],[355.53,111.19],[352.31,122.4],[349.09,133.6],[345.87,144.8],[342.64,156.01],[339.42,167.21],[336.2,178.41],[332.98,189.62],[329.76,200.82],[326.54,212.02],[323.32,223.23],[320.09,234.43],[316.88,245.63],[324.22,252.97],[331.56,260.31],[338.91,267.66],[346.25,275.0],[357.5,275.0],[368.75,275.0],[380.0,275.0],[373.13,285.0],[366.25,295.0],[357.96,286.71],[349.65,278.4],[341.33,270.08],[333.02,261.77],[324.71,253.46],[316.4,245.15],[308.09,236.84],[299.78,228.53],[291.47,220.22],[283.16,211.91],[274.85,203.6],[266.54,195.29],[258.23,186.98],[249.92,178.67],[241.6,170.35],[233.29,162.04]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"slashed","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"plain","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[300.0,300.0,0],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":11,"s":[92,92,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[694.4,-578.4],[-578.4,694.4],[694.4,1967.19],[1967.19,694.4]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1178.4,-94.4],[-94.4,1178.4],[1178.4,2451.19],[2451.19,1178.4]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Wipe"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.0,0.0],[-5.45,7.83],[-3.31,4.76],[-3.12,4.48],[-3.02,4.34],[-2.95,4.24],[-2.88,4.14],[-2.78,4.0],[-2.57,3.69],[0.0,0.0],[10.22,0.0],[6.19,0.0],[5.44,0.0],[0.0,0.0],[-2.81,9.85],[-1.7,5.97],[-1.61,5.63],[-1.56,5.47],[-1.53,5.37],[-1.51,5.29],[-1.49,5.21],[-1.46,5.1],[-1.41,4.94],[-1.3,4.56],[0.0,0.0],[10.18,0.0],[6.19,0.0],[5.82,0.0],[5.63,0.0],[5.47,0.0],[5.26,0.0],[4.85,0.0],[0.0,0.0],[0.0,-9.59],[0.0,-5.8],[0.0,-5.47],[0.0,-5.32],[0.0,-5.24],[0.0,-5.17],[0.0,-5.12],[0.0,-5.06],[0.0,-5.0],[0.0,-4.9],[0.0,-4.75],[0.0,-4.39],[0.0,0.0],[-8.81,0.0],[-5.37,0.0],[-4.94,0.0],[-4.48,0.0],[0.0,0.0],[0.0,-8.95],[0.0,-5.46],[0.0,-5.09],[0.0,-4.83],[0.0,-4.42]],"o":[[0.0,0.0],[2.57,-3.69],[2.78,-4.0],[2.88,-4.14],[2.95,-4.24],[3.02,-4.34],[3.12,-4.48],[3.31,-4.76],[5.45,-7.83],[0.0,0.0],[-5.44,0.0],[-6.19,0.0],[-10.22,0.0],[0.0,0.0],[1.3,-4.56],[1.41,-4.94],[1.46,-5.1],[1.49,-5.21],[1.51,-5.29],[1.53,-5.37],[1.56,-5.47],[1.61,-5.63],[1.7,-5.97],[2.81,-9.85],[0.0,0.0],[-4.85,0.0],[-5.26,0.0],[-5.47,0.0],[-5.63,0.0],[-5.82,0.0],[-6.19,0.0],[-10.18,0.0],[0.0,0.0],[0.0,4.39],[0.0,4.75],[0.0,4.9],[0.0,5.0],[0.0,5.06],[0.0,5.12],[0.0,5.17],[0.0,5.24],[0.0,5.32],[0.0,5.47],[0.0,5.8],[0.0,9.59],[0.0,0.0],[4.48,0.0],[4.94,0.0],[5.37,0.0],[8.81,0.0],[0.0,0.0],[0.0,4.42],[0.0,4.83],[0.0,5.09],[0.0,5.46],[0.0,8.95]],"v":[[300.0,390.0],[308.88,377.23],[317.77,364.45],[326.66,351.67],[335.55,338.89],[344.45,326.11],[353.34,313.33],[362.23,300.55],[371.12,287.77],[380.0,275.0],[362.19,275.0],[344.37,275.0],[326.56,275.0],[308.75,275.0],[313.29,259.1],[317.84,243.2],[322.39,227.27],[326.93,211.37],[331.48,195.45],[336.02,179.55],[340.57,163.63],[345.11,147.73],[349.66,131.8],[354.21,115.9],[358.75,100.0],[342.04,100.0],[325.32,100.0],[308.6,100.0],[291.88,100.0],[275.15,100.0],[258.43,100.0],[241.71,100.0],[225.0,100.0],[225.0,115.37],[225.0,130.76],[225.0,146.15],[225.0,161.53],[225.0,176.92],[225.0,192.31],[225.0,207.69],[225.0,223.08],[225.0,238.47],[225.0,253.85],[225.0,269.24],[225.0,284.63],[225.0,300.0],[239.99,300.0],[255.0,300.0],[270.0,300.0],[285.01,300.0],[300.0,300.0],[300.0,314.99],[300.0,330.0],[300.0,345.0],[300.0,360.0],[300.0,375.01]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,17.25],[0.0,10.51],[0.0,9.85],[0.0,9.47],[0.0,9.08],[0.0,8.34],[0.0,0.0],[13.77,0.0],[8.05,0.0],[0.0,0.0],[0.0,16.89],[0.0,10.23],[0.0,9.65],[0.0,9.38],[0.0,9.21],[0.0,9.07],[0.0,8.93],[0.0,8.75],[0.0,8.48],[0.0,7.82],[0.0,0.0],[-17.02,0.0],[-10.35,0.0],[-9.74,0.0],[-9.44,0.0],[-9.23,0.0],[-9.01,0.0],[-8.7,0.0],[-8.02,0.0],[0.0,0.0],[4.31,-15.09],[2.63,-9.19],[2.46,-8.62],[2.37,-8.28],[2.27,-7.94],[2.09,-7.3],[0.0,0.0],[-14.35,0.0],[-8.68,0.0],[-7.64,0.0],[0.0,0.0],[9.41,-13.59],[5.67,-8.19],[5.35,-7.73],[5.22,-7.54],[5.14,-7.42],[5.08,-7.34],[5.03,-7.27],[4.99,-7.21],[4.95,-7.16],[4.91,-7.09],[4.85,-7.01],[4.77,-6.88],[4.62,-6.67],[4.26,-6.16]],"o":[[0.0,0.0],[0.0,-8.34],[0.0,-9.08],[0.0,-9.47],[0.0,-9.85],[0.0,-10.51],[0.0,-17.25],[0.0,0.0],[-8.05,0.0],[-13.77,0.0],[0.0,0.0],[0.0,-7.82],[0.0,-8.48],[0.0,-8.75],[0.0,-8.93],[0.0,-9.07],[0.0,-9.21],[0.0,-9.38],[0.0,-9.65],[0.0,-10.23],[0.0,-16.89],[0.0,0.0],[8.02,0.0],[8.7,0.0],[9.01,0.0],[9.23,0.0],[9.44,0.0],[9.74,0.0],[10.35,0.0],[17.02,0.0],[0.0,0.0],[-2.09,7.3],[-2.27,7.94],[-2.37,8.28],[-2.46,8.62],[-2.63,9.19],[-4.31,15.09],[0.0,0.0],[7.64,0.0],[8.68,0.0],[14.35,0.0],[0.0,0.0],[-4.26,6.16],[-4.62,6.67],[-4.77,6.88],[-4.85,7.01],[-4.91,7.09],[-4.95,7.16],[-4.99,7.21],[-5.03,7.27],[-5.08,7.34],[-5.14,7.42],[-5.22,7.54],[-5.35,7.73],[-5.67,8.19],[-9.41,13.59]],"v":[[250.0,550.0],[250.0,521.44],[250.0,492.87],[250.0,464.29],[250.0,435.71],[250.0,407.13],[250.0,378.56],[250.0,350.0],[225.0,350.0],[200.0,350.0],[175.0,350.0],[175.0,322.74],[175.0,295.48],[175.0,268.18],[175.0,240.91],[175.0,213.64],[175.0,186.36],[175.0,159.09],[175.0,131.82],[175.0,104.52],[175.0,77.26],[175.0,50.0],[202.76,50.0],[230.54,50.0],[258.33,50.0],[286.11,50.0],[313.89,50.0],[341.67,50.0],[369.46,50.0],[397.24,50.0],[425.0,50.0],[417.86,74.99],[410.72,99.99],[403.57,125.0],[396.43,150.0],[389.28,175.01],[382.14,200.01],[375.0,225.0],[400.0,225.0],[425.0,225.0],[450.0,225.0],[475.0,225.0],[460.01,246.65],[445.02,268.3],[430.02,289.98],[415.01,311.65],[400.01,333.32],[385.0,354.99],[370.0,376.66],[355.0,398.34],[340.0,420.01],[324.99,441.68],[309.99,463.35],[294.98,485.02],[279.98,506.7],[264.99,528.35]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,8.95],[0.0,5.46],[0.0,5.09],[0.0,4.83],[0.0,4.42],[0.0,0.0],[8.81,0.0],[5.37,0.0],[4.94,0.0],[4.48,0.0],[0.0,0.0],[0.0,9.59],[0.0,5.8],[0.0,5.47],[0.0,5.32],[0.0,5.24],[0.0,5.17],[0.0,5.12],[0.0,5.06],[0.0,5.0],[0.0,4.9],[0.0,4.75],[0.0,4.39],[0.0,0.0],[-10.18,0.0],[-6.19,0.0],[-5.82,0.0],[-5.63,0.0],[-5.47,0.0],[-5.26,0.0],[-4.85,0.0],[0.0,0.0],[2.81,-9.85],[1.7,-5.97],[1.61,-5.63],[1.56,-5.47],[1.53,-5.37],[1.51,-5.29],[1.49,-5.21],[1.46,-5.1],[1.41,-4.94],[1.3,-4.56],[0.0,0.0],[-10.22,0.0],[-6.19,0.0],[-5.44,0.0],[0.0,0.0],[5.45,-7.83],[3.31,-4.76],[3.12,-4.48],[3.02,-4.34],[2.95,-4.24],[2.88,-4.14],[2.78,-4.0],[2.57,-3.69]],"o":[[0.0,0.0],[0.0,-4.42],[0.0,-4.83],[0.0,-5.09],[0.0,-5.46],[0.0,-8.95],[0.0,0.0],[-4.48,0.0],[-4.94,0.0],[-5.37,0.0],[-8.81,0.0],[0.0,0.0],[0.0,-4.39],[0.0,-4.75],[0.0,-4.9],[0.0,-5.0],[0.0,-5.06],[0.0,-5.12],[0.0,-5.17],[0.0,-5.24],[0.0,-5.32],[0.0,-5.47],[0.0,-5.8],[0.0,-9.59],[0.0,0.0],[4.85,0.0],[5.26,0.0],[5.47,0.0],[5.63,0.0],[5.82,0.0],[6.19,0.0],[10.18,0.0],[0.0,0.0],[-1.3,4.56],[-1.41,4.94],[-1.46,5.1],[-1.49,5.21],[-1.51,5.29],[-1.53,5.37],[-1.56,5.47],[-1.61,5.63],[-1.7,5.97],[-2.81,9.85],[0.0,0.0],[5.44,0.0],[6.19,0.0],[10.22,0.0],[0.0,0.0],[-2.57,3.69],[-2.78,4.0],[-2.88,4.14],[-2.95,4.24],[-3.02,4.34],[-3.12,4.48],[-3.31,4.76],[-5.45,7.83]],"v":[[300.0,390.0],[300.0,375.01],[300.0,360.0],[300.0,345.0],[300.0,330.0],[300.0,314.99],[300.0,300.0],[285.01,300.0],[270.0,300.0],[255.0,300.0],[239.99,300.0],[225.0,300.0],[225.0,284.63],[225.0,269.24],[225.0,253.85],[225.0,238.47],[225.0,223.08],[225.0,207.69],[225.0,192.31],[225.0,176.92],[225.0,161.53],[225.0,146.15],[225.0,130.76],[225.0,115.37],[225.0,100.0],[241.71,100.0],[258.43,100.0],[275.15,100.0],[291.88,100.0],[308.6,100.0],[325.32,100.0],[342.04,100.0],[358.75,100.0],[354.21,115.9],[349.66,131.8],[345.11,147.73],[340.57,163.63],[336.02,179.55],[331.48,195.45],[326.93,211.37],[322.39,227.27],[317.84,243.2],[313.29,259.1],[308.75,275.0],[326.56,275.0],[344.38,275.0],[362.19,275.0],[380.0,275.0],[371.12,287.77],[362.23,300.55],[353.34,313.33],[344.45,326.11],[335.55,338.89],[326.66,351.67],[317.77,364.45],[308.88,377.23]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"plain","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lottie/ic_mic_on_to_off.json b/assets/lottie/ic_mic_on_to_off.json new file mode 100644 index 0000000..e414c4e --- /dev/null +++ b/assets/lottie/ic_mic_on_to_off.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_mic_on_to_off","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"slashed","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[300.0,300.0,0],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":11,"s":[92,92,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[694.4,-578.4],[-578.4,694.4],[-1851.19,-578.4],[-578.4,-1851.19]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1178.4,-94.4],[-94.4,1178.4],[-1367.19,-94.4],[-94.4,-1367.19]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Wipe"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.9,-1.31],[2.04,2.04],[1.24,1.24],[1.17,1.17],[1.13,1.13],[1.11,1.11],[1.1,1.1],[1.08,1.08],[1.06,1.06],[1.02,1.02],[0.94,0.94],[0.0,0.0],[-0.76,1.36],[-0.71,1.39],[-0.65,1.41],[-0.6,1.44],[-0.54,1.46],[-0.48,1.49],[-0.42,1.51],[-0.36,1.53],[-0.3,1.54],[-0.24,1.55],[-0.18,1.56],[-0.12,1.57],[-0.06,1.57],[0.0,1.57],[-3.08,0.0],[-1.87,0.0],[-1.76,0.0],[-1.71,0.0],[-1.68,0.0],[-1.65,0.0],[-1.61,0.0],[-1.56,0.0],[-1.44,0.0],[0.0,0.0],[0.05,-1.76],[0.1,-1.76],[0.16,-1.75],[0.21,-1.75],[0.27,-1.74],[0.32,-1.73],[0.38,-1.72],[0.44,-1.71],[0.5,-1.7],[0.55,-1.68],[0.53,-1.49],[0.57,-1.47],[0.61,-1.46],[0.65,-1.44],[0.69,-1.43],[0.72,-1.41],[0.76,-1.39],[0.8,-1.37],[0.83,-1.35],[0.86,-1.33]],"o":[[0.0,0.0],[-0.94,-0.94],[-1.02,-1.02],[-1.06,-1.06],[-1.08,-1.08],[-1.1,-1.1],[-1.11,-1.11],[-1.13,-1.13],[-1.17,-1.17],[-1.24,-1.24],[-2.04,-2.04],[0.82,-1.34],[0.77,-1.37],[0.71,-1.4],[0.66,-1.42],[0.6,-1.45],[0.54,-1.47],[0.48,-1.49],[0.42,-1.51],[0.36,-1.52],[0.3,-1.53],[0.24,-1.54],[0.18,-1.55],[0.12,-1.56],[0.06,-1.56],[0.0,0.0],[1.44,0.0],[1.56,0.0],[1.61,0.0],[1.65,0.0],[1.68,0.0],[1.71,0.0],[1.76,0.0],[1.87,0.0],[3.08,0.0],[0.0,1.78],[-0.05,1.78],[-0.1,1.77],[-0.16,1.77],[-0.21,1.76],[-0.27,1.75],[-0.33,1.74],[-0.38,1.73],[-0.44,1.71],[-0.5,1.7],[-0.49,1.5],[-0.53,1.49],[-0.57,1.47],[-0.61,1.46],[-0.65,1.44],[-0.69,1.42],[-0.72,1.4],[-0.76,1.38],[-0.79,1.36],[-0.83,1.34],[-0.86,1.32]],"v":[[443.75,373.75],[440.46,370.46],[437.16,367.16],[433.86,363.86],[430.57,360.57],[427.27,357.27],[423.98,353.98],[420.68,350.68],[417.39,347.39],[414.09,344.09],[410.79,340.79],[407.5,337.5],[409.87,333.45],[412.08,329.31],[414.13,325.09],[416.02,320.79],[417.73,316.43],[419.27,311.99],[420.62,307.5],[421.8,302.95],[422.79,298.36],[423.59,293.73],[424.22,289.07],[424.65,284.39],[424.91,279.7],[425.0,275.0],[429.99,275.0],[435.0,275.0],[440.0,275.0],[445.0,275.0],[450.0,275.0],[455.0,275.0],[460.0,275.0],[465.0,275.0],[470.01,275.0],[475.0,275.0],[474.92,280.3],[474.69,285.6],[474.3,290.89],[473.75,296.17],[473.03,301.42],[472.14,306.65],[471.08,311.85],[469.85,317.01],[468.45,322.12],[466.88,327.19],[465.33,331.67],[463.68,336.12],[461.9,340.51],[460.02,344.86],[458.01,349.16],[455.9,353.41],[453.68,357.6],[451.35,361.73],[448.92,365.8],[446.38,369.81]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[-2.29,0.0],[-2.05,-0.5],[-1.9,-1.09],[-1.6,-1.6],[-0.9,-1.34],[-0.61,-1.53],[-0.29,-1.65],[0.0,-1.7],[0.0,-4.07],[0.0,-2.45],[0.0,-2.31],[0.0,-2.25],[0.0,-2.22],[0.0,-2.2],[0.0,-2.18],[0.0,-2.17],[0.0,-2.16],[0.0,-2.15],[0.0,-2.14],[0.0,-2.13],[0.0,-2.12],[0.0,-2.11],[0.0,-2.09],[0.0,-2.07],[0.0,-2.03],[0.0,-1.97],[0.0,-1.82],[0.0,0.0],[2.81,2.81],[1.7,1.7],[1.61,1.61],[1.56,1.56],[1.53,1.53],[1.51,1.51],[1.49,1.49],[1.46,1.46],[1.41,1.41],[1.3,1.3],[0.0,0.0],[0.0,4.08],[0.0,2.47],[0.0,2.33],[0.0,2.26],[0.0,2.23],[0.0,2.2],[0.0,2.17],[0.0,2.14],[0.0,2.1],[0.0,2.03],[0.0,1.88],[0.0,0.0],[-0.5,2.05],[-1.09,1.9],[-1.6,1.6],[-1.86,1.07],[-2.16,0.53]],"o":[[2.29,0.0],[2.16,0.53],[1.86,1.07],[1.2,1.2],[0.92,1.38],[0.6,1.51],[0.28,1.57],[0.0,0.0],[0.0,1.82],[0.0,1.97],[0.0,2.03],[0.0,2.07],[0.0,2.09],[0.0,2.11],[0.0,2.12],[0.0,2.13],[0.0,2.14],[0.0,2.15],[0.0,2.16],[0.0,2.17],[0.0,2.18],[0.0,2.2],[0.0,2.22],[0.0,2.25],[0.0,2.31],[0.0,2.45],[0.0,4.07],[0.0,0.0],[-1.3,-1.3],[-1.41,-1.41],[-1.46,-1.46],[-1.49,-1.49],[-1.51,-1.51],[-1.53,-1.53],[-1.56,-1.56],[-1.61,-1.61],[-1.7,-1.7],[-2.81,-2.81],[0.0,0.0],[0.0,-1.88],[0.0,-2.03],[0.0,-2.1],[0.0,-2.14],[0.0,-2.17],[0.0,-2.2],[0.0,-2.23],[0.0,-2.26],[0.0,-2.33],[0.0,-2.47],[0.0,-4.08],[0.0,-2.29],[0.53,-2.16],[1.07,-1.86],[1.6,-1.6],[1.9,-1.09],[2.05,-0.5]],"v":[[300.0,100.0],[306.52,100.75],[312.62,103.18],[317.81,107.19],[320.95,110.99],[323.25,115.35],[324.58,120.09],[325.0,125.0],[325.0,131.42],[325.0,137.86],[325.0,144.3],[325.0,150.74],[325.0,157.18],[325.0,163.62],[325.0,170.06],[325.0,176.5],[325.0,182.94],[325.0,189.38],[325.0,195.81],[325.0,202.25],[325.0,208.69],[325.0,215.13],[325.0,221.57],[325.0,228.01],[325.0,234.45],[325.0,240.89],[325.0,247.33],[325.0,253.75],[320.46,249.21],[315.91,244.66],[311.36,240.11],[306.82,235.57],[302.27,231.02],[297.73,226.48],[293.18,221.93],[288.64,217.39],[284.09,212.84],[279.54,208.29],[275.0,203.75],[275.0,197.2],[275.0,190.63],[275.0,184.06],[275.0,177.5],[275.0,170.94],[275.0,164.37],[275.0,157.81],[275.0,151.25],[275.0,144.69],[275.0,138.12],[275.0,131.55],[275.0,125.0],[275.75,118.48],[278.18,112.38],[282.19,107.19],[287.38,103.18],[293.48,100.75]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[1.25,-3.75],[6.46,6.46],[3.91,3.91],[3.44,3.44],[0.0,0.0],[0.0,9.8],[0.0,5.96],[0.0,5.6],[0.0,5.42],[0.0,5.26],[0.0,5.07],[0.0,4.67],[0.0,0.0],[4.79,4.79],[7.08,0.0],[4.79,-4.79],[0.0,-7.08],[0.0,-9.25],[0.0,-5.64],[0.0,-5.19],[0.0,-4.7],[0.0,0.0],[5.88,5.88],[3.58,3.58],[3.29,3.29],[2.98,2.98],[0.0,0.0],[0.0,7.19],[0.0,0.0],[-0.87,4.71],[-1.85,4.51],[-2.81,4.12],[-3.59,3.59],[-3.96,2.7],[-4.53,1.86],[-4.92,0.91],[-5.08,0.0],[-4.71,-0.87],[-4.51,-1.85],[-4.12,-2.81],[-3.59,-3.59],[-2.7,-3.96],[-1.86,-4.53],[-0.91,-4.92],[0.0,-5.08],[0.0,-9.24],[0.0,-5.61],[0.0,-5.28],[0.0,-5.13],[0.0,-5.03],[0.0,-4.94],[0.0,-4.84],[0.0,-4.68],[0.0,-4.32],[0.0,0.0],[1.04,-3.75]],"o":[[0.0,0.0],[-3.44,-3.44],[-3.91,-3.91],[-6.46,-6.46],[0.0,0.0],[0.0,-4.67],[0.0,-5.07],[0.0,-5.26],[0.0,-5.42],[0.0,-5.6],[0.0,-5.96],[0.0,-9.8],[0.0,-7.08],[-4.79,-4.79],[-7.08,0.0],[-4.79,4.79],[0.0,0.0],[0.0,4.7],[0.0,5.19],[0.0,5.64],[0.0,9.25],[0.0,0.0],[-2.98,-2.98],[-3.29,-3.29],[-3.58,-3.58],[-5.88,-5.88],[0.0,0.0],[0.0,-7.19],[0.0,-5.08],[0.91,-4.92],[1.86,-4.53],[2.7,-3.96],[3.59,-3.59],[4.12,-2.81],[4.51,-1.85],[4.71,-0.87],[5.08,0.0],[4.92,0.91],[4.53,1.86],[3.96,2.7],[3.59,3.59],[2.81,4.12],[1.85,4.51],[0.87,4.71],[0.0,0.0],[0.0,4.32],[0.0,4.68],[0.0,4.84],[0.0,4.94],[0.0,5.03],[0.0,5.13],[0.0,5.28],[0.0,5.61],[0.0,9.24],[0.0,4.58],[-1.04,3.75]],"v":[[370.0,298.75],[358.75,287.5],[347.5,276.25],[336.25,265.0],[325.0,253.75],[325.0,237.66],[325.0,221.57],[325.0,205.47],[325.0,189.38],[325.0,173.28],[325.0,157.18],[325.0,141.09],[325.0,125.0],[317.81,107.19],[300.0,100.0],[282.19,107.19],[275.0,125.0],[275.0,140.74],[275.0,156.5],[275.0,172.25],[275.0,188.01],[275.0,203.75],[265.0,193.75],[255.0,183.75],[245.0,173.75],[235.0,163.75],[225.0,153.75],[225.0,139.38],[225.0,125.0],[226.3,110.31],[230.44,96.17],[237.44,83.2],[246.88,71.87],[258.2,62.44],[271.17,55.44],[285.31,51.3],[300.0,50.0],[314.69,51.3],[328.83,55.44],[341.8,62.44],[353.12,71.87],[362.56,83.2],[369.56,96.17],[373.7,110.31],[375.0,125.0],[375.0,139.98],[375.0,154.99],[375.0,169.99],[375.0,185.0],[375.0,200.0],[375.0,215.0],[375.0,230.01],[375.0,245.01],[375.0,260.02],[375.0,275.0],[373.44,287.5]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,9.11],[0.0,5.55],[0.0,5.11],[0.0,4.63],[0.0,0.0],[5.02,1.1],[4.91,1.54],[4.77,2.0],[4.58,2.45],[4.34,2.9],[4.06,3.31],[3.76,3.67],[3.45,3.99],[3.41,4.79],[2.91,5.13],[2.36,5.45],[1.76,5.7],[1.15,5.89],[0.56,6.01],[0.0,6.05],[-9.18,0.0],[-5.36,0.0],[0.0,0.0],[-0.64,-5.32],[-1.34,-5.23],[-2.08,-5.03],[-2.79,-4.74],[-3.42,-4.36],[-3.95,-3.95],[-4.22,-3.31],[-4.65,-2.74],[-5.04,-2.08],[-5.33,-1.37],[-5.51,-0.66],[-5.59,0.0],[-6.63,1.06],[-6.44,2.22],[-5.75,3.05],[-5.24,3.99],[-6.54,-6.54],[-3.82,-3.82],[0.0,0.0],[4.31,-2.86],[4.55,-2.5],[4.75,-2.13],[5.06,-1.68],[5.23,-1.18],[5.34,-0.68],[0.0,-9.11],[0.0,-5.55],[0.0,-5.11],[0.0,-4.63],[0.0,0.0],[9.18,0.0],[5.36,0.0]],"o":[[0.0,0.0],[0.0,-4.63],[0.0,-5.11],[0.0,-5.55],[0.0,-9.11],[-5.23,-0.7],[-5.14,-1.13],[-5.01,-1.57],[-4.82,-2.02],[-4.58,-2.46],[-4.3,-2.88],[-4.0,-3.25],[-3.68,-3.59],[-3.96,-4.57],[-3.49,-4.91],[-2.95,-5.21],[-2.36,-5.47],[-1.75,-5.66],[-1.13,-5.78],[-0.54,-5.84],[0.0,0.0],[5.36,0.0],[9.18,0.0],[0.0,5.59],[0.66,5.51],[1.37,5.33],[2.08,5.04],[2.74,4.65],[3.31,4.22],[3.95,3.95],[4.36,3.42],[4.74,2.79],[5.03,2.08],[5.23,1.34],[5.32,0.64],[6.99,0.0],[6.81,-1.09],[6.31,-2.17],[5.82,-3.09],[0.0,0.0],[3.82,3.82],[6.54,6.54],[-4.05,3.21],[-4.29,2.85],[-4.5,2.48],[-4.85,2.18],[-5.02,1.67],[-5.14,1.16],[0.0,0.0],[0.0,4.63],[0.0,5.11],[0.0,5.55],[0.0,9.11],[0.0,0.0],[-5.36,0.0],[-9.18,0.0]],"v":[[275.0,525.0],[275.0,509.51],[275.0,494.0],[275.0,478.5],[275.0,462.99],[275.0,447.5],[259.63,444.8],[244.54,440.8],[229.87,435.46],[215.78,428.75],[202.4,420.72],[189.84,411.45],[178.2,401.05],[167.5,389.69],[156.45,375.65],[146.85,360.59],[138.88,344.6],[132.7,327.85],[128.35,310.53],[125.81,292.85],[125.0,275.0],[141.67,275.0],[158.33,275.0],[175.0,275.0],[175.95,291.36],[178.95,307.46],[184.12,323.01],[191.43,337.67],[200.68,351.19],[211.56,363.44],[223.81,374.32],[237.33,383.57],[251.99,390.88],[267.54,396.05],[283.64,399.05],[300.0,400.0],[320.43,398.4],[340.31,393.44],[358.4,385.61],[375.0,375.0],[386.87,386.87],[398.75,398.75],[410.62,410.62],[398.08,419.74],[384.82,427.77],[370.94,434.69],[356.08,440.47],[340.71,444.74],[325.0,447.5],[325.0,462.99],[325.0,478.5],[325.0,494.0],[325.0,509.51],[325.0,525.0],[308.33,525.0],[291.67,525.0]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[0.0,0.0],[11.25,11.25],[6.78,6.78],[6.37,6.37],[6.23,6.23],[6.13,6.13],[6.07,6.07],[6.03,6.03],[6.0,6.0],[5.98,5.98],[5.96,5.96],[5.93,5.93],[5.92,5.92],[5.9,5.9],[5.89,5.89],[5.87,5.87],[5.85,5.85],[5.84,5.84],[5.82,5.82],[5.79,5.79],[5.75,5.75],[5.71,5.71],[5.66,5.66],[5.57,5.57],[5.39,5.39],[4.99,4.99],[0.0,0.0],[-8.75,8.75],[0.0,0.0],[-11.25,-11.25],[-6.78,-6.78],[-6.37,-6.37],[-6.23,-6.23],[-6.13,-6.13],[-6.07,-6.07],[-6.03,-6.03],[-6.0,-6.0],[-5.98,-5.98],[-5.96,-5.96],[-5.93,-5.93],[-5.92,-5.92],[-5.9,-5.9],[-5.89,-5.89],[-5.87,-5.87],[-5.85,-5.85],[-5.84,-5.84],[-5.82,-5.82],[-5.79,-5.79],[-5.75,-5.75],[-5.71,-5.71],[-5.66,-5.66],[-5.57,-5.57],[-5.39,-5.39],[-4.99,-4.99],[0.0,0.0],[8.75,-8.75]],"o":[[0.0,0.0],[-4.99,-4.99],[-5.39,-5.39],[-5.57,-5.57],[-5.66,-5.66],[-5.71,-5.71],[-5.75,-5.75],[-5.79,-5.79],[-5.82,-5.82],[-5.84,-5.84],[-5.85,-5.85],[-5.87,-5.87],[-5.89,-5.89],[-5.9,-5.9],[-5.92,-5.92],[-5.93,-5.93],[-5.96,-5.96],[-5.98,-5.98],[-6.0,-6.0],[-6.03,-6.03],[-6.07,-6.07],[-6.13,-6.13],[-6.23,-6.23],[-6.37,-6.37],[-6.78,-6.78],[-11.25,-11.25],[0.0,0.0],[8.75,-8.75],[0.0,0.0],[4.99,4.99],[5.39,5.39],[5.57,5.57],[5.66,5.66],[5.71,5.71],[5.75,5.75],[5.79,5.79],[5.82,5.82],[5.84,5.84],[5.85,5.85],[5.87,5.87],[5.89,5.89],[5.9,5.9],[5.92,5.92],[5.93,5.93],[5.96,5.96],[5.98,5.98],[6.0,6.0],[6.03,6.03],[6.07,6.07],[6.13,6.13],[6.23,6.23],[6.37,6.37],[6.78,6.78],[11.25,11.25],[0.0,0.0],[-8.75,8.75]],"v":[[495.0,565.0],[477.37,547.37],[459.64,529.64],[441.97,511.97],[424.25,494.25],[406.54,476.54],[388.85,458.85],[371.17,441.17],[353.48,423.48],[335.78,405.78],[318.08,388.08],[300.39,370.39],[282.7,352.7],[265.0,335.0],[247.3,317.3],[229.61,299.61],[211.92,281.92],[194.22,264.22],[176.52,246.52],[158.83,228.83],[141.15,211.15],[123.46,193.46],[105.75,175.75],[88.03,158.03],[70.36,140.36],[52.63,122.63],[35.0,105.0],[52.5,87.5],[70.0,70.0],[87.63,87.63],[105.36,105.36],[123.03,123.03],[140.75,140.75],[158.46,158.46],[176.15,176.15],[193.83,193.83],[211.52,211.52],[229.22,229.22],[246.92,246.92],[264.61,264.61],[282.3,282.3],[300.0,300.0],[317.7,317.7],[335.39,335.39],[353.08,353.08],[370.78,370.78],[388.48,388.48],[406.17,406.17],[423.85,423.85],[441.54,441.54],[459.25,459.25],[476.97,476.97],[494.64,494.64],[512.37,512.37],[530.0,530.0],[512.5,547.5]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"slashed","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"plain","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[300.0,300.0,0],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":11,"s":[92,92,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[694.4,-578.4],[-578.4,694.4],[694.4,1967.19],[1967.19,694.4]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1178.4,-94.4],[-94.4,1178.4],[1178.4,2451.19],[2451.19,1178.4]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Wipe"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[5.08,0.0],[4.71,0.87],[4.51,1.85],[4.12,2.81],[3.59,3.59],[2.7,3.96],[1.86,4.53],[0.91,4.92],[0.0,5.08],[0.0,8.44],[0.0,5.11],[0.0,4.82],[0.0,4.69],[0.0,4.6],[0.0,4.53],[0.0,4.46],[0.0,4.38],[0.0,4.24],[0.0,3.91],[0.0,0.0],[-0.55,3.81],[-1.17,3.71],[-1.8,3.51],[-2.38,3.21],[-2.85,2.85],[-3.96,2.7],[-4.53,1.86],[-4.92,0.91],[-5.08,0.0],[-4.71,-0.87],[-4.51,-1.85],[-4.12,-2.81],[-3.59,-3.59],[-2.3,-3.09],[-1.78,-3.47],[-1.19,-3.77],[-0.57,-3.96],[0.0,-4.04],[0.0,-8.44],[0.0,-5.11],[0.0,-4.82],[0.0,-4.69],[0.0,-4.6],[0.0,-4.53],[0.0,-4.46],[0.0,-4.38],[0.0,-4.24],[0.0,-3.91],[0.0,0.0],[0.87,-4.71],[1.85,-4.51],[2.81,-4.12],[3.59,-3.59],[3.96,-2.7],[4.53,-1.86],[4.92,-0.91]],"o":[[-5.08,0.0],[-4.92,-0.91],[-4.53,-1.86],[-3.96,-2.7],[-3.59,-3.59],[-2.81,-4.12],[-1.85,-4.51],[-0.87,-4.71],[0.0,0.0],[0.0,-3.91],[0.0,-4.24],[0.0,-4.38],[0.0,-4.46],[0.0,-4.53],[0.0,-4.6],[0.0,-4.69],[0.0,-4.82],[0.0,-5.11],[0.0,-8.44],[0.0,-4.04],[0.57,-3.96],[1.19,-3.77],[1.78,-3.47],[2.3,-3.09],[3.59,-3.59],[4.12,-2.81],[4.51,-1.85],[4.71,-0.87],[5.08,0.0],[4.92,0.91],[4.53,1.86],[3.96,2.7],[2.85,2.85],[2.38,3.21],[1.8,3.51],[1.17,3.71],[0.55,3.81],[0.0,0.0],[0.0,3.91],[0.0,4.24],[0.0,4.38],[0.0,4.46],[0.0,4.53],[0.0,4.6],[0.0,4.69],[0.0,4.82],[0.0,5.11],[0.0,8.44],[0.0,5.08],[-0.91,4.92],[-1.86,4.53],[-2.7,3.96],[-3.59,3.59],[-4.12,2.81],[-4.51,1.85],[-4.71,0.87]],"v":[[300.0,350.0],[285.31,348.7],[271.17,344.56],[258.2,337.56],[246.88,328.12],[237.44,316.8],[230.44,303.83],[226.3,289.69],[225.0,275.0],[225.0,261.37],[225.0,247.74],[225.0,234.09],[225.0,220.46],[225.0,206.82],[225.0,193.18],[225.0,179.54],[225.0,165.91],[225.0,152.26],[225.0,138.63],[225.0,125.0],[225.82,113.23],[228.43,101.72],[232.91,90.81],[239.15,80.8],[246.88,71.87],[258.2,62.44],[271.17,55.44],[285.31,51.3],[300.0,50.0],[314.69,51.3],[328.83,55.44],[341.8,62.44],[353.12,71.87],[360.85,80.8],[367.09,90.81],[371.57,101.72],[374.18,113.23],[375.0,125.0],[375.0,138.63],[375.0,152.26],[375.0,165.91],[375.0,179.54],[375.0,193.18],[375.0,206.82],[375.0,220.46],[375.0,234.09],[375.0,247.74],[375.0,261.37],[375.0,275.0],[373.7,289.69],[369.56,303.83],[362.56,316.8],[353.12,328.12],[341.8,337.56],[328.83,344.56],[314.69,348.7]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[-3.5,0.0],[-2.94,-1.17],[-2.43,-2.43],[-1.07,-1.86],[-0.53,-2.16],[0.0,-2.29],[0.0,-5.26],[0.0,-3.17],[0.0,-2.99],[0.0,-2.91],[0.0,-2.87],[0.0,-2.84],[0.0,-2.82],[0.0,-2.8],[0.0,-2.78],[0.0,-2.77],[0.0,-2.75],[0.0,-2.73],[0.0,-2.71],[0.0,-2.68],[0.0,-2.64],[0.0,-2.56],[0.0,-2.36],[0.0,0.0],[1.17,-2.94],[2.43,-2.43],[1.86,-1.07],[2.16,-0.53],[2.29,0.0],[2.94,1.17],[2.43,2.43],[1.07,1.86],[0.53,2.16],[0.0,2.29],[0.0,5.26],[0.0,3.17],[0.0,2.99],[0.0,2.91],[0.0,2.87],[0.0,2.84],[0.0,2.82],[0.0,2.8],[0.0,2.78],[0.0,2.77],[0.0,2.75],[0.0,2.73],[0.0,2.71],[0.0,2.68],[0.0,2.64],[0.0,2.56],[0.0,2.36],[0.0,0.0],[-0.5,2.05],[-1.09,1.9],[-1.6,1.6],[-3.01,1.2]],"o":[[3.5,0.0],[3.01,1.2],[1.6,1.6],[1.09,1.9],[0.5,2.05],[0.0,0.0],[0.0,2.36],[0.0,2.56],[0.0,2.64],[0.0,2.68],[0.0,2.71],[0.0,2.73],[0.0,2.75],[0.0,2.77],[0.0,2.78],[0.0,2.8],[0.0,2.82],[0.0,2.84],[0.0,2.87],[0.0,2.91],[0.0,2.99],[0.0,3.17],[0.0,5.26],[0.0,3.5],[-1.2,3.01],[-1.6,1.6],[-1.9,1.09],[-2.05,0.5],[-3.5,0.0],[-3.01,-1.2],[-1.6,-1.6],[-1.09,-1.9],[-0.5,-2.05],[0.0,0.0],[0.0,-2.36],[0.0,-2.56],[0.0,-2.64],[0.0,-2.68],[0.0,-2.71],[0.0,-2.73],[0.0,-2.75],[0.0,-2.77],[0.0,-2.78],[0.0,-2.8],[0.0,-2.82],[0.0,-2.84],[0.0,-2.87],[0.0,-2.91],[0.0,-2.99],[0.0,-3.17],[0.0,-5.26],[0.0,-2.29],[0.53,-2.16],[1.07,-1.86],[2.43,-2.43],[2.94,-1.17]],"v":[[300.0,100.0],[309.65,101.75],[317.81,107.19],[321.82,112.38],[324.25,118.48],[325.0,125.0],[325.0,133.32],[325.0,141.65],[325.0,149.99],[325.0,158.32],[325.0,166.66],[325.0,175.0],[325.0,183.33],[325.0,191.66],[325.0,200.0],[325.0,208.34],[325.0,216.67],[325.0,225.0],[325.0,233.34],[325.0,241.68],[325.0,250.01],[325.0,258.35],[325.0,266.68],[325.0,275.0],[323.25,284.65],[317.81,292.81],[312.62,296.82],[306.52,299.25],[300.0,300.0],[290.35,298.25],[282.19,292.81],[278.18,287.62],[275.75,281.52],[275.0,275.0],[275.0,266.68],[275.0,258.35],[275.0,250.01],[275.0,241.68],[275.0,233.34],[275.0,225.0],[275.0,216.67],[275.0,208.34],[275.0,200.0],[275.0,191.66],[275.0,183.33],[275.0,175.0],[275.0,166.66],[275.0,158.32],[275.0,149.99],[275.0,141.65],[275.0,133.32],[275.0,125.0],[275.75,118.48],[278.18,112.38],[282.19,107.19],[290.35,101.75]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,11.03],[0.0,6.68],[0.0,5.87],[0.0,0.0],[6.63,1.66],[6.41,2.46],[6.09,3.29],[5.67,4.08],[5.16,4.78],[4.62,5.36],[4.47,6.88],[3.45,7.54],[2.3,8.06],[1.12,8.4],[0.0,8.54],[-12.5,0.0],[0.0,0.0],[-0.92,-6.34],[-1.96,-6.18],[-3.01,-5.84],[-3.98,-5.34],[-4.76,-4.76],[-5.14,-3.83],[-5.76,-2.97],[-6.27,-1.99],[-6.59,-0.96],[-6.73,0.0],[-6.34,0.92],[-6.18,1.96],[-5.84,3.01],[-5.34,3.98],[-4.76,4.76],[-3.83,5.14],[-2.97,5.76],[-1.99,6.27],[-0.96,6.59],[0.0,6.73],[-12.5,0.0],[0.0,0.0],[1.08,-8.13],[2.27,-7.96],[3.48,-7.61],[4.61,-7.09],[5.57,-6.47],[5.02,-4.64],[5.58,-4.02],[6.1,-3.29],[6.53,-2.51],[6.83,-1.71],[7.02,-0.95],[0.0,-11.03],[0.0,-6.68],[0.0,-5.87],[0.0,0.0],[12.5,0.0]],"o":[[0.0,0.0],[0.0,-5.87],[0.0,-6.68],[0.0,-11.03],[-7.02,-0.95],[-6.83,-1.71],[-6.53,-2.51],[-6.1,-3.29],[-5.58,-4.02],[-5.02,-4.64],[-5.57,-6.47],[-4.61,-7.09],[-3.48,-7.61],[-2.27,-7.96],[-1.08,-8.13],[0.0,0.0],[12.5,0.0],[0.0,6.73],[0.96,6.59],[1.99,6.27],[2.97,5.76],[3.83,5.14],[4.76,4.76],[5.34,3.98],[5.84,3.01],[6.18,1.96],[6.34,0.92],[6.73,0.0],[6.59,-0.96],[6.27,-1.99],[5.76,-2.97],[5.14,-3.83],[4.76,-4.76],[3.98,-5.34],[3.01,-5.84],[1.96,-6.18],[0.92,-6.34],[0.0,0.0],[12.5,0.0],[0.0,8.54],[-1.12,8.4],[-2.3,8.06],[-3.45,7.54],[-4.47,6.88],[-4.62,5.36],[-5.16,4.78],[-5.67,4.08],[-6.09,3.29],[-6.41,2.46],[-6.63,1.66],[0.0,0.0],[0.0,5.87],[0.0,6.68],[0.0,11.03],[0.0,0.0],[-12.5,0.0]],"v":[[275.0,525.0],[275.0,505.78],[275.0,486.56],[275.0,467.34],[275.0,448.12],[254.53,444.22],[234.66,437.97],[215.72,429.27],[198.07,418.21],[181.95,405.01],[167.5,390.0],[152.44,369.98],[140.36,348.03],[131.69,324.53],[126.62,300.0],[125.0,275.0],[150.0,275.0],[175.0,275.0],[176.38,294.61],[180.76,313.77],[188.26,331.93],[198.69,348.59],[211.56,363.44],[226.41,376.31],[243.07,386.74],[261.23,394.24],[280.39,398.62],[300.0,400.0],[319.61,398.62],[338.77,394.24],[356.93,386.74],[373.59,376.31],[388.44,363.44],[401.31,348.59],[411.74,331.93],[419.24,313.77],[423.62,294.61],[425.0,275.0],[450.0,275.0],[475.0,275.0],[473.38,300.0],[468.31,324.53],[459.64,348.03],[447.56,369.98],[432.5,390.0],[418.05,405.01],[401.93,418.21],[384.28,429.27],[365.34,437.97],[345.47,444.22],[325.0,448.12],[325.0,467.34],[325.0,486.56],[325.0,505.78],[325.0,525.0],[300.0,525.0]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[-2.29,0.0],[-2.94,1.17],[-2.43,2.43],[-1.07,1.86],[-0.53,2.16],[0.0,2.29],[0.0,5.26],[0.0,3.17],[0.0,2.99],[0.0,2.91],[0.0,2.87],[0.0,2.84],[0.0,2.82],[0.0,2.8],[0.0,2.78],[0.0,2.77],[0.0,2.75],[0.0,2.73],[0.0,2.71],[0.0,2.68],[0.0,2.64],[0.0,2.56],[0.0,2.36],[0.0,0.0],[0.5,2.05],[1.09,1.9],[1.6,1.6],[3.01,1.2],[3.5,0.0],[2.94,-1.17],[2.43,-2.43],[1.07,-1.86],[0.53,-2.16],[0.0,-2.29],[0.0,-5.26],[0.0,-3.17],[0.0,-2.99],[0.0,-2.91],[0.0,-2.87],[0.0,-2.84],[0.0,-2.82],[0.0,-2.8],[0.0,-2.78],[0.0,-2.77],[0.0,-2.75],[0.0,-2.73],[0.0,-2.71],[0.0,-2.68],[0.0,-2.64],[0.0,-2.56],[0.0,-2.36],[0.0,0.0],[-1.17,-2.94],[-2.43,-2.43],[-1.86,-1.07],[-2.16,-0.53]],"o":[[3.5,0.0],[3.01,-1.2],[1.6,-1.6],[1.09,-1.9],[0.5,-2.05],[0.0,0.0],[0.0,-2.36],[0.0,-2.56],[0.0,-2.64],[0.0,-2.68],[0.0,-2.71],[0.0,-2.73],[0.0,-2.75],[0.0,-2.77],[0.0,-2.78],[0.0,-2.8],[0.0,-2.82],[0.0,-2.84],[0.0,-2.87],[0.0,-2.91],[0.0,-2.99],[0.0,-3.17],[0.0,-5.26],[0.0,-2.29],[-0.53,-2.16],[-1.07,-1.86],[-2.43,-2.43],[-2.94,-1.17],[-3.5,0.0],[-3.01,1.2],[-1.6,1.6],[-1.09,1.9],[-0.5,2.05],[0.0,0.0],[0.0,2.36],[0.0,2.56],[0.0,2.64],[0.0,2.68],[0.0,2.71],[0.0,2.73],[0.0,2.75],[0.0,2.77],[0.0,2.78],[0.0,2.8],[0.0,2.82],[0.0,2.84],[0.0,2.87],[0.0,2.91],[0.0,2.99],[0.0,3.17],[0.0,5.26],[0.0,3.5],[1.2,3.01],[1.6,1.6],[1.9,1.09],[2.05,0.5]],"v":[[300.0,300.0],[309.65,298.25],[317.81,292.81],[321.82,287.62],[324.25,281.52],[325.0,275.0],[325.0,266.68],[325.0,258.35],[325.0,250.01],[325.0,241.68],[325.0,233.34],[325.0,225.0],[325.0,216.67],[325.0,208.34],[325.0,200.0],[325.0,191.66],[325.0,183.33],[325.0,175.0],[325.0,166.66],[325.0,158.32],[325.0,149.99],[325.0,141.65],[325.0,133.32],[325.0,125.0],[324.25,118.48],[321.82,112.38],[317.81,107.19],[309.65,101.75],[300.0,100.0],[290.35,101.75],[282.19,107.19],[278.18,112.38],[275.75,118.48],[275.0,125.0],[275.0,133.32],[275.0,141.65],[275.0,149.99],[275.0,158.32],[275.0,166.66],[275.0,175.0],[275.0,183.33],[275.0,191.66],[275.0,200.0],[275.0,208.34],[275.0,216.67],[275.0,225.0],[275.0,233.34],[275.0,241.68],[275.0,250.01],[275.0,258.35],[275.0,266.68],[275.0,275.0],[276.75,284.65],[282.19,292.81],[287.38,296.82],[293.48,299.25]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"plain","np":6,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lottie/ic_mic_to_send.json b/assets/lottie/ic_mic_to_send.json new file mode 100644 index 0000000..36634fe --- /dev/null +++ b/assets/lottie/ic_mic_to_send.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_mic_to_send","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"ic_mic_to_send","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"t":0,"s":[300.0,300.0,0],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":9,"s":[266.0,300.0,0],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":19,"s":[312.0,300.0,0],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[300.0,300.0,0]}],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":9,"s":[90,90,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[5.08,0.0],[4.71,0.87],[4.51,1.85],[4.12,2.81],[3.59,3.59],[2.7,3.96],[1.86,4.53],[0.91,4.92],[0.0,5.08],[0.0,8.44],[0.0,5.11],[0.0,4.82],[0.0,4.69],[0.0,4.6],[0.0,4.53],[0.0,4.46],[0.0,4.38],[0.0,4.24],[0.0,3.91],[0.0,0.0],[-0.55,3.81],[-1.17,3.71],[-1.8,3.51],[-2.38,3.21],[-2.85,2.85],[-3.96,2.7],[-4.53,1.86],[-4.92,0.91],[-5.08,0.0],[-4.71,-0.87],[-4.51,-1.85],[-4.12,-2.81],[-3.59,-3.59],[-2.3,-3.09],[-1.78,-3.47],[-1.19,-3.77],[-0.57,-3.96],[0.0,-4.04],[0.0,-8.44],[0.0,-5.11],[0.0,-4.82],[0.0,-4.69],[0.0,-4.6],[0.0,-4.53],[0.0,-4.46],[0.0,-4.38],[0.0,-4.24],[0.0,-3.91],[0.0,0.0],[0.87,-4.71],[1.85,-4.51],[2.81,-4.12],[3.59,-3.59],[3.96,-2.7],[4.53,-1.86],[4.92,-0.91]],"o":[[-5.08,0.0],[-4.92,-0.91],[-4.53,-1.86],[-3.96,-2.7],[-3.59,-3.59],[-2.81,-4.12],[-1.85,-4.51],[-0.87,-4.71],[0.0,0.0],[0.0,-3.91],[0.0,-4.24],[0.0,-4.38],[0.0,-4.46],[0.0,-4.53],[0.0,-4.6],[0.0,-4.69],[0.0,-4.82],[0.0,-5.11],[0.0,-8.44],[0.0,-4.04],[0.57,-3.96],[1.19,-3.77],[1.78,-3.47],[2.3,-3.09],[3.59,-3.59],[4.12,-2.81],[4.51,-1.85],[4.71,-0.87],[5.08,0.0],[4.92,0.91],[4.53,1.86],[3.96,2.7],[2.85,2.85],[2.38,3.21],[1.8,3.51],[1.17,3.71],[0.55,3.81],[0.0,0.0],[0.0,3.91],[0.0,4.24],[0.0,4.38],[0.0,4.46],[0.0,4.53],[0.0,4.6],[0.0,4.69],[0.0,4.82],[0.0,5.11],[0.0,8.44],[0.0,5.08],[-0.91,4.92],[-1.86,4.53],[-2.7,3.96],[-3.59,3.59],[-4.12,2.81],[-4.51,1.85],[-4.71,0.87]],"v":[[300.0,350.0],[285.31,348.7],[271.17,344.56],[258.2,337.56],[246.88,328.12],[237.44,316.8],[230.44,303.83],[226.3,289.69],[225.0,275.0],[225.0,261.37],[225.0,247.74],[225.0,234.09],[225.0,220.46],[225.0,206.82],[225.0,193.18],[225.0,179.54],[225.0,165.91],[225.0,152.26],[225.0,138.63],[225.0,125.0],[225.82,113.23],[228.43,101.72],[232.91,90.81],[239.15,80.8],[246.88,71.87],[258.2,62.44],[271.17,55.44],[285.31,51.3],[300.0,50.0],[314.69,51.3],[328.83,55.44],[341.8,62.44],[353.12,71.87],[360.85,80.8],[367.09,90.81],[371.57,101.72],[374.18,113.23],[375.0,125.0],[375.0,138.63],[375.0,152.26],[375.0,165.91],[375.0,179.54],[375.0,193.18],[375.0,206.82],[375.0,220.46],[375.0,234.09],[375.0,247.74],[375.0,261.37],[375.0,275.0],[373.7,289.69],[369.56,303.83],[362.56,316.8],[353.12,328.12],[341.8,337.56],[328.83,344.56],[314.69,348.7]],"c":true}]},{"t":24,"s":[{"i":[[7.77,-3.27],[7.71,-3.24],[7.63,-3.21],[7.5,-3.16],[7.27,-3.06],[6.72,-2.83],[0.0,0.0],[0.0,15.7],[0.0,9.49],[0.0,8.93],[0.0,8.71],[0.0,8.57],[0.0,8.48],[0.0,8.42],[0.0,8.35],[0.0,8.3],[0.0,8.23],[0.0,8.16],[0.0,8.07],[0.0,7.93],[0.0,7.68],[0.0,7.11],[0.0,0.0],[-15.01,-6.32],[-9.05,-3.81],[-8.53,-3.59],[-8.31,-3.5],[-8.2,-3.45],[-8.11,-3.41],[-8.05,-3.39],[-8.01,-3.37],[-7.97,-3.35],[-7.93,-3.34],[-7.89,-3.32],[-7.86,-3.31],[-7.82,-3.29],[-7.77,-3.27],[-7.71,-3.24],[-7.63,-3.21],[-7.5,-3.16],[-7.27,-3.06],[-6.72,-2.83],[0.0,0.0],[15.01,-6.32],[9.05,-3.81],[8.53,-3.59],[8.31,-3.5],[8.2,-3.45],[8.11,-3.41],[8.05,-3.39],[8.01,-3.37],[7.97,-3.35],[7.93,-3.34],[7.89,-3.32],[7.86,-3.31],[7.82,-3.29]],"o":[[-8.11,3.41],[-8.2,3.45],[-8.31,3.5],[-8.53,3.59],[-9.05,3.81],[-15.01,6.32],[0.0,0.0],[0.0,-7.11],[0.0,-7.68],[0.0,-7.93],[0.0,-8.07],[0.0,-8.16],[0.0,-8.23],[0.0,-8.3],[0.0,-8.35],[0.0,-8.42],[0.0,-8.48],[0.0,-8.57],[0.0,-8.71],[0.0,-8.93],[0.0,-9.49],[0.0,-15.7],[0.0,0.0],[6.72,2.83],[7.27,3.06],[7.5,3.16],[7.63,3.21],[7.71,3.24],[7.77,3.27],[7.82,3.29],[7.86,3.31],[7.89,3.32],[7.93,3.34],[7.97,3.35],[8.01,3.37],[8.05,3.39],[8.11,3.41],[8.2,3.45],[8.31,3.5],[8.53,3.59],[9.05,3.81],[15.01,6.32],[0.0,0.0],[-6.72,2.83],[-7.27,3.06],[-7.5,3.16],[-7.63,3.21],[-7.71,3.24],[-7.77,3.27],[-7.82,3.29],[-7.86,3.31],[-7.89,3.32],[-7.93,3.34],[-7.97,3.35],[-8.01,3.37],[-8.05,3.39]],"v":[[217.48,440.01],[193.74,450.01],[169.97,460.01],[146.21,470.02],[122.45,480.02],[98.69,490.02],[75.0,500.0],[75.0,475.04],[75.0,450.02],[75.0,425.03],[75.0,400.01],[75.0,375.01],[75.0,350.01],[75.0,325.01],[75.0,300.0],[75.0,274.99],[75.0,249.99],[75.0,224.99],[75.0,199.99],[75.0,174.97],[75.0,149.98],[75.0,124.96],[75.0,100.0],[98.69,109.98],[122.45,119.98],[146.21,129.98],[169.97,139.99],[193.74,149.99],[217.48,159.99],[241.23,169.99],[264.99,180.0],[288.75,190.0],[312.5,200.0],[336.25,210.0],[360.01,220.0],[383.77,230.01],[407.52,240.01],[431.26,250.01],[455.03,260.01],[478.79,270.02],[502.55,280.02],[526.31,290.02],[550.0,300.0],[526.31,309.98],[502.55,319.98],[478.79,329.98],[455.03,339.99],[431.26,349.99],[407.52,359.99],[383.77,369.99],[360.01,380.0],[336.25,390.0],[312.5,400.0],[288.75,410.0],[264.99,420.0],[241.23,430.01]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0.0,0.0],[0.0,11.03],[0.0,6.68],[0.0,5.87],[0.0,0.0],[6.63,1.66],[6.41,2.46],[6.09,3.29],[5.67,4.08],[5.16,4.78],[4.62,5.36],[4.47,6.88],[3.45,7.54],[2.3,8.06],[1.12,8.4],[0.0,8.54],[-12.5,0.0],[0.0,0.0],[-0.92,-6.34],[-1.96,-6.18],[-3.01,-5.84],[-3.98,-5.34],[-4.76,-4.76],[-5.14,-3.83],[-5.76,-2.97],[-6.27,-1.99],[-6.59,-0.96],[-6.73,0.0],[-6.34,0.92],[-6.18,1.96],[-5.84,3.01],[-5.34,3.98],[-4.76,4.76],[-3.83,5.14],[-2.97,5.76],[-1.99,6.27],[-0.96,6.59],[0.0,6.73],[-12.5,0.0],[0.0,0.0],[1.08,-8.13],[2.27,-7.96],[3.48,-7.61],[4.61,-7.09],[5.57,-6.47],[5.02,-4.64],[5.58,-4.02],[6.1,-3.29],[6.53,-2.51],[6.83,-1.71],[7.02,-0.95],[0.0,-11.03],[0.0,-6.68],[0.0,-5.87],[0.0,0.0],[12.5,0.0]],"o":[[0.0,0.0],[0.0,-5.87],[0.0,-6.68],[0.0,-11.03],[-7.02,-0.95],[-6.83,-1.71],[-6.53,-2.51],[-6.1,-3.29],[-5.58,-4.02],[-5.02,-4.64],[-5.57,-6.47],[-4.61,-7.09],[-3.48,-7.61],[-2.27,-7.96],[-1.08,-8.13],[0.0,0.0],[12.5,0.0],[0.0,6.73],[0.96,6.59],[1.99,6.27],[2.97,5.76],[3.83,5.14],[4.76,4.76],[5.34,3.98],[5.84,3.01],[6.18,1.96],[6.34,0.92],[6.73,0.0],[6.59,-0.96],[6.27,-1.99],[5.76,-2.97],[5.14,-3.83],[4.76,-4.76],[3.98,-5.34],[3.01,-5.84],[1.96,-6.18],[0.92,-6.34],[0.0,0.0],[12.5,0.0],[0.0,8.54],[-1.12,8.4],[-2.3,8.06],[-3.45,7.54],[-4.47,6.88],[-4.62,5.36],[-5.16,4.78],[-5.67,4.08],[-6.09,3.29],[-6.41,2.46],[-6.63,1.66],[0.0,0.0],[0.0,5.87],[0.0,6.68],[0.0,11.03],[0.0,0.0],[-12.5,0.0]],"v":[[275.0,525.0],[275.0,505.78],[275.0,486.56],[275.0,467.34],[275.0,448.12],[254.53,444.22],[234.66,437.97],[215.72,429.27],[198.07,418.21],[181.95,405.01],[167.5,390.0],[152.44,369.98],[140.36,348.03],[131.69,324.53],[126.62,300.0],[125.0,275.0],[150.0,275.0],[175.0,275.0],[176.38,294.61],[180.76,313.77],[188.26,331.93],[198.69,348.59],[211.56,363.44],[226.41,376.31],[243.07,386.74],[261.23,394.24],[280.39,398.62],[300.0,400.0],[319.61,398.62],[338.77,394.24],[356.93,386.74],[373.59,376.31],[388.44,363.44],[401.31,348.59],[411.74,331.93],[419.24,313.77],[423.62,294.61],[425.0,275.0],[450.0,275.0],[475.0,275.0],[473.38,300.0],[468.31,324.53],[459.64,348.03],[447.56,369.98],[432.5,390.0],[418.05,405.01],[401.93,418.21],[384.28,429.27],[365.34,437.97],[345.47,444.22],[325.0,448.12],[325.0,467.34],[325.0,486.56],[325.0,505.78],[325.0,525.0],[300.0,525.0]],"c":true}]},{"t":10,"s":[{"i":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"o":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"v":[[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56]],"c":true}]}],"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[-2.29,0.0],[-2.94,1.17],[-2.43,2.43],[-1.07,1.86],[-0.53,2.16],[0.0,2.29],[0.0,5.26],[0.0,3.17],[0.0,2.99],[0.0,2.91],[0.0,2.87],[0.0,2.84],[0.0,2.82],[0.0,2.8],[0.0,2.78],[0.0,2.77],[0.0,2.75],[0.0,2.73],[0.0,2.71],[0.0,2.68],[0.0,2.64],[0.0,2.56],[0.0,2.36],[0.0,0.0],[0.5,2.05],[1.09,1.9],[1.6,1.6],[3.01,1.2],[3.5,0.0],[2.94,-1.17],[2.43,-2.43],[1.07,-1.86],[0.53,-2.16],[0.0,-2.29],[0.0,-5.26],[0.0,-3.17],[0.0,-2.99],[0.0,-2.91],[0.0,-2.87],[0.0,-2.84],[0.0,-2.82],[0.0,-2.8],[0.0,-2.78],[0.0,-2.77],[0.0,-2.75],[0.0,-2.73],[0.0,-2.71],[0.0,-2.68],[0.0,-2.64],[0.0,-2.56],[0.0,-2.36],[0.0,0.0],[-1.17,-2.94],[-2.43,-2.43],[-1.86,-1.07],[-2.16,-0.53]],"o":[[3.5,0.0],[3.01,-1.2],[1.6,-1.6],[1.09,-1.9],[0.5,-2.05],[0.0,0.0],[0.0,-2.36],[0.0,-2.56],[0.0,-2.64],[0.0,-2.68],[0.0,-2.71],[0.0,-2.73],[0.0,-2.75],[0.0,-2.77],[0.0,-2.78],[0.0,-2.8],[0.0,-2.82],[0.0,-2.84],[0.0,-2.87],[0.0,-2.91],[0.0,-2.99],[0.0,-3.17],[0.0,-5.26],[0.0,-2.29],[-0.53,-2.16],[-1.07,-1.86],[-2.43,-2.43],[-2.94,-1.17],[-3.5,0.0],[-3.01,1.2],[-1.6,1.6],[-1.09,1.9],[-0.5,2.05],[0.0,0.0],[0.0,2.36],[0.0,2.56],[0.0,2.64],[0.0,2.68],[0.0,2.71],[0.0,2.73],[0.0,2.75],[0.0,2.77],[0.0,2.78],[0.0,2.8],[0.0,2.82],[0.0,2.84],[0.0,2.87],[0.0,2.91],[0.0,2.99],[0.0,3.17],[0.0,5.26],[0.0,3.5],[1.2,3.01],[1.6,1.6],[1.9,1.09],[2.05,0.5]],"v":[[300.0,300.0],[309.65,298.25],[317.81,292.81],[321.82,287.62],[324.25,281.52],[325.0,275.0],[325.0,266.68],[325.0,258.35],[325.0,250.01],[325.0,241.68],[325.0,233.34],[325.0,225.0],[325.0,216.67],[325.0,208.34],[325.0,200.0],[325.0,191.66],[325.0,183.33],[325.0,175.0],[325.0,166.66],[325.0,158.32],[325.0,149.99],[325.0,141.65],[325.0,133.32],[325.0,125.0],[324.25,118.48],[321.82,112.38],[317.81,107.19],[309.65,101.75],[300.0,100.0],[290.35,101.75],[282.19,107.19],[278.18,112.38],[275.75,118.48],[275.0,125.0],[275.0,133.32],[275.0,141.65],[275.0,149.99],[275.0,158.32],[275.0,166.66],[275.0,175.0],[275.0,183.33],[275.0,191.66],[275.0,200.0],[275.0,208.34],[275.0,216.67],[275.0,225.0],[275.0,233.34],[275.0,241.68],[275.0,250.01],[275.0,258.35],[275.0,266.68],[275.0,275.0],[276.75,284.65],[282.19,292.81],[287.38,296.82],[293.48,299.25]],"c":true}]},{"t":24,"s":[{"i":[[-7.03,2.96],[-6.62,2.79],[-6.45,2.72],[-6.35,2.68],[-6.28,2.65],[-6.23,2.63],[-6.19,2.61],[-6.14,2.59],[-6.1,2.57],[-6.04,2.55],[-5.97,2.52],[-5.88,2.48],[-5.69,2.4],[-5.26,2.22],[0.0,0.0],[11.63,4.91],[7.03,2.96],[6.62,2.79],[6.45,2.72],[6.35,2.68],[6.28,2.65],[6.23,2.63],[6.19,2.61],[6.14,2.59],[6.1,2.57],[6.04,2.55],[5.97,2.52],[5.88,2.48],[5.69,2.4],[5.26,2.22],[0.0,0.0],[0.0,-12.55],[0.0,-7.6],[0.0,-6.68],[0.0,0.0],[-11.42,-2.85],[-6.95,-1.74],[-6.52,-1.63],[-6.31,-1.58],[-6.13,-1.53],[-5.9,-1.48],[-5.44,-1.36],[0.0,0.0],[11.42,-2.85],[6.95,-1.74],[6.52,-1.63],[6.31,-1.58],[6.13,-1.53],[5.9,-1.48],[5.44,-1.36],[0.0,0.0],[0.0,-12.55],[0.0,-7.6],[0.0,-6.68],[0.0,0.0],[-11.63,4.91]],"o":[[5.69,-2.4],[5.88,-2.48],[5.97,-2.52],[6.04,-2.55],[6.1,-2.57],[6.14,-2.59],[6.19,-2.61],[6.23,-2.63],[6.28,-2.65],[6.35,-2.68],[6.45,-2.72],[6.62,-2.79],[7.03,-2.96],[11.63,-4.91],[0.0,0.0],[-5.26,-2.22],[-5.69,-2.4],[-5.88,-2.48],[-5.97,-2.52],[-6.04,-2.55],[-6.1,-2.57],[-6.14,-2.59],[-6.19,-2.61],[-6.23,-2.63],[-6.28,-2.65],[-6.35,-2.68],[-6.45,-2.72],[-6.62,-2.79],[-7.03,-2.96],[-11.63,-4.91],[0.0,0.0],[0.0,6.68],[0.0,7.6],[0.0,12.55],[0.0,0.0],[5.44,1.36],[5.9,1.48],[6.13,1.53],[6.31,1.58],[6.52,1.63],[6.95,1.74],[11.42,2.85],[0.0,0.0],[-5.44,1.36],[-5.9,1.48],[-6.13,1.53],[-6.31,1.58],[-6.52,1.63],[-6.95,1.74],[-11.42,2.85],[0.0,0.0],[0.0,6.68],[0.0,7.6],[0.0,12.55],[0.0,0.0],[5.26,-2.22]],"v":[[162.02,409.38],[180.53,401.57],[199.05,393.75],[217.57,385.94],[236.08,378.13],[254.61,370.31],[273.12,362.5],[291.64,354.69],[310.17,346.87],[328.68,339.06],[347.2,331.25],[365.72,323.43],[384.23,315.62],[402.76,307.8],[421.25,300.0],[402.76,292.2],[384.23,284.38],[365.72,276.57],[347.2,268.75],[328.68,260.94],[310.17,253.13],[291.64,245.31],[273.12,237.5],[254.61,229.69],[236.08,221.87],[217.57,214.06],[199.05,206.25],[180.53,198.43],[162.02,190.62],[143.49,182.8],[125.0,175.0],[125.0,196.87],[125.0,218.75],[125.0,240.63],[125.0,262.5],[143.74,267.19],[162.5,271.87],[181.25,276.56],[200.0,281.25],[218.75,285.94],[237.5,290.63],[256.26,295.31],[275.0,300.0],[256.26,304.69],[237.5,309.37],[218.75,314.06],[200.0,318.75],[181.25,323.44],[162.5,328.13],[143.74,332.81],[125.0,337.5],[125.0,359.37],[125.0,381.25],[125.0,403.13],[125.0,425.0],[143.49,417.2]],"c":true}]}],"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lottie/ic_mic_to_videocam.json b/assets/lottie/ic_mic_to_videocam.json new file mode 100644 index 0000000..645cb90 --- /dev/null +++ b/assets/lottie/ic_mic_to_videocam.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_mic_to_videocam","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"ic_mic_to_videocam","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"t":0,"s":[0],"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0}},{"t":10,"s":[-14],"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0}},{"t":24,"s":[0]}],"ix":10},"p":{"a":0,"k":[300.0,300.0,0],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":10,"s":[88,88,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[5.08,0.0],[4.71,0.87],[4.51,1.85],[4.12,2.81],[3.59,3.59],[2.7,3.96],[1.86,4.53],[0.91,4.92],[0.0,5.08],[0.0,8.44],[0.0,5.11],[0.0,4.82],[0.0,4.69],[0.0,4.6],[0.0,4.53],[0.0,4.46],[0.0,4.38],[0.0,4.24],[0.0,3.91],[0.0,0.0],[-0.55,3.81],[-1.17,3.71],[-1.8,3.51],[-2.38,3.21],[-2.85,2.85],[-3.96,2.7],[-4.53,1.86],[-4.92,0.91],[-5.08,0.0],[-4.71,-0.87],[-4.51,-1.85],[-4.12,-2.81],[-3.59,-3.59],[-2.3,-3.09],[-1.78,-3.47],[-1.19,-3.77],[-0.57,-3.96],[0.0,-4.04],[0.0,-8.44],[0.0,-5.11],[0.0,-4.82],[0.0,-4.69],[0.0,-4.6],[0.0,-4.53],[0.0,-4.46],[0.0,-4.38],[0.0,-4.24],[0.0,-3.91],[0.0,0.0],[0.87,-4.71],[1.85,-4.51],[2.81,-4.12],[3.59,-3.59],[3.96,-2.7],[4.53,-1.86],[4.92,-0.91]],"o":[[-5.08,0.0],[-4.92,-0.91],[-4.53,-1.86],[-3.96,-2.7],[-3.59,-3.59],[-2.81,-4.12],[-1.85,-4.51],[-0.87,-4.71],[0.0,0.0],[0.0,-3.91],[0.0,-4.24],[0.0,-4.38],[0.0,-4.46],[0.0,-4.53],[0.0,-4.6],[0.0,-4.69],[0.0,-4.82],[0.0,-5.11],[0.0,-8.44],[0.0,-4.04],[0.57,-3.96],[1.19,-3.77],[1.78,-3.47],[2.3,-3.09],[3.59,-3.59],[4.12,-2.81],[4.51,-1.85],[4.71,-0.87],[5.08,0.0],[4.92,0.91],[4.53,1.86],[3.96,2.7],[2.85,2.85],[2.38,3.21],[1.8,3.51],[1.17,3.71],[0.55,3.81],[0.0,0.0],[0.0,3.91],[0.0,4.24],[0.0,4.38],[0.0,4.46],[0.0,4.53],[0.0,4.6],[0.0,4.69],[0.0,4.82],[0.0,5.11],[0.0,8.44],[0.0,5.08],[-0.91,4.92],[-1.86,4.53],[-2.7,3.96],[-3.59,3.59],[-4.12,2.81],[-4.51,1.85],[-4.71,0.87]],"v":[[300.0,350.0],[285.31,348.7],[271.17,344.56],[258.2,337.56],[246.88,328.12],[237.44,316.8],[230.44,303.83],[226.3,289.69],[225.0,275.0],[225.0,261.37],[225.0,247.74],[225.0,234.09],[225.0,220.46],[225.0,206.82],[225.0,193.18],[225.0,179.54],[225.0,165.91],[225.0,152.26],[225.0,138.63],[225.0,125.0],[225.82,113.23],[228.43,101.72],[232.91,90.81],[239.15,80.8],[246.88,71.87],[258.2,62.44],[271.17,55.44],[285.31,51.3],[300.0,50.0],[314.69,51.3],[328.83,55.44],[341.8,62.44],[353.12,71.87],[360.85,80.8],[367.09,90.81],[371.57,101.72],[374.18,113.23],[375.0,125.0],[375.0,138.63],[375.0,152.26],[375.0,165.91],[375.0,179.54],[375.0,193.18],[375.0,206.82],[375.0,220.46],[375.0,234.09],[375.0,247.74],[375.0,261.37],[375.0,275.0],[373.7,289.69],[369.56,303.83],[362.56,316.8],[353.12,328.12],[341.8,337.56],[328.83,344.56],[314.69,348.7]],"c":true}]},{"t":24,"s":[{"i":[[13.75,0.0],[22.83,0.0],[13.89,0.0],[13.05,0.0],[12.62,0.0],[12.26,0.0],[11.81,0.0],[10.88,0.0],[0.0,0.0],[9.79,9.79],[0.0,13.75],[0.0,22.83],[0.0,13.89],[0.0,13.05],[0.0,12.62],[0.0,12.26],[0.0,11.81],[0.0,10.88],[0.0,0.0],[-9.79,9.79],[-5.87,2.45],[-6.89,0.0],[-22.83,0.0],[-13.89,0.0],[-13.05,0.0],[-12.62,0.0],[-12.26,0.0],[-11.81,0.0],[-10.88,0.0],[0.0,0.0],[-9.79,-9.79],[0.0,-13.75],[0.0,-20.65],[0.0,-12.07],[0.0,0.0],[-14.35,14.35],[-8.68,8.68],[-7.64,7.64],[0.0,0.0],[0.0,-20.93],[0.0,-12.73],[0.0,-11.96],[0.0,-11.57],[0.0,-11.24],[0.0,-10.82],[0.0,-9.97],[0.0,0.0],[14.35,14.35],[8.68,8.68],[7.64,7.64],[0.0,0.0],[0.0,-20.65],[0.0,-12.07],[0.0,0.0],[2.46,-5.9],[4.89,-4.89]],"o":[[0.0,0.0],[-10.88,0.0],[-11.81,0.0],[-12.26,0.0],[-12.62,0.0],[-13.05,0.0],[-13.89,0.0],[-22.83,0.0],[-13.75,0.0],[-9.79,-9.79],[0.0,0.0],[0.0,-10.88],[0.0,-11.81],[0.0,-12.26],[0.0,-12.62],[0.0,-13.05],[0.0,-13.89],[0.0,-22.83],[0.0,-13.75],[4.89,-4.89],[5.9,-2.46],[0.0,0.0],[10.88,0.0],[11.81,0.0],[12.26,0.0],[12.62,0.0],[13.05,0.0],[13.89,0.0],[22.83,0.0],[13.75,0.0],[9.79,9.79],[0.0,0.0],[0.0,12.07],[0.0,20.65],[0.0,0.0],[7.64,-7.64],[8.68,-8.68],[14.35,-14.35],[0.0,0.0],[0.0,9.97],[0.0,10.82],[0.0,11.24],[0.0,11.57],[0.0,11.96],[0.0,12.73],[0.0,20.93],[0.0,0.0],[-7.64,-7.64],[-8.68,-8.68],[-14.35,-14.35],[0.0,0.0],[0.0,12.07],[0.0,20.65],[0.0,6.89],[-2.45,5.87],[-9.79,9.79]],"v":[[400.0,500.0],[362.51,500.0],[325.01,500.0],[287.51,500.0],[250.0,500.0],[212.49,500.0],[174.99,500.0],[137.49,500.0],[100.0,500.0],[64.69,485.31],[50.0,450.0],[50.0,412.51],[50.0,375.01],[50.0,337.51],[50.0,300.0],[50.0,262.49],[50.0,224.99],[50.0,187.49],[50.0,150.0],[64.69,114.69],[80.82,103.69],[100.0,100.0],[137.49,100.0],[174.99,100.0],[212.49,100.0],[250.0,100.0],[287.51,100.0],[325.01,100.0],[362.51,100.0],[400.0,100.0],[435.31,114.69],[450.0,150.0],[450.0,187.5],[450.0,225.0],[450.0,262.5],[475.0,237.5],[500.0,212.5],[525.0,187.5],[550.0,162.5],[550.0,196.86],[550.0,231.24],[550.0,265.62],[550.0,300.0],[550.0,334.38],[550.0,368.76],[550.0,403.14],[550.0,437.5],[525.0,412.5],[500.0,387.5],[475.0,362.5],[450.0,337.5],[450.0,375.0],[450.0,412.5],[450.0,450.0],[446.31,469.18],[435.31,485.31]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0.0,0.0],[0.0,11.03],[0.0,6.68],[0.0,5.87],[0.0,0.0],[6.63,1.66],[6.41,2.46],[6.09,3.29],[5.67,4.08],[5.16,4.78],[4.62,5.36],[4.47,6.88],[3.45,7.54],[2.3,8.06],[1.12,8.4],[0.0,8.54],[-12.5,0.0],[0.0,0.0],[-0.92,-6.34],[-1.96,-6.18],[-3.01,-5.84],[-3.98,-5.34],[-4.76,-4.76],[-5.14,-3.83],[-5.76,-2.97],[-6.27,-1.99],[-6.59,-0.96],[-6.73,0.0],[-6.34,0.92],[-6.18,1.96],[-5.84,3.01],[-5.34,3.98],[-4.76,4.76],[-3.83,5.14],[-2.97,5.76],[-1.99,6.27],[-0.96,6.59],[0.0,6.73],[-12.5,0.0],[0.0,0.0],[1.08,-8.13],[2.27,-7.96],[3.48,-7.61],[4.61,-7.09],[5.57,-6.47],[5.02,-4.64],[5.58,-4.02],[6.1,-3.29],[6.53,-2.51],[6.83,-1.71],[7.02,-0.95],[0.0,-11.03],[0.0,-6.68],[0.0,-5.87],[0.0,0.0],[12.5,0.0]],"o":[[0.0,0.0],[0.0,-5.87],[0.0,-6.68],[0.0,-11.03],[-7.02,-0.95],[-6.83,-1.71],[-6.53,-2.51],[-6.1,-3.29],[-5.58,-4.02],[-5.02,-4.64],[-5.57,-6.47],[-4.61,-7.09],[-3.48,-7.61],[-2.27,-7.96],[-1.08,-8.13],[0.0,0.0],[12.5,0.0],[0.0,6.73],[0.96,6.59],[1.99,6.27],[2.97,5.76],[3.83,5.14],[4.76,4.76],[5.34,3.98],[5.84,3.01],[6.18,1.96],[6.34,0.92],[6.73,0.0],[6.59,-0.96],[6.27,-1.99],[5.76,-2.97],[5.14,-3.83],[4.76,-4.76],[3.98,-5.34],[3.01,-5.84],[1.96,-6.18],[0.92,-6.34],[0.0,0.0],[12.5,0.0],[0.0,8.54],[-1.12,8.4],[-2.3,8.06],[-3.45,7.54],[-4.47,6.88],[-4.62,5.36],[-5.16,4.78],[-5.67,4.08],[-6.09,3.29],[-6.41,2.46],[-6.63,1.66],[0.0,0.0],[0.0,5.87],[0.0,6.68],[0.0,11.03],[0.0,0.0],[-12.5,0.0]],"v":[[275.0,525.0],[275.0,505.78],[275.0,486.56],[275.0,467.34],[275.0,448.12],[254.53,444.22],[234.66,437.97],[215.72,429.27],[198.07,418.21],[181.95,405.01],[167.5,390.0],[152.44,369.98],[140.36,348.03],[131.69,324.53],[126.62,300.0],[125.0,275.0],[150.0,275.0],[175.0,275.0],[176.38,294.61],[180.76,313.77],[188.26,331.93],[198.69,348.59],[211.56,363.44],[226.41,376.31],[243.07,386.74],[261.23,394.24],[280.39,398.62],[300.0,400.0],[319.61,398.62],[338.77,394.24],[356.93,386.74],[373.59,376.31],[388.44,363.44],[401.31,348.59],[411.74,331.93],[419.24,313.77],[423.62,294.61],[425.0,275.0],[450.0,275.0],[475.0,275.0],[473.38,300.0],[468.31,324.53],[459.64,348.03],[447.56,369.98],[432.5,390.0],[418.05,405.01],[401.93,418.21],[384.28,429.27],[365.34,437.97],[345.47,444.22],[325.0,448.12],[325.0,467.34],[325.0,486.56],[325.0,505.78],[325.0,525.0],[300.0,525.0]],"c":true}]},{"t":10,"s":[{"i":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"o":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"v":[[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56]],"c":true}]}],"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[-2.29,0.0],[-2.94,1.17],[-2.43,2.43],[-1.07,1.86],[-0.53,2.16],[0.0,2.29],[0.0,5.26],[0.0,3.17],[0.0,2.99],[0.0,2.91],[0.0,2.87],[0.0,2.84],[0.0,2.82],[0.0,2.8],[0.0,2.78],[0.0,2.77],[0.0,2.75],[0.0,2.73],[0.0,2.71],[0.0,2.68],[0.0,2.64],[0.0,2.56],[0.0,2.36],[0.0,0.0],[0.5,2.05],[1.09,1.9],[1.6,1.6],[3.01,1.2],[3.5,0.0],[2.94,-1.17],[2.43,-2.43],[1.07,-1.86],[0.53,-2.16],[0.0,-2.29],[0.0,-5.26],[0.0,-3.17],[0.0,-2.99],[0.0,-2.91],[0.0,-2.87],[0.0,-2.84],[0.0,-2.82],[0.0,-2.8],[0.0,-2.78],[0.0,-2.77],[0.0,-2.75],[0.0,-2.73],[0.0,-2.71],[0.0,-2.68],[0.0,-2.64],[0.0,-2.56],[0.0,-2.36],[0.0,0.0],[-1.17,-2.94],[-2.43,-2.43],[-1.86,-1.07],[-2.16,-0.53]],"o":[[3.5,0.0],[3.01,-1.2],[1.6,-1.6],[1.09,-1.9],[0.5,-2.05],[0.0,0.0],[0.0,-2.36],[0.0,-2.56],[0.0,-2.64],[0.0,-2.68],[0.0,-2.71],[0.0,-2.73],[0.0,-2.75],[0.0,-2.77],[0.0,-2.78],[0.0,-2.8],[0.0,-2.82],[0.0,-2.84],[0.0,-2.87],[0.0,-2.91],[0.0,-2.99],[0.0,-3.17],[0.0,-5.26],[0.0,-2.29],[-0.53,-2.16],[-1.07,-1.86],[-2.43,-2.43],[-2.94,-1.17],[-3.5,0.0],[-3.01,1.2],[-1.6,1.6],[-1.09,1.9],[-0.5,2.05],[0.0,0.0],[0.0,2.36],[0.0,2.56],[0.0,2.64],[0.0,2.68],[0.0,2.71],[0.0,2.73],[0.0,2.75],[0.0,2.77],[0.0,2.78],[0.0,2.8],[0.0,2.82],[0.0,2.84],[0.0,2.87],[0.0,2.91],[0.0,2.99],[0.0,3.17],[0.0,5.26],[0.0,3.5],[1.2,3.01],[1.6,1.6],[1.9,1.09],[2.05,0.5]],"v":[[300.0,300.0],[309.65,298.25],[317.81,292.81],[321.82,287.62],[324.25,281.52],[325.0,275.0],[325.0,266.68],[325.0,258.35],[325.0,250.01],[325.0,241.68],[325.0,233.34],[325.0,225.0],[325.0,216.67],[325.0,208.34],[325.0,200.0],[325.0,191.66],[325.0,183.33],[325.0,175.0],[325.0,166.66],[325.0,158.32],[325.0,149.99],[325.0,141.65],[325.0,133.32],[325.0,125.0],[324.25,118.48],[321.82,112.38],[317.81,107.19],[309.65,101.75],[300.0,100.0],[290.35,101.75],[282.19,107.19],[278.18,112.38],[275.75,118.48],[275.0,125.0],[275.0,133.32],[275.0,141.65],[275.0,149.99],[275.0,158.32],[275.0,166.66],[275.0,175.0],[275.0,183.33],[275.0,191.66],[275.0,200.0],[275.0,208.34],[275.0,216.67],[275.0,225.0],[275.0,233.34],[275.0,241.68],[275.0,250.01],[275.0,258.35],[275.0,266.68],[275.0,275.0],[276.75,284.65],[282.19,292.81],[287.38,296.82],[293.48,299.25]],"c":true}]},{"t":24,"s":[{"i":[[-8.37,0.0],[-8.27,0.0],[-8.15,0.0],[-7.99,0.0],[-7.75,0.0],[-7.15,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,15.53],[0.0,9.41],[0.0,8.87],[0.0,8.63],[0.0,8.48],[0.0,8.37],[0.0,8.27],[0.0,8.15],[0.0,7.99],[0.0,7.75],[0.0,7.15],[0.0,0.0],[0.0,0.0],[0.0,0.0],[15.53,0.0],[9.41,0.0],[8.87,0.0],[8.63,0.0],[8.48,0.0],[8.37,0.0],[8.27,0.0],[8.15,0.0],[7.99,0.0],[7.75,0.0],[7.15,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-15.53],[0.0,-9.41],[0.0,-8.87],[0.0,-8.63],[0.0,-8.48],[0.0,-8.37],[0.0,-8.27],[0.0,-8.15],[0.0,-7.99],[0.0,-7.75],[0.0,-7.15],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-15.53,0.0],[-9.41,0.0],[-8.87,0.0],[-8.63,0.0],[-8.48,0.0]],"o":[[8.37,0.0],[8.48,0.0],[8.63,0.0],[8.87,0.0],[9.41,0.0],[15.53,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-7.15],[0.0,-7.75],[0.0,-7.99],[0.0,-8.15],[0.0,-8.27],[0.0,-8.37],[0.0,-8.48],[0.0,-8.63],[0.0,-8.87],[0.0,-9.41],[0.0,-15.53],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-7.15,0.0],[-7.75,0.0],[-7.99,0.0],[-8.15,0.0],[-8.27,0.0],[-8.37,0.0],[-8.48,0.0],[-8.63,0.0],[-8.87,0.0],[-9.41,0.0],[-15.53,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,7.15],[0.0,7.75],[0.0,7.99],[0.0,8.15],[0.0,8.27],[0.0,8.37],[0.0,8.48],[0.0,8.63],[0.0,8.87],[0.0,9.41],[0.0,15.53],[0.0,0.0],[0.0,0.0],[0.0,0.0],[7.15,0.0],[7.75,0.0],[7.99,0.0],[8.15,0.0],[8.27,0.0]],"v":[[250.0,450.0],[275.01,450.0],[300.01,450.0],[325.01,450.0],[350.03,450.0],[375.03,450.0],[400.0,450.0],[400.0,450.0],[400.0,450.0],[400.0,425.03],[400.0,400.03],[400.0,375.01],[400.0,350.01],[400.0,325.01],[400.0,300.0],[400.0,274.99],[400.0,249.99],[400.0,224.99],[400.0,199.97],[400.0,174.97],[400.0,150.0],[400.0,150.0],[400.0,150.0],[375.03,150.0],[350.03,150.0],[325.01,150.0],[300.01,150.0],[275.01,150.0],[250.0,150.0],[224.99,150.0],[199.99,150.0],[174.99,150.0],[149.97,150.0],[124.97,150.0],[100.0,150.0],[100.0,150.0],[100.0,150.0],[100.0,174.97],[100.0,199.97],[100.0,224.99],[100.0,249.99],[100.0,274.99],[100.0,300.0],[100.0,325.01],[100.0,350.01],[100.0,375.01],[100.0,400.03],[100.0,425.03],[100.0,450.0],[100.0,450.0],[100.0,450.0],[124.97,450.0],[149.97,450.0],[174.99,450.0],[199.99,450.0],[224.99,450.0]],"c":true}]}],"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lottie/ic_person_on_to_off.json b/assets/lottie/ic_person_on_to_off.json new file mode 100644 index 0000000..5e4ac57 --- /dev/null +++ b/assets/lottie/ic_person_on_to_off.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_person_on_to_off","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"slashed","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[300.0,300.0,0],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":11,"s":[92,92,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[694.4,-578.4],[-578.4,694.4],[-1851.19,-578.4],[-578.4,-1851.19]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1178.4,-94.4],[-94.4,1178.4],[-1367.19,-94.4],[-94.4,-1367.19]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Wipe"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.0,0.0],[12.05,12.05],[7.04,7.04],[0.0,0.0],[20.25,0.0],[12.3,0.0],[11.58,0.0],[11.25,0.0],[11.03,0.0],[10.83,0.0],[10.6,0.0],[10.25,0.0],[9.46,0.0],[0.0,0.0],[0.0,17.5],[0.0,0.0],[-7.29,11.88],[-12.08,6.25],[-9.43,3.82],[-9.78,2.95],[-9.84,2.19],[-10.0,1.46],[0.0,0.0],[0.0,0.0],[13.67,13.67],[8.3,8.3],[7.82,7.82],[7.59,7.59],[7.44,7.44],[7.31,7.31],[7.15,7.15],[6.92,6.92],[6.38,6.38],[0.0,0.0],[-8.91,8.91],[0.0,0.0],[-15.3,-15.3],[-9.21,-9.21],[-8.69,-8.69],[-8.46,-8.46],[-8.33,-8.33],[-8.27,-8.27],[-8.19,-8.19],[-8.15,-8.15],[-8.11,-8.11],[-8.06,-8.06],[-8.03,-8.03],[-7.98,-7.98],[-7.93,-7.93],[-7.87,-7.87],[-7.78,-7.78],[-7.65,-7.65],[-7.42,-7.42],[-6.86,-6.86],[0.0,0.0],[8.91,-8.91]],"o":[[0.0,0.0],[-7.04,-7.04],[-12.05,-12.05],[0.0,0.0],[-9.46,0.0],[-10.25,0.0],[-10.6,0.0],[-10.83,0.0],[-11.03,0.0],[-11.25,0.0],[-11.58,0.0],[-12.3,0.0],[-20.25,0.0],[0.0,0.0],[0.0,-17.5],[0.0,-14.17],[7.29,-11.87],[9.28,-4.74],[9.63,-3.9],[9.68,-2.92],[9.85,-2.19],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-6.38,-6.38],[-6.92,-6.92],[-7.15,-7.15],[-7.31,-7.31],[-7.44,-7.44],[-7.59,-7.59],[-7.82,-7.82],[-8.3,-8.3],[-13.67,-13.67],[0.0,0.0],[8.91,-8.91],[0.0,0.0],[6.86,6.86],[7.42,7.42],[7.65,7.65],[7.78,7.78],[7.87,7.87],[7.93,7.93],[7.98,7.98],[8.03,8.03],[8.06,8.06],[8.11,8.11],[8.15,8.15],[8.19,8.19],[8.27,8.27],[8.33,8.33],[8.46,8.46],[8.69,8.69],[9.21,9.21],[15.3,15.3],[0.0,0.0],[-8.91,8.91]],"v":[[494.38,565.62],[472.5,543.75],[450.62,521.87],[428.75,500.0],[395.91,500.0],[363.02,500.0],[330.14,500.0],[297.26,500.0],[264.37,500.0],[231.49,500.0],[198.61,500.0],[165.73,500.0],[132.84,500.0],[100.0,500.0],[100.0,465.0],[100.0,430.0],[110.94,390.94],[140.0,363.75],[168.07,350.9],[197.19,340.62],[226.48,332.97],[256.25,327.5],[256.25,327.5],[256.25,327.5],[234.09,305.34],[211.89,283.14],[189.7,260.95],[167.5,238.75],[145.31,216.56],[123.12,194.37],[100.93,172.18],[78.73,149.98],[56.54,127.79],[34.38,105.62],[52.19,87.81],[70.0,70.0],[94.19,94.19],[118.4,118.4],[142.62,142.62],[166.83,166.83],[191.03,191.03],[215.26,215.26],[239.46,239.46],[263.68,263.68],[287.89,287.89],[312.11,312.11],[336.32,336.32],[360.54,360.54],[384.74,384.74],[408.97,408.97],[433.17,433.17],[457.38,457.38],[481.6,481.6],[505.81,505.81],[530.0,530.0],[512.19,547.81]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.0,0.0],[-6.06,0.0],[-3.64,0.0],[-3.44,0.0],[-3.34,0.0],[-3.3,0.0],[-3.27,0.0],[-3.24,0.0],[-3.23,0.0],[-3.21,0.0],[-3.2,0.0],[-3.19,0.0],[-3.18,0.0],[-3.17,0.0],[-3.16,0.0],[-3.15,0.0],[-3.14,0.0],[-3.12,0.0],[-3.11,0.0],[-3.08,0.0],[-3.05,0.0],[-3.0,0.0],[-2.91,0.0],[-2.69,0.0],[0.0,0.0],[4.22,4.22],[2.56,2.56],[2.41,2.41],[2.34,2.34],[2.3,2.3],[2.27,2.27],[2.23,2.23],[2.19,2.19],[2.12,2.12],[1.95,1.95],[0.0,0.0],[0.42,0.0],[0.83,0.0],[3.33,-0.12],[3.33,-0.23],[3.32,-0.35],[3.31,-0.46],[3.29,-0.58],[3.27,-0.69],[3.25,-0.8],[2.93,-0.81],[2.91,-0.9],[2.88,-0.98],[2.85,-1.07],[2.82,-1.15],[2.79,-1.22],[2.76,-1.29],[2.73,-1.36],[2.29,-3.75],[0.0,-4.58],[0.0,-5.0]],"o":[[0.0,0.0],[2.69,0.0],[2.91,0.0],[3.0,0.0],[3.05,0.0],[3.08,0.0],[3.11,0.0],[3.12,0.0],[3.14,0.0],[3.15,0.0],[3.16,0.0],[3.17,0.0],[3.18,0.0],[3.19,0.0],[3.2,0.0],[3.21,0.0],[3.23,0.0],[3.24,0.0],[3.27,0.0],[3.3,0.0],[3.34,0.0],[3.44,0.0],[3.64,0.0],[6.06,0.0],[0.0,0.0],[-1.95,-1.95],[-2.12,-2.12],[-2.19,-2.19],[-2.23,-2.23],[-2.27,-2.27],[-2.3,-2.3],[-2.34,-2.34],[-2.41,-2.41],[-2.56,-2.56],[-4.22,-4.22],[-0.83,0.0],[-0.42,0.0],[-3.34,0.0],[-3.34,0.12],[-3.33,0.23],[-3.32,0.35],[-3.3,0.46],[-3.28,0.57],[-3.26,0.68],[-2.94,0.72],[-2.91,0.81],[-2.89,0.89],[-2.86,0.98],[-2.83,1.06],[-2.8,1.14],[-2.77,1.21],[-2.73,1.28],[-3.75,2.08],[-2.29,3.75],[0.0,0.0],[0.0,5.0]],"v":[[150.0,450.0],[159.52,450.0],[169.04,450.0],[178.58,450.0],[188.11,450.0],[197.65,450.0],[207.18,450.0],[216.71,450.0],[226.24,450.0],[235.77,450.0],[245.31,450.0],[254.84,450.0],[264.38,450.0],[273.91,450.0],[283.44,450.0],[292.98,450.0],[302.51,450.0],[312.04,450.0],[321.57,450.0],[331.1,450.0],[340.64,450.0],[350.17,450.0],[359.71,450.0],[369.23,450.0],[378.75,450.0],[371.93,443.18],[365.12,436.37],[358.3,429.55],[351.48,422.73],[344.66,415.91],[337.84,409.09],[331.02,402.27],[324.2,395.45],[317.38,388.63],[310.57,381.82],[303.75,375.0],[301.88,375.0],[300.0,375.0],[289.99,375.17],[280.0,375.69],[270.03,376.56],[260.1,377.77],[250.21,379.32],[240.38,381.22],[230.62,383.44],[221.82,385.74],[213.08,388.3],[204.43,391.12],[195.86,394.19],[187.37,397.49],[178.99,401.03],[170.69,404.78],[162.5,408.75],[153.44,417.5],[150.0,430.0],[150.0,440.0]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[-1.4,-0.74],[-1.27,-0.72],[-1.23,-0.8],[-1.17,-0.88],[-1.11,-0.96],[-1.05,-1.04],[-0.98,-1.11],[-0.91,-1.17],[-0.83,-1.23],[-0.76,-1.28],[-0.69,-1.32],[-0.62,-1.36],[-0.54,-1.39],[-0.46,-1.42],[-0.38,-1.45],[-0.3,-1.47],[-0.21,-1.49],[-0.13,-1.5],[-0.05,-1.51],[0.0,0.0],[1.97,1.97],[1.19,1.19],[1.12,1.12],[1.09,1.09],[1.08,1.08],[1.07,1.07],[1.06,1.06],[1.05,1.05],[1.05,1.05],[1.04,1.04],[1.04,1.04],[1.04,1.04],[1.04,1.04],[1.03,1.03],[1.03,1.03],[1.03,1.03],[1.03,1.03],[1.02,1.02],[1.02,1.02],[1.02,1.02],[1.01,1.01],[1.0,1.0],[0.99,0.99],[0.97,0.97],[0.95,0.95],[0.87,0.87],[0.0,0.0],[-1.48,-0.58],[-1.48,-0.58],[-1.48,-0.58],[-1.47,-0.59],[-1.47,-0.59],[-1.46,-0.62],[-1.45,-0.65],[-1.43,-0.68],[-1.42,-0.71]],"o":[[1.33,0.64],[1.29,0.73],[1.24,0.81],[1.18,0.89],[1.11,0.96],[1.04,1.03],[0.97,1.1],[0.89,1.16],[0.82,1.21],[0.77,1.3],[0.7,1.33],[0.63,1.37],[0.55,1.4],[0.46,1.43],[0.38,1.45],[0.29,1.47],[0.21,1.48],[0.13,1.49],[0.0,0.0],[0.0,0.0],[-0.87,-0.87],[-0.95,-0.95],[-0.97,-0.97],[-0.99,-0.99],[-1.0,-1.0],[-1.01,-1.01],[-1.02,-1.02],[-1.02,-1.02],[-1.02,-1.02],[-1.03,-1.03],[-1.03,-1.03],[-1.03,-1.03],[-1.03,-1.03],[-1.04,-1.04],[-1.04,-1.04],[-1.04,-1.04],[-1.04,-1.04],[-1.05,-1.05],[-1.05,-1.05],[-1.06,-1.06],[-1.07,-1.07],[-1.08,-1.08],[-1.09,-1.09],[-1.12,-1.12],[-1.19,-1.19],[-1.97,-1.97],[1.49,0.58],[1.48,0.58],[1.48,0.58],[1.48,0.59],[1.48,0.59],[1.47,0.59],[1.46,0.62],[1.44,0.65],[1.43,0.68],[1.41,0.71]],"v":[[460.0,363.75],[463.91,365.79],[467.68,368.08],[471.29,370.62],[474.72,373.4],[477.96,376.4],[480.98,379.61],[483.79,383.02],[486.38,386.59],[488.75,390.31],[490.95,394.24],[492.94,398.27],[494.69,402.42],[496.2,406.65],[497.47,410.97],[498.48,415.35],[499.24,419.79],[499.74,424.26],[500.0,428.75],[500.0,428.75],[496.91,425.66],[493.81,422.56],[490.7,419.45],[487.6,416.35],[484.5,413.25],[481.39,410.14],[478.29,407.04],[475.19,403.94],[472.09,400.84],[468.98,397.73],[465.88,394.63],[462.78,391.53],[459.68,388.43],[456.57,385.32],[453.47,382.22],[450.37,379.12],[447.27,376.02],[444.16,372.91],[441.06,369.81],[437.96,366.71],[434.86,363.61],[431.75,360.5],[428.65,357.4],[425.55,354.3],[422.44,351.19],[419.34,348.09],[416.25,345.0],[420.69,346.73],[425.14,348.47],[429.57,350.22],[434.01,351.98],[438.44,353.75],[442.83,355.56],[447.19,357.47],[451.5,359.48],[455.77,361.57]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[3.47,-2.35],[5.29,5.29],[3.2,3.2],[2.82,2.82],[0.0,0.0],[-2.77,1.77],[-2.39,2.35],[-1.93,2.82],[-1.29,3.04],[-0.65,3.31],[0.0,3.44],[1.07,4.11],[2.25,3.79],[3.22,3.22],[3.64,2.16],[4.25,1.1],[4.54,0.0],[3.24,-0.64],[3.1,-1.32],[2.83,-1.94],[2.3,-2.34],[1.8,-2.82],[1.24,-3.18],[5.29,5.29],[3.2,3.2],[2.82,2.82],[0.0,0.0],[-2.65,3.1],[-3.03,2.78],[-3.37,2.4],[-3.66,1.99],[-3.82,1.49],[-4.01,0.99],[-4.13,0.49],[-4.19,0.0],[-3.66,-0.38],[-3.61,-0.79],[-3.52,-1.22],[-3.37,-1.65],[-3.17,-2.05],[-2.94,-2.4],[-2.7,-2.7],[-2.33,-2.85],[-2.01,-3.1],[-1.64,-3.34],[-1.23,-3.54],[-0.8,-3.69],[-0.39,-3.78],[0.0,-3.81],[0.48,-4.04],[0.99,-3.98],[1.5,-3.85],[1.99,-3.66],[2.38,-3.35],[2.8,-3.05],[3.17,-2.71]],"o":[[0.0,0.0],[-2.82,-2.82],[-3.2,-3.2],[-5.29,-5.29],[3.18,-1.24],[2.82,-1.8],[2.34,-2.3],[1.94,-2.83],[1.32,-3.1],[0.64,-3.24],[0.0,-4.54],[-1.1,-4.25],[-2.16,-3.64],[-3.22,-3.22],[-3.79,-2.25],[-4.11,-1.07],[-3.44,0.0],[-3.31,0.65],[-3.04,1.29],[-2.82,1.93],[-2.35,2.39],[-1.77,2.77],[0.0,0.0],[-2.82,-2.82],[-3.2,-3.2],[-5.29,-5.29],[2.35,-3.47],[2.71,-3.17],[3.05,-2.8],[3.35,-2.38],[3.66,-1.99],[3.85,-1.5],[3.98,-0.99],[4.04,-0.48],[3.81,0.0],[3.78,0.39],[3.69,0.8],[3.54,1.23],[3.34,1.64],[3.1,2.01],[2.85,2.33],[2.7,2.7],[2.4,2.94],[2.05,3.17],[1.65,3.37],[1.22,3.52],[0.79,3.61],[0.38,3.66],[0.0,4.19],[-0.49,4.13],[-0.99,4.01],[-1.49,3.82],[-1.99,3.66],[-2.4,3.37],[-2.78,3.03],[-3.1,2.65]],"v":[[355.0,283.75],[345.78,274.53],[336.56,265.31],[327.34,256.09],[318.12,246.88],[327.04,242.35],[334.85,236.12],[341.25,228.44],[346.09,219.62],[349.05,210.01],[350.0,200.0],[348.4,187.03],[343.37,174.97],[335.31,164.69],[325.03,156.63],[312.97,151.6],[300.0,150.0],[289.99,150.95],[280.38,153.91],[271.56,158.75],[263.88,165.15],[257.65,172.96],[253.12,181.88],[243.91,172.66],[234.69,163.44],[225.47,154.22],[216.25,145.0],[223.75,135.14],[232.36,126.22],[241.99,118.43],[252.5,111.87],[263.71,106.66],[275.49,102.92],[287.66,100.71],[300.0,100.0],[311.21,100.56],[322.3,102.33],[333.11,105.37],[343.47,109.69],[353.23,115.22],[362.3,121.84],[370.62,129.37],[378.16,137.7],[384.78,146.77],[390.31,156.53],[394.63,166.89],[397.67,177.7],[399.44,188.79],[400.0,200.0],[399.29,212.34],[397.08,224.51],[393.34,236.29],[388.12,247.5],[381.57,258.01],[373.78,267.64],[364.86,276.25]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"slashed","np":6,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"plain","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[300.0,300.0,0],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":11,"s":[92,92,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[694.4,-578.4],[-578.4,694.4],[694.4,1967.19],[1967.19,694.4]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1178.4,-94.4],[-94.4,1178.4],[1178.4,2451.19],[2451.19,1178.4]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Wipe"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[3.81,0.0],[3.66,0.38],[3.61,0.79],[3.52,1.22],[3.37,1.65],[3.17,2.05],[2.94,2.4],[2.7,2.7],[2.33,2.85],[2.01,3.1],[1.64,3.34],[1.23,3.54],[0.8,3.69],[0.39,3.78],[0.0,3.81],[-0.38,3.66],[-0.79,3.61],[-1.22,3.52],[-1.65,3.37],[-2.05,3.17],[-2.4,2.94],[-2.7,2.7],[-2.85,2.33],[-3.1,2.01],[-3.34,1.64],[-3.54,1.23],[-3.69,0.8],[-3.78,0.39],[-3.81,0.0],[-3.66,-0.38],[-3.61,-0.79],[-3.52,-1.22],[-3.37,-1.65],[-3.17,-2.05],[-2.94,-2.4],[-2.7,-2.7],[-2.33,-2.85],[-2.01,-3.1],[-1.64,-3.34],[-1.23,-3.54],[-0.8,-3.69],[-0.39,-3.78],[0.0,-3.81],[0.38,-3.66],[0.79,-3.61],[1.22,-3.52],[1.65,-3.37],[2.05,-3.17],[2.4,-2.94],[2.7,-2.7],[2.85,-2.33],[3.1,-2.01],[3.34,-1.64],[3.54,-1.23],[3.69,-0.8],[3.78,-0.39]],"o":[[-3.81,0.0],[-3.78,-0.39],[-3.69,-0.8],[-3.54,-1.23],[-3.34,-1.64],[-3.1,-2.01],[-2.85,-2.33],[-2.7,-2.7],[-2.4,-2.94],[-2.05,-3.17],[-1.65,-3.37],[-1.22,-3.52],[-0.79,-3.61],[-0.38,-3.66],[0.0,-3.81],[0.39,-3.78],[0.8,-3.69],[1.23,-3.54],[1.64,-3.34],[2.01,-3.1],[2.33,-2.85],[2.7,-2.7],[2.94,-2.4],[3.17,-2.05],[3.37,-1.65],[3.52,-1.22],[3.61,-0.79],[3.66,-0.38],[3.81,0.0],[3.78,0.39],[3.69,0.8],[3.54,1.23],[3.34,1.64],[3.1,2.01],[2.85,2.33],[2.7,2.7],[2.4,2.94],[2.05,3.17],[1.65,3.37],[1.22,3.52],[0.79,3.61],[0.38,3.66],[0.0,3.81],[-0.39,3.78],[-0.8,3.69],[-1.23,3.54],[-1.64,3.34],[-2.01,3.1],[-2.33,2.85],[-2.7,2.7],[-2.94,2.4],[-3.17,2.05],[-3.37,1.65],[-3.52,1.22],[-3.61,0.79],[-3.66,0.38]],"v":[[300.0,300.0],[288.79,299.44],[277.7,297.67],[266.89,294.63],[256.53,290.31],[246.77,284.78],[237.7,278.16],[229.37,270.62],[221.84,262.3],[215.22,253.23],[209.69,243.47],[205.37,233.11],[202.33,222.3],[200.56,211.21],[200.0,200.0],[200.56,188.79],[202.33,177.7],[205.37,166.89],[209.69,156.53],[215.22,146.77],[221.84,137.7],[229.37,129.37],[237.7,121.84],[246.77,115.22],[256.53,109.69],[266.89,105.37],[277.7,102.33],[288.79,100.56],[300.0,100.0],[311.21,100.56],[322.3,102.33],[333.11,105.37],[343.47,109.69],[353.23,115.22],[362.3,121.84],[370.62,129.37],[378.16,137.7],[384.78,146.77],[390.31,156.53],[394.63,166.89],[397.67,177.7],[399.44,188.79],[400.0,200.0],[399.44,211.21],[397.67,222.3],[394.63,233.11],[390.31,243.47],[384.78,253.23],[378.16,262.3],[370.62,270.62],[362.3,278.16],[353.23,284.78],[343.47,290.31],[333.11,294.63],[322.3,297.67],[311.21,299.44]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,10.04],[0.0,6.08],[0.0,5.35],[0.0,0.0],[-1.81,6.48],[-3.66,5.96],[-4.81,4.51],[-6.08,3.14],[-5.08,2.28],[-5.18,2.06],[-5.27,1.82],[-5.36,1.57],[-5.43,1.32],[-6.71,1.21],[-6.78,0.81],[-6.83,0.4],[-6.86,0.0],[-6.81,-0.4],[-6.78,-0.81],[-6.73,-1.21],[-6.66,-1.61],[-5.4,-1.58],[-5.33,-1.84],[-5.24,-2.08],[-5.15,-2.31],[-5.05,-2.53],[-4.87,-4.57],[-3.62,-5.9],[-1.82,-6.54],[0.0,-7.05],[0.0,-10.04],[0.0,-6.08],[0.0,-5.35],[0.0,0.0],[11.53,0.0],[6.94,0.0],[6.54,0.0],[6.37,0.0],[6.28,0.0],[6.23,0.0],[6.18,0.0],[6.14,0.0],[6.12,0.0],[6.09,0.0],[6.07,0.0],[6.05,0.0],[6.02,0.0],[6.0,0.0],[5.97,0.0],[5.93,0.0],[5.9,0.0],[5.83,0.0],[5.73,0.0],[5.56,0.0],[5.14,0.0]],"o":[[0.0,0.0],[0.0,-5.35],[0.0,-6.08],[0.0,-10.04],[0.0,-7.05],[1.82,-6.54],[3.62,-5.9],[4.87,-4.57],[5.05,-2.53],[5.15,-2.31],[5.24,-2.08],[5.33,-1.84],[5.4,-1.58],[6.66,-1.61],[6.73,-1.21],[6.78,-0.81],[6.81,-0.4],[6.86,0.0],[6.83,0.4],[6.78,0.81],[6.71,1.21],[5.43,1.32],[5.36,1.57],[5.27,1.82],[5.18,2.06],[5.08,2.28],[6.08,3.14],[4.81,4.51],[3.66,5.96],[1.81,6.48],[0.0,0.0],[0.0,5.35],[0.0,6.08],[0.0,10.04],[0.0,0.0],[-5.14,0.0],[-5.56,0.0],[-5.73,0.0],[-5.83,0.0],[-5.9,0.0],[-5.93,0.0],[-5.97,0.0],[-6.0,0.0],[-6.02,0.0],[-6.05,0.0],[-6.07,0.0],[-6.09,0.0],[-6.12,0.0],[-6.14,0.0],[-6.18,0.0],[-6.23,0.0],[-6.28,0.0],[-6.37,0.0],[-6.54,0.0],[-6.94,0.0],[-11.53,0.0]],"v":[[100.0,500.0],[100.0,482.5],[100.0,465.0],[100.0,447.5],[100.0,430.0],[102.71,409.69],[110.94,390.94],[123.58,375.32],[140.0,363.75],[155.2,356.54],[170.7,350.0],[186.47,344.15],[202.5,339.04],[218.75,334.69],[238.81,330.45],[259.09,327.42],[279.51,325.6],[300.0,325.0],[320.49,325.6],[340.91,327.42],[361.19,330.45],[381.25,334.69],[397.5,339.04],[413.53,344.15],[429.3,350.0],[444.8,356.54],[460.0,363.75],[476.42,375.32],[489.06,390.94],[497.29,409.69],[500.0,430.0],[500.0,447.5],[500.0,465.0],[500.0,482.5],[500.0,500.0],[481.86,500.0],[463.65,500.0],[445.48,500.0],[427.3,500.0],[409.11,500.0],[390.91,500.0],[372.73,500.0],[354.55,500.0],[336.37,500.0],[318.18,500.0],[300.0,500.0],[281.82,500.0],[263.63,500.0],[245.45,500.0],[227.27,500.0],[209.09,500.0],[190.89,500.0],[172.7,500.0],[154.52,500.0],[136.35,500.0],[118.14,500.0]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0.0,0.0],[-7.95,0.0],[-4.77,0.0],[-4.51,0.0],[-4.38,0.0],[-4.33,0.0],[-4.29,0.0],[-4.25,0.0],[-4.23,0.0],[-4.21,0.0],[-4.2,0.0],[-4.19,0.0],[-4.17,0.0],[-4.16,0.0],[-4.15,0.0],[-4.13,0.0],[-4.11,0.0],[-4.1,0.0],[-4.07,0.0],[-4.05,0.0],[-4.0,0.0],[-3.93,0.0],[-3.82,0.0],[-3.52,0.0],[0.0,0.0],[0.0,5.0],[0.0,0.0],[2.29,3.75],[3.75,2.08],[3.65,1.67],[3.71,1.54],[3.77,1.41],[3.82,1.27],[3.87,1.12],[3.91,0.96],[3.8,0.78],[3.83,0.63],[3.86,0.47],[3.88,0.31],[3.89,0.16],[3.9,0.0],[3.89,-0.16],[3.88,-0.31],[3.87,-0.47],[3.85,-0.63],[3.82,-0.78],[3.79,-0.93],[3.9,-1.13],[3.86,-1.28],[3.81,-1.42],[3.76,-1.56],[3.7,-1.7],[3.64,-1.82],[2.29,-3.75],[0.0,-4.58],[0.0,-5.0]],"o":[[0.0,0.0],[3.52,0.0],[3.82,0.0],[3.93,0.0],[4.0,0.0],[4.05,0.0],[4.07,0.0],[4.1,0.0],[4.11,0.0],[4.13,0.0],[4.15,0.0],[4.16,0.0],[4.17,0.0],[4.19,0.0],[4.2,0.0],[4.21,0.0],[4.23,0.0],[4.25,0.0],[4.29,0.0],[4.33,0.0],[4.38,0.0],[4.51,0.0],[4.77,0.0],[7.95,0.0],[0.0,0.0],[0.0,-5.0],[0.0,-4.58],[-2.29,-3.75],[-3.64,-1.82],[-3.7,-1.7],[-3.76,-1.56],[-3.81,-1.42],[-3.86,-1.28],[-3.9,-1.13],[-3.79,-0.93],[-3.82,-0.78],[-3.85,-0.63],[-3.87,-0.47],[-3.88,-0.31],[-3.89,-0.16],[-3.9,0.0],[-3.89,0.16],[-3.88,0.31],[-3.86,0.47],[-3.83,0.63],[-3.8,0.78],[-3.91,0.96],[-3.87,1.12],[-3.82,1.27],[-3.77,1.41],[-3.71,1.54],[-3.65,1.67],[-3.75,2.08],[-2.29,3.75],[0.0,0.0],[0.0,5.0]],"v":[[150.0,450.0],[162.48,450.0],[174.97,450.0],[187.49,450.0],[199.97,450.0],[212.49,450.0],[224.99,450.0],[237.49,450.0],[249.99,450.0],[262.49,450.0],[274.99,450.0],[287.5,450.0],[300.0,450.0],[312.5,450.0],[325.01,450.0],[337.51,450.0],[350.01,450.0],[362.51,450.0],[375.01,450.0],[387.51,450.0],[400.03,450.0],[412.51,450.0],[425.03,450.0],[437.52,450.0],[450.0,450.0],[450.0,440.0],[450.0,430.0],[446.56,417.5],[437.5,408.75],[426.55,403.51],[415.43,398.65],[404.14,394.19],[392.7,390.16],[381.1,386.57],[369.38,383.44],[357.98,380.88],[346.5,378.77],[334.94,377.12],[323.33,375.94],[311.67,375.24],[300.0,375.0],[288.33,375.24],[276.67,375.94],[265.06,377.12],[253.5,378.77],[242.02,380.88],[230.62,383.44],[218.9,386.57],[207.3,390.16],[195.86,394.19],[184.57,398.65],[173.45,403.51],[162.5,408.75],[153.44,417.5],[150.0,430.0],[150.0,440.0]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[-1.91,0.0],[-1.83,0.19],[-1.81,0.39],[-1.76,0.61],[-1.68,0.83],[-1.59,1.03],[-1.47,1.2],[-1.35,1.35],[-1.16,1.42],[-1.0,1.55],[-0.82,1.67],[-0.61,1.77],[-0.4,1.84],[-0.19,1.89],[0.0,1.91],[0.19,1.83],[0.39,1.81],[0.61,1.76],[0.83,1.68],[1.03,1.59],[1.2,1.47],[1.35,1.35],[1.42,1.16],[1.55,1.0],[1.67,0.82],[1.77,0.61],[1.84,0.4],[1.89,0.19],[1.91,0.0],[1.83,-0.19],[1.81,-0.39],[1.76,-0.61],[1.68,-0.83],[1.59,-1.03],[1.47,-1.2],[1.35,-1.35],[1.16,-1.42],[1.0,-1.55],[0.82,-1.67],[0.61,-1.77],[0.4,-1.84],[0.19,-1.89],[0.0,-1.91],[-0.19,-1.83],[-0.39,-1.81],[-0.61,-1.76],[-0.83,-1.68],[-1.03,-1.59],[-1.2,-1.47],[-1.35,-1.35],[-1.42,-1.16],[-1.55,-1.0],[-1.67,-0.82],[-1.77,-0.61],[-1.84,-0.4],[-1.89,-0.19]],"o":[[1.91,0.0],[1.89,-0.19],[1.84,-0.4],[1.77,-0.61],[1.67,-0.82],[1.55,-1.0],[1.42,-1.16],[1.35,-1.35],[1.2,-1.47],[1.03,-1.59],[0.83,-1.68],[0.61,-1.76],[0.39,-1.81],[0.19,-1.83],[0.0,-1.91],[-0.19,-1.89],[-0.4,-1.84],[-0.61,-1.77],[-0.82,-1.67],[-1.0,-1.55],[-1.16,-1.42],[-1.35,-1.35],[-1.47,-1.2],[-1.59,-1.03],[-1.68,-0.83],[-1.76,-0.61],[-1.81,-0.39],[-1.83,-0.19],[-1.91,0.0],[-1.89,0.19],[-1.84,0.4],[-1.77,0.61],[-1.67,0.82],[-1.55,1.0],[-1.42,1.16],[-1.35,1.35],[-1.2,1.47],[-1.03,1.59],[-0.83,1.68],[-0.61,1.76],[-0.39,1.81],[-0.19,1.83],[0.0,1.91],[0.19,1.89],[0.4,1.84],[0.61,1.77],[0.82,1.67],[1.0,1.55],[1.16,1.42],[1.35,1.35],[1.47,1.2],[1.59,1.03],[1.68,0.83],[1.76,0.61],[1.81,0.39],[1.83,0.19]],"v":[[300.0,250.0],[305.61,249.72],[311.15,248.83],[316.55,247.31],[321.73,245.16],[326.62,242.39],[331.15,239.08],[335.31,235.31],[339.08,231.15],[342.39,226.62],[345.16,221.73],[347.31,216.55],[348.83,211.15],[349.72,205.61],[350.0,200.0],[349.72,194.39],[348.83,188.85],[347.31,183.45],[345.16,178.27],[342.39,173.38],[339.08,168.85],[335.31,164.69],[331.15,160.92],[326.62,157.61],[321.73,154.84],[316.55,152.69],[311.15,151.17],[305.61,150.28],[300.0,150.0],[294.39,150.28],[288.85,151.17],[283.45,152.69],[278.27,154.84],[273.38,157.61],[268.85,160.92],[264.69,164.69],[260.92,168.85],[257.61,173.38],[254.84,178.27],[252.69,183.45],[251.17,188.85],[250.28,194.39],[250.0,200.0],[250.28,205.61],[251.17,211.15],[252.69,216.55],[254.84,221.73],[257.61,226.62],[260.92,231.15],[264.69,235.31],[268.85,239.08],[273.38,242.39],[278.27,245.16],[283.45,247.31],[288.85,248.83],[294.39,249.72]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"plain","np":6,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lottie/ic_search.json b/assets/lottie/ic_search.json new file mode 100644 index 0000000..88e182b --- /dev/null +++ b/assets/lottie/ic_search.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":60,"w":240,"h":240,"nm":"system-outline-42-search","ddd":0,"assets":[{"id":"comp_1","nm":"hover-pinch","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Search","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":16,"s":[-18]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":31,"s":[11]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":46,"s":[-4]},{"t":60,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.331,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[180.148,180.511,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.475,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[180.148,200.511,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.556,"y":1},"o":{"x":0.333,"y":0},"t":25,"s":[180.148,165.844,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":40,"s":[180.148,186.844,0],"to":[0,0,0],"ti":[0,0,0]},{"t":54,"s":[180.148,180.511,0]}],"ix":2,"l":2},"a":{"a":0,"k":[63.899,64.26,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-2.929,2.929],[2.929,2.929],[0,0]],"o":[[2.929,-2.929],[0,0],[2.929,2.929]],"v":[[91.552,91.552],[91.552,80.946],[80.946,91.552]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[30.431,30.431],[32.218,-32.218],[-32.218,-32.218],[-32.412,27.054],[0,0],[0,0],[0,0]],"o":[[-32.218,-32.218],[-32.218,32.218],[30.431,30.431],[0,0],[0,0],[0,0],[27.054,-32.412]],"v":[[47.087,-69.585],[-69.585,-69.585],[-69.585,47.087],[41.546,52.152],[80.946,91.552],[91.552,80.946],[52.152,41.546]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[-26.36,26.36],[-26.36,-26.36],[26.36,-26.36],[26.36,26.36]],"o":[[26.36,-26.36],[26.36,26.36],[-26.36,26.36],[-26.36,-26.36]],"v":[[-58.979,-58.979],[36.48,-58.979],[36.48,36.48],[-58.979,36.48]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.071,0.075,0.192,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-outline-42-search').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"glass (Stroke)","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300,"st":0,"ct":1,"bm":0}]},{"id":"comp_2","nm":"morph-select:0.5","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Dot 2","parent":3,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-11.245,-11.246,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0,0,0],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":35,"s":[0,0,100]},{"t":60,"s":[155,155,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-24.853,0],[0,24.853],[24.853,0],[0,-24.853]],"o":[[24.853,0],[0,-24.853],[-24.853,0],[0,24.853]],"v":[[0,45],[45,0],[0,-45],[-45,0]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.071,0.075,0.192,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-outline-42-search').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":35,"op":300,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Dot","parent":3,"tt":2,"tp":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-11.245,-11.246,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0,0,0],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,16.667]},"t":0,"s":[0,0,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[1,1,1],"y":[0,0,0]},"t":14,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":30,"s":[100,100,100]},{"t":48,"s":[178,178,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-24.853,0],[0,24.853],[24.853,0],[0,-24.853]],"o":[[24.853,0],[0,-24.853],[-24.853,0],[0,24.853]],"v":[[0,45],[45,0],[0,-45],[-45,0]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.071,0.075,0.192,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-outline-42-search').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Search","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[-13]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":20,"s":[6]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":29,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":30,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":40,"s":[-13]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":50,"s":[6]},{"t":59,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[180.148,180.511,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":8,"s":[180.148,190.511,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":18,"s":[180.148,173.844,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.167,"y":0.167},"t":27,"s":[180.148,180.511,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":30,"s":[180.148,180.511,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":38,"s":[180.148,190.511,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":48,"s":[180.148,173.844,0],"to":[0,0,0],"ti":[0,0,0]},{"t":57,"s":[180.148,180.511,0]}],"ix":2,"l":2},"a":{"a":0,"k":[63.899,64.26,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-2.929,2.929],[2.929,2.929],[0,0]],"o":[[2.929,-2.929],[0,0],[2.929,2.929]],"v":[[91.552,91.552],[91.552,80.946],[80.946,91.552]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[30.431,30.431],[32.218,-32.218],[-32.218,-32.218],[-32.412,27.054],[0,0],[0,0],[0,0]],"o":[[-32.218,-32.218],[-32.218,32.218],[30.431,30.431],[0,0],[0,0],[0,0],[27.054,-32.412]],"v":[[47.087,-69.585],[-69.585,-69.585],[-69.585,47.087],[41.546,52.152],[80.946,91.552],[91.552,80.946],[52.152,41.546]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[-26.36,26.36],[-26.36,-26.36],[26.36,-26.36],[26.36,26.36]],"o":[[26.36,-26.36],[26.36,26.36],[-26.36,26.36],[-26.36,-26.36]],"v":[[-58.979,-58.979],[36.48,-58.979],[36.48,36.48],[-58.979,36.48]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.071,0.075,0.192,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-outline-42-search').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"glass (Stroke)","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"control","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"primary","np":3,"mn":"ADBE Color Control","ix":1,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[0.071,0.075,0.192],"ix":1}}]}],"ip":0,"op":201,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"hover-pinch","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[120,120,0],"ix":2,"l":2},"a":{"a":0,"k":[120,120,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":240,"h":240,"ip":0,"op":70,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"morph-select:0.5","cl":"5","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[120,120,0],"ix":2,"l":2},"a":{"a":0,"k":[120,120,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":240,"h":240,"ip":70,"op":140,"st":70,"bm":0}],"markers":[{"tm":0,"cm":"default:hover-pinch","dr":60}],"props":{}} \ No newline at end of file diff --git a/assets/lottie/ic_send_to_mic.json b/assets/lottie/ic_send_to_mic.json new file mode 100644 index 0000000..f06ca37 --- /dev/null +++ b/assets/lottie/ic_send_to_mic.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_send_to_mic","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"ic_send_to_mic","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"t":0,"s":[0],"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0}},{"t":10,"s":[9],"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0}},{"t":24,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"t":0,"s":[300.0,300.0,0],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":10,"s":[330.0,300.0,0],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":19,"s":[290.0,300.0,0],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[300.0,300.0,0]}],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":10,"s":[91,91,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0.0,0.0],[0.0,15.7],[0.0,9.49],[0.0,8.93],[0.0,8.71],[0.0,8.57],[0.0,8.48],[0.0,8.42],[0.0,8.35],[0.0,8.3],[0.0,8.23],[0.0,8.16],[0.0,8.07],[0.0,7.93],[0.0,7.68],[0.0,7.11],[0.0,0.0],[-15.01,-6.32],[-9.05,-3.81],[-8.53,-3.59],[-8.31,-3.5],[-8.2,-3.45],[-8.11,-3.41],[-8.05,-3.39],[-8.01,-3.37],[-7.97,-3.35],[-7.93,-3.34],[-7.89,-3.32],[-7.86,-3.31],[-7.82,-3.29],[-7.77,-3.27],[-7.71,-3.24],[-7.63,-3.21],[-7.5,-3.16],[-7.27,-3.06],[-6.72,-2.83],[0.0,0.0],[15.01,-6.32],[9.05,-3.81],[8.53,-3.59],[8.31,-3.5],[8.2,-3.45],[8.11,-3.41],[8.05,-3.39],[8.01,-3.37],[7.97,-3.35],[7.93,-3.34],[7.89,-3.32],[7.86,-3.31],[7.82,-3.29],[7.77,-3.27],[7.71,-3.24],[7.63,-3.21],[7.5,-3.16],[7.27,-3.06],[6.72,-2.83]],"o":[[0.0,0.0],[0.0,-7.11],[0.0,-7.68],[0.0,-7.93],[0.0,-8.07],[0.0,-8.16],[0.0,-8.23],[0.0,-8.3],[0.0,-8.35],[0.0,-8.42],[0.0,-8.48],[0.0,-8.57],[0.0,-8.71],[0.0,-8.93],[0.0,-9.49],[0.0,-15.7],[0.0,0.0],[6.72,2.83],[7.27,3.06],[7.5,3.16],[7.63,3.21],[7.71,3.24],[7.77,3.27],[7.82,3.29],[7.86,3.31],[7.89,3.32],[7.93,3.34],[7.97,3.35],[8.01,3.37],[8.05,3.39],[8.11,3.41],[8.2,3.45],[8.31,3.5],[8.53,3.59],[9.05,3.81],[15.01,6.32],[0.0,0.0],[-6.72,2.83],[-7.27,3.06],[-7.5,3.16],[-7.63,3.21],[-7.71,3.24],[-7.77,3.27],[-7.82,3.29],[-7.86,3.31],[-7.89,3.32],[-7.93,3.34],[-7.97,3.35],[-8.01,3.37],[-8.05,3.39],[-8.11,3.41],[-8.2,3.45],[-8.31,3.5],[-8.53,3.59],[-9.05,3.81],[-15.01,6.32]],"v":[[75.0,500.0],[75.0,475.04],[75.0,450.02],[75.0,425.03],[75.0,400.01],[75.0,375.01],[75.0,350.01],[75.0,325.01],[75.0,300.0],[75.0,274.99],[75.0,249.99],[75.0,224.99],[75.0,199.99],[75.0,174.97],[75.0,149.98],[75.0,124.96],[75.0,100.0],[98.69,109.98],[122.45,119.98],[146.21,129.98],[169.97,139.99],[193.74,149.99],[217.48,159.99],[241.23,169.99],[264.99,180.0],[288.75,190.0],[312.5,200.0],[336.25,210.0],[360.01,220.0],[383.77,230.01],[407.52,240.01],[431.26,250.01],[455.03,260.01],[478.79,270.02],[502.55,280.02],[526.31,290.02],[550.0,300.0],[526.31,309.98],[502.55,319.98],[478.79,329.98],[455.03,339.99],[431.26,349.99],[407.52,359.99],[383.77,369.99],[360.01,380.0],[336.25,390.0],[312.5,400.0],[288.75,410.0],[264.99,420.0],[241.23,430.01],[217.48,440.01],[193.74,450.01],[169.97,460.01],[146.21,470.02],[122.45,480.02],[98.69,490.02]],"c":true}]},{"t":24,"s":[{"i":[[1.86,4.53],[0.91,4.92],[0.0,5.08],[0.0,8.44],[0.0,5.11],[0.0,4.82],[0.0,4.69],[0.0,4.6],[0.0,4.53],[0.0,4.46],[0.0,4.38],[0.0,4.24],[0.0,3.91],[0.0,0.0],[-0.55,3.81],[-1.17,3.71],[-1.8,3.51],[-2.38,3.21],[-2.85,2.85],[-3.96,2.7],[-4.53,1.86],[-4.92,0.91],[-5.08,0.0],[-4.71,-0.87],[-4.51,-1.85],[-4.12,-2.81],[-3.59,-3.59],[-2.3,-3.09],[-1.78,-3.47],[-1.19,-3.77],[-0.57,-3.96],[0.0,-4.04],[0.0,-8.44],[0.0,-5.11],[0.0,-4.82],[0.0,-4.69],[0.0,-4.6],[0.0,-4.53],[0.0,-4.46],[0.0,-4.38],[0.0,-4.24],[0.0,-3.91],[0.0,0.0],[0.87,-4.71],[1.85,-4.51],[2.81,-4.12],[3.59,-3.59],[3.96,-2.7],[4.53,-1.86],[4.92,-0.91],[5.08,0.0],[4.71,0.87],[4.51,1.85],[4.12,2.81],[3.59,3.59],[2.7,3.96]],"o":[[-1.85,-4.51],[-0.87,-4.71],[0.0,0.0],[0.0,-3.91],[0.0,-4.24],[0.0,-4.38],[0.0,-4.46],[0.0,-4.53],[0.0,-4.6],[0.0,-4.69],[0.0,-4.82],[0.0,-5.11],[0.0,-8.44],[0.0,-4.04],[0.57,-3.96],[1.19,-3.77],[1.78,-3.47],[2.3,-3.09],[3.59,-3.59],[4.12,-2.81],[4.51,-1.85],[4.71,-0.87],[5.08,0.0],[4.92,0.91],[4.53,1.86],[3.96,2.7],[2.85,2.85],[2.38,3.21],[1.8,3.51],[1.17,3.71],[0.55,3.81],[0.0,0.0],[0.0,3.91],[0.0,4.24],[0.0,4.38],[0.0,4.46],[0.0,4.53],[0.0,4.6],[0.0,4.69],[0.0,4.82],[0.0,5.11],[0.0,8.44],[0.0,5.08],[-0.91,4.92],[-1.86,4.53],[-2.7,3.96],[-3.59,3.59],[-4.12,2.81],[-4.51,1.85],[-4.71,0.87],[-5.08,0.0],[-4.92,-0.91],[-4.53,-1.86],[-3.96,-2.7],[-3.59,-3.59],[-2.81,-4.12]],"v":[[230.44,303.83],[226.3,289.69],[225.0,275.0],[225.0,261.37],[225.0,247.74],[225.0,234.09],[225.0,220.46],[225.0,206.82],[225.0,193.18],[225.0,179.54],[225.0,165.91],[225.0,152.26],[225.0,138.63],[225.0,125.0],[225.82,113.23],[228.43,101.72],[232.91,90.81],[239.15,80.8],[246.88,71.87],[258.2,62.44],[271.17,55.44],[285.31,51.3],[300.0,50.0],[314.69,51.3],[328.83,55.44],[341.8,62.44],[353.12,71.87],[360.85,80.8],[367.09,90.81],[371.57,101.72],[374.18,113.23],[375.0,125.0],[375.0,138.63],[375.0,152.26],[375.0,165.91],[375.0,179.54],[375.0,193.18],[375.0,206.82],[375.0,220.46],[375.0,234.09],[375.0,247.74],[375.0,261.37],[375.0,275.0],[373.7,289.69],[369.56,303.83],[362.56,316.8],[353.12,328.12],[341.8,337.56],[328.83,344.56],[314.69,348.7],[300.0,350.0],[285.31,348.7],[271.17,344.56],[258.2,337.56],[246.88,328.12],[237.44,316.8]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":12,"s":[{"i":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"o":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"v":[[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56]],"c":true}]},{"t":24,"s":[{"i":[[0.0,0.0],[0.0,11.03],[0.0,6.68],[0.0,5.87],[0.0,0.0],[6.63,1.66],[6.41,2.46],[6.09,3.29],[5.67,4.08],[5.16,4.78],[4.62,5.36],[4.47,6.88],[3.45,7.54],[2.3,8.06],[1.12,8.4],[0.0,8.54],[-12.5,0.0],[0.0,0.0],[-0.92,-6.34],[-1.96,-6.18],[-3.01,-5.84],[-3.98,-5.34],[-4.76,-4.76],[-5.14,-3.83],[-5.76,-2.97],[-6.27,-1.99],[-6.59,-0.96],[-6.73,0.0],[-6.34,0.92],[-6.18,1.96],[-5.84,3.01],[-5.34,3.98],[-4.76,4.76],[-3.83,5.14],[-2.97,5.76],[-1.99,6.27],[-0.96,6.59],[0.0,6.73],[-12.5,0.0],[0.0,0.0],[1.08,-8.13],[2.27,-7.96],[3.48,-7.61],[4.61,-7.09],[5.57,-6.47],[5.02,-4.64],[5.58,-4.02],[6.1,-3.29],[6.53,-2.51],[6.83,-1.71],[7.02,-0.95],[0.0,-11.03],[0.0,-6.68],[0.0,-5.87],[0.0,0.0],[12.5,0.0]],"o":[[0.0,0.0],[0.0,-5.87],[0.0,-6.68],[0.0,-11.03],[-7.02,-0.95],[-6.83,-1.71],[-6.53,-2.51],[-6.1,-3.29],[-5.58,-4.02],[-5.02,-4.64],[-5.57,-6.47],[-4.61,-7.09],[-3.48,-7.61],[-2.27,-7.96],[-1.08,-8.13],[0.0,0.0],[12.5,0.0],[0.0,6.73],[0.96,6.59],[1.99,6.27],[2.97,5.76],[3.83,5.14],[4.76,4.76],[5.34,3.98],[5.84,3.01],[6.18,1.96],[6.34,0.92],[6.73,0.0],[6.59,-0.96],[6.27,-1.99],[5.76,-2.97],[5.14,-3.83],[4.76,-4.76],[3.98,-5.34],[3.01,-5.84],[1.96,-6.18],[0.92,-6.34],[0.0,0.0],[12.5,0.0],[0.0,8.54],[-1.12,8.4],[-2.3,8.06],[-3.45,7.54],[-4.47,6.88],[-4.62,5.36],[-5.16,4.78],[-5.67,4.08],[-6.09,3.29],[-6.41,2.46],[-6.63,1.66],[0.0,0.0],[0.0,5.87],[0.0,6.68],[0.0,11.03],[0.0,0.0],[-12.5,0.0]],"v":[[275.0,525.0],[275.0,505.78],[275.0,486.56],[275.0,467.34],[275.0,448.12],[254.53,444.22],[234.66,437.97],[215.72,429.27],[198.07,418.21],[181.95,405.01],[167.5,390.0],[152.44,369.98],[140.36,348.03],[131.69,324.53],[126.62,300.0],[125.0,275.0],[150.0,275.0],[175.0,275.0],[176.38,294.61],[180.76,313.77],[188.26,331.93],[198.69,348.59],[211.56,363.44],[226.41,376.31],[243.07,386.74],[261.23,394.24],[280.39,398.62],[300.0,400.0],[319.61,398.62],[338.77,394.24],[356.93,386.74],[373.59,376.31],[388.44,363.44],[401.31,348.59],[411.74,331.93],[419.24,313.77],[423.62,294.61],[425.0,275.0],[450.0,275.0],[475.0,275.0],[473.38,300.0],[468.31,324.53],[459.64,348.03],[447.56,369.98],[432.5,390.0],[418.05,405.01],[401.93,418.21],[384.28,429.27],[365.34,437.97],[345.47,444.22],[325.0,448.12],[325.0,467.34],[325.0,486.56],[325.0,505.78],[325.0,525.0],[300.0,525.0]],"c":true}]}],"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0.0,0.0],[-11.63,4.91],[-7.03,2.96],[-6.62,2.79],[-6.45,2.72],[-6.35,2.68],[-6.28,2.65],[-6.23,2.63],[-6.19,2.61],[-6.14,2.59],[-6.1,2.57],[-6.04,2.55],[-5.97,2.52],[-5.88,2.48],[-5.69,2.4],[-5.26,2.22],[0.0,0.0],[11.63,4.91],[7.03,2.96],[6.62,2.79],[6.45,2.72],[6.35,2.68],[6.28,2.65],[6.23,2.63],[6.19,2.61],[6.14,2.59],[6.1,2.57],[6.04,2.55],[5.97,2.52],[5.88,2.48],[5.69,2.4],[5.26,2.22],[0.0,0.0],[0.0,-12.55],[0.0,-7.6],[0.0,-6.68],[0.0,0.0],[-11.42,-2.85],[-6.95,-1.74],[-6.52,-1.63],[-6.31,-1.58],[-6.13,-1.53],[-5.9,-1.48],[-5.44,-1.36],[0.0,0.0],[11.42,-2.85],[6.95,-1.74],[6.52,-1.63],[6.31,-1.58],[6.13,-1.53],[5.9,-1.48],[5.44,-1.36],[0.0,0.0],[0.0,-12.55],[0.0,-7.6],[0.0,-6.68]],"o":[[0.0,0.0],[5.26,-2.22],[5.69,-2.4],[5.88,-2.48],[5.97,-2.52],[6.04,-2.55],[6.1,-2.57],[6.14,-2.59],[6.19,-2.61],[6.23,-2.63],[6.28,-2.65],[6.35,-2.68],[6.45,-2.72],[6.62,-2.79],[7.03,-2.96],[11.63,-4.91],[0.0,0.0],[-5.26,-2.22],[-5.69,-2.4],[-5.88,-2.48],[-5.97,-2.52],[-6.04,-2.55],[-6.1,-2.57],[-6.14,-2.59],[-6.19,-2.61],[-6.23,-2.63],[-6.28,-2.65],[-6.35,-2.68],[-6.45,-2.72],[-6.62,-2.79],[-7.03,-2.96],[-11.63,-4.91],[0.0,0.0],[0.0,6.68],[0.0,7.6],[0.0,12.55],[0.0,0.0],[5.44,1.36],[5.9,1.48],[6.13,1.53],[6.31,1.58],[6.52,1.63],[6.95,1.74],[11.42,2.85],[0.0,0.0],[-5.44,1.36],[-5.9,1.48],[-6.13,1.53],[-6.31,1.58],[-6.52,1.63],[-6.95,1.74],[-11.42,2.85],[0.0,0.0],[0.0,6.68],[0.0,7.6],[0.0,12.55]],"v":[[125.0,425.0],[143.49,417.2],[162.02,409.38],[180.53,401.57],[199.05,393.75],[217.57,385.94],[236.08,378.13],[254.61,370.31],[273.12,362.5],[291.64,354.69],[310.17,346.87],[328.68,339.06],[347.2,331.25],[365.72,323.43],[384.23,315.62],[402.76,307.8],[421.25,300.0],[402.76,292.2],[384.23,284.38],[365.72,276.57],[347.2,268.75],[328.68,260.94],[310.17,253.13],[291.64,245.31],[273.12,237.5],[254.61,229.69],[236.08,221.87],[217.57,214.06],[199.05,206.25],[180.53,198.43],[162.02,190.62],[143.49,182.8],[125.0,175.0],[125.0,196.87],[125.0,218.75],[125.0,240.63],[125.0,262.5],[143.74,267.19],[162.5,271.87],[181.25,276.56],[200.0,281.25],[218.75,285.94],[237.5,290.63],[256.26,295.31],[275.0,300.0],[256.26,304.69],[237.5,309.37],[218.75,314.06],[200.0,318.75],[181.25,323.44],[162.5,328.13],[143.74,332.81],[125.0,337.5],[125.0,359.37],[125.0,381.25],[125.0,403.13]],"c":true}]},{"t":24,"s":[{"i":[[-1.86,-1.07],[-2.16,-0.53],[-2.29,0.0],[-2.94,1.17],[-2.43,2.43],[-1.07,1.86],[-0.53,2.16],[0.0,2.29],[0.0,5.26],[0.0,3.17],[0.0,2.99],[0.0,2.91],[0.0,2.87],[0.0,2.84],[0.0,2.82],[0.0,2.8],[0.0,2.78],[0.0,2.77],[0.0,2.75],[0.0,2.73],[0.0,2.71],[0.0,2.68],[0.0,2.64],[0.0,2.56],[0.0,2.36],[0.0,0.0],[0.5,2.05],[1.09,1.9],[1.6,1.6],[3.01,1.2],[3.5,0.0],[2.94,-1.17],[2.43,-2.43],[1.07,-1.86],[0.53,-2.16],[0.0,-2.29],[0.0,-5.26],[0.0,-3.17],[0.0,-2.99],[0.0,-2.91],[0.0,-2.87],[0.0,-2.84],[0.0,-2.82],[0.0,-2.8],[0.0,-2.78],[0.0,-2.77],[0.0,-2.75],[0.0,-2.73],[0.0,-2.71],[0.0,-2.68],[0.0,-2.64],[0.0,-2.56],[0.0,-2.36],[0.0,0.0],[-1.17,-2.94],[-2.43,-2.43]],"o":[[1.9,1.09],[2.05,0.5],[3.5,0.0],[3.01,-1.2],[1.6,-1.6],[1.09,-1.9],[0.5,-2.05],[0.0,0.0],[0.0,-2.36],[0.0,-2.56],[0.0,-2.64],[0.0,-2.68],[0.0,-2.71],[0.0,-2.73],[0.0,-2.75],[0.0,-2.77],[0.0,-2.78],[0.0,-2.8],[0.0,-2.82],[0.0,-2.84],[0.0,-2.87],[0.0,-2.91],[0.0,-2.99],[0.0,-3.17],[0.0,-5.26],[0.0,-2.29],[-0.53,-2.16],[-1.07,-1.86],[-2.43,-2.43],[-2.94,-1.17],[-3.5,0.0],[-3.01,1.2],[-1.6,1.6],[-1.09,1.9],[-0.5,2.05],[0.0,0.0],[0.0,2.36],[0.0,2.56],[0.0,2.64],[0.0,2.68],[0.0,2.71],[0.0,2.73],[0.0,2.75],[0.0,2.77],[0.0,2.78],[0.0,2.8],[0.0,2.82],[0.0,2.84],[0.0,2.87],[0.0,2.91],[0.0,2.99],[0.0,3.17],[0.0,5.26],[0.0,3.5],[1.2,3.01],[1.6,1.6]],"v":[[287.38,296.82],[293.48,299.25],[300.0,300.0],[309.65,298.25],[317.81,292.81],[321.82,287.62],[324.25,281.52],[325.0,275.0],[325.0,266.68],[325.0,258.35],[325.0,250.01],[325.0,241.68],[325.0,233.34],[325.0,225.0],[325.0,216.67],[325.0,208.34],[325.0,200.0],[325.0,191.66],[325.0,183.33],[325.0,175.0],[325.0,166.66],[325.0,158.32],[325.0,149.99],[325.0,141.65],[325.0,133.32],[325.0,125.0],[324.25,118.48],[321.82,112.38],[317.81,107.19],[309.65,101.75],[300.0,100.0],[290.35,101.75],[282.19,107.19],[278.18,112.38],[275.75,118.48],[275.0,125.0],[275.0,133.32],[275.0,141.65],[275.0,149.99],[275.0,158.32],[275.0,166.66],[275.0,175.0],[275.0,183.33],[275.0,191.66],[275.0,200.0],[275.0,208.34],[275.0,216.67],[275.0,225.0],[275.0,233.34],[275.0,241.68],[275.0,250.01],[275.0,258.35],[275.0,266.68],[275.0,275.0],[276.75,284.65],[282.19,292.81]],"c":true}]}],"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lottie/ic_send_to_videocam.json b/assets/lottie/ic_send_to_videocam.json new file mode 100644 index 0000000..f8b0300 --- /dev/null +++ b/assets/lottie/ic_send_to_videocam.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_send_to_videocam","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"ic_send_to_videocam","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"t":0,"s":[0],"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0}},{"t":10,"s":[-11],"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0}},{"t":24,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"t":0,"s":[300.0,300.0,0],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":10,"s":[324.0,300.0,0],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":19,"s":[292.0,300.0,0],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[300.0,300.0,0]}],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":10,"s":[90,90,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0.0,0.0],[0.0,15.7],[0.0,9.49],[0.0,8.93],[0.0,8.71],[0.0,8.57],[0.0,8.48],[0.0,8.42],[0.0,8.35],[0.0,8.3],[0.0,8.23],[0.0,8.16],[0.0,8.07],[0.0,7.93],[0.0,7.68],[0.0,7.11],[0.0,0.0],[-15.01,-6.32],[-9.05,-3.81],[-8.53,-3.59],[-8.31,-3.5],[-8.2,-3.45],[-8.11,-3.41],[-8.05,-3.39],[-8.01,-3.37],[-7.97,-3.35],[-7.93,-3.34],[-7.89,-3.32],[-7.86,-3.31],[-7.82,-3.29],[-7.77,-3.27],[-7.71,-3.24],[-7.63,-3.21],[-7.5,-3.16],[-7.27,-3.06],[-6.72,-2.83],[0.0,0.0],[15.01,-6.32],[9.05,-3.81],[8.53,-3.59],[8.31,-3.5],[8.2,-3.45],[8.11,-3.41],[8.05,-3.39],[8.01,-3.37],[7.97,-3.35],[7.93,-3.34],[7.89,-3.32],[7.86,-3.31],[7.82,-3.29],[7.77,-3.27],[7.71,-3.24],[7.63,-3.21],[7.5,-3.16],[7.27,-3.06],[6.72,-2.83]],"o":[[0.0,0.0],[0.0,-7.11],[0.0,-7.68],[0.0,-7.93],[0.0,-8.07],[0.0,-8.16],[0.0,-8.23],[0.0,-8.3],[0.0,-8.35],[0.0,-8.42],[0.0,-8.48],[0.0,-8.57],[0.0,-8.71],[0.0,-8.93],[0.0,-9.49],[0.0,-15.7],[0.0,0.0],[6.72,2.83],[7.27,3.06],[7.5,3.16],[7.63,3.21],[7.71,3.24],[7.77,3.27],[7.82,3.29],[7.86,3.31],[7.89,3.32],[7.93,3.34],[7.97,3.35],[8.01,3.37],[8.05,3.39],[8.11,3.41],[8.2,3.45],[8.31,3.5],[8.53,3.59],[9.05,3.81],[15.01,6.32],[0.0,0.0],[-6.72,2.83],[-7.27,3.06],[-7.5,3.16],[-7.63,3.21],[-7.71,3.24],[-7.77,3.27],[-7.82,3.29],[-7.86,3.31],[-7.89,3.32],[-7.93,3.34],[-7.97,3.35],[-8.01,3.37],[-8.05,3.39],[-8.11,3.41],[-8.2,3.45],[-8.31,3.5],[-8.53,3.59],[-9.05,3.81],[-15.01,6.32]],"v":[[75.0,500.0],[75.0,475.04],[75.0,450.02],[75.0,425.03],[75.0,400.01],[75.0,375.01],[75.0,350.01],[75.0,325.01],[75.0,300.0],[75.0,274.99],[75.0,249.99],[75.0,224.99],[75.0,199.99],[75.0,174.97],[75.0,149.98],[75.0,124.96],[75.0,100.0],[98.69,109.98],[122.45,119.98],[146.21,129.98],[169.97,139.99],[193.74,149.99],[217.48,159.99],[241.23,169.99],[264.99,180.0],[288.75,190.0],[312.5,200.0],[336.25,210.0],[360.01,220.0],[383.77,230.01],[407.52,240.01],[431.26,250.01],[455.03,260.01],[478.79,270.02],[502.55,280.02],[526.31,290.02],[550.0,300.0],[526.31,309.98],[502.55,319.98],[478.79,329.98],[455.03,339.99],[431.26,349.99],[407.52,359.99],[383.77,369.99],[360.01,380.0],[336.25,390.0],[312.5,400.0],[288.75,410.0],[264.99,420.0],[241.23,430.01],[217.48,440.01],[193.74,450.01],[169.97,460.01],[146.21,470.02],[122.45,480.02],[98.69,490.02]],"c":true}]},{"t":24,"s":[{"i":[[11.81,0.0],[10.88,0.0],[0.0,0.0],[9.79,9.79],[0.0,13.75],[0.0,22.83],[0.0,13.89],[0.0,13.05],[0.0,12.62],[0.0,12.26],[0.0,11.81],[0.0,10.88],[0.0,0.0],[-9.79,9.79],[-5.87,2.45],[-6.89,0.0],[-22.83,0.0],[-13.89,0.0],[-13.05,0.0],[-12.62,0.0],[-12.26,0.0],[-11.81,0.0],[-10.88,0.0],[0.0,0.0],[-9.79,-9.79],[0.0,-13.75],[0.0,-20.65],[0.0,-12.07],[0.0,0.0],[-14.35,14.35],[-8.68,8.68],[-7.64,7.64],[0.0,0.0],[0.0,-20.93],[0.0,-12.73],[0.0,-11.96],[0.0,-11.57],[0.0,-11.24],[0.0,-10.82],[0.0,-9.97],[0.0,0.0],[14.35,14.35],[8.68,8.68],[7.64,7.64],[0.0,0.0],[0.0,-20.65],[0.0,-12.07],[0.0,0.0],[2.46,-5.9],[4.89,-4.89],[13.75,0.0],[22.83,0.0],[13.89,0.0],[13.05,0.0],[12.62,0.0],[12.26,0.0]],"o":[[-13.89,0.0],[-22.83,0.0],[-13.75,0.0],[-9.79,-9.79],[0.0,0.0],[0.0,-10.88],[0.0,-11.81],[0.0,-12.26],[0.0,-12.62],[0.0,-13.05],[0.0,-13.89],[0.0,-22.83],[0.0,-13.75],[4.89,-4.89],[5.9,-2.46],[0.0,0.0],[10.88,0.0],[11.81,0.0],[12.26,0.0],[12.62,0.0],[13.05,0.0],[13.89,0.0],[22.83,0.0],[13.75,0.0],[9.79,9.79],[0.0,0.0],[0.0,12.07],[0.0,20.65],[0.0,0.0],[7.64,-7.64],[8.68,-8.68],[14.35,-14.35],[0.0,0.0],[0.0,9.97],[0.0,10.82],[0.0,11.24],[0.0,11.57],[0.0,11.96],[0.0,12.73],[0.0,20.93],[0.0,0.0],[-7.64,-7.64],[-8.68,-8.68],[-14.35,-14.35],[0.0,0.0],[0.0,12.07],[0.0,20.65],[0.0,6.89],[-2.45,5.87],[-9.79,9.79],[0.0,0.0],[-10.88,0.0],[-11.81,0.0],[-12.26,0.0],[-12.62,0.0],[-13.05,0.0]],"v":[[174.99,500.0],[137.49,500.0],[100.0,500.0],[64.69,485.31],[50.0,450.0],[50.0,412.51],[50.0,375.01],[50.0,337.51],[50.0,300.0],[50.0,262.49],[50.0,224.99],[50.0,187.49],[50.0,150.0],[64.69,114.69],[80.82,103.69],[100.0,100.0],[137.49,100.0],[174.99,100.0],[212.49,100.0],[250.0,100.0],[287.51,100.0],[325.01,100.0],[362.51,100.0],[400.0,100.0],[435.31,114.69],[450.0,150.0],[450.0,187.5],[450.0,225.0],[450.0,262.5],[475.0,237.5],[500.0,212.5],[525.0,187.5],[550.0,162.5],[550.0,196.86],[550.0,231.24],[550.0,265.62],[550.0,300.0],[550.0,334.38],[550.0,368.76],[550.0,403.14],[550.0,437.5],[525.0,412.5],[500.0,387.5],[475.0,362.5],[450.0,337.5],[450.0,375.0],[450.0,412.5],[450.0,450.0],[446.31,469.18],[435.31,485.31],[400.0,500.0],[362.51,500.0],[325.01,500.0],[287.51,500.0],[250.0,500.0],[212.49,500.0]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0.0,0.0],[-11.63,4.91],[-7.03,2.96],[-6.62,2.79],[-6.45,2.72],[-6.35,2.68],[-6.28,2.65],[-6.23,2.63],[-6.19,2.61],[-6.14,2.59],[-6.1,2.57],[-6.04,2.55],[-5.97,2.52],[-5.88,2.48],[-5.69,2.4],[-5.26,2.22],[0.0,0.0],[11.63,4.91],[7.03,2.96],[6.62,2.79],[6.45,2.72],[6.35,2.68],[6.28,2.65],[6.23,2.63],[6.19,2.61],[6.14,2.59],[6.1,2.57],[6.04,2.55],[5.97,2.52],[5.88,2.48],[5.69,2.4],[5.26,2.22],[0.0,0.0],[0.0,-12.55],[0.0,-7.6],[0.0,-6.68],[0.0,0.0],[-11.42,-2.85],[-6.95,-1.74],[-6.52,-1.63],[-6.31,-1.58],[-6.13,-1.53],[-5.9,-1.48],[-5.44,-1.36],[0.0,0.0],[11.42,-2.85],[6.95,-1.74],[6.52,-1.63],[6.31,-1.58],[6.13,-1.53],[5.9,-1.48],[5.44,-1.36],[0.0,0.0],[0.0,-12.55],[0.0,-7.6],[0.0,-6.68]],"o":[[0.0,0.0],[5.26,-2.22],[5.69,-2.4],[5.88,-2.48],[5.97,-2.52],[6.04,-2.55],[6.1,-2.57],[6.14,-2.59],[6.19,-2.61],[6.23,-2.63],[6.28,-2.65],[6.35,-2.68],[6.45,-2.72],[6.62,-2.79],[7.03,-2.96],[11.63,-4.91],[0.0,0.0],[-5.26,-2.22],[-5.69,-2.4],[-5.88,-2.48],[-5.97,-2.52],[-6.04,-2.55],[-6.1,-2.57],[-6.14,-2.59],[-6.19,-2.61],[-6.23,-2.63],[-6.28,-2.65],[-6.35,-2.68],[-6.45,-2.72],[-6.62,-2.79],[-7.03,-2.96],[-11.63,-4.91],[0.0,0.0],[0.0,6.68],[0.0,7.6],[0.0,12.55],[0.0,0.0],[5.44,1.36],[5.9,1.48],[6.13,1.53],[6.31,1.58],[6.52,1.63],[6.95,1.74],[11.42,2.85],[0.0,0.0],[-5.44,1.36],[-5.9,1.48],[-6.13,1.53],[-6.31,1.58],[-6.52,1.63],[-6.95,1.74],[-11.42,2.85],[0.0,0.0],[0.0,6.68],[0.0,7.6],[0.0,12.55]],"v":[[125.0,425.0],[143.49,417.2],[162.02,409.38],[180.53,401.57],[199.05,393.75],[217.57,385.94],[236.08,378.13],[254.61,370.31],[273.12,362.5],[291.64,354.69],[310.17,346.87],[328.68,339.06],[347.2,331.25],[365.72,323.43],[384.23,315.62],[402.76,307.8],[421.25,300.0],[402.76,292.2],[384.23,284.38],[365.72,276.57],[347.2,268.75],[328.68,260.94],[310.17,253.13],[291.64,245.31],[273.12,237.5],[254.61,229.69],[236.08,221.87],[217.57,214.06],[199.05,206.25],[180.53,198.43],[162.02,190.62],[143.49,182.8],[125.0,175.0],[125.0,196.87],[125.0,218.75],[125.0,240.63],[125.0,262.5],[143.74,267.19],[162.5,271.87],[181.25,276.56],[200.0,281.25],[218.75,285.94],[237.5,290.63],[256.26,295.31],[275.0,300.0],[256.26,304.69],[237.5,309.37],[218.75,314.06],[200.0,318.75],[181.25,323.44],[162.5,328.13],[143.74,332.81],[125.0,337.5],[125.0,359.37],[125.0,381.25],[125.0,403.13]],"c":true}]},{"t":24,"s":[{"i":[[-8.63,0.0],[-8.48,0.0],[-8.37,0.0],[-8.27,0.0],[-8.15,0.0],[-7.99,0.0],[-7.75,0.0],[-7.15,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,15.53],[0.0,9.41],[0.0,8.87],[0.0,8.63],[0.0,8.48],[0.0,8.37],[0.0,8.27],[0.0,8.15],[0.0,7.99],[0.0,7.75],[0.0,7.15],[0.0,0.0],[0.0,0.0],[0.0,0.0],[15.53,0.0],[9.41,0.0],[8.87,0.0],[8.63,0.0],[8.48,0.0],[8.37,0.0],[8.27,0.0],[8.15,0.0],[7.99,0.0],[7.75,0.0],[7.15,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-15.53],[0.0,-9.41],[0.0,-8.87],[0.0,-8.63],[0.0,-8.48],[0.0,-8.37],[0.0,-8.27],[0.0,-8.15],[0.0,-7.99],[0.0,-7.75],[0.0,-7.15],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-15.53,0.0],[-9.41,0.0],[-8.87,0.0]],"o":[[8.15,0.0],[8.27,0.0],[8.37,0.0],[8.48,0.0],[8.63,0.0],[8.87,0.0],[9.41,0.0],[15.53,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-7.15],[0.0,-7.75],[0.0,-7.99],[0.0,-8.15],[0.0,-8.27],[0.0,-8.37],[0.0,-8.48],[0.0,-8.63],[0.0,-8.87],[0.0,-9.41],[0.0,-15.53],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-7.15,0.0],[-7.75,0.0],[-7.99,0.0],[-8.15,0.0],[-8.27,0.0],[-8.37,0.0],[-8.48,0.0],[-8.63,0.0],[-8.87,0.0],[-9.41,0.0],[-15.53,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,7.15],[0.0,7.75],[0.0,7.99],[0.0,8.15],[0.0,8.27],[0.0,8.37],[0.0,8.48],[0.0,8.63],[0.0,8.87],[0.0,9.41],[0.0,15.53],[0.0,0.0],[0.0,0.0],[0.0,0.0],[7.15,0.0],[7.75,0.0],[7.99,0.0]],"v":[[199.99,450.0],[224.99,450.0],[250.0,450.0],[275.01,450.0],[300.01,450.0],[325.01,450.0],[350.03,450.0],[375.03,450.0],[400.0,450.0],[400.0,450.0],[400.0,450.0],[400.0,425.03],[400.0,400.03],[400.0,375.01],[400.0,350.01],[400.0,325.01],[400.0,300.0],[400.0,274.99],[400.0,249.99],[400.0,224.99],[400.0,199.97],[400.0,174.97],[400.0,150.0],[400.0,150.0],[400.0,150.0],[375.03,150.0],[350.03,150.0],[325.01,150.0],[300.01,150.0],[275.01,150.0],[250.0,150.0],[224.99,150.0],[199.99,150.0],[174.99,150.0],[149.97,150.0],[124.97,150.0],[100.0,150.0],[100.0,150.0],[100.0,150.0],[100.0,174.97],[100.0,199.97],[100.0,224.99],[100.0,249.99],[100.0,274.99],[100.0,300.0],[100.0,325.01],[100.0,350.01],[100.0,375.01],[100.0,400.03],[100.0,425.03],[100.0,450.0],[100.0,450.0],[100.0,450.0],[124.97,450.0],[149.97,450.0],[174.99,450.0]],"c":true}]}],"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lottie/ic_settings.json b/assets/lottie/ic_settings.json new file mode 100644 index 0000000..6bc273e --- /dev/null +++ b/assets/lottie/ic_settings.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":60,"w":500,"h":500,"nm":"system-regular-63-settings-cog","ddd":0,"assets":[{"id":"comp_1","nm":"hover-cog-1","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.38],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"t":60,"s":[180]}],"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[65.415,17.27],[65.425,17.27]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[-5.279,-1.58],[-4.338,0],[-4.502,1.145],[-2.995,1.872],[-0.218,0.122],[-4.41,7.876],[-0.134,0.213],[-1.581,5.285],[0,4.342],[1.146,4.497],[1.868,2.989],[0.123,0.22],[7.877,4.411],[0.214,0.133],[5.283,1.581],[4.342,0],[4.498,-1.145],[2.992,-1.871],[0.22,-0.123],[4.411,-7.878],[0.134,-0.213],[1.581,-5.283],[0,-4.341],[-1.144,-4.497],[-1.871,-2.993],[-0.123,-0.219],[-7.879,-4.413],[-0.213,-0.132]],"o":[[4.502,1.145],[4.338,0],[5.28,-1.581],[0.212,-0.132],[7.878,-4.413],[0.123,-0.22],[1.868,-2.99],[1.146,-4.495],[0,-4.342],[-1.581,-5.285],[-0.134,-0.214],[-4.411,-7.877],[-0.219,-0.123],[-2.991,-1.87],[-4.497,-1.145],[-4.341,0],[-5.282,1.581],[-0.214,0.133],[-7.876,4.41],[-0.123,0.219],[-1.87,2.992],[-1.145,4.498],[0,4.341],[1.581,5.284],[0.133,0.213],[4.41,7.876],[0.218,0.122],[2.996,1.872]],"v":[[-13.321,50.103],[-0.001,51.829],[13.318,50.104],[25.456,45.04],[26.102,44.658],[44.885,25.874],[45.271,25.224],[50.331,13.088],[52.057,-0.229],[50.331,-13.547],[45.271,-25.682],[44.885,-26.332],[26.103,-45.116],[25.453,-45.5],[13.316,-50.562],[-0.001,-52.287],[-13.319,-50.562],[-25.455,-45.5],[-26.105,-45.115],[-44.888,-26.332],[-45.272,-25.684],[-50.334,-13.548],[-52.059,-0.229],[-50.335,13.087],[-45.271,25.226],[-44.888,25.874],[-26.104,44.658],[-25.458,45.04]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[7.066,0],[7.15,1.852],[0.174,0.051],[7.142,4.396],[7.169,12.665],[1.507,5.085],[0.045,0.174],[0,7.071],[-1.853,7.147],[-0.051,0.173],[-4.395,7.138],[-12.665,7.167],[-5.085,1.507],[-0.174,0.045],[-7.071,0],[-7.146,-1.853],[-0.172,-0.051],[-7.138,-4.395],[-7.169,-12.666],[-1.508,-5.086],[-0.045,-0.174],[0,-7.073],[1.854,-7.145],[0.051,-0.171],[4.395,-7.136],[12.666,-7.17],[5.083,-1.507],[0.176,-0.045]],"o":[[-7.065,0],[-0.175,-0.046],[-5.082,-1.506],[-12.667,-7.17],[-4.395,-7.139],[-0.051,-0.172],[-1.853,-7.146],[0,-7.071],[0.045,-0.175],[1.508,-5.085],[7.17,-12.666],[7.138,-4.396],[0.172,-0.051],[7.147,-1.853],[7.072,0],[0.174,0.045],[5.086,1.508],[12.664,7.168],[4.394,7.134],[0.051,0.172],[1.854,7.146],[0,7.074],[-0.045,0.173],[-1.508,5.086],[-7.168,12.664],[-7.142,4.396],[-0.174,0.051],[-7.149,1.852]],"v":[[-0.001,83.129],[-21.426,80.338],[-21.949,80.193],[-41.716,71.788],[-72.018,41.488],[-80.423,21.717],[-80.567,21.197],[-83.359,-0.229],[-80.567,-21.656],[-80.423,-22.178],[-72.018,-41.946],[-41.719,-72.244],[-21.949,-80.651],[-21.43,-80.795],[-0.001,-83.587],[21.427,-80.795],[21.946,-80.651],[41.717,-72.244],[72.015,-41.945],[80.42,-22.178],[80.563,-21.659],[83.357,-0.229],[80.563,21.2],[80.42,21.717],[72.015,41.487],[41.716,71.787],[21.946,80.193],[21.423,80.338]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[-0.007,0.004],[0,0]],"o":[[0,0],[0.006,-0.003]],"v":[[142.26,-118.378],[142.24,-118.367]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[0,0],[-0.007,-0.004]],"o":[[0.007,0.004],[0,0]],"v":[[-142.23,-118.359],[-142.211,-118.348]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ind":5,"ty":"sh","ix":6,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[173.794,64.156],[173.804,64.156]],"c":true},"ix":2},"nm":"Path 6","mn":"ADBE Vector Shape - Group","hd":false},{"ind":6,"ty":"sh","ix":7,"ks":{"a":0,"k":{"i":[[0.007,0.003],[0,0]],"o":[[0,0],[-0.006,-0.003]],"v":[[142.209,117.89],[142.228,117.901]],"c":true},"ix":2},"nm":"Path 7","mn":"ADBE Vector Shape - Group","hd":false},{"ind":7,"ty":"sh","ix":8,"ks":{"a":0,"k":{"i":[[0,0],[0.006,-0.003]],"o":[[-0.007,0.003],[0,0]],"v":[[-142.24,117.907],[-142.259,117.918]],"c":true},"ix":2},"nm":"Path 8","mn":"ADBE Vector Shape - Group","hd":false},{"ind":8,"ty":"sh","ix":9,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[-6.556,2.028],[-14.196,13.044],[-5.925,-3.438],[0,0],[0,0],[0,0],[-1.534,6.696],[0,9.907],[1.949,8.508],[-5.948,3.436],[0,0],[0,0],[0,0],[5.044,4.635],[17.51,5.416],[0,6.862],[0,0],[0,0],[0,0],[6.556,-2.027],[14.196,-13.046],[5.926,3.438],[0,0],[0,0],[0,0],[1.535,-6.697],[0,-9.912],[-1.949,-8.505],[5.949,-3.437],[0,0],[0,0],[0,0],[-5.046,-4.636],[-17.514,-5.417],[0,-6.862]],"o":[[0,0],[0,0],[0,-6.862],[17.513,-5.417],[5.045,-4.635],[0,0],[0,0],[0,0],[-5.948,-3.437],[1.949,-8.51],[0,-9.908],[-1.534,-6.696],[0,0],[0,0],[0,0],[-5.927,3.438],[-14.198,-13.046],[-6.556,-2.027],[0,0],[0,0],[0,0],[0,6.862],[-17.511,5.416],[-5.046,4.636],[0,0],[0,0],[0,0],[5.949,3.437],[-1.948,8.503],[0,9.911],[1.534,6.697],[0,0],[0,0],[0,0],[5.927,-3.439],[14.194,13.044],[6.556,2.028],[0,0]],"v":[[-31.226,176.828],[31.224,176.828],[31.224,143.104],[42.249,128.152],[90.035,100.33],[108.479,98.318],[137.809,115.337],[168.973,61.378],[139.879,44.572],[132.452,27.526],[135.39,-0.229],[132.452,-27.984],[139.879,-45.03],[168.973,-61.837],[137.809,-115.795],[108.479,-98.776],[90.035,-100.788],[42.25,-128.611],[31.224,-143.562],[31.224,-177.286],[-31.226,-177.286],[-31.226,-143.562],[-42.252,-128.611],[-90.036,-100.788],[-108.48,-98.776],[-137.812,-115.795],[-168.976,-61.836],[-139.881,-45.031],[-132.455,-27.982],[-135.392,-0.229],[-132.454,27.525],[-139.881,44.572],[-168.976,61.378],[-137.812,115.337],[-108.48,98.318],[-90.036,100.33],[-42.251,128.152],[-31.226,143.104]],"c":true},"ix":2},"nm":"Path 9","mn":"ADBE Vector Shape - Group","hd":false},{"ind":9,"ty":"sh","ix":10,"ks":{"a":0,"k":{"i":[[14.373,0],[0,0],[0,14.373],[0,0],[12.287,9.814],[0,0],[7.26,12.444],[0,0],[0.067,0.125],[-12.372,6.865],[0,0],[0,8.014],[-1.062,7.435],[0,0],[-6.843,12.634],[-0.072,0.124],[0,0],[-6.784,1.764],[-6.019,-3.511],[0,0],[-14.219,5.629],[0,0],[-14.373,0],[0,0],[0,-14.373],[0,0],[-12.288,-9.814],[0,0],[-7.261,-12.448],[0,0],[-0.068,-0.125],[12.369,-6.866],[0,0],[0,-8.012],[1.062,-7.437],[0,0],[6.844,-12.634],[0.071,-0.124],[0,0],[12.468,7.269],[0,0],[14.221,-5.63],[0,0]],"o":[[0,0],[-14.373,0],[0,0],[-14.221,-5.63],[0,0],[-12.438,7.253],[0,0],[-0.071,-0.124],[-6.844,-12.634],[0,0],[-1.062,-7.436],[0,-8.014],[0,0],[-12.371,-6.865],[0.068,-0.125],[0,0],[3.559,-6.101],[6.745,-1.756],[0,0],[12.287,-9.814],[0,0],[0,-14.373],[0,0],[14.373,0],[0,0],[14.218,5.629],[0,0],[12.436,-7.256],[0,0],[0.071,0.124],[6.842,12.633],[0,0],[1.062,7.437],[0,8.011],[0,0],[12.372,6.865],[-0.067,0.125],[0,0],[-7.293,12.502],[0,0],[-12.288,9.814],[0,0],[0,14.373]],"v":[[36.457,208.128],[-36.459,208.128],[-62.526,182.062],[-62.526,154.174],[-102.371,130.96],[-126.521,144.973],[-162.269,135.573],[-198.761,72.389],[-198.97,72.015],[-188.935,36.76],[-165.094,22.989],[-166.692,-0.229],[-165.094,-23.448],[-188.936,-37.219],[-198.971,-72.473],[-198.761,-72.848],[-162.303,-135.973],[-146.283,-148.136],[-126.489,-145.413],[-102.371,-131.418],[-62.526,-154.633],[-62.526,-182.52],[-36.459,-208.586],[36.457,-208.586],[62.524,-182.52],[62.524,-154.633],[102.369,-131.418],[126.519,-145.432],[162.266,-136.032],[198.759,-72.848],[198.969,-72.473],[188.935,-37.221],[165.092,-23.448],[166.69,-0.229],[165.092,22.989],[188.934,36.761],[198.968,72.015],[198.759,72.389],[162.3,135.514],[126.49,144.957],[102.369,130.96],[62.524,154.174],[62.524,182.062]],"c":true},"ix":2},"nm":"Path 10","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.071,0.075,0.192,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-63-settings-cog').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":11,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":425,"st":60,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"control","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"primary","np":3,"mn":"ADBE Color Control","ix":1,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[0.071,0.075,0.192],"ix":1}}]}],"ip":0,"op":271,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"hover-cog-1","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":500,"h":500,"ip":0,"op":70,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"default:hover-cog-1","dr":60}],"props":{}} \ No newline at end of file diff --git a/assets/lottie/ic_videocam_on_to_off.json b/assets/lottie/ic_videocam_on_to_off.json new file mode 100644 index 0000000..e759f3f --- /dev/null +++ b/assets/lottie/ic_videocam_on_to_off.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_videocam_on_to_off","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"slashed","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[300.0,300.0,0],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":11,"s":[92,92,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[675.65,-597.15],[-597.15,675.65],[-1869.94,-597.15],[-597.15,-1869.94]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1197.15,-75.65],[-75.65,1197.15],[-1348.44,-75.65],[-75.65,-1348.44]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Wipe"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.0,0.0],[11.75,11.75],[7.16,7.16],[6.59,6.59],[5.97,5.97],[0.0,0.0],[0.0,-10.47],[0.0,0.0],[9.18,9.18],[5.36,5.36],[0.0,0.0],[0.0,15.47],[0.0,9.42],[0.0,8.83],[0.0,8.49],[0.0,8.14],[0.0,7.48],[0.0,0.0],[0.0,0.0],[0.0,0.0],[15.47,0.0],[9.42,0.0],[8.83,0.0],[8.49,0.0],[8.14,0.0],[7.48,0.0],[0.0,0.0],[9.18,9.18],[5.36,5.36],[0.0,0.0],[-17.46,0.0],[-10.62,0.0],[-9.98,0.0],[-9.65,0.0],[-9.38,0.0],[-9.03,0.0],[-8.32,0.0],[0.0,0.0],[-9.79,-9.79],[0.0,-13.75],[0.0,-16.14],[0.0,-9.77],[0.0,-8.59],[0.0,0.0],[-11.75,11.75],[-7.16,7.16],[-6.59,6.59],[-5.97,5.97],[0.0,0.0],[0.0,-20.93],[0.0,-12.73],[0.0,-11.96],[0.0,-11.57],[0.0,-11.24],[0.0,-10.82],[0.0,-9.97]],"o":[[0.0,0.0],[-5.97,-5.97],[-6.59,-6.59],[-7.16,-7.16],[-11.75,-11.75],[0.0,0.0],[0.0,10.47],[0.0,0.0],[-5.36,-5.36],[-9.18,-9.18],[0.0,0.0],[0.0,-7.48],[0.0,-8.14],[0.0,-8.49],[0.0,-8.83],[0.0,-9.42],[0.0,-15.47],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-7.48,0.0],[-8.14,0.0],[-8.49,0.0],[-8.83,0.0],[-9.42,0.0],[-15.47,0.0],[0.0,0.0],[-5.36,-5.36],[-9.18,-9.18],[0.0,0.0],[8.32,0.0],[9.03,0.0],[9.38,0.0],[9.65,0.0],[9.98,0.0],[10.62,0.0],[17.46,0.0],[13.75,0.0],[9.79,9.79],[0.0,0.0],[0.0,8.59],[0.0,9.77],[0.0,16.14],[0.0,0.0],[5.97,-5.97],[6.59,-6.59],[7.16,-7.16],[11.75,-11.75],[0.0,0.0],[0.0,9.97],[0.0,10.82],[0.0,11.24],[0.0,11.57],[0.0,11.96],[0.0,12.73],[0.0,20.93]],"v":[[550.0,437.5],[530.01,417.51],[510.0,397.5],[490.0,377.5],[469.99,357.49],[450.0,337.5],[450.0,358.44],[450.0,379.38],[433.33,362.71],[416.67,346.04],[400.0,329.38],[400.0,303.76],[400.0,278.13],[400.0,252.5],[400.0,226.87],[400.0,201.24],[400.0,175.61],[400.0,150.0],[400.0,150.0],[400.0,150.0],[374.39,150.0],[348.76,150.0],[323.13,150.0],[297.5,150.0],[271.87,150.0],[246.24,150.0],[220.63,150.0],[203.96,133.33],[187.29,116.67],[170.62,100.0],[199.29,100.0],[227.96,100.0],[256.63,100.0],[285.31,100.0],[313.99,100.0],[342.66,100.0],[371.34,100.0],[400.0,100.0],[435.31,114.69],[450.0,150.0],[450.0,178.12],[450.0,206.25],[450.0,234.38],[450.0,262.5],[469.99,242.51],[490.0,222.5],[510.0,202.5],[530.01,182.49],[550.0,162.5],[550.0,196.86],[550.0,231.24],[550.0,265.62],[550.0,300.0],[550.0,334.38],[550.0,368.76],[550.0,403.14]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.0,0.0],[12.17,12.17],[7.34,7.34],[6.89,6.89],[6.73,6.73],[6.63,6.63],[6.56,6.56],[6.52,6.52],[6.49,6.49],[6.46,6.46],[6.44,6.44],[6.42,6.42],[6.4,6.4],[6.39,6.39],[6.37,6.37],[6.35,6.35],[6.33,6.33],[6.32,6.32],[6.29,6.29],[6.26,6.26],[6.22,6.22],[6.18,6.18],[6.12,6.12],[6.03,6.03],[5.83,5.83],[5.4,5.4],[0.0,0.0],[-8.75,8.75],[0.0,0.0],[-12.17,-12.17],[-7.34,-7.34],[-6.89,-6.89],[-6.73,-6.73],[-6.63,-6.63],[-6.56,-6.56],[-6.52,-6.52],[-6.49,-6.49],[-6.46,-6.46],[-6.44,-6.44],[-6.42,-6.42],[-6.4,-6.4],[-6.39,-6.39],[-6.37,-6.37],[-6.35,-6.35],[-6.33,-6.33],[-6.32,-6.32],[-6.29,-6.29],[-6.26,-6.26],[-6.22,-6.22],[-6.18,-6.18],[-6.12,-6.12],[-6.03,-6.03],[-5.83,-5.83],[-5.4,-5.4],[0.0,0.0],[8.75,-8.75]],"o":[[0.0,0.0],[-5.4,-5.4],[-5.83,-5.83],[-6.03,-6.03],[-6.12,-6.12],[-6.18,-6.18],[-6.22,-6.22],[-6.26,-6.26],[-6.29,-6.29],[-6.32,-6.32],[-6.33,-6.33],[-6.35,-6.35],[-6.37,-6.37],[-6.39,-6.39],[-6.4,-6.4],[-6.42,-6.42],[-6.44,-6.44],[-6.46,-6.46],[-6.49,-6.49],[-6.52,-6.52],[-6.56,-6.56],[-6.63,-6.63],[-6.73,-6.73],[-6.89,-6.89],[-7.34,-7.34],[-12.17,-12.17],[0.0,0.0],[8.75,-8.75],[0.0,0.0],[5.4,5.4],[5.83,5.83],[6.03,6.03],[6.12,6.12],[6.18,6.18],[6.22,6.22],[6.26,6.26],[6.29,6.29],[6.32,6.32],[6.33,6.33],[6.35,6.35],[6.37,6.37],[6.39,6.39],[6.4,6.4],[6.42,6.42],[6.44,6.44],[6.46,6.46],[6.49,6.49],[6.52,6.52],[6.56,6.56],[6.63,6.63],[6.73,6.73],[6.89,6.89],[7.34,7.34],[12.17,12.17],[0.0,0.0],[-8.75,8.75]],"v":[[513.75,583.75],[494.68,564.68],[475.51,545.51],[456.4,526.4],[437.23,507.23],[418.08,488.08],[398.95,468.95],[379.83,449.83],[360.7,430.7],[341.56,411.56],[322.41,392.41],[303.28,373.28],[284.14,354.14],[265.0,335.0],[245.86,315.86],[226.72,296.72],[207.59,277.59],[188.44,258.44],[169.3,239.3],[150.17,220.17],[131.05,201.05],[111.92,181.92],[92.77,162.77],[73.6,143.6],[54.49,124.49],[35.32,105.32],[16.25,86.25],[33.75,68.75],[51.25,51.25],[70.32,70.32],[89.49,89.49],[108.6,108.6],[127.77,127.77],[146.92,146.92],[166.05,166.05],[185.17,185.17],[204.3,204.3],[223.44,223.44],[242.59,242.59],[261.72,261.72],[280.86,280.86],[300.0,300.0],[319.14,319.14],[338.28,338.28],[357.41,357.41],[376.56,376.56],[395.7,395.7],[414.83,414.83],[433.95,433.95],[453.08,453.08],[472.23,472.23],[491.4,491.4],[510.51,510.51],[529.68,529.68],[548.75,548.75],[531.25,566.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0.0,0.0],[-7.04,0.0],[-4.25,0.0],[-4.01,0.0],[-3.91,0.0],[-3.84,0.0],[-3.8,0.0],[-3.77,0.0],[-3.75,0.0],[-3.72,0.0],[-3.69,0.0],[-3.66,0.0],[-3.62,0.0],[-3.56,0.0],[-3.45,0.0],[-3.19,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-7.04],[0.0,-4.25],[0.0,-4.01],[0.0,-3.91],[0.0,-3.84],[0.0,-3.8],[0.0,-3.77],[0.0,-3.75],[0.0,-3.72],[0.0,-3.69],[0.0,-3.66],[0.0,-3.62],[0.0,-3.56],[0.0,-3.45],[0.0,-3.19],[0.0,0.0],[5.17,5.17],[3.11,3.11],[2.93,2.93],[2.86,2.86],[2.82,2.82],[2.79,2.79],[2.77,2.77],[2.75,2.75],[2.74,2.74],[2.73,2.73],[2.72,2.72],[2.71,2.71],[2.7,2.7],[2.69,2.69],[2.68,2.68],[2.66,2.66],[2.64,2.64],[2.61,2.61],[2.57,2.57],[2.49,2.49],[2.31,2.31]],"o":[[0.0,0.0],[3.19,0.0],[3.45,0.0],[3.56,0.0],[3.62,0.0],[3.66,0.0],[3.69,0.0],[3.72,0.0],[3.75,0.0],[3.77,0.0],[3.8,0.0],[3.84,0.0],[3.91,0.0],[4.01,0.0],[4.25,0.0],[7.04,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,3.19],[0.0,3.45],[0.0,3.56],[0.0,3.62],[0.0,3.66],[0.0,3.69],[0.0,3.72],[0.0,3.75],[0.0,3.77],[0.0,3.8],[0.0,3.84],[0.0,3.91],[0.0,4.01],[0.0,4.25],[0.0,7.04],[0.0,0.0],[-2.31,-2.31],[-2.49,-2.49],[-2.57,-2.57],[-2.61,-2.61],[-2.64,-2.64],[-2.66,-2.66],[-2.68,-2.68],[-2.69,-2.69],[-2.7,-2.7],[-2.71,-2.71],[-2.72,-2.72],[-2.73,-2.73],[-2.74,-2.74],[-2.75,-2.75],[-2.77,-2.77],[-2.79,-2.79],[-2.82,-2.82],[-2.86,-2.86],[-2.93,-2.93],[-3.11,-3.11],[-5.17,-5.17]],"v":[[220.63,150.0],[231.82,150.0],[243.04,150.0],[254.25,150.0],[265.46,150.0],[276.67,150.0],[287.89,150.0],[299.1,150.0],[310.31,150.0],[321.53,150.0],[332.74,150.0],[343.95,150.0],[355.16,150.0],[366.38,150.0],[377.59,150.0],[388.81,150.0],[400.0,150.0],[400.0,150.0],[400.0,150.0],[400.0,161.19],[400.0,172.41],[400.0,183.62],[400.0,194.84],[400.0,206.05],[400.0,217.26],[400.0,228.47],[400.0,239.69],[400.0,250.9],[400.0,262.11],[400.0,273.33],[400.0,284.54],[400.0,295.75],[400.0,306.96],[400.0,318.18],[400.0,329.38],[391.86,321.24],[383.7,313.08],[375.55,304.92],[367.4,296.78],[359.24,288.62],[351.08,280.46],[342.93,272.3],[334.78,264.15],[326.62,256.0],[318.47,247.84],[310.31,239.69],[302.16,231.53],[294.0,223.38],[285.85,215.22],[277.7,207.07],[269.54,198.92],[261.38,190.76],[253.22,182.6],[245.08,174.45],[236.92,166.3],[228.76,158.14]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,10.99],[0.0,6.64],[0.0,6.25],[0.0,6.1],[0.0,6.0],[0.0,5.94],[0.0,5.89],[0.0,5.85],[0.0,5.81],[0.0,5.76],[0.0,5.71],[0.0,5.65],[0.0,5.55],[0.0,5.38],[0.0,4.97],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-9.81,-9.81],[-5.91,-5.91],[-5.57,-5.57],[-5.43,-5.43],[-5.35,-5.35],[-5.3,-5.3],[-5.26,-5.26],[-5.22,-5.22],[-5.2,-5.2],[-5.17,-5.17],[-5.14,-5.14],[-5.1,-5.1],[-5.06,-5.06],[-5.01,-5.01],[-4.92,-4.92],[-4.77,-4.77],[-4.41,-4.41],[0.0,0.0],[0.0,0.0],[0.0,0.0],[10.99,0.0],[6.64,0.0],[6.25,0.0],[6.1,0.0],[6.0,0.0],[5.94,0.0],[5.89,0.0],[5.85,0.0],[5.81,0.0],[5.76,0.0],[5.71,0.0],[5.65,0.0],[5.55,0.0],[5.38,0.0],[4.97,0.0]],"o":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-4.97],[0.0,-5.38],[0.0,-5.55],[0.0,-5.65],[0.0,-5.71],[0.0,-5.76],[0.0,-5.81],[0.0,-5.85],[0.0,-5.89],[0.0,-5.94],[0.0,-6.0],[0.0,-6.1],[0.0,-6.25],[0.0,-6.64],[0.0,-10.99],[0.0,0.0],[0.0,0.0],[0.0,0.0],[4.41,4.41],[4.77,4.77],[4.92,4.92],[5.01,5.01],[5.06,5.06],[5.1,5.1],[5.14,5.14],[5.17,5.17],[5.2,5.2],[5.22,5.22],[5.26,5.26],[5.3,5.3],[5.35,5.35],[5.43,5.43],[5.57,5.57],[5.91,5.91],[9.81,9.81],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-4.97,0.0],[-5.38,0.0],[-5.55,0.0],[-5.65,0.0],[-5.71,0.0],[-5.76,0.0],[-5.81,0.0],[-5.85,0.0],[-5.89,0.0],[-5.94,0.0],[-6.0,0.0],[-6.1,0.0],[-6.25,0.0],[-6.64,0.0],[-10.99,0.0]],"v":[[100.0,450.0],[100.0,450.0],[100.0,450.0],[100.0,432.53],[100.0,415.01],[100.0,397.52],[100.0,380.01],[100.0,362.51],[100.0,345.01],[100.0,327.5],[100.0,310.0],[100.0,292.5],[100.0,274.99],[100.0,257.49],[100.0,239.99],[100.0,222.48],[100.0,204.99],[100.0,187.47],[100.0,170.0],[100.0,170.0],[100.0,170.0],[115.54,185.54],[131.09,201.09],[146.64,216.64],[162.2,232.2],[177.77,247.77],[193.32,263.32],[208.88,278.88],[224.44,294.44],[240.0,310.0],[255.56,325.56],[271.12,341.12],[286.68,356.68],[302.23,372.23],[317.8,387.8],[333.36,403.36],[348.91,418.91],[364.46,434.46],[380.0,450.0],[380.0,450.0],[380.0,450.0],[362.53,450.0],[345.01,450.0],[327.52,450.0],[310.01,450.0],[292.51,450.0],[275.01,450.0],[257.5,450.0],[240.0,450.0],[222.5,450.0],[204.99,450.0],[187.49,450.0],[169.99,450.0],[152.48,450.0],[134.99,450.0],[117.47,450.0]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[0.0,0.0],[-12.5,-12.5],[0.0,0.0],[12.5,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-22.83],[0.0,-13.89],[0.0,-13.05],[0.0,-12.62],[0.0,-12.26],[0.0,-11.81],[0.0,-10.88],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-22.83,0.0],[-13.89,0.0],[-13.05,0.0],[-12.62,0.0],[-12.26,0.0],[-11.81,0.0],[-10.88,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,12.5],[0.0,0.0],[-12.5,-12.5],[0.0,0.0],[0.0,0.0],[9.79,-9.79],[13.75,0.0],[20.43,0.0],[12.41,0.0],[11.68,0.0],[11.33,0.0],[11.07,0.0],[10.81,0.0],[10.44,0.0],[9.62,0.0],[0.0,0.0],[9.79,9.79],[0.0,13.75],[0.0,20.43],[0.0,12.41],[0.0,11.68],[0.0,11.33],[0.0,11.07],[0.0,10.81],[0.0,10.44],[0.0,9.62],[0.0,0.0],[-9.79,9.79],[-13.75,0.0]],"o":[[0.0,0.0],[12.5,12.5],[0.0,0.0],[-12.5,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,10.88],[0.0,11.81],[0.0,12.26],[0.0,12.62],[0.0,13.05],[0.0,13.89],[0.0,22.83],[0.0,0.0],[0.0,0.0],[0.0,0.0],[10.88,0.0],[11.81,0.0],[12.26,0.0],[12.62,0.0],[13.05,0.0],[13.89,0.0],[22.83,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-12.5],[0.0,0.0],[12.5,12.5],[0.0,0.0],[0.0,13.75],[-9.79,9.79],[0.0,0.0],[-9.62,0.0],[-10.44,0.0],[-10.81,0.0],[-11.07,0.0],[-11.33,0.0],[-11.68,0.0],[-12.41,0.0],[-20.43,0.0],[-13.75,0.0],[-9.79,-9.79],[0.0,0.0],[0.0,-9.62],[0.0,-10.44],[0.0,-10.81],[0.0,-11.07],[0.0,-11.33],[0.0,-11.68],[0.0,-12.41],[0.0,-20.43],[0.0,-13.75],[9.79,-9.79],[0.0,0.0]],"v":[[100.0,100.0],[125.0,125.0],[150.0,150.0],[125.0,150.0],[100.0,150.0],[100.0,150.0],[100.0,150.0],[100.0,187.49],[100.0,224.99],[100.0,262.49],[100.0,300.0],[100.0,337.51],[100.0,375.01],[100.0,412.51],[100.0,450.0],[100.0,450.0],[100.0,450.0],[137.49,450.0],[174.99,450.0],[212.49,450.0],[250.0,450.0],[287.51,450.0],[325.01,450.0],[362.51,450.0],[400.0,450.0],[400.0,450.0],[400.0,450.0],[400.0,425.0],[400.0,400.0],[425.0,425.0],[450.0,450.0],[450.0,450.0],[435.31,485.31],[400.0,500.0],[366.69,500.0],[333.35,500.0],[300.01,500.0],[266.67,500.0],[233.33,500.0],[199.99,500.0],[166.65,500.0],[133.31,500.0],[100.0,500.0],[64.69,485.31],[50.0,450.0],[50.0,416.69],[50.0,383.35],[50.0,350.01],[50.0,316.67],[50.0,283.33],[50.0,249.99],[50.0,216.65],[50.0,183.31],[50.0,150.0],[64.69,114.69],[100.0,100.0]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"slashed","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"plain","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[300.0,300.0,0],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":11,"s":[92,92,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[675.65,-597.15],[-597.15,675.65],[675.65,1948.44],[1948.44,675.65]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1197.15,-75.65],[-75.65,1197.15],[1197.15,2469.94],[2469.94,1197.15]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Wipe"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.0,0.0],[9.79,9.79],[0.0,13.75],[0.0,22.83],[0.0,13.89],[0.0,13.05],[0.0,12.62],[0.0,12.26],[0.0,11.81],[0.0,10.88],[0.0,0.0],[-9.79,9.79],[-5.87,2.45],[-6.89,0.0],[-22.83,0.0],[-13.89,0.0],[-13.05,0.0],[-12.62,0.0],[-12.26,0.0],[-11.81,0.0],[-10.88,0.0],[0.0,0.0],[-9.79,-9.79],[0.0,-13.75],[0.0,-20.65],[0.0,-12.07],[0.0,0.0],[-14.35,14.35],[-8.68,8.68],[-7.64,7.64],[0.0,0.0],[0.0,-20.93],[0.0,-12.73],[0.0,-11.96],[0.0,-11.57],[0.0,-11.24],[0.0,-10.82],[0.0,-9.97],[0.0,0.0],[14.35,14.35],[8.68,8.68],[7.64,7.64],[0.0,0.0],[0.0,-20.65],[0.0,-12.07],[0.0,0.0],[2.46,-5.9],[4.89,-4.89],[13.75,0.0],[22.83,0.0],[13.89,0.0],[13.05,0.0],[12.62,0.0],[12.26,0.0],[11.81,0.0],[10.88,0.0]],"o":[[-13.75,0.0],[-9.79,-9.79],[0.0,0.0],[0.0,-10.88],[0.0,-11.81],[0.0,-12.26],[0.0,-12.62],[0.0,-13.05],[0.0,-13.89],[0.0,-22.83],[0.0,-13.75],[4.89,-4.89],[5.9,-2.46],[0.0,0.0],[10.88,0.0],[11.81,0.0],[12.26,0.0],[12.62,0.0],[13.05,0.0],[13.89,0.0],[22.83,0.0],[13.75,0.0],[9.79,9.79],[0.0,0.0],[0.0,12.07],[0.0,20.65],[0.0,0.0],[7.64,-7.64],[8.68,-8.68],[14.35,-14.35],[0.0,0.0],[0.0,9.97],[0.0,10.82],[0.0,11.24],[0.0,11.57],[0.0,11.96],[0.0,12.73],[0.0,20.93],[0.0,0.0],[-7.64,-7.64],[-8.68,-8.68],[-14.35,-14.35],[0.0,0.0],[0.0,12.07],[0.0,20.65],[0.0,6.89],[-2.45,5.87],[-9.79,9.79],[0.0,0.0],[-10.88,0.0],[-11.81,0.0],[-12.26,0.0],[-12.62,0.0],[-13.05,0.0],[-13.89,0.0],[-22.83,0.0]],"v":[[100.0,500.0],[64.69,485.31],[50.0,450.0],[50.0,412.51],[50.0,375.01],[50.0,337.51],[50.0,300.0],[50.0,262.49],[50.0,224.99],[50.0,187.49],[50.0,150.0],[64.69,114.69],[80.82,103.69],[100.0,100.0],[137.49,100.0],[174.99,100.0],[212.49,100.0],[250.0,100.0],[287.51,100.0],[325.01,100.0],[362.51,100.0],[400.0,100.0],[435.31,114.69],[450.0,150.0],[450.0,187.5],[450.0,225.0],[450.0,262.5],[475.0,237.5],[500.0,212.5],[525.0,187.5],[550.0,162.5],[550.0,196.86],[550.0,231.24],[550.0,265.62],[550.0,300.0],[550.0,334.38],[550.0,368.76],[550.0,403.14],[550.0,437.5],[525.0,412.5],[500.0,387.5],[475.0,362.5],[450.0,337.5],[450.0,375.0],[450.0,412.5],[450.0,450.0],[446.31,469.18],[435.31,485.31],[400.0,500.0],[362.51,500.0],[325.01,500.0],[287.51,500.0],[250.0,500.0],[212.49,500.0],[174.99,500.0],[137.49,500.0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.0,0.0],[-15.53,0.0],[-9.41,0.0],[-8.87,0.0],[-8.63,0.0],[-8.48,0.0],[-8.37,0.0],[-8.27,0.0],[-8.15,0.0],[-7.99,0.0],[-7.75,0.0],[-7.15,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,15.53],[0.0,9.41],[0.0,8.87],[0.0,8.63],[0.0,8.48],[0.0,8.37],[0.0,8.27],[0.0,8.15],[0.0,7.99],[0.0,7.75],[0.0,7.15],[0.0,0.0],[0.0,0.0],[0.0,0.0],[15.53,0.0],[9.41,0.0],[8.87,0.0],[8.63,0.0],[8.48,0.0],[8.37,0.0],[8.27,0.0],[8.15,0.0],[7.99,0.0],[7.75,0.0],[7.15,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-15.53],[0.0,-9.41],[0.0,-8.87],[0.0,-8.63],[0.0,-8.48],[0.0,-8.37],[0.0,-8.27],[0.0,-8.15],[0.0,-7.99],[0.0,-7.75],[0.0,-7.15],[0.0,0.0],[0.0,0.0]],"o":[[0.0,0.0],[7.15,0.0],[7.75,0.0],[7.99,0.0],[8.15,0.0],[8.27,0.0],[8.37,0.0],[8.48,0.0],[8.63,0.0],[8.87,0.0],[9.41,0.0],[15.53,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-7.15],[0.0,-7.75],[0.0,-7.99],[0.0,-8.15],[0.0,-8.27],[0.0,-8.37],[0.0,-8.48],[0.0,-8.63],[0.0,-8.87],[0.0,-9.41],[0.0,-15.53],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-7.15,0.0],[-7.75,0.0],[-7.99,0.0],[-8.15,0.0],[-8.27,0.0],[-8.37,0.0],[-8.48,0.0],[-8.63,0.0],[-8.87,0.0],[-9.41,0.0],[-15.53,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,7.15],[0.0,7.75],[0.0,7.99],[0.0,8.15],[0.0,8.27],[0.0,8.37],[0.0,8.48],[0.0,8.63],[0.0,8.87],[0.0,9.41],[0.0,15.53],[0.0,0.0],[0.0,0.0]],"v":[[100.0,450.0],[124.97,450.0],[149.97,450.0],[174.99,450.0],[199.99,450.0],[224.99,450.0],[250.0,450.0],[275.01,450.0],[300.01,450.0],[325.01,450.0],[350.03,450.0],[375.03,450.0],[400.0,450.0],[400.0,450.0],[400.0,450.0],[400.0,425.03],[400.0,400.03],[400.0,375.01],[400.0,350.01],[400.0,325.01],[400.0,300.0],[400.0,274.99],[400.0,249.99],[400.0,224.99],[400.0,199.97],[400.0,174.97],[400.0,150.0],[400.0,150.0],[400.0,150.0],[375.03,150.0],[350.03,150.0],[325.01,150.0],[300.01,150.0],[275.01,150.0],[250.0,150.0],[224.99,150.0],[199.99,150.0],[174.99,150.0],[149.97,150.0],[124.97,150.0],[100.0,150.0],[100.0,150.0],[100.0,150.0],[100.0,174.97],[100.0,199.97],[100.0,224.99],[100.0,249.99],[100.0,274.99],[100.0,300.0],[100.0,325.01],[100.0,350.01],[100.0,375.01],[100.0,400.03],[100.0,425.03],[100.0,450.0],[100.0,450.0]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,15.53],[0.0,9.41],[0.0,8.87],[0.0,8.63],[0.0,8.48],[0.0,8.37],[0.0,8.27],[0.0,8.15],[0.0,7.99],[0.0,7.75],[0.0,7.15],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-15.53,0.0],[-9.41,0.0],[-8.87,0.0],[-8.63,0.0],[-8.48,0.0],[-8.37,0.0],[-8.27,0.0],[-8.15,0.0],[-7.99,0.0],[-7.75,0.0],[-7.15,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-15.53],[0.0,-9.41],[0.0,-8.87],[0.0,-8.63],[0.0,-8.48],[0.0,-8.37],[0.0,-8.27],[0.0,-8.15],[0.0,-7.99],[0.0,-7.75],[0.0,-7.15],[0.0,0.0],[0.0,0.0],[0.0,0.0],[15.53,0.0],[9.41,0.0],[8.87,0.0],[8.63,0.0],[8.48,0.0],[8.37,0.0],[8.27,0.0],[8.15,0.0],[7.99,0.0],[7.75,0.0],[7.15,0.0]],"o":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-7.15],[0.0,-7.75],[0.0,-7.99],[0.0,-8.15],[0.0,-8.27],[0.0,-8.37],[0.0,-8.48],[0.0,-8.63],[0.0,-8.87],[0.0,-9.41],[0.0,-15.53],[0.0,0.0],[0.0,0.0],[0.0,0.0],[7.15,0.0],[7.75,0.0],[7.99,0.0],[8.15,0.0],[8.27,0.0],[8.37,0.0],[8.48,0.0],[8.63,0.0],[8.87,0.0],[9.41,0.0],[15.53,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,7.15],[0.0,7.75],[0.0,7.99],[0.0,8.15],[0.0,8.27],[0.0,8.37],[0.0,8.48],[0.0,8.63],[0.0,8.87],[0.0,9.41],[0.0,15.53],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-7.15,0.0],[-7.75,0.0],[-7.99,0.0],[-8.15,0.0],[-8.27,0.0],[-8.37,0.0],[-8.48,0.0],[-8.63,0.0],[-8.87,0.0],[-9.41,0.0],[-15.53,0.0]],"v":[[100.0,450.0],[100.0,450.0],[100.0,450.0],[100.0,425.03],[100.0,400.03],[100.0,375.01],[100.0,350.01],[100.0,325.01],[100.0,300.0],[100.0,274.99],[100.0,249.99],[100.0,224.99],[100.0,199.97],[100.0,174.97],[100.0,150.0],[100.0,150.0],[100.0,150.0],[124.97,150.0],[149.97,150.0],[174.99,150.0],[199.99,150.0],[224.99,150.0],[250.0,150.0],[275.01,150.0],[300.01,150.0],[325.01,150.0],[350.03,150.0],[375.03,150.0],[400.0,150.0],[400.0,150.0],[400.0,150.0],[400.0,174.97],[400.0,199.97],[400.0,224.99],[400.0,249.99],[400.0,274.99],[400.0,300.0],[400.0,325.01],[400.0,350.01],[400.0,375.01],[400.0,400.03],[400.0,425.03],[400.0,450.0],[400.0,450.0],[400.0,450.0],[375.03,450.0],[350.03,450.0],[325.01,450.0],[300.01,450.0],[275.01,450.0],[250.0,450.0],[224.99,450.0],[199.99,450.0],[174.99,450.0],[149.97,450.0],[124.97,450.0]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"plain","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lottie/ic_videocam_to_mic.json b/assets/lottie/ic_videocam_to_mic.json new file mode 100644 index 0000000..438c204 --- /dev/null +++ b/assets/lottie/ic_videocam_to_mic.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_videocam_to_mic","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"ic_videocam_to_mic","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"t":0,"s":[0],"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0}},{"t":11,"s":[14],"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0}},{"t":24,"s":[0]}],"ix":10},"p":{"a":0,"k":[300.0,300.0,0],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":11,"s":[111,111,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0.0,0.0],[9.79,9.79],[0.0,13.75],[0.0,22.83],[0.0,13.89],[0.0,13.05],[0.0,12.62],[0.0,12.26],[0.0,11.81],[0.0,10.88],[0.0,0.0],[-9.79,9.79],[-5.87,2.45],[-6.89,0.0],[-22.83,0.0],[-13.89,0.0],[-13.05,0.0],[-12.62,0.0],[-12.26,0.0],[-11.81,0.0],[-10.88,0.0],[0.0,0.0],[-9.79,-9.79],[0.0,-13.75],[0.0,-20.65],[0.0,-12.07],[0.0,0.0],[-14.35,14.35],[-8.68,8.68],[-7.64,7.64],[0.0,0.0],[0.0,-20.93],[0.0,-12.73],[0.0,-11.96],[0.0,-11.57],[0.0,-11.24],[0.0,-10.82],[0.0,-9.97],[0.0,0.0],[14.35,14.35],[8.68,8.68],[7.64,7.64],[0.0,0.0],[0.0,-20.65],[0.0,-12.07],[0.0,0.0],[2.46,-5.9],[4.89,-4.89],[13.75,0.0],[22.83,0.0],[13.89,0.0],[13.05,0.0],[12.62,0.0],[12.26,0.0],[11.81,0.0],[10.88,0.0]],"o":[[-13.75,0.0],[-9.79,-9.79],[0.0,0.0],[0.0,-10.88],[0.0,-11.81],[0.0,-12.26],[0.0,-12.62],[0.0,-13.05],[0.0,-13.89],[0.0,-22.83],[0.0,-13.75],[4.89,-4.89],[5.9,-2.46],[0.0,0.0],[10.88,0.0],[11.81,0.0],[12.26,0.0],[12.62,0.0],[13.05,0.0],[13.89,0.0],[22.83,0.0],[13.75,0.0],[9.79,9.79],[0.0,0.0],[0.0,12.07],[0.0,20.65],[0.0,0.0],[7.64,-7.64],[8.68,-8.68],[14.35,-14.35],[0.0,0.0],[0.0,9.97],[0.0,10.82],[0.0,11.24],[0.0,11.57],[0.0,11.96],[0.0,12.73],[0.0,20.93],[0.0,0.0],[-7.64,-7.64],[-8.68,-8.68],[-14.35,-14.35],[0.0,0.0],[0.0,12.07],[0.0,20.65],[0.0,6.89],[-2.45,5.87],[-9.79,9.79],[0.0,0.0],[-10.88,0.0],[-11.81,0.0],[-12.26,0.0],[-12.62,0.0],[-13.05,0.0],[-13.89,0.0],[-22.83,0.0]],"v":[[100.0,500.0],[64.69,485.31],[50.0,450.0],[50.0,412.51],[50.0,375.01],[50.0,337.51],[50.0,300.0],[50.0,262.49],[50.0,224.99],[50.0,187.49],[50.0,150.0],[64.69,114.69],[80.82,103.69],[100.0,100.0],[137.49,100.0],[174.99,100.0],[212.49,100.0],[250.0,100.0],[287.51,100.0],[325.01,100.0],[362.51,100.0],[400.0,100.0],[435.31,114.69],[450.0,150.0],[450.0,187.5],[450.0,225.0],[450.0,262.5],[475.0,237.5],[500.0,212.5],[525.0,187.5],[550.0,162.5],[550.0,196.86],[550.0,231.24],[550.0,265.62],[550.0,300.0],[550.0,334.38],[550.0,368.76],[550.0,403.14],[550.0,437.5],[525.0,412.5],[500.0,387.5],[475.0,362.5],[450.0,337.5],[450.0,375.0],[450.0,412.5],[450.0,450.0],[446.31,469.18],[435.31,485.31],[400.0,500.0],[362.51,500.0],[325.01,500.0],[287.51,500.0],[250.0,500.0],[212.49,500.0],[174.99,500.0],[137.49,500.0]],"c":true}]},{"t":24,"s":[{"i":[[0.0,5.08],[0.0,8.44],[0.0,5.11],[0.0,4.82],[0.0,4.69],[0.0,4.6],[0.0,4.53],[0.0,4.46],[0.0,4.38],[0.0,4.24],[0.0,3.91],[0.0,0.0],[-0.55,3.81],[-1.17,3.71],[-1.8,3.51],[-2.38,3.21],[-2.85,2.85],[-3.96,2.7],[-4.53,1.86],[-4.92,0.91],[-5.08,0.0],[-4.71,-0.87],[-4.51,-1.85],[-4.12,-2.81],[-3.59,-3.59],[-2.3,-3.09],[-1.78,-3.47],[-1.19,-3.77],[-0.57,-3.96],[0.0,-4.04],[0.0,-8.44],[0.0,-5.11],[0.0,-4.82],[0.0,-4.69],[0.0,-4.6],[0.0,-4.53],[0.0,-4.46],[0.0,-4.38],[0.0,-4.24],[0.0,-3.91],[0.0,0.0],[0.87,-4.71],[1.85,-4.51],[2.81,-4.12],[3.59,-3.59],[3.96,-2.7],[4.53,-1.86],[4.92,-0.91],[5.08,0.0],[4.71,0.87],[4.51,1.85],[4.12,2.81],[3.59,3.59],[2.7,3.96],[1.86,4.53],[0.91,4.92]],"o":[[0.0,0.0],[0.0,-3.91],[0.0,-4.24],[0.0,-4.38],[0.0,-4.46],[0.0,-4.53],[0.0,-4.6],[0.0,-4.69],[0.0,-4.82],[0.0,-5.11],[0.0,-8.44],[0.0,-4.04],[0.57,-3.96],[1.19,-3.77],[1.78,-3.47],[2.3,-3.09],[3.59,-3.59],[4.12,-2.81],[4.51,-1.85],[4.71,-0.87],[5.08,0.0],[4.92,0.91],[4.53,1.86],[3.96,2.7],[2.85,2.85],[2.38,3.21],[1.8,3.51],[1.17,3.71],[0.55,3.81],[0.0,0.0],[0.0,3.91],[0.0,4.24],[0.0,4.38],[0.0,4.46],[0.0,4.53],[0.0,4.6],[0.0,4.69],[0.0,4.82],[0.0,5.11],[0.0,8.44],[0.0,5.08],[-0.91,4.92],[-1.86,4.53],[-2.7,3.96],[-3.59,3.59],[-4.12,2.81],[-4.51,1.85],[-4.71,0.87],[-5.08,0.0],[-4.92,-0.91],[-4.53,-1.86],[-3.96,-2.7],[-3.59,-3.59],[-2.81,-4.12],[-1.85,-4.51],[-0.87,-4.71]],"v":[[225.0,275.0],[225.0,261.37],[225.0,247.74],[225.0,234.09],[225.0,220.46],[225.0,206.82],[225.0,193.18],[225.0,179.54],[225.0,165.91],[225.0,152.26],[225.0,138.63],[225.0,125.0],[225.82,113.23],[228.43,101.72],[232.91,90.81],[239.15,80.8],[246.88,71.87],[258.2,62.44],[271.17,55.44],[285.31,51.3],[300.0,50.0],[314.69,51.3],[328.83,55.44],[341.8,62.44],[353.12,71.87],[360.85,80.8],[367.09,90.81],[371.57,101.72],[374.18,113.23],[375.0,125.0],[375.0,138.63],[375.0,152.26],[375.0,165.91],[375.0,179.54],[375.0,193.18],[375.0,206.82],[375.0,220.46],[375.0,234.09],[375.0,247.74],[375.0,261.37],[375.0,275.0],[373.7,289.69],[369.56,303.83],[362.56,316.8],[353.12,328.12],[341.8,337.56],[328.83,344.56],[314.69,348.7],[300.0,350.0],[285.31,348.7],[271.17,344.56],[258.2,337.56],[246.88,328.12],[237.44,316.8],[230.44,303.83],[226.3,289.69]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":12,"s":[{"i":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"o":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"v":[[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56]],"c":true}]},{"t":24,"s":[{"i":[[0.0,0.0],[0.0,11.03],[0.0,6.68],[0.0,5.87],[0.0,0.0],[6.63,1.66],[6.41,2.46],[6.09,3.29],[5.67,4.08],[5.16,4.78],[4.62,5.36],[4.47,6.88],[3.45,7.54],[2.3,8.06],[1.12,8.4],[0.0,8.54],[-12.5,0.0],[0.0,0.0],[-0.92,-6.34],[-1.96,-6.18],[-3.01,-5.84],[-3.98,-5.34],[-4.76,-4.76],[-5.14,-3.83],[-5.76,-2.97],[-6.27,-1.99],[-6.59,-0.96],[-6.73,0.0],[-6.34,0.92],[-6.18,1.96],[-5.84,3.01],[-5.34,3.98],[-4.76,4.76],[-3.83,5.14],[-2.97,5.76],[-1.99,6.27],[-0.96,6.59],[0.0,6.73],[-12.5,0.0],[0.0,0.0],[1.08,-8.13],[2.27,-7.96],[3.48,-7.61],[4.61,-7.09],[5.57,-6.47],[5.02,-4.64],[5.58,-4.02],[6.1,-3.29],[6.53,-2.51],[6.83,-1.71],[7.02,-0.95],[0.0,-11.03],[0.0,-6.68],[0.0,-5.87],[0.0,0.0],[12.5,0.0]],"o":[[0.0,0.0],[0.0,-5.87],[0.0,-6.68],[0.0,-11.03],[-7.02,-0.95],[-6.83,-1.71],[-6.53,-2.51],[-6.1,-3.29],[-5.58,-4.02],[-5.02,-4.64],[-5.57,-6.47],[-4.61,-7.09],[-3.48,-7.61],[-2.27,-7.96],[-1.08,-8.13],[0.0,0.0],[12.5,0.0],[0.0,6.73],[0.96,6.59],[1.99,6.27],[2.97,5.76],[3.83,5.14],[4.76,4.76],[5.34,3.98],[5.84,3.01],[6.18,1.96],[6.34,0.92],[6.73,0.0],[6.59,-0.96],[6.27,-1.99],[5.76,-2.97],[5.14,-3.83],[4.76,-4.76],[3.98,-5.34],[3.01,-5.84],[1.96,-6.18],[0.92,-6.34],[0.0,0.0],[12.5,0.0],[0.0,8.54],[-1.12,8.4],[-2.3,8.06],[-3.45,7.54],[-4.47,6.88],[-4.62,5.36],[-5.16,4.78],[-5.67,4.08],[-6.09,3.29],[-6.41,2.46],[-6.63,1.66],[0.0,0.0],[0.0,5.87],[0.0,6.68],[0.0,11.03],[0.0,0.0],[-12.5,0.0]],"v":[[275.0,525.0],[275.0,505.78],[275.0,486.56],[275.0,467.34],[275.0,448.12],[254.53,444.22],[234.66,437.97],[215.72,429.27],[198.07,418.21],[181.95,405.01],[167.5,390.0],[152.44,369.98],[140.36,348.03],[131.69,324.53],[126.62,300.0],[125.0,275.0],[150.0,275.0],[175.0,275.0],[176.38,294.61],[180.76,313.77],[188.26,331.93],[198.69,348.59],[211.56,363.44],[226.41,376.31],[243.07,386.74],[261.23,394.24],[280.39,398.62],[300.0,400.0],[319.61,398.62],[338.77,394.24],[356.93,386.74],[373.59,376.31],[388.44,363.44],[401.31,348.59],[411.74,331.93],[419.24,313.77],[423.62,294.61],[425.0,275.0],[450.0,275.0],[475.0,275.0],[473.38,300.0],[468.31,324.53],[459.64,348.03],[447.56,369.98],[432.5,390.0],[418.05,405.01],[401.93,418.21],[384.28,429.27],[365.34,437.97],[345.47,444.22],[325.0,448.12],[325.0,467.34],[325.0,486.56],[325.0,505.78],[325.0,525.0],[300.0,525.0]],"c":true}]}],"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0.0,0.0],[-15.53,0.0],[-9.41,0.0],[-8.87,0.0],[-8.63,0.0],[-8.48,0.0],[-8.37,0.0],[-8.27,0.0],[-8.15,0.0],[-7.99,0.0],[-7.75,0.0],[-7.15,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,15.53],[0.0,9.41],[0.0,8.87],[0.0,8.63],[0.0,8.48],[0.0,8.37],[0.0,8.27],[0.0,8.15],[0.0,7.99],[0.0,7.75],[0.0,7.15],[0.0,0.0],[0.0,0.0],[0.0,0.0],[15.53,0.0],[9.41,0.0],[8.87,0.0],[8.63,0.0],[8.48,0.0],[8.37,0.0],[8.27,0.0],[8.15,0.0],[7.99,0.0],[7.75,0.0],[7.15,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-15.53],[0.0,-9.41],[0.0,-8.87],[0.0,-8.63],[0.0,-8.48],[0.0,-8.37],[0.0,-8.27],[0.0,-8.15],[0.0,-7.99],[0.0,-7.75],[0.0,-7.15],[0.0,0.0],[0.0,0.0]],"o":[[0.0,0.0],[7.15,0.0],[7.75,0.0],[7.99,0.0],[8.15,0.0],[8.27,0.0],[8.37,0.0],[8.48,0.0],[8.63,0.0],[8.87,0.0],[9.41,0.0],[15.53,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-7.15],[0.0,-7.75],[0.0,-7.99],[0.0,-8.15],[0.0,-8.27],[0.0,-8.37],[0.0,-8.48],[0.0,-8.63],[0.0,-8.87],[0.0,-9.41],[0.0,-15.53],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-7.15,0.0],[-7.75,0.0],[-7.99,0.0],[-8.15,0.0],[-8.27,0.0],[-8.37,0.0],[-8.48,0.0],[-8.63,0.0],[-8.87,0.0],[-9.41,0.0],[-15.53,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,7.15],[0.0,7.75],[0.0,7.99],[0.0,8.15],[0.0,8.27],[0.0,8.37],[0.0,8.48],[0.0,8.63],[0.0,8.87],[0.0,9.41],[0.0,15.53],[0.0,0.0],[0.0,0.0]],"v":[[100.0,450.0],[124.97,450.0],[149.97,450.0],[174.99,450.0],[199.99,450.0],[224.99,450.0],[250.0,450.0],[275.01,450.0],[300.01,450.0],[325.01,450.0],[350.03,450.0],[375.03,450.0],[400.0,450.0],[400.0,450.0],[400.0,450.0],[400.0,425.03],[400.0,400.03],[400.0,375.01],[400.0,350.01],[400.0,325.01],[400.0,300.0],[400.0,274.99],[400.0,249.99],[400.0,224.99],[400.0,199.97],[400.0,174.97],[400.0,150.0],[400.0,150.0],[400.0,150.0],[375.03,150.0],[350.03,150.0],[325.01,150.0],[300.01,150.0],[275.01,150.0],[250.0,150.0],[224.99,150.0],[199.99,150.0],[174.99,150.0],[149.97,150.0],[124.97,150.0],[100.0,150.0],[100.0,150.0],[100.0,150.0],[100.0,174.97],[100.0,199.97],[100.0,224.99],[100.0,249.99],[100.0,274.99],[100.0,300.0],[100.0,325.01],[100.0,350.01],[100.0,375.01],[100.0,400.03],[100.0,425.03],[100.0,450.0],[100.0,450.0]],"c":true}]},{"t":24,"s":[{"i":[[0.0,-2.36],[0.0,0.0],[-1.17,-2.94],[-2.43,-2.43],[-1.86,-1.07],[-2.16,-0.53],[-2.29,0.0],[-2.94,1.17],[-2.43,2.43],[-1.07,1.86],[-0.53,2.16],[0.0,2.29],[0.0,5.26],[0.0,3.17],[0.0,2.99],[0.0,2.91],[0.0,2.87],[0.0,2.84],[0.0,2.82],[0.0,2.8],[0.0,2.78],[0.0,2.77],[0.0,2.75],[0.0,2.73],[0.0,2.71],[0.0,2.68],[0.0,2.64],[0.0,2.56],[0.0,2.36],[0.0,0.0],[0.5,2.05],[1.09,1.9],[1.6,1.6],[3.01,1.2],[3.5,0.0],[2.94,-1.17],[2.43,-2.43],[1.07,-1.86],[0.53,-2.16],[0.0,-2.29],[0.0,-5.26],[0.0,-3.17],[0.0,-2.99],[0.0,-2.91],[0.0,-2.87],[0.0,-2.84],[0.0,-2.82],[0.0,-2.8],[0.0,-2.78],[0.0,-2.77],[0.0,-2.75],[0.0,-2.73],[0.0,-2.71],[0.0,-2.68],[0.0,-2.64],[0.0,-2.56]],"o":[[0.0,5.26],[0.0,3.5],[1.2,3.01],[1.6,1.6],[1.9,1.09],[2.05,0.5],[3.5,0.0],[3.01,-1.2],[1.6,-1.6],[1.09,-1.9],[0.5,-2.05],[0.0,0.0],[0.0,-2.36],[0.0,-2.56],[0.0,-2.64],[0.0,-2.68],[0.0,-2.71],[0.0,-2.73],[0.0,-2.75],[0.0,-2.77],[0.0,-2.78],[0.0,-2.8],[0.0,-2.82],[0.0,-2.84],[0.0,-2.87],[0.0,-2.91],[0.0,-2.99],[0.0,-3.17],[0.0,-5.26],[0.0,-2.29],[-0.53,-2.16],[-1.07,-1.86],[-2.43,-2.43],[-2.94,-1.17],[-3.5,0.0],[-3.01,1.2],[-1.6,1.6],[-1.09,1.9],[-0.5,2.05],[0.0,0.0],[0.0,2.36],[0.0,2.56],[0.0,2.64],[0.0,2.68],[0.0,2.71],[0.0,2.73],[0.0,2.75],[0.0,2.77],[0.0,2.78],[0.0,2.8],[0.0,2.82],[0.0,2.84],[0.0,2.87],[0.0,2.91],[0.0,2.99],[0.0,3.17]],"v":[[275.0,266.68],[275.0,275.0],[276.75,284.65],[282.19,292.81],[287.38,296.82],[293.48,299.25],[300.0,300.0],[309.65,298.25],[317.81,292.81],[321.82,287.62],[324.25,281.52],[325.0,275.0],[325.0,266.68],[325.0,258.35],[325.0,250.01],[325.0,241.68],[325.0,233.34],[325.0,225.0],[325.0,216.67],[325.0,208.34],[325.0,200.0],[325.0,191.66],[325.0,183.33],[325.0,175.0],[325.0,166.66],[325.0,158.32],[325.0,149.99],[325.0,141.65],[325.0,133.32],[325.0,125.0],[324.25,118.48],[321.82,112.38],[317.81,107.19],[309.65,101.75],[300.0,100.0],[290.35,101.75],[282.19,107.19],[278.18,112.38],[275.75,118.48],[275.0,125.0],[275.0,133.32],[275.0,141.65],[275.0,149.99],[275.0,158.32],[275.0,166.66],[275.0,175.0],[275.0,183.33],[275.0,191.66],[275.0,200.0],[275.0,208.34],[275.0,216.67],[275.0,225.0],[275.0,233.34],[275.0,241.68],[275.0,250.01],[275.0,258.35]],"c":true}]}],"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lottie/ic_videocam_to_send.json b/assets/lottie/ic_videocam_to_send.json new file mode 100644 index 0000000..5bab860 --- /dev/null +++ b/assets/lottie/ic_videocam_to_send.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_videocam_to_send","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"ic_videocam_to_send","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"t":0,"s":[0],"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0}},{"t":9,"s":[10],"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0}},{"t":24,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"t":0,"s":[300.0,300.0,0],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":9,"s":[274.0,300.0,0],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":19,"s":[310.0,300.0,0],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[300.0,300.0,0]}],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":9,"s":[92,92,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0.0,0.0],[9.79,9.79],[0.0,13.75],[0.0,22.83],[0.0,13.89],[0.0,13.05],[0.0,12.62],[0.0,12.26],[0.0,11.81],[0.0,10.88],[0.0,0.0],[-9.79,9.79],[-5.87,2.45],[-6.89,0.0],[-22.83,0.0],[-13.89,0.0],[-13.05,0.0],[-12.62,0.0],[-12.26,0.0],[-11.81,0.0],[-10.88,0.0],[0.0,0.0],[-9.79,-9.79],[0.0,-13.75],[0.0,-20.65],[0.0,-12.07],[0.0,0.0],[-14.35,14.35],[-8.68,8.68],[-7.64,7.64],[0.0,0.0],[0.0,-20.93],[0.0,-12.73],[0.0,-11.96],[0.0,-11.57],[0.0,-11.24],[0.0,-10.82],[0.0,-9.97],[0.0,0.0],[14.35,14.35],[8.68,8.68],[7.64,7.64],[0.0,0.0],[0.0,-20.65],[0.0,-12.07],[0.0,0.0],[2.46,-5.9],[4.89,-4.89],[13.75,0.0],[22.83,0.0],[13.89,0.0],[13.05,0.0],[12.62,0.0],[12.26,0.0],[11.81,0.0],[10.88,0.0]],"o":[[-13.75,0.0],[-9.79,-9.79],[0.0,0.0],[0.0,-10.88],[0.0,-11.81],[0.0,-12.26],[0.0,-12.62],[0.0,-13.05],[0.0,-13.89],[0.0,-22.83],[0.0,-13.75],[4.89,-4.89],[5.9,-2.46],[0.0,0.0],[10.88,0.0],[11.81,0.0],[12.26,0.0],[12.62,0.0],[13.05,0.0],[13.89,0.0],[22.83,0.0],[13.75,0.0],[9.79,9.79],[0.0,0.0],[0.0,12.07],[0.0,20.65],[0.0,0.0],[7.64,-7.64],[8.68,-8.68],[14.35,-14.35],[0.0,0.0],[0.0,9.97],[0.0,10.82],[0.0,11.24],[0.0,11.57],[0.0,11.96],[0.0,12.73],[0.0,20.93],[0.0,0.0],[-7.64,-7.64],[-8.68,-8.68],[-14.35,-14.35],[0.0,0.0],[0.0,12.07],[0.0,20.65],[0.0,6.89],[-2.45,5.87],[-9.79,9.79],[0.0,0.0],[-10.88,0.0],[-11.81,0.0],[-12.26,0.0],[-12.62,0.0],[-13.05,0.0],[-13.89,0.0],[-22.83,0.0]],"v":[[100.0,500.0],[64.69,485.31],[50.0,450.0],[50.0,412.51],[50.0,375.01],[50.0,337.51],[50.0,300.0],[50.0,262.49],[50.0,224.99],[50.0,187.49],[50.0,150.0],[64.69,114.69],[80.82,103.69],[100.0,100.0],[137.49,100.0],[174.99,100.0],[212.49,100.0],[250.0,100.0],[287.51,100.0],[325.01,100.0],[362.51,100.0],[400.0,100.0],[435.31,114.69],[450.0,150.0],[450.0,187.5],[450.0,225.0],[450.0,262.5],[475.0,237.5],[500.0,212.5],[525.0,187.5],[550.0,162.5],[550.0,196.86],[550.0,231.24],[550.0,265.62],[550.0,300.0],[550.0,334.38],[550.0,368.76],[550.0,403.14],[550.0,437.5],[525.0,412.5],[500.0,387.5],[475.0,362.5],[450.0,337.5],[450.0,375.0],[450.0,412.5],[450.0,450.0],[446.31,469.18],[435.31,485.31],[400.0,500.0],[362.51,500.0],[325.01,500.0],[287.51,500.0],[250.0,500.0],[212.49,500.0],[174.99,500.0],[137.49,500.0]],"c":true}]},{"t":24,"s":[{"i":[[0.0,9.49],[0.0,8.93],[0.0,8.71],[0.0,8.57],[0.0,8.48],[0.0,8.42],[0.0,8.35],[0.0,8.3],[0.0,8.23],[0.0,8.16],[0.0,8.07],[0.0,7.93],[0.0,7.68],[0.0,7.11],[0.0,0.0],[-15.01,-6.32],[-9.05,-3.81],[-8.53,-3.59],[-8.31,-3.5],[-8.2,-3.45],[-8.11,-3.41],[-8.05,-3.39],[-8.01,-3.37],[-7.97,-3.35],[-7.93,-3.34],[-7.89,-3.32],[-7.86,-3.31],[-7.82,-3.29],[-7.77,-3.27],[-7.71,-3.24],[-7.63,-3.21],[-7.5,-3.16],[-7.27,-3.06],[-6.72,-2.83],[0.0,0.0],[15.01,-6.32],[9.05,-3.81],[8.53,-3.59],[8.31,-3.5],[8.2,-3.45],[8.11,-3.41],[8.05,-3.39],[8.01,-3.37],[7.97,-3.35],[7.93,-3.34],[7.89,-3.32],[7.86,-3.31],[7.82,-3.29],[7.77,-3.27],[7.71,-3.24],[7.63,-3.21],[7.5,-3.16],[7.27,-3.06],[6.72,-2.83],[0.0,0.0],[0.0,15.7]],"o":[[0.0,-7.68],[0.0,-7.93],[0.0,-8.07],[0.0,-8.16],[0.0,-8.23],[0.0,-8.3],[0.0,-8.35],[0.0,-8.42],[0.0,-8.48],[0.0,-8.57],[0.0,-8.71],[0.0,-8.93],[0.0,-9.49],[0.0,-15.7],[0.0,0.0],[6.72,2.83],[7.27,3.06],[7.5,3.16],[7.63,3.21],[7.71,3.24],[7.77,3.27],[7.82,3.29],[7.86,3.31],[7.89,3.32],[7.93,3.34],[7.97,3.35],[8.01,3.37],[8.05,3.39],[8.11,3.41],[8.2,3.45],[8.31,3.5],[8.53,3.59],[9.05,3.81],[15.01,6.32],[0.0,0.0],[-6.72,2.83],[-7.27,3.06],[-7.5,3.16],[-7.63,3.21],[-7.71,3.24],[-7.77,3.27],[-7.82,3.29],[-7.86,3.31],[-7.89,3.32],[-7.93,3.34],[-7.97,3.35],[-8.01,3.37],[-8.05,3.39],[-8.11,3.41],[-8.2,3.45],[-8.31,3.5],[-8.53,3.59],[-9.05,3.81],[-15.01,6.32],[0.0,0.0],[0.0,-7.11]],"v":[[75.0,450.02],[75.0,425.03],[75.0,400.01],[75.0,375.01],[75.0,350.01],[75.0,325.01],[75.0,300.0],[75.0,274.99],[75.0,249.99],[75.0,224.99],[75.0,199.99],[75.0,174.97],[75.0,149.98],[75.0,124.96],[75.0,100.0],[98.69,109.98],[122.45,119.98],[146.21,129.98],[169.97,139.99],[193.74,149.99],[217.48,159.99],[241.23,169.99],[264.99,180.0],[288.75,190.0],[312.5,200.0],[336.25,210.0],[360.01,220.0],[383.77,230.01],[407.52,240.01],[431.26,250.01],[455.03,260.01],[478.79,270.02],[502.55,280.02],[526.31,290.02],[550.0,300.0],[526.31,309.98],[502.55,319.98],[478.79,329.98],[455.03,339.99],[431.26,349.99],[407.52,359.99],[383.77,369.99],[360.01,380.0],[336.25,390.0],[312.5,400.0],[288.75,410.0],[264.99,420.0],[241.23,430.01],[217.48,440.01],[193.74,450.01],[169.97,460.01],[146.21,470.02],[122.45,480.02],[98.69,490.02],[75.0,500.0],[75.0,475.04]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0.0,0.0],[-15.53,0.0],[-9.41,0.0],[-8.87,0.0],[-8.63,0.0],[-8.48,0.0],[-8.37,0.0],[-8.27,0.0],[-8.15,0.0],[-7.99,0.0],[-7.75,0.0],[-7.15,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,15.53],[0.0,9.41],[0.0,8.87],[0.0,8.63],[0.0,8.48],[0.0,8.37],[0.0,8.27],[0.0,8.15],[0.0,7.99],[0.0,7.75],[0.0,7.15],[0.0,0.0],[0.0,0.0],[0.0,0.0],[15.53,0.0],[9.41,0.0],[8.87,0.0],[8.63,0.0],[8.48,0.0],[8.37,0.0],[8.27,0.0],[8.15,0.0],[7.99,0.0],[7.75,0.0],[7.15,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-15.53],[0.0,-9.41],[0.0,-8.87],[0.0,-8.63],[0.0,-8.48],[0.0,-8.37],[0.0,-8.27],[0.0,-8.15],[0.0,-7.99],[0.0,-7.75],[0.0,-7.15],[0.0,0.0],[0.0,0.0]],"o":[[0.0,0.0],[7.15,0.0],[7.75,0.0],[7.99,0.0],[8.15,0.0],[8.27,0.0],[8.37,0.0],[8.48,0.0],[8.63,0.0],[8.87,0.0],[9.41,0.0],[15.53,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-7.15],[0.0,-7.75],[0.0,-7.99],[0.0,-8.15],[0.0,-8.27],[0.0,-8.37],[0.0,-8.48],[0.0,-8.63],[0.0,-8.87],[0.0,-9.41],[0.0,-15.53],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-7.15,0.0],[-7.75,0.0],[-7.99,0.0],[-8.15,0.0],[-8.27,0.0],[-8.37,0.0],[-8.48,0.0],[-8.63,0.0],[-8.87,0.0],[-9.41,0.0],[-15.53,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,7.15],[0.0,7.75],[0.0,7.99],[0.0,8.15],[0.0,8.27],[0.0,8.37],[0.0,8.48],[0.0,8.63],[0.0,8.87],[0.0,9.41],[0.0,15.53],[0.0,0.0],[0.0,0.0]],"v":[[100.0,450.0],[124.97,450.0],[149.97,450.0],[174.99,450.0],[199.99,450.0],[224.99,450.0],[250.0,450.0],[275.01,450.0],[300.01,450.0],[325.01,450.0],[350.03,450.0],[375.03,450.0],[400.0,450.0],[400.0,450.0],[400.0,450.0],[400.0,425.03],[400.0,400.03],[400.0,375.01],[400.0,350.01],[400.0,325.01],[400.0,300.0],[400.0,274.99],[400.0,249.99],[400.0,224.99],[400.0,199.97],[400.0,174.97],[400.0,150.0],[400.0,150.0],[400.0,150.0],[375.03,150.0],[350.03,150.0],[325.01,150.0],[300.01,150.0],[275.01,150.0],[250.0,150.0],[224.99,150.0],[199.99,150.0],[174.99,150.0],[149.97,150.0],[124.97,150.0],[100.0,150.0],[100.0,150.0],[100.0,150.0],[100.0,174.97],[100.0,199.97],[100.0,224.99],[100.0,249.99],[100.0,274.99],[100.0,300.0],[100.0,325.01],[100.0,350.01],[100.0,375.01],[100.0,400.03],[100.0,425.03],[100.0,450.0],[100.0,450.0]],"c":true}]},{"t":24,"s":[{"i":[[0.0,0.0],[0.0,-12.55],[0.0,-7.6],[0.0,-6.68],[0.0,0.0],[-11.63,4.91],[-7.03,2.96],[-6.62,2.79],[-6.45,2.72],[-6.35,2.68],[-6.28,2.65],[-6.23,2.63],[-6.19,2.61],[-6.14,2.59],[-6.1,2.57],[-6.04,2.55],[-5.97,2.52],[-5.88,2.48],[-5.69,2.4],[-5.26,2.22],[0.0,0.0],[11.63,4.91],[7.03,2.96],[6.62,2.79],[6.45,2.72],[6.35,2.68],[6.28,2.65],[6.23,2.63],[6.19,2.61],[6.14,2.59],[6.1,2.57],[6.04,2.55],[5.97,2.52],[5.88,2.48],[5.69,2.4],[5.26,2.22],[0.0,0.0],[0.0,-12.55],[0.0,-7.6],[0.0,-6.68],[0.0,0.0],[-11.42,-2.85],[-6.95,-1.74],[-6.52,-1.63],[-6.31,-1.58],[-6.13,-1.53],[-5.9,-1.48],[-5.44,-1.36],[0.0,0.0],[11.42,-2.85],[6.95,-1.74],[6.52,-1.63],[6.31,-1.58],[6.13,-1.53],[5.9,-1.48],[5.44,-1.36]],"o":[[0.0,0.0],[0.0,6.68],[0.0,7.6],[0.0,12.55],[0.0,0.0],[5.26,-2.22],[5.69,-2.4],[5.88,-2.48],[5.97,-2.52],[6.04,-2.55],[6.1,-2.57],[6.14,-2.59],[6.19,-2.61],[6.23,-2.63],[6.28,-2.65],[6.35,-2.68],[6.45,-2.72],[6.62,-2.79],[7.03,-2.96],[11.63,-4.91],[0.0,0.0],[-5.26,-2.22],[-5.69,-2.4],[-5.88,-2.48],[-5.97,-2.52],[-6.04,-2.55],[-6.1,-2.57],[-6.14,-2.59],[-6.19,-2.61],[-6.23,-2.63],[-6.28,-2.65],[-6.35,-2.68],[-6.45,-2.72],[-6.62,-2.79],[-7.03,-2.96],[-11.63,-4.91],[0.0,0.0],[0.0,6.68],[0.0,7.6],[0.0,12.55],[0.0,0.0],[5.44,1.36],[5.9,1.48],[6.13,1.53],[6.31,1.58],[6.52,1.63],[6.95,1.74],[11.42,2.85],[0.0,0.0],[-5.44,1.36],[-5.9,1.48],[-6.13,1.53],[-6.31,1.58],[-6.52,1.63],[-6.95,1.74],[-11.42,2.85]],"v":[[125.0,337.5],[125.0,359.37],[125.0,381.25],[125.0,403.13],[125.0,425.0],[143.49,417.2],[162.02,409.38],[180.53,401.57],[199.05,393.75],[217.57,385.94],[236.08,378.13],[254.61,370.31],[273.12,362.5],[291.64,354.69],[310.17,346.87],[328.68,339.06],[347.2,331.25],[365.72,323.43],[384.23,315.62],[402.76,307.8],[421.25,300.0],[402.76,292.2],[384.23,284.38],[365.72,276.57],[347.2,268.75],[328.68,260.94],[310.17,253.13],[291.64,245.31],[273.12,237.5],[254.61,229.69],[236.08,221.87],[217.57,214.06],[199.05,206.25],[180.53,198.43],[162.02,190.62],[143.49,182.8],[125.0,175.0],[125.0,196.87],[125.0,218.75],[125.0,240.63],[125.0,262.5],[143.74,267.19],[162.5,271.87],[181.25,276.56],[200.0,281.25],[218.75,285.94],[237.5,290.63],[256.26,295.31],[275.0,300.0],[256.26,304.69],[237.5,309.37],[218.75,314.06],[200.0,318.75],[181.25,323.44],[162.5,328.13],[143.74,332.81]],"c":true}]}],"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lottie/ic_volume_on_to_off.json b/assets/lottie/ic_volume_on_to_off.json new file mode 100644 index 0000000..ed5f670 --- /dev/null +++ b/assets/lottie/ic_volume_on_to_off.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_volume_on_to_off","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"slashed","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[300.0,300.0,0],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":11,"s":[92,92,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[694.4,-578.4],[-578.4,694.4],[-1851.19,-578.4],[-578.4,-1851.19]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1178.4,-94.4],[-94.4,1178.4],[-1367.19,-94.4],[-94.4,-1367.19]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Wipe"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.0,0.0],[13.88,13.88],[8.11,8.11],[0.0,0.0],[11.67,-4.79],[12.5,-2.92],[0.0,12.81],[0.0,0.0],[-5.62,2.08],[-5.0,2.92],[15.26,15.26],[8.92,8.92],[0.0,0.0],[0.0,-18.65],[0.0,-11.29],[0.0,-9.93],[0.0,0.0],[14.69,14.69],[8.95,8.95],[8.23,8.23],[7.46,7.46],[0.0,0.0],[18.36,0.0],[10.73,0.0],[0.0,0.0],[0.0,21.52],[0.0,13.03],[0.0,11.45],[0.0,0.0],[-20.0,0.0],[0.0,0.0],[14.1,14.1],[8.59,8.59],[7.91,7.91],[7.16,7.16],[0.0,0.0],[0.0,0.0],[-15.3,-15.3],[-9.21,-9.21],[-8.69,-8.69],[-8.46,-8.46],[-8.33,-8.33],[-8.27,-8.27],[-8.19,-8.19],[-8.15,-8.15],[-8.11,-8.11],[-8.06,-8.06],[-8.03,-8.03],[-7.98,-7.98],[-7.93,-7.93],[-7.87,-7.87],[-7.78,-7.78],[-7.65,-7.65],[-7.42,-7.42],[-6.86,-6.86],[0.0,0.0]],"o":[[0.0,0.0],[-8.11,-8.11],[-13.88,-13.88],[-10.42,6.67],[-11.67,4.79],[0.0,0.0],[0.0,-12.81],[5.83,-2.08],[5.62,-2.08],[0.0,0.0],[-8.92,-8.92],[-15.26,-15.26],[0.0,0.0],[0.0,9.93],[0.0,11.29],[0.0,18.65],[0.0,0.0],[-7.46,-7.46],[-8.23,-8.23],[-8.95,-8.95],[-14.69,-14.69],[0.0,0.0],[-10.73,0.0],[-18.36,0.0],[0.0,0.0],[0.0,-11.45],[0.0,-13.03],[0.0,-21.52],[0.0,0.0],[20.0,0.0],[0.0,0.0],[-7.16,-7.16],[-7.91,-7.91],[-8.59,-8.59],[-14.1,-14.1],[0.0,0.0],[0.0,0.0],[6.86,6.86],[7.42,7.42],[7.65,7.65],[7.78,7.78],[7.87,7.87],[7.93,7.93],[7.98,7.98],[8.03,8.03],[8.06,8.06],[8.11,8.11],[8.15,8.15],[8.19,8.19],[8.27,8.27],[8.33,8.33],[8.46,8.46],[8.69,8.69],[9.21,9.21],[15.3,15.3],[0.0,0.0]],"v":[[495.0,565.0],[469.79,539.79],[444.58,514.58],[419.38,489.38],[386.25,506.56],[350.0,518.12],[350.0,492.5],[350.0,466.88],[367.19,460.62],[383.12,453.12],[355.42,425.42],[327.71,397.71],[300.0,370.0],[300.0,402.5],[300.0,435.0],[300.0,467.5],[300.0,500.0],[275.01,475.01],[250.0,450.0],[225.0,425.0],[199.99,399.99],[175.0,375.0],[141.67,375.0],[108.33,375.0],[75.0,375.0],[75.0,337.5],[75.0,300.0],[75.0,262.5],[75.0,225.0],[115.0,225.0],[155.0,225.0],[131.01,201.01],[107.0,177.0],[83.0,153.0],[58.99,128.99],[35.0,105.0],[70.0,70.0],[94.19,94.19],[118.4,118.4],[142.62,142.62],[166.83,166.83],[191.03,191.03],[215.26,215.26],[239.46,239.46],[263.68,263.68],[287.89,287.89],[312.11,312.11],[336.32,336.32],[360.54,360.54],[384.74,384.74],[408.97,408.97],[433.17,433.17],[457.38,457.38],[481.6,481.6],[505.81,505.81],[530.0,530.0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[2.83,-4.4],[5.2,5.2],[3.15,3.15],[2.77,2.77],[0.0,0.0],[-1.96,4.43],[-1.58,4.59],[-1.18,4.73],[-0.79,4.85],[-0.39,4.92],[0.0,4.95],[0.44,5.27],[0.91,5.23],[1.4,5.14],[1.9,5.0],[2.38,4.82],[2.83,4.6],[3.23,4.35],[3.51,3.94],[3.88,3.61],[4.24,3.23],[4.55,2.81],[4.82,2.37],[5.04,1.93],[5.2,1.5],[0.0,7.35],[0.0,4.45],[0.0,3.91],[0.0,0.0],[-4.8,-1.39],[-4.71,-1.7],[-4.6,-2.02],[-4.46,-2.34],[-4.29,-2.65],[-4.1,-2.96],[-3.89,-3.25],[-3.66,-3.52],[-3.43,-3.77],[-3.19,-3.98],[-2.87,-4.1],[-2.6,-4.29],[-2.3,-4.47],[-1.98,-4.63],[-1.65,-4.77],[-1.31,-4.89],[-0.97,-4.98],[-0.63,-5.05],[-0.31,-5.09],[0.0,-5.11],[0.37,-5.33],[0.75,-5.3],[1.14,-5.25],[1.53,-5.16],[1.81,-4.87],[2.16,-4.74],[2.5,-4.58]],"o":[[0.0,0.0],[-2.77,-2.77],[-3.15,-3.15],[-5.2,-5.2],[2.35,-4.29],[1.97,-4.45],[1.58,-4.59],[1.19,-4.76],[0.79,-4.83],[0.39,-4.86],[0.0,-5.43],[-0.45,-5.39],[-0.93,-5.31],[-1.41,-5.18],[-1.9,-5.0],[-2.36,-4.77],[-2.78,-4.51],[-3.22,-4.34],[-3.58,-4.02],[-3.93,-3.65],[-4.24,-3.24],[-4.52,-2.79],[-4.75,-2.34],[-4.92,-1.89],[0.0,0.0],[0.0,-3.91],[0.0,-4.45],[0.0,-7.35],[4.98,1.13],[4.9,1.42],[4.79,1.73],[4.65,2.04],[4.49,2.35],[4.3,2.66],[4.09,2.95],[3.86,3.23],[3.62,3.48],[3.37,3.71],[3.19,3.98],[2.92,4.17],[2.63,4.34],[2.32,4.51],[1.99,4.65],[1.65,4.76],[1.3,4.86],[0.96,4.93],[0.62,4.97],[0.3,4.99],[0.0,5.43],[-0.37,5.41],[-0.75,5.35],[-1.14,5.27],[-1.49,5.05],[-1.83,4.92],[-2.17,4.77],[-2.5,4.59]],"v":[[490.0,420.0],[480.94,410.94],[471.88,401.88],[462.81,392.81],[453.75,383.75],[460.22,370.68],[465.55,357.11],[469.69,343.12],[472.66,328.7],[474.42,314.09],[475.0,299.37],[474.34,283.33],[472.29,267.4],[468.8,251.72],[463.83,236.45],[457.41,221.72],[449.64,207.67],[440.62,194.38],[430.52,181.95],[419.32,170.51],[407.07,160.19],[393.88,151.12],[379.86,143.37],[365.19,136.96],[350.0,131.88],[350.0,119.06],[350.0,106.25],[350.0,93.44],[350.0,80.63],[364.68,84.4],[379.1,89.09],[393.18,94.71],[406.84,101.28],[420.0,108.79],[432.6,117.23],[444.56,126.54],[455.84,136.66],[466.41,147.53],[476.25,159.06],[485.34,171.19],[493.62,183.87],[501.01,197.09],[507.47,210.8],[512.93,224.93],[517.36,239.42],[520.76,254.18],[523.15,269.14],[524.55,284.23],[525.0,299.37],[524.45,315.52],[522.78,331.58],[519.94,347.48],[515.94,363.12],[510.98,378.0],[505.0,392.49],[498.0,406.52]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0.69,-1.94],[2.51,2.51],[1.52,1.52],[1.43,1.43],[1.39,1.39],[1.37,1.37],[1.36,1.36],[1.34,1.34],[1.33,1.33],[1.32,1.32],[1.3,1.3],[1.28,1.28],[1.24,1.24],[1.15,1.15],[0.0,0.0],[0.0,3.63],[0.0,2.2],[0.0,2.07],[0.0,2.01],[0.0,1.98],[0.0,1.96],[0.0,1.94],[0.0,1.92],[0.0,1.9],[0.0,1.88],[0.0,1.85],[0.0,1.79],[0.0,1.65],[0.0,0.0],[-1.67,-0.88],[-1.62,-0.97],[-1.57,-1.06],[-1.51,-1.14],[-1.45,-1.23],[-1.38,-1.31],[-1.3,-1.38],[-1.23,-1.46],[-1.15,-1.52],[-1.07,-1.58],[-0.99,-1.64],[-0.9,-1.67],[-0.82,-1.72],[-0.73,-1.76],[-0.65,-1.79],[-0.55,-1.83],[-0.46,-1.86],[-0.37,-1.88],[-0.27,-1.9],[-0.18,-1.91],[-0.09,-1.92],[0.0,-1.92],[0.11,-2.05],[0.23,-2.04],[0.35,-2.03],[0.46,-2.0],[0.58,-1.97]],"o":[[0.0,0.0],[-1.15,-1.15],[-1.24,-1.24],[-1.28,-1.28],[-1.3,-1.3],[-1.32,-1.32],[-1.33,-1.33],[-1.34,-1.34],[-1.36,-1.36],[-1.37,-1.37],[-1.39,-1.39],[-1.43,-1.43],[-1.52,-1.52],[-2.51,-2.51],[0.0,0.0],[0.0,-1.65],[0.0,-1.79],[0.0,-1.85],[0.0,-1.88],[0.0,-1.9],[0.0,-1.92],[0.0,-1.94],[0.0,-1.96],[0.0,-1.98],[0.0,-2.01],[0.0,-2.07],[0.0,-2.2],[0.0,-3.63],[1.73,0.81],[1.69,0.9],[1.64,0.98],[1.58,1.06],[1.52,1.15],[1.45,1.23],[1.38,1.31],[1.3,1.38],[1.22,1.45],[1.14,1.51],[1.05,1.57],[0.99,1.65],[0.91,1.69],[0.83,1.73],[0.74,1.77],[0.65,1.8],[0.55,1.83],[0.46,1.85],[0.36,1.87],[0.27,1.88],[0.18,1.89],[0.09,1.9],[0.0,2.07],[-0.12,2.06],[-0.23,2.05],[-0.35,2.02],[-0.46,1.99],[-0.58,1.96]],"v":[[406.25,336.25],[402.24,332.24],[398.22,328.22],[394.2,324.2],[390.18,320.18],[386.16,316.16],[382.14,312.14],[378.12,308.12],[374.11,304.11],[370.09,300.09],[366.07,296.07],[362.05,292.05],[358.03,288.03],[354.01,284.01],[350.0,280.0],[350.0,274.21],[350.0,268.4],[350.0,262.59],[350.0,256.79],[350.0,250.98],[350.0,245.18],[350.0,239.38],[350.0,233.57],[350.0,227.77],[350.0,221.96],[350.0,216.16],[350.0,210.35],[350.0,204.54],[350.0,198.75],[355.1,201.29],[360.06,204.09],[364.87,207.14],[369.51,210.45],[373.96,214.01],[378.2,217.81],[382.23,221.85],[386.02,226.1],[389.57,230.56],[392.88,235.2],[395.94,240.0],[398.78,244.98],[401.37,250.08],[403.72,255.31],[405.79,260.66],[407.59,266.09],[409.11,271.62],[410.35,277.21],[411.3,282.86],[411.97,288.56],[412.37,294.27],[412.5,300.0],[412.33,306.17],[411.81,312.32],[410.94,318.44],[409.72,324.46],[408.15,330.41]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0.0,0.0],[2.55,2.55],[1.54,1.54],[1.45,1.45],[1.42,1.42],[1.39,1.39],[1.38,1.38],[1.37,1.37],[1.36,1.36],[1.35,1.35],[1.34,1.34],[1.33,1.33],[1.31,1.31],[1.29,1.29],[1.25,1.25],[1.15,1.15],[0.0,0.0],[-2.41,2.41],[-1.45,1.45],[-1.37,1.37],[-1.33,1.33],[-1.31,1.31],[-1.3,1.3],[-1.29,1.29],[-1.28,1.28],[-1.27,1.27],[-1.27,1.27],[-1.26,1.26],[-1.25,1.25],[-1.23,1.23],[-1.21,1.21],[-1.17,1.17],[-1.09,1.09],[0.0,0.0],[0.0,-3.59],[0.0,-2.15],[0.0,-2.03],[0.0,-1.99],[0.0,-1.95],[0.0,-1.94],[0.0,-1.92],[0.0,-1.91],[0.0,-1.9],[0.0,-1.9],[0.0,-1.89],[0.0,-1.88],[0.0,-1.88],[0.0,-1.87],[0.0,-1.86],[0.0,-1.85],[0.0,-1.84],[0.0,-1.83],[0.0,-1.81],[0.0,-1.78],[0.0,-1.73],[0.0,-1.59]],"o":[[0.0,0.0],[-1.15,-1.15],[-1.25,-1.25],[-1.29,-1.29],[-1.31,-1.31],[-1.33,-1.33],[-1.34,-1.34],[-1.35,-1.35],[-1.36,-1.36],[-1.37,-1.37],[-1.38,-1.38],[-1.39,-1.39],[-1.42,-1.42],[-1.45,-1.45],[-1.54,-1.54],[-2.55,-2.55],[0.0,0.0],[1.09,-1.09],[1.17,-1.17],[1.21,-1.21],[1.23,-1.23],[1.25,-1.25],[1.26,-1.26],[1.27,-1.27],[1.27,-1.27],[1.28,-1.28],[1.29,-1.29],[1.3,-1.3],[1.31,-1.31],[1.33,-1.33],[1.37,-1.37],[1.45,-1.45],[2.41,-2.41],[0.0,0.0],[0.0,1.59],[0.0,1.73],[0.0,1.78],[0.0,1.81],[0.0,1.83],[0.0,1.84],[0.0,1.85],[0.0,1.86],[0.0,1.87],[0.0,1.88],[0.0,1.88],[0.0,1.89],[0.0,1.9],[0.0,1.9],[0.0,1.91],[0.0,1.92],[0.0,1.94],[0.0,1.95],[0.0,1.99],[0.0,2.03],[0.0,2.15],[0.0,3.59]],"v":[[300.0,230.0],[295.94,225.94],[291.88,221.88],[287.82,217.82],[283.75,213.75],[279.69,209.69],[275.63,205.63],[271.56,201.56],[267.5,197.5],[263.44,193.44],[259.37,189.37],[255.31,185.31],[251.25,181.25],[247.18,177.18],[243.12,173.12],[239.06,169.06],[235.0,165.0],[238.82,161.18],[242.64,157.36],[246.47,153.53],[250.29,149.71],[254.12,145.88],[257.94,142.06],[261.76,138.24],[265.59,134.41],[269.41,130.59],[273.24,126.76],[277.06,122.94],[280.88,119.12],[284.71,115.29],[288.53,111.47],[292.36,107.64],[296.18,103.82],[300.0,100.0],[300.0,105.65],[300.0,111.29],[300.0,116.94],[300.0,122.61],[300.0,128.25],[300.0,133.91],[300.0,139.56],[300.0,145.21],[300.0,150.87],[300.0,156.52],[300.0,162.17],[300.0,167.83],[300.0,173.48],[300.0,179.13],[300.0,184.79],[300.0,190.44],[300.0,196.09],[300.0,201.75],[300.0,207.39],[300.0,213.06],[300.0,218.71],[300.0,224.35]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,4.47],[0.0,2.72],[0.0,2.56],[0.0,2.47],[0.0,2.4],[0.0,2.31],[0.0,2.13],[0.0,0.0],[3.06,3.06],[1.86,1.86],[1.75,1.75],[1.7,1.7],[1.66,1.66],[1.62,1.62],[1.57,1.57],[1.44,1.44],[0.0,0.0],[0.0,0.0],[4.93,0.0],[2.99,0.0],[2.82,0.0],[2.74,0.0],[2.68,0.0],[2.64,0.0],[2.58,0.0],[2.49,0.0],[2.3,0.0],[0.0,0.0],[0.0,-4.31],[0.0,-2.63],[0.0,-2.46],[0.0,-2.37],[0.0,-2.27],[0.0,-2.09],[0.0,0.0],[-4.39,0.0],[-2.67,0.0],[-2.51,0.0],[-2.44,0.0],[-2.39,0.0],[-2.35,0.0],[-2.3,0.0],[-2.22,0.0],[-2.05,0.0],[0.0,0.0],[-3.03,-3.03],[-1.83,-1.83],[-1.73,-1.73],[-1.68,-1.68],[-1.65,-1.65],[-1.62,-1.62],[-1.6,-1.6],[-1.57,-1.57],[-1.52,-1.52],[-1.4,-1.4]],"o":[[0.0,0.0],[0.0,-2.13],[0.0,-2.31],[0.0,-2.4],[0.0,-2.47],[0.0,-2.56],[0.0,-2.72],[0.0,-4.47],[0.0,0.0],[-1.44,-1.44],[-1.57,-1.57],[-1.62,-1.62],[-1.66,-1.66],[-1.7,-1.7],[-1.75,-1.75],[-1.86,-1.86],[-3.06,-3.06],[0.0,0.0],[0.0,0.0],[-2.3,0.0],[-2.49,0.0],[-2.58,0.0],[-2.64,0.0],[-2.68,0.0],[-2.74,0.0],[-2.82,0.0],[-2.99,0.0],[-4.93,0.0],[0.0,0.0],[0.0,2.09],[0.0,2.27],[0.0,2.37],[0.0,2.46],[0.0,2.63],[0.0,4.31],[0.0,0.0],[2.05,0.0],[2.22,0.0],[2.3,0.0],[2.35,0.0],[2.39,0.0],[2.44,0.0],[2.51,0.0],[2.67,0.0],[4.39,0.0],[0.0,0.0],[1.4,1.4],[1.52,1.52],[1.57,1.57],[1.6,1.6],[1.62,1.62],[1.65,1.65],[1.68,1.68],[1.73,1.73],[1.83,1.83],[3.03,3.03]],"v":[[250.0,378.75],[250.0,371.41],[250.0,364.06],[250.0,356.72],[250.0,349.37],[250.0,342.03],[250.0,334.69],[250.0,327.34],[250.0,320.0],[245.0,315.0],[240.0,310.0],[235.0,305.0],[230.0,300.0],[225.0,295.0],[220.0,290.0],[215.0,285.0],[210.0,280.0],[205.0,275.0],[205.0,275.0],[197.01,275.0],[189.01,275.0],[181.0,275.0],[173.0,275.0],[165.0,275.0],[157.0,275.0],[149.0,275.0],[140.99,275.0],[132.99,275.0],[125.0,275.0],[125.0,282.14],[125.0,289.28],[125.0,296.43],[125.0,303.57],[125.0,310.72],[125.0,317.86],[125.0,325.0],[132.12,325.0],[139.24,325.0],[146.37,325.0],[153.5,325.0],[160.62,325.0],[167.75,325.0],[174.88,325.0],[182.01,325.0],[189.13,325.0],[196.25,325.0],[201.13,329.88],[206.02,334.77],[210.91,339.66],[215.79,344.54],[220.68,349.43],[225.57,354.32],[230.46,359.21],[235.34,364.09],[240.23,368.98],[245.12,373.87]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ind":5,"ty":"sh","ix":6,"ks":{"a":0,"k":{"i":[[0.0,0.0],[3.03,3.03],[1.83,1.83],[1.73,1.73],[1.68,1.68],[1.65,1.65],[1.62,1.62],[1.6,1.6],[1.57,1.57],[1.52,1.52],[1.4,1.4],[0.0,0.0],[4.39,0.0],[2.67,0.0],[2.51,0.0],[2.44,0.0],[2.39,0.0],[2.35,0.0],[2.3,0.0],[2.22,0.0],[2.05,0.0],[0.0,0.0],[0.0,4.31],[0.0,2.63],[0.0,2.46],[0.0,2.37],[0.0,2.27],[0.0,2.09],[0.0,0.0],[-4.93,0.0],[-2.99,0.0],[-2.82,0.0],[-2.74,0.0],[-2.68,0.0],[-2.64,0.0],[-2.58,0.0],[-2.49,0.0],[-2.3,0.0],[0.0,0.0],[0.0,0.0],[-3.06,-3.06],[-1.86,-1.86],[-1.75,-1.75],[-1.7,-1.7],[-1.66,-1.66],[-1.62,-1.62],[-1.57,-1.57],[-1.44,-1.44],[0.0,0.0],[0.0,-4.47],[0.0,-2.72],[0.0,-2.56],[0.0,-2.47],[0.0,-2.4],[0.0,-2.31],[0.0,-2.13]],"o":[[0.0,0.0],[-1.4,-1.4],[-1.52,-1.52],[-1.57,-1.57],[-1.6,-1.6],[-1.62,-1.62],[-1.65,-1.65],[-1.68,-1.68],[-1.73,-1.73],[-1.83,-1.83],[-3.03,-3.03],[0.0,0.0],[-2.05,0.0],[-2.22,0.0],[-2.3,0.0],[-2.35,0.0],[-2.39,0.0],[-2.44,0.0],[-2.51,0.0],[-2.67,0.0],[-4.39,0.0],[0.0,0.0],[0.0,-2.09],[0.0,-2.27],[0.0,-2.37],[0.0,-2.46],[0.0,-2.63],[0.0,-4.31],[0.0,0.0],[2.3,0.0],[2.49,0.0],[2.58,0.0],[2.64,0.0],[2.68,0.0],[2.74,0.0],[2.82,0.0],[2.99,0.0],[4.93,0.0],[0.0,0.0],[0.0,0.0],[1.44,1.44],[1.57,1.57],[1.62,1.62],[1.66,1.66],[1.7,1.7],[1.75,1.75],[1.86,1.86],[3.06,3.06],[0.0,0.0],[0.0,2.13],[0.0,2.31],[0.0,2.4],[0.0,2.47],[0.0,2.56],[0.0,2.72],[0.0,4.47]],"v":[[250.0,378.75],[245.12,373.87],[240.23,368.98],[235.34,364.09],[230.46,359.21],[225.57,354.32],[220.68,349.43],[215.79,344.54],[210.91,339.66],[206.02,334.77],[201.13,329.88],[196.25,325.0],[189.13,325.0],[182.01,325.0],[174.88,325.0],[167.75,325.0],[160.63,325.0],[153.5,325.0],[146.37,325.0],[139.24,325.0],[132.12,325.0],[125.0,325.0],[125.0,317.86],[125.0,310.72],[125.0,303.57],[125.0,296.43],[125.0,289.28],[125.0,282.14],[125.0,275.0],[132.99,275.0],[140.99,275.0],[149.0,275.0],[157.0,275.0],[165.0,275.0],[173.0,275.0],[181.0,275.0],[189.01,275.0],[197.01,275.0],[205.0,275.0],[205.0,275.0],[210.0,280.0],[215.0,285.0],[220.0,290.0],[225.0,295.0],[230.0,300.0],[235.0,305.0],[240.0,310.0],[245.0,315.0],[250.0,320.0],[250.0,327.34],[250.0,334.69],[250.0,342.03],[250.0,349.38],[250.0,356.72],[250.0,364.06],[250.0,371.41]],"c":true},"ix":2},"nm":"Path 6","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"slashed","np":8,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"plain","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[300.0,300.0,0],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":11,"s":[92,92,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[694.4,-578.4],[-578.4,694.4],[694.4,1967.19],[1967.19,694.4]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1178.4,-94.4],[-94.4,1178.4],[1178.4,2451.19],[2451.19,1178.4]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Wipe"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[7.15,-1.61],[0.0,9.41],[0.0,5.5],[0.0,0.0],[-6.76,2.88],[-6.37,3.77],[-5.85,4.65],[-5.22,5.44],[-4.53,6.1],[-3.15,5.31],[-2.57,5.65],[-1.93,5.94],[-1.27,6.16],[-0.62,6.29],[0.0,6.35],[0.6,6.13],[1.25,6.06],[1.93,5.92],[2.59,5.7],[3.22,5.42],[3.78,5.08],[5.1,5.32],[5.82,4.62],[6.46,3.82],[6.96,2.96],[7.32,2.11],[0.0,9.41],[0.0,5.5],[0.0,0.0],[-6.78,-2.17],[-6.57,-2.81],[-6.29,-3.46],[-5.93,-4.11],[-5.52,-4.71],[-5.05,-5.25],[-4.57,-5.71],[-3.93,-5.95],[-3.34,-6.33],[-2.7,-6.67],[-2.01,-6.95],[-1.32,-7.15],[-0.64,-7.27],[0.0,-7.32],[0.62,-7.09],[1.29,-7.02],[2.0,-6.89],[2.7,-6.69],[3.38,-6.41],[4.01,-6.08],[4.57,-5.71],[4.95,-5.14],[5.45,-4.65],[5.92,-4.1],[6.34,-3.49],[6.69,-2.86],[6.96,-2.22]],"o":[[0.0,0.0],[0.0,-5.5],[0.0,-9.41],[7.32,-2.11],[6.96,-2.96],[6.46,-3.82],[5.82,-4.62],[5.1,-5.32],[3.78,-5.08],[3.22,-5.42],[2.59,-5.7],[1.93,-5.92],[1.25,-6.06],[0.6,-6.13],[0.0,-6.35],[-0.62,-6.29],[-1.27,-6.16],[-1.93,-5.94],[-2.57,-5.65],[-3.15,-5.31],[-4.53,-6.1],[-5.22,-5.44],[-5.85,-4.65],[-6.37,-3.77],[-6.76,-2.88],[0.0,0.0],[0.0,-5.5],[0.0,-9.41],[7.15,1.61],[6.96,2.22],[6.69,2.86],[6.34,3.49],[5.92,4.1],[5.45,4.65],[4.95,5.14],[4.57,5.71],[4.01,6.08],[3.38,6.41],[2.7,6.69],[2.0,6.89],[1.29,7.02],[0.62,7.09],[0.0,7.32],[-0.64,7.27],[-1.32,7.15],[-2.01,6.95],[-2.7,6.67],[-3.34,6.33],[-3.93,5.95],[-4.57,5.71],[-5.05,5.25],[-5.52,4.71],[-5.93,4.11],[-6.29,3.46],[-6.57,2.81],[-6.78,2.17]],"v":[[350.0,518.12],[350.0,501.04],[350.0,483.96],[350.0,466.88],[371.13,459.39],[391.14,449.29],[409.6,436.59],[426.17,421.5],[440.62,404.38],[451.02,388.79],[459.71,372.19],[466.5,354.73],[471.29,336.62],[474.1,318.09],[475.0,299.37],[474.1,280.66],[471.29,262.13],[466.5,244.02],[459.71,226.56],[451.02,209.96],[440.62,194.38],[426.17,177.25],[409.6,162.16],[391.14,149.46],[371.13,139.36],[350.0,131.88],[350.0,114.79],[350.0,97.71],[350.0,80.63],[370.9,86.3],[391.19,93.85],[410.65,103.33],[429.06,114.72],[446.22,127.93],[461.97,142.78],[476.25,159.06],[488.99,176.55],[500.01,195.17],[509.13,214.8],[516.2,235.25],[521.17,256.31],[524.07,277.75],[525.0,299.37],[524.07,321.0],[521.17,342.44],[516.2,363.5],[509.13,383.95],[500.01,403.58],[488.99,422.2],[476.25,439.69],[461.97,455.97],[446.22,470.82],[429.06,484.03],[410.65,495.42],[391.19,504.9],[370.9,512.45]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,11.42],[0.0,6.95],[0.0,6.52],[0.0,6.31],[0.0,6.13],[0.0,5.9],[0.0,5.44],[0.0,0.0],[-11.75,0.0],[-7.16,0.0],[-6.59,0.0],[-5.97,0.0],[0.0,0.0],[-8.51,8.51],[-5.17,5.17],[-4.87,4.87],[-4.72,4.72],[-4.61,4.61],[-4.5,4.5],[-4.35,4.35],[-4.01,4.01],[0.0,0.0],[0.0,-12.64],[0.0,-7.62],[0.0,-7.18],[0.0,-7.0],[0.0,-6.9],[0.0,-6.83],[0.0,-6.78],[0.0,-6.74],[0.0,-6.71],[0.0,-6.68],[0.0,-6.65],[0.0,-6.62],[0.0,-6.58],[0.0,-6.54],[0.0,-6.49],[0.0,-6.43],[0.0,-6.32],[0.0,-6.12],[0.0,-5.66],[0.0,0.0],[8.51,8.51],[5.17,5.17],[4.87,4.87],[4.72,4.72],[4.61,4.61],[4.5,4.5],[4.35,4.35],[4.01,4.01],[0.0,0.0],[11.75,0.0],[7.16,0.0],[6.59,0.0],[5.97,0.0]],"o":[[0.0,0.0],[0.0,-5.44],[0.0,-5.9],[0.0,-6.13],[0.0,-6.31],[0.0,-6.52],[0.0,-6.95],[0.0,-11.42],[0.0,0.0],[5.97,0.0],[6.59,0.0],[7.16,0.0],[11.75,0.0],[0.0,0.0],[4.01,-4.01],[4.35,-4.35],[4.5,-4.5],[4.61,-4.61],[4.72,-4.72],[4.87,-4.87],[5.17,-5.17],[8.51,-8.51],[0.0,0.0],[0.0,5.66],[0.0,6.12],[0.0,6.32],[0.0,6.43],[0.0,6.49],[0.0,6.54],[0.0,6.58],[0.0,6.62],[0.0,6.65],[0.0,6.68],[0.0,6.71],[0.0,6.74],[0.0,6.78],[0.0,6.83],[0.0,6.9],[0.0,7.0],[0.0,7.18],[0.0,7.62],[0.0,12.64],[0.0,0.0],[-4.01,-4.01],[-4.35,-4.35],[-4.5,-4.5],[-4.61,-4.61],[-4.72,-4.72],[-4.87,-4.87],[-5.17,-5.17],[-8.51,-8.51],[0.0,0.0],[-5.97,0.0],[-6.59,0.0],[-7.16,0.0],[-11.75,0.0]],"v":[[75.0,375.0],[75.0,356.26],[75.0,337.5],[75.0,318.75],[75.0,300.0],[75.0,281.25],[75.0,262.5],[75.0,243.74],[75.0,225.0],[94.99,225.0],[115.0,225.0],[135.0,225.0],[155.01,225.0],[175.0,225.0],[188.88,211.12],[202.77,197.23],[216.66,183.34],[230.55,169.45],[244.45,155.55],[258.34,141.66],[272.23,127.77],[286.12,113.88],[300.0,100.0],[300.0,119.95],[300.0,139.96],[300.0,159.97],[300.0,179.97],[300.0,199.99],[300.0,219.99],[300.0,239.99],[300.0,259.99],[300.0,280.0],[300.0,300.0],[300.0,320.0],[300.0,340.01],[300.0,360.01],[300.0,380.01],[300.0,400.01],[300.0,420.03],[300.0,440.03],[300.0,460.04],[300.0,480.05],[300.0,500.0],[286.12,486.12],[272.23,472.23],[258.34,458.34],[244.45,444.45],[230.55,430.55],[216.66,416.66],[202.77,402.77],[188.88,388.88],[175.0,375.0],[155.01,375.0],[135.0,375.0],[115.0,375.0],[94.99,375.0]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[2.38,-1.11],[0.0,5.33],[0.0,3.2],[0.0,3.02],[0.0,2.94],[0.0,2.9],[0.0,2.87],[0.0,2.85],[0.0,2.84],[0.0,2.83],[0.0,2.82],[0.0,2.81],[0.0,2.8],[0.0,2.79],[0.0,2.78],[0.0,2.77],[0.0,2.76],[0.0,2.75],[0.0,2.73],[0.0,2.71],[0.0,2.69],[0.0,2.64],[0.0,2.56],[0.0,2.36],[0.0,0.0],[-2.26,-1.25],[-2.17,-1.42],[-2.06,-1.58],[-1.94,-1.74],[-1.81,-1.89],[-1.66,-2.03],[-1.51,-2.15],[-1.36,-2.26],[-1.2,-2.32],[-1.04,-2.4],[-0.87,-2.47],[-0.7,-2.53],[-0.52,-2.58],[-0.34,-2.62],[-0.17,-2.64],[0.0,-2.65],[0.17,-2.56],[0.34,-2.55],[0.52,-2.53],[0.7,-2.49],[0.88,-2.44],[1.05,-2.38],[1.21,-2.31],[1.36,-2.23],[1.5,-2.1],[1.65,-1.99],[1.8,-1.87],[1.95,-1.73],[2.08,-1.58],[2.19,-1.43],[2.3,-1.27]],"o":[[0.0,0.0],[0.0,-2.36],[0.0,-2.56],[0.0,-2.64],[0.0,-2.69],[0.0,-2.71],[0.0,-2.73],[0.0,-2.75],[0.0,-2.76],[0.0,-2.77],[0.0,-2.78],[0.0,-2.79],[0.0,-2.8],[0.0,-2.81],[0.0,-2.82],[0.0,-2.83],[0.0,-2.84],[0.0,-2.85],[0.0,-2.87],[0.0,-2.9],[0.0,-2.94],[0.0,-3.02],[0.0,-3.2],[0.0,-5.33],[2.39,1.12],[2.3,1.28],[2.2,1.44],[2.08,1.59],[1.94,1.74],[1.8,1.88],[1.65,2.01],[1.49,2.12],[1.37,2.27],[1.21,2.35],[1.05,2.42],[0.87,2.48],[0.7,2.53],[0.52,2.56],[0.34,2.59],[0.17,2.6],[0.0,2.61],[-0.17,2.6],[-0.34,2.58],[-0.52,2.54],[-0.7,2.49],[-0.87,2.43],[-1.04,2.36],[-1.19,2.28],[-1.37,2.24],[-1.52,2.13],[-1.67,2.01],[-1.81,1.87],[-1.94,1.72],[-2.06,1.57],[-2.17,1.41],[-2.26,1.25]],"v":[[350.0,400.0],[350.0,391.63],[350.0,383.25],[350.0,374.85],[350.0,366.48],[350.0,358.08],[350.0,349.69],[350.0,341.31],[350.0,332.92],[350.0,324.54],[350.0,316.15],[350.0,307.76],[350.0,299.38],[350.0,290.99],[350.0,282.6],[350.0,274.21],[350.0,265.83],[350.0,257.44],[350.0,249.06],[350.0,240.67],[350.0,232.27],[350.0,223.9],[350.0,215.5],[350.0,207.12],[350.0,198.75],[356.98,202.31],[363.69,206.35],[370.08,210.88],[376.11,215.88],[381.74,221.33],[386.93,227.2],[391.66,233.44],[395.94,240.0],[399.78,246.88],[403.15,254.0],[406.03,261.33],[408.39,268.85],[410.21,276.51],[411.49,284.28],[412.25,292.13],[412.5,300.0],[412.25,307.76],[411.49,315.49],[410.19,323.15],[408.36,330.69],[406.0,338.09],[403.13,345.31],[399.76,352.3],[395.94,359.06],[391.65,365.58],[386.89,371.77],[381.69,377.59],[376.06,382.99],[370.03,387.95],[363.65,392.44],[356.95,396.46]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0.0,0.0],[4.09,-4.09],[2.49,-2.49],[2.34,-2.34],[2.26,-2.26],[2.2,-2.2],[2.12,-2.12],[1.95,-1.95],[0.0,0.0],[5.42,0.0],[3.3,0.0],[3.1,0.0],[3.0,0.0],[2.91,0.0],[2.8,0.0],[2.58,0.0],[0.0,0.0],[0.0,-4.97],[0.0,-3.03],[0.0,-2.83],[0.0,-2.68],[0.0,-2.45],[0.0,0.0],[-5.42,0.0],[-3.3,0.0],[-3.1,0.0],[-3.0,0.0],[-2.91,0.0],[-2.8,0.0],[-2.58,0.0],[0.0,0.0],[-4.09,-4.09],[-2.49,-2.49],[-2.34,-2.34],[-2.26,-2.26],[-2.2,-2.2],[-2.12,-2.12],[-1.95,-1.95],[0.0,0.0],[0.0,5.52],[0.0,3.32],[0.0,3.14],[0.0,3.06],[0.0,3.01],[0.0,2.98],[0.0,2.96],[0.0,2.94],[0.0,2.92],[0.0,2.91],[0.0,2.89],[0.0,2.87],[0.0,2.85],[0.0,2.82],[0.0,2.77],[0.0,2.68],[0.0,2.48]],"o":[[0.0,0.0],[-1.95,1.95],[-2.12,2.12],[-2.2,2.2],[-2.26,2.26],[-2.34,2.34],[-2.49,2.49],[-4.09,4.09],[0.0,0.0],[-2.58,0.0],[-2.8,0.0],[-2.91,0.0],[-3.0,0.0],[-3.1,0.0],[-3.3,0.0],[-5.42,0.0],[0.0,0.0],[0.0,2.45],[0.0,2.68],[0.0,2.83],[0.0,3.03],[0.0,4.97],[0.0,0.0],[2.58,0.0],[2.8,0.0],[2.91,0.0],[3.0,0.0],[3.1,0.0],[3.3,0.0],[5.42,0.0],[0.0,0.0],[1.95,1.95],[2.12,2.12],[2.2,2.2],[2.26,2.26],[2.34,2.34],[2.49,2.49],[4.09,4.09],[0.0,0.0],[0.0,-2.48],[0.0,-2.68],[0.0,-2.77],[0.0,-2.82],[0.0,-2.85],[0.0,-2.87],[0.0,-2.89],[0.0,-2.91],[0.0,-2.92],[0.0,-2.94],[0.0,-2.96],[0.0,-2.98],[0.0,-3.01],[0.0,-3.06],[0.0,-3.14],[0.0,-3.32],[0.0,-5.52]],"v":[[250.0,221.25],[243.28,227.97],[236.56,234.69],[229.85,241.4],[223.12,248.12],[216.4,254.85],[209.69,261.56],[202.97,268.28],[196.25,275.0],[187.35,275.0],[178.44,275.0],[169.53,275.0],[160.62,275.0],[151.72,275.0],[142.81,275.0],[133.9,275.0],[125.0,275.0],[125.0,283.33],[125.0,291.67],[125.0,300.0],[125.0,308.33],[125.0,316.67],[125.0,325.0],[133.9,325.0],[142.81,325.0],[151.72,325.0],[160.62,325.0],[169.53,325.0],[178.44,325.0],[187.35,325.0],[196.25,325.0],[202.97,331.72],[209.69,338.44],[216.4,345.15],[223.12,351.88],[229.85,358.6],[236.56,365.31],[243.28,372.03],[250.0,378.75],[250.0,370.01],[250.0,361.26],[250.0,352.51],[250.0,343.76],[250.0,335.01],[250.0,326.25],[250.0,317.5],[250.0,308.75],[250.0,300.0],[250.0,291.25],[250.0,282.5],[250.0,273.75],[250.0,264.99],[250.0,256.24],[250.0,247.49],[250.0,238.74],[250.0,229.99]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[0.0,0.0],[4.09,4.09],[2.49,2.49],[2.34,2.34],[2.26,2.26],[2.2,2.2],[2.12,2.12],[1.95,1.95],[0.0,0.0],[5.42,0.0],[3.3,0.0],[3.1,0.0],[3.0,0.0],[2.91,0.0],[2.8,0.0],[2.58,0.0],[0.0,0.0],[0.0,4.97],[0.0,3.03],[0.0,2.83],[0.0,2.68],[0.0,2.45],[0.0,0.0],[-5.42,0.0],[-3.3,0.0],[-3.1,0.0],[-3.0,0.0],[-2.91,0.0],[-2.8,0.0],[-2.58,0.0],[0.0,0.0],[-4.09,4.09],[-2.49,2.49],[-2.34,2.34],[-2.26,2.26],[-2.2,2.2],[-2.12,2.12],[-1.95,1.95],[0.0,0.0],[0.0,-5.52],[0.0,-3.32],[0.0,-3.14],[0.0,-3.06],[0.0,-3.01],[0.0,-2.98],[0.0,-2.96],[0.0,-2.94],[0.0,-2.92],[0.0,-2.91],[0.0,-2.89],[0.0,-2.87],[0.0,-2.85],[0.0,-2.82],[0.0,-2.77],[0.0,-2.68],[0.0,-2.48]],"o":[[0.0,0.0],[-1.95,-1.95],[-2.12,-2.12],[-2.2,-2.2],[-2.26,-2.26],[-2.34,-2.34],[-2.49,-2.49],[-4.09,-4.09],[0.0,0.0],[-2.58,0.0],[-2.8,0.0],[-2.91,0.0],[-3.0,0.0],[-3.1,0.0],[-3.3,0.0],[-5.42,0.0],[0.0,0.0],[0.0,-2.45],[0.0,-2.68],[0.0,-2.83],[0.0,-3.03],[0.0,-4.97],[0.0,0.0],[2.58,0.0],[2.8,0.0],[2.91,0.0],[3.0,0.0],[3.1,0.0],[3.3,0.0],[5.42,0.0],[0.0,0.0],[1.95,-1.95],[2.12,-2.12],[2.2,-2.2],[2.26,-2.26],[2.34,-2.34],[2.49,-2.49],[4.09,-4.09],[0.0,0.0],[0.0,2.48],[0.0,2.68],[0.0,2.77],[0.0,2.82],[0.0,2.85],[0.0,2.87],[0.0,2.89],[0.0,2.91],[0.0,2.92],[0.0,2.94],[0.0,2.96],[0.0,2.98],[0.0,3.01],[0.0,3.06],[0.0,3.14],[0.0,3.32],[0.0,5.52]],"v":[[250.0,378.75],[243.28,372.03],[236.56,365.31],[229.85,358.6],[223.13,351.87],[216.4,345.15],[209.69,338.44],[202.97,331.72],[196.25,325.0],[187.35,325.0],[178.44,325.0],[169.53,325.0],[160.62,325.0],[151.72,325.0],[142.81,325.0],[133.9,325.0],[125.0,325.0],[125.0,316.67],[125.0,308.33],[125.0,300.0],[125.0,291.67],[125.0,283.33],[125.0,275.0],[133.9,275.0],[142.81,275.0],[151.72,275.0],[160.62,275.0],[169.53,275.0],[178.44,275.0],[187.35,275.0],[196.25,275.0],[202.97,268.28],[209.69,261.56],[216.4,254.85],[223.12,248.12],[229.85,241.4],[236.56,234.69],[243.28,227.97],[250.0,221.25],[250.0,229.99],[250.0,238.74],[250.0,247.49],[250.0,256.24],[250.0,264.99],[250.0,273.75],[250.0,282.5],[250.0,291.25],[250.0,300.0],[250.0,308.75],[250.0,317.5],[250.0,326.25],[250.0,335.01],[250.0,343.76],[250.0,352.51],[250.0,361.26],[250.0,370.01]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"plain","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/meteor.png b/assets/meteor.png new file mode 100644 index 0000000..4b8b93f Binary files /dev/null and b/assets/meteor.png differ diff --git a/assets/meteor_icon.png b/assets/meteor_icon.png new file mode 100644 index 0000000..f356a2c Binary files /dev/null and b/assets/meteor_icon.png differ diff --git a/assets/plugins/info/main.js b/assets/plugins/info/main.js new file mode 100644 index 0000000..932882f --- /dev/null +++ b/assets/plugins/info/main.js @@ -0,0 +1,29 @@ +import { chat, contact, runtime, ui } from 'komet:api'; + +function date(value) { + if (!Number.isInteger(value) || value <= 0) return '—'; + return new Date(value).toLocaleString(); +} + +export async function info() { + if (!(await runtime.isOnline())) { + await ui.notify('Нет соединения'); + return; + } + const id = await chat.sendText('сбор данных...'); + const peer = await contact.getPeer(); + if (!peer) { + await chat.editText(id, 'Команда доступна только в диалоге'); + return; + } + const summary = [ + `Никнейм: ${peer.displayName || '—'}`, + `Дата регистрации: ${date(peer.registrationTime)}`, + `Дата последнего изменения профиля: ${date(peer.updateTime)}`, + `id: ${peer.id}`, + `Регион: ${peer.country || '—'}`, + `Флаги: ${peer.options.length ? peer.options.join(', ') : '—'}`, + 'ip: not fetched' + ].join('\n'); + await chat.editText(id, summary); +} \ No newline at end of file diff --git a/assets/plugins/info/manifest.json b/assets/plugins/info/manifest.json new file mode 100644 index 0000000..44348e1 --- /dev/null +++ b/assets/plugins/info/manifest.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "pw.qlyra.info", + "name": "ИКАР Info", + "version": "1.0.0", + "apiVersion": 1, + "description": "Сводка данных о собеседнике", + "author": "ИКАР", + "main": "main.js", + "permissions": ["chat.write", "chat.edit", "ui.notify", "contact.read"], + "commands": [ + {"name": "/info", "description": "сводка данных о человеке", "handler": "info"} + ] +} \ No newline at end of file diff --git a/assets/plugins/nekos/.gitkeep b/assets/plugins/nekos/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/assets/plugins/nekos/.gitkeep @@ -0,0 +1 @@ + diff --git a/assets/plugins/nekos/main.js b/assets/plugins/nekos/main.js new file mode 100644 index 0000000..569946b --- /dev/null +++ b/assets/plugins/nekos/main.js @@ -0,0 +1,33 @@ +import { chat, network, ui } from 'komet:api'; + +export async function nekogirl() { + const response = await network.fetch('https://api.nekosapi.com/v4/images/random?rating=safe', { + headers: { Accept: 'application/json' } + }); + if (response.status !== 200) { + await ui.notify(`Nekogirl вернул HTTP ${response.status}`); + return; + } + + let data; + try { + data = JSON.parse(response.body); + } catch (_) { + await ui.notify('Nekogirl вернул некорректный ответ'); + return; + } + + if (typeof data[0].url !== 'string' || !data[0].url.startsWith('https://')) { + await ui.notify('Nekogirl не вернул ссылку на изображение'); + return; + } + + const filename = data[0].url.split('/').pop() || 'nekogirl.jpg'; + + const source = data[0].url; + await chat.sendPhoto({ + url: data[0].url, + filename, + caption: `Случайная кошкодевочкa` + }); +} \ No newline at end of file diff --git a/assets/plugins/nekos/manifest.json b/assets/plugins/nekos/manifest.json new file mode 100644 index 0000000..440c5ae --- /dev/null +++ b/assets/plugins/nekos/manifest.json @@ -0,0 +1 @@ +{"schemaVersion":1,"id":"pw.qlyra.nekos","name":"ИКАР Nekos","version":"1.0.0","apiVersion":1,"description":"Случайные картинки кошкодевочек через nekosapi.com","author":"nyakokitsu","main":"main.js","permissions":["network","chat.photo","chat.write","ui.notify"],"commands":[{"name":"/nekogirl","description":"отправить случайную картинку кошкодевочки","handler":"nekogirl"}]} \ No newline at end of file diff --git a/assets/plugins/weather/main.js b/assets/plugins/weather/main.js new file mode 100644 index 0000000..0f0e776 --- /dev/null +++ b/assets/plugins/weather/main.js @@ -0,0 +1,63 @@ +import { chat, network, ui } from 'komet:api'; + +function value(source, key, fallback = '—') { + const result = source?.[key]; + return result === undefined || result === null || result === '' ? fallback : String(result); +} + +function description(source) { + return source?.weatherDesc?.[0]?.value || '—'; +} + +function dayLabel(date, index) { + if (index === 0) return 'Сегодня'; + if (index === 1) return 'Завтра'; + return date || `День ${index + 1}`; +} + +export async function weather(context) { + const city = context.arguments.city.trim(); + const url = `https://wttr.in/${encodeURIComponent(city)}?format=j1&lang=ru`; + const response = await network.fetch(url, { + headers: { Accept: 'application/json' } + }); + if (response.status !== 200) { + await ui.notify(`wttr.in вернул HTTP ${response.status}`); + return; + } + + let data; + try { + data = JSON.parse(response.body); + } catch (_) { + await ui.notify('wttr.in вернул некорректный ответ'); + return; + } + + const current = data.current_condition?.[0]; + const area = data.nearest_area?.[0]; + if (!current) { + await ui.notify('Погода для этого города не найдена'); + return; + } + + const place = area?.areaName?.[0]?.value || city; + const country = area?.country?.[0]?.value; + const lines = [ + `Погода: ${place}${country ? `, ${country}` : ''}`, + `${description(current)}, ${value(current, 'temp_C')} °C`, + `Ощущается как ${value(current, 'FeelsLikeC')} °C`, + `Влажность ${value(current, 'humidity')}% · ветер ${value(current, 'windspeedKmph')} км/ч`, + '', + 'Прогноз:' + ]; + + for (const [index, day] of (data.weather || []).slice(0, 3).entries()) { + const noon = day.hourly?.find(item => item.time === '1200') || day.hourly?.[4] || day.hourly?.[0]; + lines.push( + `${dayLabel(day.date, index)}: ${description(noon)}, ${value(day, 'mintempC')}…${value(day, 'maxtempC')} °C` + ); + } + + await chat.sendText(lines.join('\n')); +} \ No newline at end of file diff --git a/assets/plugins/weather/manifest.json b/assets/plugins/weather/manifest.json new file mode 100644 index 0000000..386258a --- /dev/null +++ b/assets/plugins/weather/manifest.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "id": "pw.qlyra.weather", + "name": "ИКАР Weather", + "version": "1.0.0", + "apiVersion": 1, + "description": "Текущая погода и прогноз через wttr.in", + "author": "ИКАР", + "main": "main.js", + "permissions": ["chat.write", "ui.notify", "network"], + "commands": [ + { + "name": "/weather", + "description": "погода и прогноз для города", + "handler": "weather", + "arguments": [ + {"name": "city", "description": "название города", "rest": true} + ] + } + ] +} \ No newline at end of file diff --git a/assets/qlyra.png b/assets/qlyra.png new file mode 100644 index 0000000..95e7936 Binary files /dev/null and b/assets/qlyra.png differ diff --git a/assets/qlyra_icon.png b/assets/qlyra_icon.png new file mode 100644 index 0000000..95e7936 Binary files /dev/null and b/assets/qlyra_icon.png differ diff --git a/assets/wallpapers/patterns/bubbles.svg b/assets/wallpapers/patterns/bubbles.svg new file mode 100644 index 0000000..590fc10 --- /dev/null +++ b/assets/wallpapers/patterns/bubbles.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/wallpapers/patterns/hearts.svg b/assets/wallpapers/patterns/hearts.svg new file mode 100644 index 0000000..ac5f0d8 --- /dev/null +++ b/assets/wallpapers/patterns/hearts.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/wallpapers/patterns/planes.svg b/assets/wallpapers/patterns/planes.svg new file mode 100644 index 0000000..525b9dd --- /dev/null +++ b/assets/wallpapers/patterns/planes.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/wallpapers/patterns/plus.svg b/assets/wallpapers/patterns/plus.svg new file mode 100644 index 0000000..fb558b6 --- /dev/null +++ b/assets/wallpapers/patterns/plus.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/wallpapers/patterns/rings.svg b/assets/wallpapers/patterns/rings.svg new file mode 100644 index 0000000..cbd62a4 --- /dev/null +++ b/assets/wallpapers/patterns/rings.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/wallpapers/patterns/stars.svg b/assets/wallpapers/patterns/stars.svg new file mode 100644 index 0000000..88c305a --- /dev/null +++ b/assets/wallpapers/patterns/stars.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/cargokit_options.yaml b/cargokit_options.yaml new file mode 100644 index 0000000..e0d7c00 --- /dev/null +++ b/cargokit_options.yaml @@ -0,0 +1 @@ +use_precompiled_binaries: false diff --git a/design/animations/.gitkeep b/design/animations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/design/qlyra-design-system.svg b/design/qlyra-design-system.svg new file mode 100644 index 0000000..1f0dea0 --- /dev/null +++ b/design/qlyra-design-system.svg @@ -0,0 +1,320 @@ + + + + + + + + + + Qlyra — Design System + Seed #C1C4FF · Material 3 Expressive · Inter + + + + Цвета — Light + + + + + primary + #4D55A9 + + + + primaryContainer + #DFE0FF + + + + surface + #FEFBFF + + + + surfaceContainer + #F2EEF8 + + + + surfaceContainerHigh + #ECE8F2 + + + + surfaceContainerHighest + #E6E2EC + + + + + onSurface + #1B1B21 + + + + onSurfaceVariant + #46464F + + + + outline + #767680 + + + + outlineVariant + #C6C5D0 + + + + error + #BA1A1A + + + + errorContainer + #FFDAD6 + + + + + + Цвета — Dark + + + + + + primary + #BCC2FF + + + + primaryContainer + #353D90 + + + + surface + #131318 + + + + surfaceContainer + #1F1F25 + + + + surfaceContainerHigh + #2A2A30 + + + + surfaceContainerHighest + #35353B + + + + + onSurface + #E4E1E9 + + + + onSurfaceVariant + #C6C5D0 + + + + outline + #90909A + + + + outlineVariant + #46464F + + + + error + #FFB4AB + + + + errorContainer + #93000A + + + + + + Типографика — Inter + + Display / 32 / 700 + Headline / 22 / 600 + Title / 18 / 600 + Subtitle / 16 / 500 + Body / 14 / 400 — основной текст в интерфейсе. + Caption / 13 / 400 — подсказки, метаданные. + LABEL / 12 / 500 / UPPER + + + + + Компонент: Settings Item + + + + + + Цифровой ID + › + + + + + + + Облачное хранилище + + BETA + › + + + + + + + Уведомления + + + + + + + + + Выйти из аккаунта + + + + + Кнопки + + + + Filled · Primary + + + + + Tonal + + + + + Outlined + + + + Text button + + + + + Destructive + + + + + + + + + + + Компонент: InfoActionSheet + + + + + + ☁ + Облачное хранилище + Через МАХ + + + + + + Работает при белых списках + Можете передать файл при ограничениях. + + + + + Файлы до 4 ГБ, безлимитно + Хранилище большого объёма. + + + + + Без конфиденциальности + Файлы хранятся на сервере МАХ. + + + + + ОК + + + + + Шаблон экрана + + + + + ← + Заголовок + + + + + ☁ + Заголовок состояния + Подпись · описание + + + + + Phone + + + + + + ← + Облачное хранилище + + + + + ☁ + Скоро + Экран в разработке + + + + + Импорт в Figma: File → Place / drag & drop. Слои сохранят имена (id), текст редактируемый. + + diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/ikar_server/.dockerignore b/ikar_server/.dockerignore new file mode 100644 index 0000000..4d045ad --- /dev/null +++ b/ikar_server/.dockerignore @@ -0,0 +1,17 @@ +.git +.vs +.codex-* +.env +.env.* +!.env.example +**/bin +**/obj +docker-data +src/Ikar.Server/Data/Attachments +src/Ikar.Server/Data/*.db +src/Ikar.Server/Data/*.db-shm +src/Ikar.Server/Data/*.db-wal +src/Ikar.Client/bin +src/Ikar.Client/obj +server.out.log +server.err.log diff --git a/ikar_server/.env.example b/ikar_server/.env.example new file mode 100644 index 0000000..87043fb --- /dev/null +++ b/ikar_server/.env.example @@ -0,0 +1,8 @@ +IKAR_DATA_PATH=/mnt/data/ikar-data +IKAR_BACKUP_PATH=/mnt/data/ikar-backups +TURN_HOST=ikar.kusoft.xyz +TURN_REALM=ikar.kusoft.xyz +TURN_EXTERNAL_IP=195.216.241.165 +TURN_PRIVATE_IP=192.168.0.185 +TURN_USERNAME=ikar +TURN_PASSWORD=replace-with-a-long-random-secret diff --git a/ikar_server/Dockerfile.release b/ikar_server/Dockerfile.release new file mode 100644 index 0000000..b2c6eee --- /dev/null +++ b/ikar_server/Dockerfile.release @@ -0,0 +1,10 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY publish/ ./ +COPY docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod 755 /app/docker-entrypoint.sh +ENV ASPNETCORE_ENVIRONMENT=Production ASPNETCORE_URLS=http://+:5099 +USER 1000:1000 +EXPOSE 5099 +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/ikar_server/Dockerfile.server b/ikar_server/Dockerfile.server new file mode 100644 index 0000000..c7b2a96 --- /dev/null +++ b/ikar_server/Dockerfile.server @@ -0,0 +1,27 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY ["src/Ikar.Server/Ikar.Server.csproj", "src/Ikar.Server/"] +COPY ["src/Ikar.Shared/Ikar.Shared.csproj", "src/Ikar.Shared/"] +RUN dotnet restore "src/Ikar.Server/Ikar.Server.csproj" + +COPY . . +RUN dotnet publish "src/Ikar.Server/Ikar.Server.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:5099 + +COPY --from=build /app/publish . + +EXPOSE 5099 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl --fail http://127.0.0.1:5099/health || exit 1 + +ENTRYPOINT ["dotnet", "Ikar.Server.dll"] diff --git a/ikar_server/asr/Dockerfile b/ikar_server/asr/Dockerfile new file mode 100644 index 0000000..4e6b832 --- /dev/null +++ b/ikar_server/asr/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg ca-certificates && rm -rf /var/lib/apt/lists/* +RUN pip install --no-cache-dir vosk==0.3.45 +COPY model.zip /tmp/model.zip +RUN python -c "import hashlib,zipfile; p='/tmp/model.zip'; assert hashlib.sha256(open(p,'rb').read()).hexdigest() == '961d5ff98a17f4aa6de69864d0aa71fa5bac682301d2b5d17a3f24c5c99a46d4'; zipfile.ZipFile(p).extractall('/model')" && rm /tmp/model.zip +WORKDIR /service +COPY service.py ./ +USER 1000:1000 +ENV OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 +CMD ["python", "service.py"] diff --git a/ikar_server/asr/model.zip b/ikar_server/asr/model.zip new file mode 100644 index 0000000..02860c7 Binary files /dev/null and b/ikar_server/asr/model.zip differ diff --git a/ikar_server/asr/service.py b/ikar_server/asr/service.py new file mode 100644 index 0000000..d26f86b --- /dev/null +++ b/ikar_server/asr/service.py @@ -0,0 +1,38 @@ +"""Private, local Russian speech recognition. The model stays on the Pi.""" +import json, subprocess, threading +from pathlib import Path +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from vosk import Model, KaldiRecognizer, SetLogLevel + +SetLogLevel(-1) +model = Model('/model/vosk-model-small-ru-0.22') +lock = threading.Semaphore(1) +root = Path('/app/Data/Attachments').resolve() + +class Handler(BaseHTTPRequestHandler): + def answer(self, status, body): + content = json.dumps(body, ensure_ascii=False).encode() + self.send_response(status); self.send_header('Content-Type','application/json'); self.send_header('Content-Length',str(len(content))); self.end_headers(); self.wfile.write(content) + def do_GET(self): + self.answer(200, {'status':'ok','engine':'vosk','language':'ru'}) + def do_POST(self): + if self.path != '/transcribe': return self.answer(404, {}) + try: + length = int(self.headers.get('Content-Length','0')) + if length <= 0 or length > 4096: return self.answer(400, {}) + data = json.loads(self.rfile.read(length)) + path = Path(data['path']).resolve() + if not path.is_relative_to(root) or not path.is_file(): return self.answer(403, {'error':'audio path rejected'}) + with lock: + decoded = subprocess.run(['ffmpeg','-nostdin','-loglevel','error','-threads','1','-i',str(path),'-t','600','-ar','16000','-ac','1','-f','s16le','pipe:1'], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=90, check=True) + recognizer = KaldiRecognizer(model, 16000) + words = [] + for offset in range(0,len(decoded.stdout),8000): + if recognizer.AcceptWaveform(decoded.stdout[offset:offset+8000]): words.append(json.loads(recognizer.Result()).get('text','')) + words.append(json.loads(recognizer.FinalResult()).get('text','')) + self.answer(200, {'text':' '.join(part for part in words if part)}) + except Exception: + self.answer(422, {'error':'audio could not be decoded'}) + def log_message(self, *_): pass + +ThreadingHTTPServer(('0.0.0.0',8766), Handler).serve_forever() diff --git a/ikar_server/backup.py b/ikar_server/backup.py new file mode 100644 index 0000000..89928c4 --- /dev/null +++ b/ikar_server/backup.py @@ -0,0 +1,27 @@ +"""Consistent SQLite backup plus attachment files; only IKAR paths are used.""" +from pathlib import Path +import datetime, os, sqlite3, tarfile, tempfile + +root = Path('/mnt/data/ikar_server') +if not os.path.ismount('/mnt/data'): + raise SystemExit('External disk is not mounted') +source = root / 'shared/data' +stamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ') +destination = root / f'backups/ikar-{stamp}.tar.gz' +with tempfile.TemporaryDirectory(prefix='backup-', dir=root / 'backups') as temp: + database = Path(temp) / 'ikar.db' + with sqlite3.connect(f'file:{source / "ikar.db"}?mode=ro', uri=True) as live, sqlite3.connect(database) as backup: + live.backup(backup) + if backup.execute('pragma quick_check').fetchone()[0] != 'ok': + raise SystemExit('Database backup failed integrity check') + with tarfile.open(destination, 'w:gz') as bundle: + bundle.add(database, arcname='data/ikar.db') + for directory in source.iterdir(): + if directory.is_dir() and directory.name not in ('QlyraUploads', 'AppUpdates'): + bundle.add(directory, arcname=f'data/{directory.name}') +os.chmod(destination, 0o600) +cutoff = datetime.datetime.now(datetime.timezone.utc).timestamp() - 14 * 86400 +for previous in (root / 'backups').glob('ikar-*.tar.gz'): + if previous != destination and previous.is_file() and previous.stat().st_mtime < cutoff: + previous.unlink() +print(destination) diff --git a/ikar_server/compose.yaml b/ikar_server/compose.yaml new file mode 100644 index 0000000..1bdbb9f --- /dev/null +++ b/ikar_server/compose.yaml @@ -0,0 +1,70 @@ +name: ikar_server +services: + ikar_server: + image: ikar_server:20261006 + container_name: ikar_server + restart: unless-stopped + user: "1000:1000" + env_file: /mnt/data/ikar_server/shared/server.env + ports: + - "127.0.0.1:5099:5099" + volumes: + - /mnt/data/ikar_server/shared/data:/app/Data + - /mnt/data/ikar_server/backups:/backups:ro + environment: + Backup__Directory: /backups + Backup__RequireFreshBackup: "true" + DOTNET_GCHeapHardLimit: "0x20000000" + IkarUpdates__LocalOnly: "true" + mem_limit: 768m + cpus: 1.5 + pids_limit: 256 + security_opt: + - no-new-privileges:true + cap_drop: [ALL] + logging: + driver: json-file + options: {max-size: "10m", max-file: "3"} + turn: + image: coturn/coturn:4.14.0-r0 + container_name: ikar_server_turn + restart: unless-stopped + network_mode: host + command: + - --no-cli + - --fingerprint + - --lt-cred-mech + - --realm=ikar.kusoft.xyz + - --user=${TURN_USERNAME}:${TURN_PASSWORD} + - --listening-ip=${TURN_PRIVATE_IP} + - --relay-ip=${TURN_PRIVATE_IP} + - --external-ip=${TURN_EXTERNAL_IP}/${TURN_PRIVATE_IP} + - --listening-port=3478 + - --min-port=49160 + - --max-port=49200 + - --no-tls + - --no-dtls + - --no-multicast-peers + - --log-file=stdout + mem_limit: 192m + cpus: 0.5 + pids_limit: 64 + logging: + driver: json-file + options: {max-size: "5m", max-file: "2"} + asr: + build: ./asr + image: ikar_server_asr:20261006 + container_name: ikar_server_asr + restart: unless-stopped + user: "1000:1000" + volumes: + - /mnt/data/ikar_server/shared/data/Attachments:/app/Data/Attachments:ro + cpus: 0.5 + mem_limit: 512m + pids_limit: 64 + security_opt: [no-new-privileges:true] + cap_drop: [ALL] + logging: + driver: json-file + options: {max-size: "5m", max-file: "2"} diff --git a/ikar_server/configure_pi.py b/ikar_server/configure_pi.py new file mode 100644 index 0000000..7dfad17 --- /dev/null +++ b/ikar_server/configure_pi.py @@ -0,0 +1,77 @@ +"""Install only IKAR units and its own Compose services on the mounted disk.""" +from pathlib import Path +import json, os, subprocess + +root = Path('/mnt/data/ikar_server') +release = root / 'releases/20261006' +assert os.path.ismount('/mnt/data') and root.resolve() == root +assert release.is_dir() and (root / 'shared/server.env').is_file() +marker = root / 'shared/data/.ikar-volume' +marker.write_text('IKAR external data volume\n') +os.chown(marker,1000,1000) +current = root / 'current' +if current.is_symlink(): current.unlink() +elif current.exists(): raise SystemExit('Unexpected current directory') +current.symlink_to(release, target_is_directory=True) +def env_read(path): + return dict(line.split('=',1) for line in path.read_text().splitlines() if '=' in line and not line.startswith('#')) +turn = env_read(root / 'shared/turn.env') +server_path = root / 'shared/server.env' +server = env_read(server_path) +ip = turn['TURN_EXTERNAL_IP'] +server.update({ + 'WebRtc__IceServers__0__Urls__0':f'stun:{ip}:3478', + 'WebRtc__IceServers__1__Urls__0':f'turn:{ip}:3478?transport=udp', + 'WebRtc__IceServers__1__Urls__1':f'turn:{ip}:3478?transport=tcp', + 'IkarUpdates__LocalOnly':'true', + 'Logging__LogLevel__Microsoft.EntityFrameworkCore.Database.Command':'Warning', + 'Logging__LogLevel__System.Net.Http.HttpClient':'Warning', +}) +server_path.write_text(''.join(f'{key}={value}\n' for key,value in server.items())) +os.chmod(server_path,0o600); os.chown(server_path,1000,1000) +unit_root = Path('/etc/systemd/system') +(unit_root / 'ikar_server.service').write_text('''[Unit] +Description=IKAR messenger on the external disk +Requires=docker.service +After=docker.service network-online.target +RequiresMountsFor=/mnt/data + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/mnt/data/ikar_server/current +ExecStartPre=/usr/bin/mountpoint -q /mnt/data +ExecStart=/usr/bin/docker compose --env-file /mnt/data/ikar_server/shared/turn.env up -d +ExecStop=/usr/bin/docker compose --env-file /mnt/data/ikar_server/shared/turn.env stop +TimeoutStartSec=90 + +[Install] +WantedBy=multi-user.target +''') +(unit_root / 'ikar_server-backup.service').write_text('''[Unit] +Description=Consistent IKAR backup +RequiresMountsFor=/mnt/data +After=ikar_server.service + +[Service] +Type=oneshot +User=sevenhill +ExecStart=/usr/bin/python3 /mnt/data/ikar_server/current/backup.py +Nice=15 +IOSchedulingClass=idle +''') +(unit_root / 'ikar_server-backup.timer').write_text('''[Unit] +Description=Daily IKAR backup + +[Timer] +OnCalendar=*-*-* 03:30:00 +Persistent=true +RandomizedDelaySec=300 + +[Install] +WantedBy=timers.target +''') +subprocess.run(['systemctl','daemon-reload'],check=True) +subprocess.run(['docker','compose','--project-directory',str(current),'--env-file',str(root / 'shared/turn.env'),'up','-d'],check=True) +subprocess.run(['systemctl','enable','--now','ikar_server.service','ikar_server-backup.timer'],check=True) +print(json.dumps({'root':str(root),'release':str(release),'externalIp':ip,'backupTimer':'enabled'})) diff --git a/ikar_server/docker-entrypoint.sh b/ikar_server/docker-entrypoint.sh new file mode 100644 index 0000000..8c4e8b2 --- /dev/null +++ b/ikar_server/docker-entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu +if [ ! -f /app/Data/.ikar-volume ]; then + echo 'IKAR external data volume is absent.' >&2 + exit 78 +fi +exec dotnet Ikar.Server.dll diff --git a/ikar_server/prepare_pi.py b/ikar_server/prepare_pi.py new file mode 100644 index 0000000..09cc429 --- /dev/null +++ b/ikar_server/prepare_pi.py @@ -0,0 +1,63 @@ +"""Prepare only our new directories and credentials on the external disk.""" +from pathlib import Path +import os, secrets, shutil, socket, subprocess, tarfile, json + +root = Path('/mnt/data/ikar_server') +if not os.path.ismount('/mnt/data'): + raise SystemExit('External disk is not mounted') +if (root / 'shared/server.env').exists(): + raise SystemExit('Credentials already exist; inspect before updating') +root.mkdir(exist_ok=True) +for name in ('shared/data/secrets', 'releases/20261005', 'backups', 'rollback'): + (root / name).mkdir(parents=True, exist_ok=True) +baseline = subprocess.check_output(['docker', 'ps', '--format', '{{json .}}'], text=True) +(root / 'rollback/services-before.jsonl').write_text(baseline) +archive = root / 'rollback/old-ikar-20261005.tar.gz' +with tarfile.open(archive, 'w:gz') as bundle: + for name in ('ikar-deploy', 'ikar-data'): + source = Path('/mnt/data') / name + if source.exists(): + bundle.add(source, arcname=name) +os.chmod(archive, 0o600) +old = {} +for line in Path('/mnt/data/ikar-deploy/.env.server').read_text().splitlines(): + if '=' in line and not line.lstrip().startswith('#'): + key, value = line.split('=', 1) + old[key] = value +turn = {} +for line in Path('/mnt/data/ikar-deploy/.env').read_text().splitlines(): + if '=' in line: + key, value = line.split('=', 1) + turn[key] = value +shutil.copy2('/mnt/data/ikar-data/secrets/firebase-admin.json', root / 'shared/data/secrets/firebase-admin.json') +username = 'ikar' +password = secrets.token_urlsafe(32) +private_ip = socket.gethostbyname(socket.gethostname()) +if private_ip.startswith('127.'): + private_ip = subprocess.check_output(['hostname', '-I'], text=True).split()[0] +try: + public_ip = socket.gethostbyname('ikar.kusoft.xyz') +except socket.gaierror: + public_ip = socket.gethostbyname('argus.kusoft.xyz') +values = { + 'ASPNETCORE_ENVIRONMENT': 'Production', 'SeedDemoData': 'false', + 'ConnectionStrings__IkarDb': 'Data Source=/app/Data/ikar.db', + 'Jwt__SigningKey': secrets.token_urlsafe(64), + 'Push__FirebaseProjectId': old.get('Push__FirebaseProjectId', 'ikar-3bdfb'), + 'Push__ServiceAccountJsonPath': '/app/Data/secrets/firebase-admin.json', + 'Admin__Username': 'admin', 'Admin__Password': secrets.token_urlsafe(32), + 'QlyraPublicEndpoint__BaseUrl': 'https://ikar.kusoft.xyz', + 'WebRtc__IceServers__0__Urls__0': 'stun:ikar.kusoft.xyz:3478', + 'WebRtc__IceServers__1__Urls__0': 'turn:ikar.kusoft.xyz:3478?transport=udp', + 'WebRtc__IceServers__1__Urls__1': 'turn:ikar.kusoft.xyz:3478?transport=tcp', + 'WebRtc__IceServers__1__Username': username, 'WebRtc__IceServers__1__Credential': password, +} +values.update({k: v for k, v in old.items() if k.startswith('EmailAuth__')}) +env = root / 'shared/server.env' +env.write_text(''.join(f'{k}={v}\n' for k, v in values.items())) +os.chmod(env, 0o600) +env = root / 'shared/turn.env' +env.write_text(f'TURN_USERNAME={username}\nTURN_PASSWORD={password}\nTURN_PRIVATE_IP={private_ip}\nTURN_EXTERNAL_IP={public_ip}\n') +os.chmod(env, 0o600) +subprocess.run(['chown', '-R', '1000:1000', str(root)], check=True) +print(json.dumps({'path': str(root), 'oldArchiveBytes': archive.stat().st_size, 'privateIp': private_ip, 'publicIp': public_ip})) diff --git a/ikar_server/src/Ikar.Server.Tests/AdminUserDeletionTests.cs b/ikar_server/src/Ikar.Server.Tests/AdminUserDeletionTests.cs new file mode 100644 index 0000000..1b47a6d --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/AdminUserDeletionTests.cs @@ -0,0 +1,180 @@ +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Admin; +using Ikar.Server.Infrastructure.Bots; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Shared; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.FileProviders; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Tests; + +public sealed class AdminUserDeletionTests +{ + [Fact] + public async Task DeleteUserAsync_UserOwnsBot_RemovesOwnerAndBotUser() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + using var tempRoot = new TempDirectory(); + var service = CreateService(dbContext, tempRoot.Path); + + var owner = CreateUser("owner"); + var botUser = CreateUser("helperbot", isBot: true); + var bot = new Bot + { + User = botUser, + OwnerUser = owner, + TokenHash = "bot-token" + }; + var chat = CreateDirectChat(owner, botUser); + chat.Messages.Add(new Message + { + Chat = chat, + Sender = botUser, + Text = "bot history", + SentAt = DateTimeOffset.UtcNow + }); + dbContext.AddRange(owner, botUser, bot, chat); + await dbContext.SaveChangesAsync(); + + var deleted = await service.DeleteUserAsync(owner.Id, CancellationToken.None); + + Assert.True(deleted); + Assert.False(await dbContext.Users.AnyAsync(x => x.Id == owner.Id)); + Assert.False(await dbContext.Users.AnyAsync(x => x.Id == botUser.Id)); + Assert.False(await dbContext.Bots.AnyAsync(x => x.Id == bot.Id)); + Assert.False(await dbContext.Chats.AnyAsync(x => x.Id == chat.Id)); + } + + [Fact] + public async Task DeleteUserAsync_UserHasRestrictingRows_RemovesRowsWithoutThrowing() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + using var tempRoot = new TempDirectory(); + var service = CreateService(dbContext, tempRoot.Path); + + var user = CreateUser("deleted"); + var peer = CreateUser("peer"); + var chat = new Chat + { + Type = ChatType.Group, + Title = "Group", + CreatedBy = peer, + LastActivityAt = DateTimeOffset.UtcNow + }; + chat.Members.Add(new ChatMember { Chat = chat, User = user, Role = ChatMemberRole.Member }); + chat.Members.Add(new ChatMember { Chat = chat, User = peer, IsOwner = true, Role = ChatMemberRole.Owner }); + var message = new Message + { + Chat = chat, + Sender = user, + Text = "remove me", + SentAt = DateTimeOffset.UtcNow + }; + chat.Messages.Add(message); + var inviteLink = new ChatInviteLink + { + Chat = chat, + CreatedBy = user, + Token = "invite-token" + }; + var topic = new ChatTopic + { + Chat = chat, + CreatedBy = user, + Title = "Topic", + Icon = "chat" + }; + var moderationEntry = new ModerationLogEntry + { + Chat = chat, + ActorUser = user, + TargetType = ModerationLogTargetType.Message, + TargetId = message.Id, + Action = "message.deleted" + }; + dbContext.AddRange(user, peer, chat, inviteLink, topic, moderationEntry); + await dbContext.SaveChangesAsync(); + + var deleted = await service.DeleteUserAsync(user.Id, CancellationToken.None); + + Assert.True(deleted); + Assert.False(await dbContext.Users.AnyAsync(x => x.Id == user.Id)); + Assert.False(await dbContext.Messages.AnyAsync(x => x.Id == message.Id)); + Assert.False(await dbContext.ChatInviteLinks.AnyAsync(x => x.Id == inviteLink.Id)); + Assert.False(await dbContext.ChatTopics.AnyAsync(x => x.Id == topic.Id)); + Assert.False(await dbContext.ModerationLogEntries.AnyAsync(x => x.Id == moderationEntry.Id)); + Assert.True(await dbContext.Users.AnyAsync(x => x.Id == peer.Id)); + Assert.True(await dbContext.Chats.AnyAsync(x => x.Id == chat.Id)); + } + + private static AdminService CreateService(IkarDbContext dbContext, string contentRootPath) + { + var attachmentStorageService = new AttachmentStorageService(new TestWebHostEnvironment(contentRootPath)); + return new AdminService( + dbContext, + new TestWebHostEnvironment(contentRootPath), + new ServerRuntimeState(DateTimeOffset.UtcNow), + attachmentStorageService, + new BotDeletionService(dbContext, attachmentStorageService), + new NoopHubContext()); + } + + private static User CreateUser(string username, bool isBot = false) => + new() + { + Id = Guid.NewGuid(), + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = username, + PasswordHash = "test", + IsBot = isBot + }; + + private static Chat CreateDirectChat(User first, User second) + { + var chat = new Chat + { + Type = ChatType.Direct, + CreatedBy = first, + LastActivityAt = DateTimeOffset.UtcNow + }; + chat.Members.Add(new ChatMember { Chat = chat, User = first, IsOwner = true, Role = ChatMemberRole.Owner }); + chat.Members.Add(new ChatMember { Chat = chat, User = second }); + return chat; + } + + private sealed class TempDirectory : IDisposable + { + public TempDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ikar-admin-delete-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + } + + private sealed class TestWebHostEnvironment(string contentRootPath) : IWebHostEnvironment + { + public string ApplicationName { get; set; } = "Ikar.Server.Tests"; + public IFileProvider ContentRootFileProvider { get; set; } = new PhysicalFileProvider(contentRootPath); + public string ContentRootPath { get; set; } = contentRootPath; + public string EnvironmentName { get; set; } = "Test"; + public string WebRootPath { get; set; } = contentRootPath; + public IFileProvider WebRootFileProvider { get; set; } = new PhysicalFileProvider(contentRootPath); + } +} diff --git a/ikar_server/src/Ikar.Server.Tests/AuthEmailControllerTests.cs b/ikar_server/src/Ikar.Server.Tests/AuthEmailControllerTests.cs new file mode 100644 index 0000000..e36d7d8 --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/AuthEmailControllerTests.cs @@ -0,0 +1,218 @@ +using Ikar.Server.Controllers; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Push; +using Ikar.Shared; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace Ikar.Server.Tests; + +public sealed class AuthEmailControllerTests +{ + [Fact] + public async Task VerifyEmailCode_NewEmail_CreatesUserAndSession() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var sender = new RecordingEmailCodeSender(); + var challengeStore = new EmailAuthChallengeStore(); + var controller = CreateController(dbContext, challengeStore, sender); + + var challengeResult = await controller.RequestEmailCode( + new RequestEmailCodeRequest("user@yandex.ru", "+10000000001"), + CancellationToken.None); + var challenge = ControllerTestFixture.ValueOf(challengeResult); + + var sessionResult = await controller.VerifyEmailCode( + new VerifyEmailCodeRequest(challenge.ChallengeId, "user@yandex.ru", "+10000000001", sender.LastCode!, "Иван"), + CancellationToken.None); + var session = ControllerTestFixture.ValueOf(sessionResult); + + Assert.Equal("Иван", session.User.DisplayName); + Assert.NotEmpty(session.AccessToken); + Assert.NotEmpty(session.RefreshToken); + Assert.Contains(dbContext.Users, user => user.NormalizedEmail == "USER@YANDEX.RU"); + Assert.Contains(dbContext.Users, user => user.NormalizedPhoneNumber == PhoneNumberNormalizer.Normalize("+10000000001")); + } + + [Fact] + public async Task RequestEmailCode_NewAccountWithUnsupportedEmailDomain_ReturnsValidationProblem() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var sender = new RecordingEmailCodeSender(); + var controller = CreateController(dbContext, new EmailAuthChallengeStore(), sender); + + var result = await controller.RequestEmailCode( + new RequestEmailCodeRequest("user@gmail.com", "+10000000009"), + CancellationToken.None); + + var badRequest = Assert.IsType(result.Result); + Assert.Equal(EmailAddressNormalizer.UnsupportedRegistrationDomainMessage, badRequest.Value); + Assert.Null(sender.LastCode); + } + + [Fact] + public async Task VerifyEmailCode_ExistingPhoneOnlyUser_LinksEmailAndCreatesSession() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + const string phoneNumber = "+10000000002"; + var existingUser = new User + { + Username = "legacy", + NormalizedUsername = "LEGACY", + DisplayName = "Legacy", + PasswordHash = "test", + PhoneNumber = phoneNumber, + NormalizedPhoneNumber = PhoneNumberNormalizer.Normalize(phoneNumber) + }; + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + + var sender = new RecordingEmailCodeSender(); + var controller = CreateController(dbContext, new EmailAuthChallengeStore(), sender); + + var challengeResult = await controller.RequestEmailCode( + new RequestEmailCodeRequest("legacy@mail.ru", phoneNumber), + CancellationToken.None); + var challenge = ControllerTestFixture.ValueOf(challengeResult); + + var sessionResult = await controller.VerifyEmailCode( + new VerifyEmailCodeRequest(challenge.ChallengeId, "legacy@mail.ru", phoneNumber, sender.LastCode!, null), + CancellationToken.None); + var session = ControllerTestFixture.ValueOf(sessionResult); + + var storedUser = Assert.Single(dbContext.Users, user => user.Id == existingUser.Id); + Assert.Equal(existingUser.Id, session.User.Id); + Assert.Equal("LEGACY@MAIL.RU", storedUser.NormalizedEmail); + Assert.Equal(PhoneNumberNormalizer.Normalize(phoneNumber), storedUser.NormalizedPhoneNumber); + } + + [Fact] + public async Task RequestEmailCode_EmailAndPhoneFromDifferentAccounts_ReturnsConflict() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + dbContext.Users.AddRange( + new User + { + Username = "email-user", + NormalizedUsername = "EMAIL-USER", + DisplayName = "Email", + PasswordHash = "test", + Email = "user@yandex.ru", + NormalizedEmail = "USER@YANDEX.RU", + PhoneNumber = "+10000000003", + NormalizedPhoneNumber = "10000000003" + }, + new User + { + Username = "phone-user", + NormalizedUsername = "PHONE-USER", + DisplayName = "Phone", + PasswordHash = "test", + PhoneNumber = "+10000000004", + NormalizedPhoneNumber = "10000000004" + }); + await dbContext.SaveChangesAsync(); + + var sender = new RecordingEmailCodeSender(); + var controller = CreateController(dbContext, new EmailAuthChallengeStore(), sender); + + var result = await controller.RequestEmailCode( + new RequestEmailCodeRequest("user@yandex.ru", "+10000000004"), + CancellationToken.None); + + Assert.IsType(result.Result); + Assert.Null(sender.LastCode); + } + + [Fact] + public async Task VerifyEmailCode_WithDifferentPhoneThanChallenge_ReturnsUnauthorized() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var sender = new RecordingEmailCodeSender(); + var controller = CreateController(dbContext, new EmailAuthChallengeStore(), sender); + + var challengeResult = await controller.RequestEmailCode( + new RequestEmailCodeRequest("user@yandex.ru", "+10000000005"), + CancellationToken.None); + var challenge = ControllerTestFixture.ValueOf(challengeResult); + + var sessionResult = await controller.VerifyEmailCode( + new VerifyEmailCodeRequest(challenge.ChallengeId, "user@yandex.ru", "+10000000006", sender.LastCode!, "Иван"), + CancellationToken.None); + + Assert.IsType(sessionResult.Result); + } + + [Fact] + public async Task RequestPhoneCode_RejectsUnknownPhone() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var controller = CreateController(dbContext, new EmailAuthChallengeStore(), new RecordingEmailCodeSender()); + + var result = await controller.RequestPhoneCode( + new RequestPhoneCodeRequest("+10000009999"), + CancellationToken.None); + + Assert.IsType(result.Result); + } + + private static AuthController CreateController( + IkarDbContext dbContext, + EmailAuthChallengeStore emailChallengeStore, + IEmailCodeSender emailCodeSender) + { + var controller = new AuthController( + dbContext, + new TokenService(dbContext, Options.Create(new JwtOptions())), + new PresenceTracker(), + new PhoneAuthChallengeStore(), + emailChallengeStore, + emailCodeSender, + new SuccessfulPushNotificationSender()); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + return controller; + } + + private sealed class RecordingEmailCodeSender : IEmailCodeSender + { + public string? LastCode { get; private set; } + + public Task SendLoginCodeAsync( + string email, + string code, + DateTimeOffset expiresAt, + CancellationToken cancellationToken = default) + { + LastCode = code; + return Task.CompletedTask; + } + } + + private sealed class SuccessfulPushNotificationSender : IPushNotificationSender + { + public Task SendMessageAsync(Chat chat, Message message, IReadOnlyCollection devices, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task SendMessageReactionAsync(Chat chat, Message message, User reactor, string emoji, IReadOnlyCollection devices, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task SendIncomingCallAsync(IncomingCallDto call, IReadOnlyCollection devices, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task SendCallEndedAsync(CallEndedDto call, IReadOnlyCollection devices, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task SendAppUpdateAvailableAsync(AndroidAppReleaseDto release, IReadOnlyCollection devices, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task SendLoginCodeAsync(string code, string challengeId, DateTimeOffset expiresAt, IReadOnlyCollection devices, CancellationToken cancellationToken = default) => Task.FromResult(true); + } +} diff --git a/ikar_server/src/Ikar.Server.Tests/BotApiControllerTests.cs b/ikar_server/src/Ikar.Server.Tests/BotApiControllerTests.cs new file mode 100644 index 0000000..2ec7122 --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/BotApiControllerTests.cs @@ -0,0 +1,790 @@ +using Ikar.Server.Controllers; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Bots; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Push; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Shared; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.FileProviders; + +namespace Ikar.Server.Tests; + +public sealed class BotApiControllerTests +{ + [Fact] + public async Task SendDocument_DecodesPercentEncodedFileName() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("documentbot", isBot: true); + var tokenService = new BotTokenService(); + var token = "bot-token"; + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = tokenService.HashToken(token) + }; + var chat = CreateDirectChat(owner, botUser); + + dbContext.AddRange(owner, botUser, bot, chat); + await dbContext.SaveChangesAsync(); + + await using var stream = new MemoryStream("%PDF-1.3"u8.ToArray()); + var encodedFileName = "Fluke_8508A_" + Uri.EscapeDataString("методика.pdf"); + var file = new FormFile(stream, 0, stream.Length, "file", encodedFileName) + { + Headers = new HeaderDictionary(), + ContentType = "application/pdf" + }; + var controller = CreateController(dbContext, tokenService, tempRoot); + + var response = await controller.SendDocument( + token, + new BotSendAttachmentRequest + { + ChatId = chat.Id, + Text = "", + File = file + }, + CancellationToken.None); + + var message = ControllerTestFixture.ValueOf(response); + var attachment = Assert.Single(message.Attachments); + Assert.Equal("Fluke_8508A_методика.pdf", attachment.FileName); + Assert.Equal("application/pdf", attachment.ContentType); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task SendDocument_SavesOggAsVoiceNoteAttachment() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("documentbot", isBot: true); + var tokenService = new BotTokenService(); + var token = "bot-token"; + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = tokenService.HashToken(token) + }; + var chat = CreateDirectChat(owner, botUser); + + dbContext.AddRange(owner, botUser, bot, chat); + await dbContext.SaveChangesAsync(); + + await using var stream = new MemoryStream([1, 2, 3, 4]); + var file = new FormFile(stream, 0, stream.Length, "file", "song.ogg") + { + Headers = new HeaderDictionary(), + ContentType = "audio/ogg" + }; + var controller = CreateController(dbContext, tokenService, tempRoot); + + var response = await controller.SendDocument( + token, + new BotSendAttachmentRequest + { + ChatId = chat.Id, + Text = "", + File = file + }, + CancellationToken.None); + + var message = ControllerTestFixture.ValueOf(response); + var attachment = Assert.Single(message.Attachments); + Assert.Equal(AttachmentKind.VoiceNote, attachment.Kind); + Assert.Equal("audio/ogg", attachment.ContentType); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task SendPhoto_SavesImageAttachment() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("photobot", isBot: true); + var tokenService = new BotTokenService(); + var token = "bot-token"; + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = tokenService.HashToken(token) + }; + var chat = CreateDirectChat(owner, botUser); + + dbContext.AddRange(owner, botUser, bot, chat); + await dbContext.SaveChangesAsync(); + + await using var stream = new MemoryStream([0xFF, 0xD8, 0xFF, 0xD9]); + var file = new FormFile(stream, 0, stream.Length, "file", "photo.jpg") + { + Headers = new HeaderDictionary(), + ContentType = "image/jpeg" + }; + var controller = CreateController(dbContext, tokenService, tempRoot); + + var response = await controller.SendPhoto( + token, + new BotSendAttachmentRequest + { + ChatId = chat.Id, + Text = "caption", + File = file + }, + CancellationToken.None); + + var message = ControllerTestFixture.ValueOf(response); + Assert.Equal("caption", message.Text); + var attachment = Assert.Single(message.Attachments); + Assert.Equal(AttachmentKind.File, attachment.Kind); + Assert.Equal("image/jpeg", attachment.ContentType); + Assert.Equal("photo.jpg", attachment.FileName); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task SendPhoto_RejectsVideoAttachment() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("photobot", isBot: true); + var tokenService = new BotTokenService(); + var token = "bot-token"; + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = tokenService.HashToken(token) + }; + var chat = CreateDirectChat(owner, botUser); + + dbContext.AddRange(owner, botUser, bot, chat); + await dbContext.SaveChangesAsync(); + + await using var stream = new MemoryStream([1, 2, 3, 4]); + var file = new FormFile(stream, 0, stream.Length, "file", "clip.mp4") + { + Headers = new HeaderDictionary(), + ContentType = "video/mp4" + }; + var controller = CreateController(dbContext, tokenService, tempRoot); + + var response = await controller.SendPhoto( + token, + new BotSendAttachmentRequest + { + ChatId = chat.Id, + Text = "", + File = file + }, + CancellationToken.None); + + var badRequest = Assert.IsType(response.Result); + Assert.Equal("sendPhoto accepts only image files.", badRequest.Value); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task SendVideo_SavesVideoAttachment() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("videobot", isBot: true); + var tokenService = new BotTokenService(); + var token = "bot-token"; + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = tokenService.HashToken(token) + }; + var chat = CreateDirectChat(owner, botUser); + + dbContext.AddRange(owner, botUser, bot, chat); + await dbContext.SaveChangesAsync(); + + await using var stream = new MemoryStream([0, 0, 0, 24, 102, 116, 121, 112]); + var file = new FormFile(stream, 0, stream.Length, "file", "clip.mp4") + { + Headers = new HeaderDictionary(), + ContentType = "video/mp4" + }; + var controller = CreateController(dbContext, tokenService, tempRoot); + + var response = await controller.SendVideo( + token, + new BotSendAttachmentRequest + { + ChatId = chat.Id, + Text = "clip", + File = file + }, + CancellationToken.None); + + var message = ControllerTestFixture.ValueOf(response); + Assert.Equal("clip", message.Text); + var attachment = Assert.Single(message.Attachments); + Assert.Equal(AttachmentKind.File, attachment.Kind); + Assert.Equal("video/mp4", attachment.ContentType); + Assert.Equal("clip.mp4", attachment.FileName); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task SendMediaGroup_SavesImageAndVideoAlbum() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("albumbot", isBot: true); + var tokenService = new BotTokenService(); + var token = "bot-token"; + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = tokenService.HashToken(token) + }; + var chat = CreateDirectChat(owner, botUser); + + dbContext.AddRange(owner, botUser, bot, chat); + await dbContext.SaveChangesAsync(); + + var photo = CreateFormFile([0xFF, 0xD8, 0xFF, 0xD9], "photo.jpg", "image/jpeg"); + var video = CreateFormFile([0, 0, 0, 24, 102, 116, 121, 112], "clip.mp4", "video/mp4"); + var controller = CreateController(dbContext, tokenService, tempRoot); + + var response = await controller.SendMediaGroup( + token, + new BotSendMediaGroupRequest + { + ChatId = chat.Id, + Text = "album", + Files = [photo, video] + }, + CancellationToken.None); + + var message = ControllerTestFixture.ValueOf(response); + Assert.Equal("album", message.Text); + Assert.NotNull(message.MediaAlbumId); + Assert.Equal(2, message.Attachments.Count); + Assert.Equal("photo.jpg", message.Attachments[0].FileName); + Assert.Equal(0, message.Attachments[0].SortOrder); + Assert.Equal("clip.mp4", message.Attachments[1].FileName); + Assert.Equal(1, message.Attachments[1].SortOrder); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task SendMediaGroup_RejectsNonMediaAttachment() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("albumbot", isBot: true); + var tokenService = new BotTokenService(); + var token = "bot-token"; + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = tokenService.HashToken(token) + }; + var chat = CreateDirectChat(owner, botUser); + + dbContext.AddRange(owner, botUser, bot, chat); + await dbContext.SaveChangesAsync(); + + var photo = CreateFormFile([0xFF, 0xD8, 0xFF, 0xD9], "photo.jpg", "image/jpeg"); + var document = CreateFormFile([0x25, 0x50, 0x44, 0x46], "manual.pdf", "application/pdf"); + var controller = CreateController(dbContext, tokenService, tempRoot); + + var response = await controller.SendMediaGroup( + token, + new BotSendMediaGroupRequest + { + ChatId = chat.Id, + Files = [photo, document] + }, + CancellationToken.None); + + var badRequest = Assert.IsType(response.Result); + Assert.Equal("sendMediaGroup accepts only image and video files.", badRequest.Value); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task SendVoice_SavesOctetStreamOggNameAsVoiceNoteAttachment() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("voicebot", isBot: true); + var tokenService = new BotTokenService(); + var token = "bot-token"; + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = tokenService.HashToken(token) + }; + var chat = CreateDirectChat(owner, botUser); + + dbContext.AddRange(owner, botUser, bot, chat); + await dbContext.SaveChangesAsync(); + + await using var stream = new MemoryStream([1, 2, 3, 4, 5]); + var file = new FormFile(stream, 0, stream.Length, "file", "Правила ogg") + { + Headers = new HeaderDictionary(), + ContentType = "application/octet-stream" + }; + var controller = CreateController(dbContext, tokenService, tempRoot); + + var response = await controller.SendVoice( + token, + new BotSendAttachmentRequest + { + ChatId = chat.Id, + Text = "", + File = file + }, + CancellationToken.None); + + var message = ControllerTestFixture.ValueOf(response); + var attachment = Assert.Single(message.Attachments); + Assert.Equal(AttachmentKind.VoiceNote, attachment.Kind); + Assert.Equal("audio/ogg", attachment.ContentType); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task SendMessage_ChannelWithLinkedDiscussion_CreatesDiscussionRoot() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("newsbot", isBot: true); + var tokenService = new BotTokenService(); + var token = "bot-token"; + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = tokenService.HashToken(token) + }; + var group = CreateGroup(owner, botUser, canBotPost: true); + var channel = CreateChannel(owner, botUser, group); + + dbContext.AddRange(owner, botUser, bot, group, channel); + await dbContext.SaveChangesAsync(); + + var controller = CreateController(dbContext, tokenService, tempRoot); + var response = await controller.SendMessage( + token, + new BotSendMessageRequest(channel.Id, "Bot channel post"), + CancellationToken.None); + + var postDto = ControllerTestFixture.ValueOf(response); + Assert.NotNull(postDto.DiscussionMessageId); + + var discussionRoot = dbContext.Messages.Single(x => x.Id == postDto.DiscussionMessageId); + Assert.Equal(group.Id, discussionRoot.ChatId); + Assert.Equal("Bot channel post", discussionRoot.Text); + Assert.Equal(channel.Title, discussionRoot.ForwardedFromDisplayName); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task SendVoice_ChannelWithLinkedDiscussion_MirrorsVoiceAttachmentToDiscussionRoot() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("voicebot", isBot: true); + var tokenService = new BotTokenService(); + var token = "bot-token"; + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = tokenService.HashToken(token) + }; + var group = CreateGroup(owner, botUser, canBotPost: true); + var channel = CreateChannel(owner, botUser, group); + + dbContext.AddRange(owner, botUser, bot, group, channel); + await dbContext.SaveChangesAsync(); + + var file = CreateFormFile([1, 2, 3, 4], "rules.ogg", "application/octet-stream"); + var controller = CreateController(dbContext, tokenService, tempRoot); + var response = await controller.SendVoice( + token, + new BotSendAttachmentRequest + { + ChatId = channel.Id, + Text = "Rules", + File = file + }, + CancellationToken.None); + + var postDto = ControllerTestFixture.ValueOf(response); + Assert.NotNull(postDto.DiscussionMessageId); + + var discussionRoot = dbContext.Messages + .Include(x => x.Attachments) + .Single(x => x.Id == postDto.DiscussionMessageId); + var mirroredAttachment = Assert.Single(discussionRoot.Attachments); + Assert.Equal(group.Id, discussionRoot.ChatId); + Assert.Equal("Rules", discussionRoot.Text); + Assert.Equal(AttachmentKind.VoiceNote, mirroredAttachment.Kind); + Assert.Equal("audio/ogg", mirroredAttachment.ContentType); + Assert.Equal("rules.ogg", mirroredAttachment.OriginalFileName); + Assert.True(File.Exists(mirroredAttachment.StoragePath)); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task SendMessage_DeniesRestrictedGroupBot() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("restrictedbot", isBot: true); + var tokenService = new BotTokenService(); + var token = "bot-token"; + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = tokenService.HashToken(token) + }; + var group = CreateGroup(owner, botUser, canBotPost: false); + + dbContext.AddRange(owner, botUser, bot, group); + await dbContext.SaveChangesAsync(); + + var controller = CreateController(dbContext, tokenService, tempRoot); + + var response = await controller.SendMessage( + token, + new BotSendMessageRequest(group.Id, "test"), + CancellationToken.None); + + var forbidden = Assert.IsType(response.Result); + Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + private static BotApiController CreateController( + IkarDbContext dbContext, + BotTokenService tokenService, + string contentRootPath) => + new( + dbContext, + new PresenceTracker(), + tokenService, + new BotUpdateService(dbContext), + new NoopHubContext(), + new PushDispatchQueue(), + new AttachmentStorageService(new TestWebHostEnvironment(contentRootPath))); + + private static FormFile CreateFormFile(byte[] content, string fileName, string contentType) + { + var stream = new MemoryStream(content); + return new FormFile(stream, 0, stream.Length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + } + + private static User CreateUser(string username, bool isBot = false) => + new() + { + Id = Guid.NewGuid(), + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = username, + PasswordHash = "test", + IsBot = isBot + }; + + private static Chat CreateChannel(User owner, User botUser, Chat linkedDiscussionChat) + { + var channel = new Chat + { + Id = Guid.NewGuid(), + Type = ChatType.Channel, + Title = "News", + CreatedById = owner.Id, + CreatedBy = owner, + LinkedDiscussionChatId = linkedDiscussionChat.Id, + CreatedAt = new DateTimeOffset(2026, 5, 21, 12, 0, 0, TimeSpan.Zero) + }; + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = owner.Id, + User = owner, + IsOwner = true, + Role = ChatMemberRole.Owner, + CanPostMessages = true, + CanDeleteMessages = true, + CanInviteUsers = true, + CanPinMessages = true + }); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = botUser.Id, + User = botUser, + Role = ChatMemberRole.Admin, + CanPostMessages = true + }); + return channel; + } + + private static Chat CreateDirectChat(User first, User second) + { + var chat = new Chat + { + Id = Guid.NewGuid(), + Type = ChatType.Direct, + CreatedById = first.Id, + CreatedBy = first, + CreatedAt = new DateTimeOffset(2026, 5, 21, 12, 0, 0, TimeSpan.Zero) + }; + chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = first.Id, User = first }); + chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = second.Id, User = second }); + return chat; + } + + private static Chat CreateGroup(User owner, User botUser, bool canBotPost) + { + var chat = new Chat + { + Id = Guid.NewGuid(), + Type = ChatType.Group, + Title = "Group", + CreatedById = owner.Id, + CreatedBy = owner, + CreatedAt = new DateTimeOffset(2026, 5, 21, 12, 0, 0, TimeSpan.Zero) + }; + chat.Members.Add(new ChatMember + { + ChatId = chat.Id, + Chat = chat, + UserId = owner.Id, + User = owner, + IsOwner = true, + Role = ChatMemberRole.Owner, + CanPostMessages = true, + CanDeleteMessages = true, + CanInviteUsers = true, + CanPinMessages = true + }); + chat.Members.Add(new ChatMember + { + ChatId = chat.Id, + Chat = chat, + UserId = botUser.Id, + User = botUser, + Role = ChatMemberRole.Member, + CanPostMessages = canBotPost + }); + return chat; + } + + private sealed class TestWebHostEnvironment(string contentRootPath) : IWebHostEnvironment + { + public string ApplicationName { get; set; } = "Ikar.Server.Tests"; + public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider(); + public string WebRootPath { get; set; } = contentRootPath; + public string EnvironmentName { get; set; } = "Development"; + public string ContentRootPath { get; set; } = contentRootPath; + public IFileProvider ContentRootFileProvider { get; set; } = new PhysicalFileProvider(contentRootPath); + } +} diff --git a/ikar_server/src/Ikar.Server.Tests/BotCreatorServiceTests.cs b/ikar_server/src/Ikar.Server.Tests/BotCreatorServiceTests.cs new file mode 100644 index 0000000..6327cdd --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/BotCreatorServiceTests.cs @@ -0,0 +1,106 @@ +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Bots; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Push; +using Ikar.Shared; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Tests; + +public sealed class BotCreatorServiceTests +{ + [Fact] + public async Task TryHandleIncomingMessage_RussianCreateCommand_CreatesBot() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var service = CreateService(dbContext); + await service.EnsureCreatorBotAsync(); + + var owner = CreateUser("owner", "Owner"); + dbContext.Users.Add(owner); + await dbContext.SaveChangesAsync(); + + var creatorUser = await dbContext.Users.SingleAsync(x => x.NormalizedUsername == BotCreatorService.CreatorUsername.ToUpperInvariant()); + var chat = new Chat + { + Type = ChatType.Direct, + CreatedById = owner.Id, + LastActivityAt = DateTimeOffset.UtcNow, + Members = + [ + new ChatMember { UserId = owner.Id, IsOwner = true, Role = ChatMemberRole.Owner }, + new ChatMember { UserId = creatorUser.Id } + ] + }; + var message = new Message + { + Chat = chat, + Sender = owner, + Text = "/создать_бота Эхо | echobot | тестовый бот", + SentAt = DateTimeOffset.UtcNow + }; + chat.Messages.Add(message); + dbContext.Chats.Add(chat); + await dbContext.SaveChangesAsync(); + + var handled = await service.TryHandleIncomingMessageAsync(message.Id); + + Assert.True(handled); + var createdBot = await dbContext.Bots + .Include(x => x.User) + .SingleOrDefaultAsync(x => x.User.NormalizedUsername == "ECHOBOT"); + Assert.NotNull(createdBot); + Assert.Equal(owner.Id, createdBot.OwnerUserId); + Assert.Contains(dbContext.Messages, x => x.ChatId == chat.Id && x.SenderId == creatorUser.Id && x.Text.Contains("Токен")); + } + + [Fact] + public async Task UsersSearch_FindsCreatorBot() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var service = CreateService(dbContext); + await service.EnsureCreatorBotAsync(); + + var owner = CreateUser("owner", "Owner"); + dbContext.Users.Add(owner); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateUsersController(dbContext, owner.Id); + var response = await controller.Search("botfatherbot", CancellationToken.None); + + var users = ControllerTestFixture.ValueOf(response); + var creator = Assert.Single(users); + Assert.True(creator.IsBot); + Assert.Equal(BotCreatorService.CreatorUsername, creator.Username); + Assert.Equal("Создатель ботов", creator.DisplayName); + } + + private static BotCreatorService CreateService(Ikar.Server.Data.IkarDbContext dbContext) + { + var pushQueue = new PushDispatchQueue(); + return new BotCreatorService( + dbContext, + new BotTokenService(), + new BotDeletionService(dbContext, ControllerTestFixture.CreateAttachmentStorageService(Path.GetTempPath())), + new PresenceTracker(), + new NoopHubContext(), + pushQueue); + } + + private static User CreateUser(string username, string displayName) + { + var user = new User + { + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = displayName + }; + user.PasswordHash = new PasswordHasher().HashPassword(user, "password"); + return user; + } +} diff --git a/ikar_server/src/Ikar.Server.Tests/BotUpdateServiceTests.cs b/ikar_server/src/Ikar.Server.Tests/BotUpdateServiceTests.cs new file mode 100644 index 0000000..fcb62da --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/BotUpdateServiceTests.cs @@ -0,0 +1,141 @@ +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Bots; +using Ikar.Shared; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Tests; + +public sealed class BotUpdateServiceTests +{ + [Fact] + public async Task EnqueueIncomingMessageAsync_CreatesUpdateForGroupBotMember() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var botUser = CreateUser("groupbot", isBot: true); + var bot = CreateBot(owner, botUser); + var group = CreateChat(ChatType.Group, owner, botUser, botCanPost: false); + var message = CreateMessage(group, owner, "hello group bot"); + dbContext.AddRange(owner, botUser, bot, group, message); + await dbContext.SaveChangesAsync(); + + var service = new BotUpdateService(dbContext); + await service.EnqueueIncomingMessageAsync(message.Id, CancellationToken.None); + + var update = await dbContext.BotUpdates.SingleAsync(); + Assert.Equal(bot.Id, update.BotId); + Assert.Equal(group.Id, update.ChatId); + Assert.Equal(message.Id, update.MessageId); + } + + [Fact] + public async Task EnqueueIncomingMessageAsync_CreatesUpdateForChannelAdminBotOnly() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var adminBotUser = CreateUser("adminbot", isBot: true); + var subscriberBotUser = CreateUser("subscriberbot", isBot: true); + var adminBot = CreateBot(owner, adminBotUser); + var subscriberBot = CreateBot(owner, subscriberBotUser); + var channel = CreateChat(ChatType.Channel, owner, adminBotUser, botCanPost: true); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = subscriberBotUser.Id, + User = subscriberBotUser, + Role = ChatMemberRole.Member, + CanPostMessages = false + }); + var message = CreateMessage(channel, owner, "channel post"); + dbContext.AddRange(owner, adminBotUser, subscriberBotUser, adminBot, subscriberBot, channel, message); + await dbContext.SaveChangesAsync(); + + var service = new BotUpdateService(dbContext); + await service.EnqueueIncomingMessageAsync(message.Id, CancellationToken.None); + + var update = await dbContext.BotUpdates.SingleAsync(); + Assert.Equal(adminBot.Id, update.BotId); + Assert.DoesNotContain(dbContext.BotUpdates, x => x.BotId == subscriberBot.Id); + } + + private static Bot CreateBot(User owner, User botUser) => + new() + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = $"token-{botUser.Username}", + IsEnabled = true + }; + + private static User CreateUser(string username, bool isBot = false) => + new() + { + Id = Guid.NewGuid(), + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = username, + PasswordHash = "test", + IsBot = isBot + }; + + private static Chat CreateChat(ChatType type, User owner, User botUser, bool botCanPost) + { + var chat = new Chat + { + Id = Guid.NewGuid(), + Type = type, + Title = type == ChatType.Channel ? "Channel" : "Group", + CreatedById = owner.Id, + CreatedBy = owner, + CreatedAt = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero) + }; + chat.Members.Add(new ChatMember + { + ChatId = chat.Id, + Chat = chat, + UserId = owner.Id, + User = owner, + IsOwner = true, + Role = ChatMemberRole.Owner, + CanPostMessages = true, + CanDeleteMessages = true, + CanInviteUsers = true, + CanPinMessages = true + }); + chat.Members.Add(new ChatMember + { + ChatId = chat.Id, + Chat = chat, + UserId = botUser.Id, + User = botUser, + Role = botCanPost ? ChatMemberRole.Admin : ChatMemberRole.Member, + CanPostMessages = botCanPost, + CanDeleteMessages = botCanPost, + CanInviteUsers = botCanPost, + CanPinMessages = botCanPost + }); + return chat; + } + + private static Message CreateMessage(Chat chat, User sender, string text) + { + var message = new Message + { + Id = Guid.NewGuid(), + ChatId = chat.Id, + Chat = chat, + SenderId = sender.Id, + Sender = sender, + Text = text, + SentAt = new DateTimeOffset(2026, 6, 1, 12, 5, 0, TimeSpan.Zero) + }; + chat.Messages.Add(message); + return message; + } +} diff --git a/ikar_server/src/Ikar.Server.Tests/BotsControllerTests.cs b/ikar_server/src/Ikar.Server.Tests/BotsControllerTests.cs new file mode 100644 index 0000000..05aacf1 --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/BotsControllerTests.cs @@ -0,0 +1,168 @@ +using Ikar.Server.Data.Entities; +using Ikar.Shared; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Tests; + +public sealed class BotsControllerTests +{ + [Fact] + public async Task CreateBot_ReturnsGoneBecauseBotsAreCreatedThroughCreatorBot() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + dbContext.Users.Add(owner); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateBotsController(dbContext, owner.Id); + var response = controller.CreateBot(new CreateBotRequest("helperbot", "Helper", "test bot")); + + var result = Assert.IsType(response.Result); + Assert.Equal(410, result.StatusCode); + var message = Assert.IsType(result.Value); + Assert.Contains("@botfatherbot", message); + Assert.Contains("/создать_бота", message); + Assert.False(await dbContext.Bots.AnyAsync()); + Assert.False(await dbContext.Users.AnyAsync(x => x.IsBot)); + } + + [Fact] + public async Task DeleteBot_RemovesBotUserAndDirectChatHistory() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("helperbot", isBot: true); + botUser.Sessions.Add(new UserSession + { + UserId = botUser.Id, + User = botUser, + TokenHash = "bot-session" + }); + botUser.PushDevices.Add(new PushDevice + { + UserId = botUser.Id, + User = botUser, + Platform = PushPlatform.Android, + InstallationId = "bot-installation", + DeviceToken = "bot-device-token", + NotificationsEnabled = true + }); + + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = "bot-token" + }; + var command = new BotCommand + { + BotId = bot.Id, + Bot = bot, + Command = "help", + Description = "Show help" + }; + + var chat = CreateDirectChat(owner, botUser); + var message = new Message + { + Id = Guid.NewGuid(), + ChatId = chat.Id, + Chat = chat, + SenderId = botUser.Id, + Sender = botUser, + Text = "history", + SentAt = new DateTimeOffset(2026, 5, 17, 12, 0, 0, TimeSpan.Zero) + }; + chat.Messages.Add(message); + var update = new BotUpdate + { + BotId = bot.Id, + Bot = bot, + ChatId = chat.Id, + Chat = chat, + MessageId = message.Id, + Message = message, + CreatedAt = message.SentAt + }; + + dbContext.AddRange(owner, botUser, bot, command, chat, update); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateBotsController(dbContext, owner.Id); + var response = await controller.DeleteBot(bot.Id, CancellationToken.None); + + Assert.IsType(response); + Assert.False(await dbContext.Bots.AnyAsync(x => x.Id == bot.Id)); + Assert.False(await dbContext.BotCommands.AnyAsync(x => x.BotId == bot.Id)); + Assert.False(await dbContext.BotUpdates.AnyAsync(x => x.BotId == bot.Id)); + Assert.False(await dbContext.UserSessions.AnyAsync(x => x.UserId == botUser.Id)); + Assert.False(await dbContext.PushDevices.AnyAsync(x => x.UserId == botUser.Id)); + + Assert.False(await dbContext.Users.AnyAsync(x => x.Id == botUser.Id)); + Assert.False(await dbContext.Chats.AnyAsync(x => x.Id == chat.Id)); + Assert.False(await dbContext.Messages.AnyAsync(x => x.Id == message.Id)); + } + + [Fact] + public async Task DeleteBot_ReturnsNotFoundForBotOwnedByAnotherUser() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var otherUser = CreateUser("other"); + var botUser = CreateUser("otherbot", isBot: true); + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = "other-bot-token" + }; + + dbContext.AddRange(owner, otherUser, botUser, bot); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateBotsController(dbContext, otherUser.Id); + var response = await controller.DeleteBot(bot.Id, CancellationToken.None); + + Assert.IsType(response); + Assert.True(await dbContext.Bots.AnyAsync(x => x.Id == bot.Id)); + } + + private static User CreateUser(string username, bool isBot = false) => + new() + { + Id = Guid.NewGuid(), + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = username, + PasswordHash = "test", + IsBot = isBot + }; + + private static Chat CreateDirectChat(User first, User second) + { + var chat = new Chat + { + Id = Guid.NewGuid(), + Type = ChatType.Direct, + CreatedById = first.Id, + CreatedBy = first, + CreatedAt = new DateTimeOffset(2026, 5, 17, 11, 0, 0, TimeSpan.Zero) + }; + chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = first.Id, User = first }); + chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = second.Id, User = second }); + return chat; + } +} diff --git a/ikar_server/src/Ikar.Server.Tests/ChannelDtoMapperTests.cs b/ikar_server/src/Ikar.Server.Tests/ChannelDtoMapperTests.cs new file mode 100644 index 0000000..09d7c53 --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/ChannelDtoMapperTests.cs @@ -0,0 +1,269 @@ +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Shared; + +namespace Ikar.Server.Tests; + +public sealed class ChannelDtoMapperTests +{ + [Fact] + public void ChannelDetails_HideOtherSubscribersFromRegularSubscriber() + { + var owner = CreateUser("owner", "Owner"); + var subscriber = CreateUser("subscriber", "Subscriber"); + var hiddenSubscriber = CreateUser("hidden", "Hidden Subscriber"); + var channel = CreateChannel(owner, subscriber, hiddenSubscriber); + + var subscriberDetails = channel.ToDetailsDto(subscriber.Id, new PresenceTracker()); + var ownerDetails = channel.ToDetailsDto(owner.Id, new PresenceTracker()); + + Assert.Equal(3, subscriberDetails.SubscriberCount); + Assert.Equal("Product news and release notes.", subscriberDetails.Description); + Assert.Equal(1, subscriberDetails.AdminCount); + Assert.False(subscriberDetails.CanPostAsChannel); + Assert.False(subscriberDetails.CanSendMessages); + Assert.Contains(subscriberDetails.Members!, member => member.User.Id == owner.Id); + Assert.Contains(subscriberDetails.Members!, member => member.User.Id == subscriber.Id); + Assert.DoesNotContain(subscriberDetails.Members!, member => member.User.Id == hiddenSubscriber.Id); + Assert.Equal(3, ownerDetails.Members!.Count); + Assert.True(ownerDetails.CanPostAsChannel); + } + + [Fact] + public void PublicChannelDetails_AllowPreviewAndSubscribe_ForNonMember() + { + var owner = CreateUser("owner", "Owner"); + var subscriber = CreateUser("subscriber", "Subscriber"); + var viewer = CreateUser("viewer", "Viewer"); + var channel = CreateChannel(owner, subscriber); + channel.PublicUsername = "ikar_updates"; + + var details = channel.ToDetailsDto(viewer.Id, new PresenceTracker()); + + Assert.Equal(2, details.SubscriberCount); + Assert.False(details.IsSubscribed); + Assert.True(details.CanSubscribe); + Assert.False(details.CanSendMessages); + Assert.False(details.CanPostAsChannel); + var visibleMember = Assert.Single(details.Members!); + Assert.Equal(owner.Id, visibleMember.User.Id); + } + + [Fact] + public void PublicChannelPreview_HidesContactOnlyProfileFields_ForNonMember() + { + var owner = CreateUser("owner", "Owner"); + owner.PhoneNumber = "+10000000001"; + owner.AvatarStoragePath = "owner-avatar.png"; + owner.PrivacySettings = new UserPrivacySettings + { + UserId = owner.Id, + User = owner, + PhoneNumberVisibility = "contacts", + LastSeenVisibility = "contacts", + ProfilePhotoVisibility = "contacts" + }; + owner.Sessions.Add(new UserSession + { + Id = Guid.NewGuid(), + UserId = owner.Id, + User = owner, + LastSeenAt = DateTimeOffset.UtcNow.AddMinutes(-5) + }); + var viewer = CreateUser("viewer", "Viewer"); + var channel = CreateChannel(owner); + channel.PublicUsername = "ikar_updates"; + + var details = channel.ToDetailsDto(viewer.Id, new PresenceTracker()); + + var visibleMember = Assert.Single(details.Members!); + Assert.Equal(owner.Id, visibleMember.User.Id); + Assert.Null(visibleMember.User.PhoneNumber); + Assert.Null(visibleMember.User.LastSeenAt); + Assert.Null(visibleMember.User.AvatarPath); + } + + [Fact] + public void ChannelDetails_ShowsContactOnlyProfileFields_ForSubscriber() + { + var owner = CreateUser("owner", "Owner"); + owner.PhoneNumber = "+10000000001"; + owner.AvatarStoragePath = "owner-avatar.png"; + owner.PrivacySettings = new UserPrivacySettings + { + UserId = owner.Id, + User = owner, + PhoneNumberVisibility = "contacts", + LastSeenVisibility = "contacts", + ProfilePhotoVisibility = "contacts" + }; + owner.Sessions.Add(new UserSession + { + Id = Guid.NewGuid(), + UserId = owner.Id, + User = owner, + LastSeenAt = DateTimeOffset.UtcNow.AddMinutes(-5) + }); + var subscriber = CreateUser("subscriber", "Subscriber"); + var channel = CreateChannel(owner, subscriber); + + var details = channel.ToDetailsDto(subscriber.Id, new PresenceTracker()); + + var ownerMember = Assert.Single(details.Members!, member => member.User.Id == owner.Id); + Assert.Equal(owner.PhoneNumber, ownerMember.User.PhoneNumber); + Assert.NotNull(ownerMember.User.LastSeenAt); + Assert.NotNull(ownerMember.User.AvatarPath); + } + + [Fact] + public void ChannelPost_MasksSenderForRegularSubscriber_AndKeepsViewCount() + { + var owner = CreateUser("owner", "Owner"); + var subscriber = CreateUser("subscriber", "Subscriber"); + var channel = CreateChannel(owner, subscriber); + var post = new Message + { + Id = Guid.NewGuid(), + ChatId = channel.Id, + Chat = channel, + SenderId = owner.Id, + Sender = owner, + Text = "Channel post", + PostedAsChannel = true, + SentAt = DateTimeOffset.UtcNow + }; + post.Views.Add(new MessageView + { + MessageId = post.Id, + ViewerUserId = subscriber.Id, + ViewedAt = DateTimeOffset.UtcNow + }); + + var subscriberDto = post.ToDto(new PresenceTracker(), subscriber.Id); + var ownerDto = post.ToDto(new PresenceTracker(), owner.Id); + + Assert.True(subscriberDto.PostedAsChannel); + Assert.Equal(channel.Id, subscriberDto.Sender.Id); + Assert.Equal("Ikar Updates", subscriberDto.DisplayAuthorName); + Assert.Equal(1, subscriberDto.ViewCount); + Assert.Equal(owner.Id, ownerDto.Sender.Id); + } + + [Fact] + public void MessageAttachment_MapsExplicitKindToVoiceNote() + { + var owner = CreateUser("owner", "Owner"); + var channel = CreateChannel(owner); + var message = new Message + { + Id = Guid.NewGuid(), + ChatId = channel.Id, + Chat = channel, + SenderId = owner.Id, + Sender = owner, + Text = string.Empty, + SentAt = DateTimeOffset.UtcNow + }; + message.Attachments.Add(new MessageAttachment + { + Id = Guid.NewGuid(), + MessageId = message.Id, + Message = message, + OriginalFileName = "VOICE.OGG", + ContentType = "application/ogg", + Kind = AttachmentKind.VoiceNote, + StoragePath = "VOICE.OGG", + FileSizeBytes = 4096 + }); + + var dto = message.ToDto(new PresenceTracker(), owner.Id); + var attachment = Assert.Single(dto.Attachments); + Assert.Equal(AttachmentKind.VoiceNote, attachment.Kind); + } + + [Fact] + public void MessageAttachment_InfersVoiceNoteFromOggLikeFileName() + { + var owner = CreateUser("owner", "Owner"); + var channel = CreateChannel(owner); + var message = new Message + { + Id = Guid.NewGuid(), + ChatId = channel.Id, + Chat = channel, + SenderId = owner.Id, + Sender = owner, + Text = string.Empty, + SentAt = DateTimeOffset.UtcNow + }; + message.Attachments.Add(new MessageAttachment + { + Id = Guid.NewGuid(), + MessageId = message.Id, + Message = message, + OriginalFileName = "Правила ogg", + ContentType = "application/octet-stream", + StoragePath = "rules", + FileSizeBytes = 4096 + }); + + var dto = message.ToDto(new PresenceTracker(), owner.Id); + var attachment = Assert.Single(dto.Attachments); + Assert.Equal(AttachmentKind.VoiceNote, attachment.Kind); + } + + private static Chat CreateChannel(User owner, params User[] subscribers) + { + var channel = new Chat + { + Id = Guid.NewGuid(), + Type = ChatType.Channel, + Title = "Ikar Updates", + Description = "Product news and release notes.", + CreatedById = owner.Id, + CreatedBy = owner, + CreatedAt = DateTimeOffset.UtcNow + }; + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = owner.Id, + User = owner, + IsOwner = true, + Role = ChatMemberRole.Owner, + CanPinMessages = true, + CanDeleteMessages = true, + CanInviteUsers = true, + CanPostMessages = true + }); + + foreach (var subscriber in subscribers) + { + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = subscriber.Id, + User = subscriber, + Role = ChatMemberRole.Member, + CanPinMessages = false, + CanDeleteMessages = false, + CanInviteUsers = false, + CanPostMessages = false + }); + } + + return channel; + } + + private static User CreateUser(string username, string displayName) => + new() + { + Id = Guid.NewGuid(), + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = displayName, + PasswordHash = "test" + }; +} diff --git a/ikar_server/src/Ikar.Server.Tests/ChannelMembersControllerTests.cs b/ikar_server/src/Ikar.Server.Tests/ChannelMembersControllerTests.cs new file mode 100644 index 0000000..0ef23f3 --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/ChannelMembersControllerTests.cs @@ -0,0 +1,1203 @@ +using Ikar.Server.Controllers; +using Ikar.Server.Data.Entities; +using Ikar.Shared; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Tests; + +public sealed class ChannelMembersControllerTests +{ + [Fact] + public async Task GetPublicChannelByUsername_ReturnsPreviewForNonMember() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var viewer = CreateUser("viewer"); + var channel = CreateChannel(owner); + channel.PublicUsername = "newsroom"; + dbContext.AddRange(owner, viewer, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id); + var response = await controller.GetPublicChannelByUsername("@newsroom", CancellationToken.None); + + var details = ControllerTestFixture.ValueOf(response); + Assert.Equal(channel.Id, details.Id); + Assert.Equal(ChatType.Channel, details.Type); + Assert.False(details.IsSubscribed); + Assert.True(details.CanSubscribe); + Assert.Equal("newsroom", details.PublicUsername); + } + + [Fact] + public async Task SearchPage_ReturnsPublicChannelForNonMember() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var viewer = CreateUser("viewer"); + var channel = CreateChannel(owner); + channel.Title = "City News"; + channel.PublicUsername = "city_news"; + channel.Description = "Daily city updates"; + dbContext.AddRange(owner, viewer, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id); + var response = await controller.SearchPage( + q: "city", + filter: null, + type: null, + page: 1, + limit: 20, + date: null, + dateFrom: null, + dateTo: null, + authorId: null, + author: null, + CancellationToken.None); + + var page = ControllerTestFixture.ValueOf(response); + var result = Assert.Single(page.Results, x => x.Chat.Id == channel.Id); + Assert.Null(result.Message); + Assert.Equal(ChatType.Channel, result.Chat.Type); + Assert.Equal("city_news", result.Chat.PublicUsername); + Assert.True(result.Chat.CanSubscribe); + Assert.False(result.Chat.IsSubscribed); + } + + [Fact] + public async Task AddMembers_CanAddBotAsChannelAdmin() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var bot = CreateUser("news_bot", isBot: true); + var channel = CreateChannel(owner); + dbContext.AddRange(owner, bot, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.AddMembers( + channel.Id, + new AddChatMembersRequest([bot.Id], ChatMemberRole.Admin), + CancellationToken.None); + + var details = ControllerTestFixture.ValueOf(response); + var members = details.Members ?? throw new InvalidOperationException("Chat members were not returned."); + var botMember = Assert.Single(members, member => member.User.Id == bot.Id); + Assert.True(botMember.User.IsBot); + Assert.Equal(ChatMemberRole.Admin, botMember.Role); + Assert.True(botMember.Permissions.CanManageMembers); + Assert.True(botMember.Permissions.CanPostMessages); + Assert.True(botMember.Permissions.CanPinMessages); + Assert.True(botMember.Permissions.CanDeleteMessages); + Assert.True(botMember.Permissions.CanInviteUsers); + Assert.Equal(2, details.SubscriberCount); + Assert.Equal(2, details.AdminCount); + } + + [Fact] + public async Task AddMembers_CanAddMemberToGroup() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var member = CreateUser("member"); + var group = CreateGroup(owner); + dbContext.AddRange(owner, member, group); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.AddMembers( + group.Id, + new AddChatMembersRequest([member.Id]), + CancellationToken.None); + + var details = ControllerTestFixture.ValueOf(response); + var addedMember = Assert.Single(details.Members ?? [], x => x.User.Id == member.Id); + Assert.Equal(ChatMemberRole.Member, addedMember.Role); + Assert.False(addedMember.IsOwner); + Assert.True(addedMember.Permissions.CanPostMessages); + Assert.Equal(2, details.SubscriberCount); + } + + [Fact] + public async Task AddMembers_DeniesWhenCandidateBlockedAdmin() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var member = CreateUser("member"); + var group = CreateGroup(owner); + dbContext.AddRange( + owner, + member, + group, + new UserPrivacySettings + { + UserId = member.Id, + User = member, + BlockedUserIds = owner.Id.ToString() + }); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.AddMembers( + group.Id, + new AddChatMembersRequest([member.Id]), + CancellationToken.None); + + var forbidden = Assert.IsType(response.Result); + Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode); + Assert.DoesNotContain(dbContext.ChatMembers, x => x.ChatId == group.Id && x.UserId == member.Id); + } + + [Fact] + public async Task SendMessage_DeniesRestrictedGroupMember() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var member = CreateUser("member"); + var group = CreateGroup(owner); + group.Members.Add(new ChatMember + { + ChatId = group.Id, + Chat = group, + UserId = member.Id, + User = member, + Role = ChatMemberRole.Member, + CanPostMessages = false + }); + dbContext.AddRange(owner, member, group); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, member.Id); + var response = await controller.SendMessage( + group.Id, + new SendMessageRequest("test"), + CancellationToken.None); + + var forbidden = Assert.IsType(response.Result); + Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode); + } + + [Fact] + public async Task RemoveMember_CanRemoveRegularChannelMember() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var member = CreateUser("member"); + var channel = CreateChannel(owner); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = member.Id, + User = member, + Role = ChatMemberRole.Member + }); + dbContext.AddRange(owner, member, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.RemoveMember(channel.Id, member.Id, CancellationToken.None); + + var details = ControllerTestFixture.ValueOf(response); + Assert.DoesNotContain(details.Members ?? [], x => x.User.Id == member.Id); + Assert.False(dbContext.ChatMembers.Any(x => x.ChatId == channel.Id && x.UserId == member.Id)); + Assert.Equal(1, details.SubscriberCount); + } + + [Fact] + public async Task JoinByInviteLink_ReturnsPendingApprovalResultForModeratedInvite() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var requester = CreateUser("requester"); + var channel = CreateChannel(owner); + var invite = new ChatInviteLink + { + ChatId = channel.Id, + Chat = channel, + CreatedById = owner.Id, + CreatedBy = owner, + Token = "moderated-token", + CreatesJoinRequest = true + }; + channel.InviteLinks.Add(invite); + dbContext.AddRange(owner, requester, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, requester.Id); + var response = await controller.JoinByInviteLink(invite.Token, CancellationToken.None); + + var accepted = Assert.IsType(response.Result); + var result = Assert.IsType(accepted.Value); + Assert.Equal(JoinChatResultStatus.PendingApproval, result.Status); + Assert.NotNull(result.JoinRequestId); + Assert.Equal(ChatJoinRequestStatus.Pending, result.JoinRequestStatus); + Assert.Null(result.Chat); + Assert.Single(dbContext.ChatJoinRequests, x => x.ChatId == channel.Id && x.UserId == requester.Id); + } + + [Fact] + public async Task CreateInviteLink_ReturnsExpirationAndUsageLimits() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var channel = CreateChannel(owner); + dbContext.AddRange(owner, channel); + await dbContext.SaveChangesAsync(); + var expiresAt = DateTimeOffset.UtcNow.AddHours(2); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.CreateInviteLink( + channel.Id, + new CreateChatInviteLinkRequest( + Title: "limited", + CreatesJoinRequest: false, + ExpiresAt: expiresAt, + MaxUses: 3, + MemberLimit: 10), + CancellationToken.None); + + var link = ControllerTestFixture.ValueOf(response); + Assert.Equal("limited", link.Title); + Assert.False(link.CreatesJoinRequest); + Assert.Equal(expiresAt, link.ExpiresAt); + Assert.Equal(3, link.MaxUses); + Assert.Equal(0, link.UsesCount); + Assert.Equal(10, link.MemberLimit); + + var stored = Assert.Single(dbContext.ChatInviteLinks, x => x.ChatId == channel.Id); + Assert.Equal(expiresAt, stored.ExpiresAt); + Assert.Equal(3, stored.MaxUses); + Assert.Equal(0, stored.UsesCount); + Assert.Equal(10, stored.MemberLimit); + } + + [Fact] + public async Task JoinByInviteLink_RejectsExpiredLink() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var viewer = CreateUser("viewer"); + var channel = CreateChannel(owner); + var invite = new ChatInviteLink + { + ChatId = channel.Id, + Chat = channel, + CreatedById = owner.Id, + CreatedBy = owner, + Token = "expired-token", + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-1) + }; + channel.InviteLinks.Add(invite); + dbContext.AddRange(owner, viewer, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id); + var response = await controller.JoinByInviteLink(invite.Token, CancellationToken.None); + + var badRequest = Assert.IsType(response.Result); + Assert.Equal("Invite link has expired.", badRequest.Value); + Assert.Equal(0, invite.UsesCount); + Assert.DoesNotContain(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == viewer.Id); + } + + [Fact] + public async Task JoinByInviteLink_IncrementsUsesAndRejectsAfterMaxUses() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var firstViewer = CreateUser("first"); + var secondViewer = CreateUser("second"); + var channel = CreateChannel(owner); + var invite = new ChatInviteLink + { + ChatId = channel.Id, + Chat = channel, + CreatedById = owner.Id, + CreatedBy = owner, + Token = "single-use-token", + MaxUses = 1 + }; + channel.InviteLinks.Add(invite); + dbContext.AddRange(owner, firstViewer, secondViewer, channel); + await dbContext.SaveChangesAsync(); + + var firstController = ControllerTestFixture.CreateChatsController(dbContext, firstViewer.Id); + var firstResponse = await firstController.JoinByInviteLink(invite.Token, CancellationToken.None); + + var firstResult = ControllerTestFixture.ValueOf(firstResponse); + Assert.Equal(JoinChatResultStatus.Joined, firstResult.Status); + Assert.Equal(1, invite.UsesCount); + Assert.Contains(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == firstViewer.Id); + + var secondController = ControllerTestFixture.CreateChatsController(dbContext, secondViewer.Id); + var secondResponse = await secondController.JoinByInviteLink(invite.Token, CancellationToken.None); + + var badRequest = Assert.IsType(secondResponse.Result); + Assert.Equal("Invite link usage limit reached.", badRequest.Value); + Assert.Equal(1, invite.UsesCount); + Assert.DoesNotContain(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == secondViewer.Id); + } + + [Fact] + public async Task JoinByInviteLink_RejectsWhenMemberLimitReached() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var viewer = CreateUser("viewer"); + var channel = CreateChannel(owner); + var invite = new ChatInviteLink + { + ChatId = channel.Id, + Chat = channel, + CreatedById = owner.Id, + CreatedBy = owner, + Token = "full-token", + MemberLimit = 1 + }; + channel.InviteLinks.Add(invite); + dbContext.AddRange(owner, viewer, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id); + var response = await controller.JoinByInviteLink(invite.Token, CancellationToken.None); + + var badRequest = Assert.IsType(response.Result); + Assert.Equal("Chat member limit reached.", badRequest.Value); + Assert.Equal(0, invite.UsesCount); + Assert.DoesNotContain(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == viewer.Id); + } + + [Fact] + public async Task UpdateMemberPermissions_CannotPromoteMemberToOwner() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var member = CreateUser("member"); + var channel = CreateChannel(owner); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = member.Id, + User = member, + IsOwner = false, + Role = ChatMemberRole.Member + }); + dbContext.AddRange(owner, member, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.UpdateMemberPermissions( + channel.Id, + member.Id, + new UpdateChatMemberPermissionsRequest( + ChatMemberRole.Owner, + CanPinMessages: true, + CanDeleteMessages: true, + CanInviteUsers: true, + CanPostMessages: true), + CancellationToken.None); + + var badRequest = Assert.IsType(response.Result); + Assert.Equal("Ownership can only be transferred by a dedicated owner transfer action.", badRequest.Value); + + var members = dbContext.ChatMembers.Where(x => x.ChatId == channel.Id).ToList(); + Assert.Single(members, x => x.IsOwner || x.Role == ChatMemberRole.Owner); + Assert.Equal(ChatMemberRole.Member, Assert.Single(members, x => x.UserId == member.Id).Role); + } + + [Fact] + public async Task UpdateChatState_CanLinkAndClearChannelDiscussionGroup() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var channel = CreateChannel(owner); + var group = CreateGroup(owner); + dbContext.AddRange(owner, channel, group); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + + var linkResponse = await controller.UpdateChatState( + channel.Id, + new ChatStateRequest(LinkedDiscussionChatId: group.Id), + CancellationToken.None); + + var linked = ControllerTestFixture.ValueOf(linkResponse); + Assert.Equal(group.Id, linked.LinkedDiscussionChatId); + Assert.Equal(group.Id, dbContext.Chats.Single(x => x.Id == channel.Id).LinkedDiscussionChatId); + + var clearResponse = await controller.UpdateChatState( + channel.Id, + new ChatStateRequest(ClearLinkedDiscussionChat: true), + CancellationToken.None); + + var cleared = ControllerTestFixture.ValueOf(clearResponse); + Assert.Null(cleared.LinkedDiscussionChatId); + Assert.Null(dbContext.Chats.Single(x => x.Id == channel.Id).LinkedDiscussionChatId); + } + + [Fact] + public async Task SendMessage_ChannelWithLinkedDiscussion_CreatesDiscussionRootAndCountsNestedReplies() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var channel = CreateChannel(owner); + var group = CreateGroup(owner); + channel.LinkedDiscussionChatId = group.Id; + dbContext.AddRange(owner, channel, group); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var postResponse = await controller.SendMessage( + channel.Id, + new SendMessageRequest("Breaking news"), + CancellationToken.None); + + var postDto = ControllerTestFixture.ValueOf(postResponse); + Assert.NotNull(postDto.DiscussionMessageId); + Assert.Equal(0, postDto.CommentCount); + + var rootMessage = dbContext.Messages.Single(x => x.Id == postDto.DiscussionMessageId); + Assert.Equal(group.Id, rootMessage.ChatId); + Assert.Equal("Breaking news", rootMessage.Text); + Assert.Equal(channel.Title, rootMessage.ForwardedFromDisplayName); + + var firstReplyResponse = await controller.SendMessage( + group.Id, + new SendMessageRequest("First comment", ReplyToMessageId: rootMessage.Id), + CancellationToken.None); + var firstReply = ControllerTestFixture.ValueOf(firstReplyResponse); + + await controller.SendMessage( + group.Id, + new SendMessageRequest("Nested comment", ReplyToMessageId: firstReply.Id), + CancellationToken.None); + + var storedPost = dbContext.Messages.Single(x => x.Id == postDto.Id); + Assert.Equal(2, storedPost.CommentCount); + } + + [Fact] + public async Task UploadAttachment_ChannelWithLinkedDiscussion_MirrorsAttachmentToDiscussionRoot() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-channel-upload-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var channel = CreateChannel(owner); + var group = CreateGroup(owner); + channel.LinkedDiscussionChatId = group.Id; + dbContext.AddRange(owner, channel, group); + await dbContext.SaveChangesAsync(); + + var storage = ControllerTestFixture.CreateAttachmentStorageService(tempRoot); + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id, storage); + var response = await controller.UploadAttachment( + channel.Id, + new UploadAttachmentRequest + { + Text = "Photo post", + File = CreateFormFile([0xFF, 0xD8, 0xFF, 0xE0], "photo.jpg", "image/jpeg") + }, + CancellationToken.None); + + var postDto = ControllerTestFixture.ValueOf(response); + Assert.NotNull(postDto.DiscussionMessageId); + + var discussionRoot = dbContext.Messages + .Include(x => x.Attachments) + .Single(x => x.Id == postDto.DiscussionMessageId); + var mirroredAttachment = Assert.Single(discussionRoot.Attachments); + Assert.Equal(group.Id, discussionRoot.ChatId); + Assert.Equal("Photo post", discussionRoot.Text); + Assert.Equal("photo.jpg", mirroredAttachment.OriginalFileName); + Assert.Equal("image/jpeg", mirroredAttachment.ContentType); + Assert.True(File.Exists(mirroredAttachment.StoragePath)); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task UploadAttachmentAlbum_ChannelWithLinkedDiscussion_MirrorsAlbumToDiscussionRoot() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-channel-album-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var channel = CreateChannel(owner); + var group = CreateGroup(owner); + channel.LinkedDiscussionChatId = group.Id; + dbContext.AddRange(owner, channel, group); + await dbContext.SaveChangesAsync(); + + var storage = ControllerTestFixture.CreateAttachmentStorageService(tempRoot); + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id, storage); + var response = await controller.UploadAttachmentAlbum( + channel.Id, + new UploadAttachmentAlbumRequest + { + Text = "Gallery", + Files = + [ + CreateFormFile([0xFF, 0xD8, 0xFF, 0xE0], "first.jpg", "image/jpeg"), + CreateFormFile([0xFF, 0xD8, 0xFF, 0xE1], "second.jpg", "image/jpeg") + ] + }, + CancellationToken.None); + + var postDto = ControllerTestFixture.ValueOf(response); + Assert.NotNull(postDto.DiscussionMessageId); + + var channelPost = dbContext.Messages.Single(x => x.Id == postDto.Id); + var discussionRoot = dbContext.Messages + .Include(x => x.Attachments) + .Single(x => x.Id == postDto.DiscussionMessageId); + Assert.Equal(channelPost.MediaAlbumId, discussionRoot.MediaAlbumId); + Assert.Equal( + new[] { "first.jpg", "second.jpg" }, + discussionRoot.Attachments.OrderBy(x => x.SortOrder).Select(x => x.OriginalFileName)); + Assert.All(discussionRoot.Attachments, attachment => Assert.True(File.Exists(attachment.StoragePath))); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task UpdateMessage_ChannelPostWithLinkedDiscussion_UpdatesDiscussionRootText() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var channel = CreateChannel(owner); + var group = CreateGroup(owner); + channel.LinkedDiscussionChatId = group.Id; + dbContext.AddRange(owner, channel, group); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var postResponse = await controller.SendMessage( + channel.Id, + new SendMessageRequest("Old text"), + CancellationToken.None); + var postDto = ControllerTestFixture.ValueOf(postResponse); + + var updateResponse = await controller.UpdateMessage( + channel.Id, + postDto.Id, + new UpdateMessageRequest("New text"), + CancellationToken.None); + + var updatedDto = ControllerTestFixture.ValueOf(updateResponse); + var discussionRoot = dbContext.Messages.Single(x => x.Id == postDto.DiscussionMessageId); + Assert.Equal("New text", updatedDto.Text); + Assert.Equal("New text", discussionRoot.Text); + Assert.NotNull(discussionRoot.EditedAt); + } + + [Fact] + public async Task DeleteMessage_ChannelPostWithLinkedDiscussion_DeletesDiscussionRoot() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var channel = CreateChannel(owner); + var group = CreateGroup(owner); + channel.LinkedDiscussionChatId = group.Id; + dbContext.AddRange(owner, channel, group); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var postResponse = await controller.SendMessage( + channel.Id, + new SendMessageRequest("Post to delete"), + CancellationToken.None); + var postDto = ControllerTestFixture.ValueOf(postResponse); + + var deleteResponse = await controller.DeleteMessage(channel.Id, postDto.Id, CancellationToken.None); + + var deletedDto = ControllerTestFixture.ValueOf(deleteResponse); + var discussionRoot = dbContext.Messages.Single(x => x.Id == postDto.DiscussionMessageId); + Assert.NotNull(deletedDto.DeletedAt); + Assert.NotNull(discussionRoot.DeletedAt); + Assert.Equal(string.Empty, discussionRoot.Text); + } + + [Fact] + public async Task UpdateChatState_CanUpdateChannelProfile() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var channel = CreateChannel(owner); + dbContext.AddRange(owner, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.UpdateChatState( + channel.Id, + new ChatStateRequest( + Title: "Daily News", + Description: "Verified city updates", + PublicUsername: "daily_news"), + CancellationToken.None); + + var summary = ControllerTestFixture.ValueOf(response); + var stored = dbContext.Chats.Single(x => x.Id == channel.Id); + Assert.Equal("Daily News", summary.Title); + Assert.Equal("Daily News", stored.Title); + Assert.Equal("Verified city updates", stored.Description); + Assert.Equal("daily_news", stored.PublicUsername); + Assert.Contains(dbContext.ModerationLogEntries, x => + x.ChatId == channel.Id && + x.ActorUserId == owner.Id && + x.Action == "chat.profile.updated"); + } + + [Fact] + public async Task UpdateChatState_CanClearChannelDescriptionAndPublicUsername() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var channel = CreateChannel(owner); + channel.Description = "Old description"; + channel.PublicUsername = "old_news"; + dbContext.AddRange(owner, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.UpdateChatState( + channel.Id, + new ChatStateRequest(ClearDescription: true, ClearPublicUsername: true), + CancellationToken.None); + + ControllerTestFixture.ValueOf(response); + var stored = dbContext.Chats.Single(x => x.Id == channel.Id); + Assert.Null(stored.Description); + Assert.Null(stored.PublicUsername); + } + + [Fact] + public async Task UpdateChatState_DeniesMemberProfileUpdate() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var member = CreateUser("member"); + var channel = CreateChannel(owner); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = member.Id, + User = member, + Role = ChatMemberRole.Member + }); + dbContext.AddRange(owner, member, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, member.Id); + var response = await controller.UpdateChatState( + channel.Id, + new ChatStateRequest(Title: "Member title"), + CancellationToken.None); + + var forbidden = Assert.IsType(response.Result); + Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode); + Assert.Equal("Only admins can update chat profile.", forbidden.Value); + Assert.Equal("News", dbContext.Chats.Single(x => x.Id == channel.Id).Title); + } + + [Fact] + public async Task UpdateChatState_RejectsTakenPublicUsername() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var otherOwner = CreateUser("other"); + var channel = CreateChannel(owner); + var otherChannel = CreateChannel(otherOwner); + otherChannel.PublicUsername = "taken_name"; + dbContext.AddRange(owner, otherOwner, channel, otherChannel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.UpdateChatState( + channel.Id, + new ChatStateRequest(PublicUsername: "taken_name"), + CancellationToken.None); + + var conflict = Assert.IsType(response.Result); + Assert.Equal("Public username is already taken.", conflict.Value); + Assert.Equal("news", dbContext.Chats.Single(x => x.Id == channel.Id).PublicUsername); + } + + [Fact] + public async Task TransferOwnership_MovesOwnerRoleToChannelMember() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var member = CreateUser("member"); + var channel = CreateChannel(owner); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = member.Id, + User = member, + IsOwner = false, + Role = ChatMemberRole.Member + }); + dbContext.AddRange(owner, member, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.TransferOwnership(channel.Id, member.Id, CancellationToken.None); + + var details = ControllerTestFixture.ValueOf(response); + Assert.Equal(member.Id, dbContext.Chats.Single(x => x.Id == channel.Id).CreatedById); + var ownerMember = Assert.Single(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == owner.Id); + var targetMember = Assert.Single(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == member.Id); + Assert.False(ownerMember.IsOwner); + Assert.Equal(ChatMemberRole.Admin, ownerMember.Role); + Assert.True(ownerMember.CanPostMessages); + Assert.True(targetMember.IsOwner); + Assert.Equal(ChatMemberRole.Owner, targetMember.Role); + Assert.True(targetMember.CanInviteUsers); + Assert.Single(dbContext.ChatMembers.Where(x => x.ChatId == channel.Id && (x.IsOwner || x.Role == ChatMemberRole.Owner))); + var returnedTarget = Assert.Single(details.Members ?? [], x => x.User.Id == member.Id); + Assert.True(returnedTarget.IsOwner); + Assert.Equal(ChatMemberRole.Owner, returnedTarget.Role); + Assert.Contains(dbContext.ModerationLogEntries, x => + x.ChatId == channel.Id && + x.ActorUserId == owner.Id && + x.TargetId == member.Id && + x.Action == "member.owner.transferred"); + } + + [Fact] + public async Task TransferOwnership_RejectsAdminActor() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var admin = CreateUser("admin"); + var member = CreateUser("member"); + var channel = CreateChannel(owner); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = admin.Id, + User = admin, + IsOwner = false, + Role = ChatMemberRole.Admin, + CanPinMessages = true, + CanDeleteMessages = true, + CanInviteUsers = true, + CanPostMessages = true + }); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = member.Id, + User = member, + IsOwner = false, + Role = ChatMemberRole.Member + }); + dbContext.AddRange(owner, admin, member, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, admin.Id); + var response = await controller.TransferOwnership(channel.Id, member.Id, CancellationToken.None); + + var forbidden = Assert.IsType(response.Result); + Assert.Equal(Microsoft.AspNetCore.Http.StatusCodes.Status403Forbidden, forbidden.StatusCode); + Assert.Equal("Only owner can transfer ownership.", forbidden.Value); + var ownerMember = Assert.Single(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == owner.Id); + Assert.True(ownerMember.IsOwner); + Assert.Equal(ChatMemberRole.Owner, ownerMember.Role); + } + + [Fact] + public async Task TransferOwnership_RejectsBotTarget() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var bot = CreateUser("bot", isBot: true); + var channel = CreateChannel(owner); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = bot.Id, + User = bot, + IsOwner = false, + Role = ChatMemberRole.Admin, + CanPinMessages = true, + CanDeleteMessages = true, + CanInviteUsers = true, + CanPostMessages = true + }); + dbContext.AddRange(owner, bot, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.TransferOwnership(channel.Id, bot.Id, CancellationToken.None); + + var badRequest = Assert.IsType(response.Result); + Assert.Equal("Bot cannot become chat owner.", badRequest.Value); + var ownerMember = Assert.Single(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == owner.Id); + var botMember = Assert.Single(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == bot.Id); + Assert.True(ownerMember.IsOwner); + Assert.False(botMember.IsOwner); + Assert.Equal(ChatMemberRole.Admin, botMember.Role); + } + + [Fact] + public async Task DeleteChat_BlocksOwnerLeavingChannelWithoutDeletingForEveryone() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var member = CreateUser("member"); + var channel = CreateChannel(owner); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = member.Id, + User = member, + IsOwner = false, + Role = ChatMemberRole.Member + }); + dbContext.AddRange(owner, member, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.DeleteChat(channel.Id, forEveryone: false, CancellationToken.None); + + var badRequest = Assert.IsType(response); + Assert.Equal("Owner must transfer ownership or delete the chat for everyone.", badRequest.Value); + Assert.Equal(2, dbContext.ChatMembers.Count(x => x.ChatId == channel.Id)); + Assert.True(dbContext.Chats.Any(x => x.Id == channel.Id)); + } + + [Fact] + public async Task UpdateMessage_AllowsChannelAdminWithPostPermissionToEditChannelPost() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var admin = CreateUser("admin"); + var channel = CreateChannel(owner); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = admin.Id, + User = admin, + Role = ChatMemberRole.Admin, + CanPostMessages = true + }); + var message = CreateTextMessage(channel, owner, "Original"); + dbContext.AddRange(owner, admin, channel, message); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, admin.Id); + var response = await controller.UpdateMessage( + channel.Id, + message.Id, + new UpdateMessageRequest("Updated by admin"), + CancellationToken.None); + + var updated = ControllerTestFixture.ValueOf(response); + Assert.Equal("Updated by admin", updated.Text); + Assert.NotNull(dbContext.Messages.Single(x => x.Id == message.Id).EditedAt); + } + + [Fact] + public async Task UpdateMessage_DeniesChannelMemberWithoutPostPermission() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var member = CreateUser("member"); + var channel = CreateChannel(owner); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = member.Id, + User = member, + Role = ChatMemberRole.Member, + CanPostMessages = false + }); + var message = CreateTextMessage(channel, owner, "Original"); + dbContext.AddRange(owner, member, channel, message); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, member.Id); + var response = await controller.UpdateMessage( + channel.Id, + message.Id, + new UpdateMessageRequest("Updated by admin"), + CancellationToken.None); + + var forbidden = Assert.IsType(response.Result); + Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode); + Assert.Equal("You cannot edit this message.", forbidden.Value); + Assert.Equal("Original", dbContext.Messages.Single(x => x.Id == message.Id).Text); + } + + [Fact] + public async Task DownloadAttachment_DoesNotServeDeletedMessageAttachment() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var channel = CreateChannel(owner); + var message = CreateMessageWithAttachment(channel, owner, deletedAt: DateTimeOffset.UtcNow); + dbContext.AddRange(owner, channel, message); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.DownloadAttachment(channel.Id, message.Attachments.Single().Id, CancellationToken.None); + + Assert.IsType(response); + } + + [Fact] + public async Task UploadAttachmentAlbum_RejectsSingleFile() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var group = CreateGroup(owner); + dbContext.AddRange(owner, group); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.UploadAttachmentAlbum( + group.Id, + new UploadAttachmentAlbumRequest + { + Files = [CreateFormFile([1, 2, 3], "photo.jpg", "image/jpeg")] + }, + CancellationToken.None); + + var badRequest = Assert.IsType(response.Result); + Assert.Equal("Albums must contain at least 2 files.", badRequest.Value); + } + + [Fact] + public async Task UploadAttachmentAlbum_RejectsDocumentFile() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var group = CreateGroup(owner); + dbContext.AddRange(owner, group); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.UploadAttachmentAlbum( + group.Id, + new UploadAttachmentAlbumRequest + { + Files = + [ + CreateFormFile([1, 2, 3], "photo.jpg", "image/jpeg"), + CreateFormFile([0x25, 0x50, 0x44, 0x46], "manual.pdf", "application/pdf") + ] + }, + CancellationToken.None); + + var badRequest = Assert.IsType(response.Result); + Assert.Equal("Albums accept only image and video files.", badRequest.Value); + } + + [Fact] + public async Task DownloadAttachment_DoesNotServeExpiredMessageAttachment() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var channel = CreateChannel(owner); + var message = CreateMessageWithAttachment(channel, owner, expiresAt: DateTimeOffset.UtcNow.AddSeconds(-1)); + dbContext.AddRange(owner, channel, message); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.DownloadAttachment(channel.Id, message.Attachments.Single().Id, CancellationToken.None); + + Assert.IsType(response); + } + + private static User CreateUser(string username, bool isBot = false) => + new() + { + Id = Guid.NewGuid(), + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = username, + PasswordHash = "test", + IsBot = isBot + }; + + private static Chat CreateChannel(User owner) + { + var channel = new Chat + { + Id = Guid.NewGuid(), + Type = ChatType.Channel, + Title = "News", + Description = "Public news channel", + PublicUsername = "news", + CreatedById = owner.Id, + CreatedBy = owner, + CreatedAt = new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero), + LastActivityAt = new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero) + }; + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = owner.Id, + User = owner, + IsOwner = true, + Role = ChatMemberRole.Owner, + CanPinMessages = true, + CanDeleteMessages = true, + CanInviteUsers = true, + CanPostMessages = true + }); + + return channel; + } + + private static Chat CreateGroup(User owner) + { + var group = new Chat + { + Id = Guid.NewGuid(), + Type = ChatType.Group, + Title = "Group", + CreatedById = owner.Id, + CreatedBy = owner, + CreatedAt = new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero), + LastActivityAt = new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero) + }; + group.Members.Add(new ChatMember + { + ChatId = group.Id, + Chat = group, + UserId = owner.Id, + User = owner, + IsOwner = true, + Role = ChatMemberRole.Owner, + CanPinMessages = true, + CanDeleteMessages = true, + CanInviteUsers = true, + CanPostMessages = true + }); + + return group; + } + + private static FormFile CreateFormFile(byte[] content, string fileName, string contentType) + { + var stream = new MemoryStream(content); + return new FormFile(stream, 0, stream.Length, "files", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + } + + private static Message CreateTextMessage(Chat chat, User sender, string text) + { + var message = new Message + { + Id = Guid.NewGuid(), + ChatId = chat.Id, + Chat = chat, + SenderId = sender.Id, + Sender = sender, + Text = text, + SentAt = DateTimeOffset.UtcNow + }; + chat.Messages.Add(message); + return message; + } + + private static Message CreateMessageWithAttachment( + Chat chat, + User sender, + DateTimeOffset? deletedAt = null, + DateTimeOffset? expiresAt = null) + { + var message = new Message + { + Id = Guid.NewGuid(), + ChatId = chat.Id, + Chat = chat, + SenderId = sender.Id, + Sender = sender, + Text = string.Empty, + SentAt = DateTimeOffset.UtcNow, + DeletedAt = deletedAt, + ExpiresAt = expiresAt + }; + message.Attachments.Add(new MessageAttachment + { + Id = Guid.NewGuid(), + MessageId = message.Id, + Message = message, + OriginalFileName = "document.pdf", + ContentType = "application/pdf", + StoragePath = "missing.pdf" + }); + + return message; + } +} diff --git a/ikar_server/src/Ikar.Server.Tests/ClientVersionRequestReaderTests.cs b/ikar_server/src/Ikar.Server.Tests/ClientVersionRequestReaderTests.cs new file mode 100644 index 0000000..42c669c --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/ClientVersionRequestReaderTests.cs @@ -0,0 +1,55 @@ +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Shared; +using Microsoft.AspNetCore.Http; + +namespace Ikar.Server.Tests; + +public sealed class ClientVersionRequestReaderTests +{ + [Fact] + public void Read_UsesClientVersionHeaders() + { + var context = new DefaultHttpContext(); + context.Request.Headers[ClientVersionMetadata.PlatformHeaderName] = " android "; + context.Request.Headers[ClientVersionMetadata.VersionCodeHeaderName] = "168"; + context.Request.Headers[ClientVersionMetadata.VersionNameHeaderName] = "3.55"; + + var snapshot = ClientVersionRequestReader.Read(context.Request); + + Assert.Equal("android", snapshot.ClientPlatform); + Assert.Equal(168, snapshot.ClientVersionCode); + Assert.Equal("3.55", snapshot.ClientVersionName); + } + + [Fact] + public void Apply_DoesNotClearExistingClientVersionWhenSnapshotIsEmpty() + { + var session = new UserSession + { + ClientPlatform = "android", + ClientVersionCode = 168, + ClientVersionName = "3.55" + }; + + var changed = ClientVersionRequestReader.Apply(session, new(null, null, null)); + + Assert.False(changed); + Assert.Equal("android", session.ClientPlatform); + Assert.Equal(168, session.ClientVersionCode); + Assert.Equal("3.55", session.ClientVersionName); + } + + [Fact] + public void Apply_StoresClientVersionOnPushDevice() + { + var device = new PushDevice(); + + var changed = ClientVersionRequestReader.Apply(device, new("android", 168, "3.55")); + + Assert.True(changed); + Assert.Equal("android", device.ClientPlatform); + Assert.Equal(168, device.ClientVersionCode); + Assert.Equal("3.55", device.ClientVersionName); + } +} diff --git a/ikar_server/src/Ikar.Server.Tests/ControllerTestFixture.cs b/ikar_server/src/Ikar.Server.Tests/ControllerTestFixture.cs new file mode 100644 index 0000000..eae7291 --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/ControllerTestFixture.cs @@ -0,0 +1,259 @@ +using System.Security.Claims; +using Ikar.Server.Controllers; +using Ikar.Server.Data; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Bots; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Push; +using Ikar.Server.Infrastructure.Storage; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.FileProviders; + +namespace Ikar.Server.Tests; + +internal sealed class SqliteTestDatabase : IAsyncDisposable +{ + private readonly SqliteConnection _connection = new("Data Source=:memory:"); + + public async Task CreateContextAsync() + { + await _connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + var dbContext = new IkarDbContext(options); + await dbContext.Database.EnsureCreatedAsync(); + return dbContext; + } + + public async ValueTask DisposeAsync() + { + await _connection.DisposeAsync(); + } +} + +internal static class ControllerTestFixture +{ + public static ChatsController CreateChatsController( + IkarDbContext dbContext, + Guid userId, + AttachmentStorageService? attachmentStorageService = null) + { + var pushDispatchQueue = new PushDispatchQueue(); + var hubContext = new NoopHubContext(); + var botAttachmentStorageService = attachmentStorageService ?? CreateAttachmentStorageService(Path.GetTempPath()); + var controller = new ChatsController( + dbContext, + new PresenceTracker(), + hubContext, + attachmentStorageService: attachmentStorageService!, + pushDispatchQueue, + new BotUpdateService(dbContext), + new BotCreatorService( + dbContext, + new BotTokenService(), + new BotDeletionService(dbContext, botAttachmentStorageService), + new PresenceTracker(), + hubContext, + pushDispatchQueue)); + AttachUser(controller, userId); + return controller; + } + + public static UsersController CreateUsersController(IkarDbContext dbContext, Guid userId) + { + var controller = new UsersController( + dbContext, + new PresenceTracker(), + avatarStorageService: null!); + AttachUser(controller, userId); + return controller; + } + + public static BotsController CreateBotsController(IkarDbContext dbContext, Guid userId) + { + var attachmentStorageService = CreateAttachmentStorageService(Path.GetTempPath()); + var controller = new BotsController( + dbContext, + new BotTokenService(), + new BotDeletionService(dbContext, attachmentStorageService)); + AttachUser(controller, userId); + return controller; + } + + public static StoriesController CreateStoriesController(IkarDbContext dbContext, Guid userId) + { + return CreateStoriesController(dbContext, userId, new NoopHubContext()); + } + + public static StoriesController CreateStoriesController( + IkarDbContext dbContext, + Guid userId, + IHubContext hubContext, + AttachmentStorageService? attachmentStorageService = null) + { + var controller = new StoriesController( + dbContext, + new PresenceTracker(), + hubContext, + attachmentStorageService ?? new AttachmentStorageService(new TestWebHostEnvironment(Path.GetTempPath()))); + AttachUser(controller, userId); + return controller; + } + + public static AttachmentStorageService CreateAttachmentStorageService(string contentRootPath) => + new(new TestWebHostEnvironment(contentRootPath)); + + public static QlyraUploadStore CreateQlyraUploadStore(string contentRootPath) => + new(new TestWebHostEnvironment(contentRootPath)); + + public static T ValueOf(ActionResult actionResult) + { + var ok = Assert.IsType(actionResult.Result); + return Assert.IsAssignableFrom(ok.Value); + } + + private static void AttachUser(ControllerBase controller, Guid userId) + { + var identity = new ClaimsIdentity( + [new Claim(ClaimTypes.NameIdentifier, userId.ToString())], + authenticationType: "Test"); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(identity) + } + }; + } + + private sealed class TestWebHostEnvironment(string contentRootPath) : IWebHostEnvironment + { + public string ApplicationName { get; set; } = "Ikar.Server.Tests"; + public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider(); + public string WebRootPath { get; set; } = contentRootPath; + public string EnvironmentName { get; set; } = "Development"; + public string ContentRootPath { get; set; } = contentRootPath; + public IFileProvider ContentRootFileProvider { get; set; } = new PhysicalFileProvider(contentRootPath); + } +} + +internal sealed record RecordedHubSend( + string TargetKind, + IReadOnlyList Targets, + string Method, + int ArgumentCount); + +internal sealed class RecordingHubContext : IHubContext + where THub : Hub +{ + private readonly RecordingHubClients _clients = new(); + + public IHubClients Clients => _clients; + + public IGroupManager Groups { get; } = new RecordingGroupManager(); + + public IReadOnlyList Sends => _clients.Sends; + + private sealed class RecordingHubClients : IHubClients + { + private readonly List _sends = []; + + public IReadOnlyList Sends => _sends; + + public IClientProxy All => Proxy("All", []); + + public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => Proxy("AllExcept", excludedConnectionIds); + + public IClientProxy Client(string connectionId) => Proxy("Client", [connectionId]); + + public IClientProxy Clients(IReadOnlyList connectionIds) => Proxy("Clients", connectionIds); + + public IClientProxy Group(string groupName) => Proxy("Group", [groupName]); + + public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => + Proxy("GroupExcept", [groupName, .. excludedConnectionIds]); + + public IClientProxy Groups(IReadOnlyList groupNames) => Proxy("Groups", groupNames); + + public IClientProxy User(string userId) => Proxy("User", [userId]); + + public IClientProxy Users(IReadOnlyList userIds) => Proxy("Users", userIds); + + private IClientProxy Proxy(string targetKind, IReadOnlyList targets) => + new RecordingClientProxy(_sends, targetKind, targets); + } + + private sealed class RecordingClientProxy( + List sends, + string targetKind, + IReadOnlyList targets) : IClientProxy + { + public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) + { + sends.Add(new RecordedHubSend(targetKind, targets.ToList(), method, args.Length)); + return Task.CompletedTask; + } + } + + private sealed class RecordingGroupManager : IGroupManager + { + public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => + Task.CompletedTask; + } +} + +internal sealed class NoopHubContext : IHubContext + where THub : Hub +{ + public IHubClients Clients { get; } = new NoopHubClients(); + + public IGroupManager Groups { get; } = new NoopGroupManager(); + + private sealed class NoopHubClients : IHubClients + { + private static readonly IClientProxy Proxy = new NoopClientProxy(); + + public IClientProxy All => Proxy; + + public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => Proxy; + + public IClientProxy Client(string connectionId) => Proxy; + + public IClientProxy Clients(IReadOnlyList connectionIds) => Proxy; + + public IClientProxy Group(string groupName) => Proxy; + + public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => Proxy; + + public IClientProxy Groups(IReadOnlyList groupNames) => Proxy; + + public IClientProxy User(string userId) => Proxy; + + public IClientProxy Users(IReadOnlyList userIds) => Proxy; + } + + private sealed class NoopClientProxy : IClientProxy + { + public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) => + Task.CompletedTask; + } + + private sealed class NoopGroupManager : IGroupManager + { + public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => + Task.CompletedTask; + } +} diff --git a/ikar_server/src/Ikar.Server.Tests/DeviceKeyAuthenticationTests.cs b/ikar_server/src/Ikar.Server.Tests/DeviceKeyAuthenticationTests.cs new file mode 100644 index 0000000..2dbdbfc --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/DeviceKeyAuthenticationTests.cs @@ -0,0 +1,49 @@ +using System.Security.Cryptography; +using Ikar.Server.Infrastructure.Auth; + +namespace Ikar.Server.Tests; + +public sealed class DeviceKeyAuthenticationTests +{ + [Fact] + public void DeviceChallenge_AcceptsOnlySignatureFromRegisteredPublicKey() + { + using var deviceKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); + using var otherKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var publicKey = Convert.ToBase64String(deviceKey.ExportSubjectPublicKeyInfo()); + var store = new DeviceAuthChallengeStore(); + var challenge = store.Create( + "79991234567", + "00112233445566778899aabbccddeeff", + publicKey, + isRegistered: false, + bindCredential: true); + var payload = Convert.FromBase64String(challenge.SignaturePayload); + var signature = Convert.ToBase64String(deviceKey.SignData( + payload, + HashAlgorithmName.SHA256, + DSASignatureFormat.Rfc3279DerSequence)); + var wrongSignature = Convert.ToBase64String(otherKey.SignData( + payload, + HashAlgorithmName.SHA256, + DSASignatureFormat.Rfc3279DerSequence)); + + Assert.True(DeviceKeyVerifier.IsValidPublicKey(publicKey)); + var derivedInstallationId = DeviceKeyVerifier.DeriveInstallationId(publicKey); + Assert.Equal(32, derivedInstallationId.Length); + Assert.Matches("^[a-f0-9]{32}$", derivedInstallationId); + Assert.Equal(derivedInstallationId, DeviceKeyVerifier.DeriveInstallationId(publicKey)); + Assert.True(DeviceKeyVerifier.Verify(publicKey, challenge.SignaturePayload, signature)); + Assert.False(DeviceKeyVerifier.Verify(publicKey, challenge.SignaturePayload, wrongSignature)); + Assert.True(store.Consume(challenge.Id, challenge)); + Assert.Null(store.Get(challenge.Id)); + Assert.False(store.Consume(challenge.Id, challenge)); + } + + [Fact] + public void DeviceKeyVerifier_RejectsMalformedInputs() + { + Assert.False(DeviceKeyVerifier.IsValidPublicKey("not-base64")); + Assert.False(DeviceKeyVerifier.Verify("not-base64", "bad", "bad")); + } +} diff --git a/ikar_server/src/Ikar.Server.Tests/Ikar.Server.Tests.csproj b/ikar_server/src/Ikar.Server.Tests/Ikar.Server.Tests.csproj new file mode 100644 index 0000000..8336950 --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/Ikar.Server.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/ikar_server/src/Ikar.Server.Tests/IkarClientFeatureTests.cs b/ikar_server/src/Ikar.Server.Tests/IkarClientFeatureTests.cs new file mode 100644 index 0000000..58445cd --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/IkarClientFeatureTests.cs @@ -0,0 +1,159 @@ +using System.Text.Json; +using Ikar.Server.Controllers; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Calls; +using Microsoft.Extensions.Options; +using Ikar.Shared; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Tests; + +public sealed partial class QlyraCompatibilityControllerTests +{ + [Fact] + public async Task ProfileDeletion_CanBeCanceledBeforeThirtyDays_AndExecutesWhenDue() + { + await using var database=new SqliteTestDatabase(); + await using var db=await database.CreateContextAsync(); + var user=new User {Username="retention",NormalizedUsername="RETENTION",DisplayName="Retention",PasswordHash="test"}; + db.Users.Add(user); await db.SaveChangesAsync(); + var controller=CreateController(db,user.Id); + var deletion=await RequestOk(controller,199,new {delete=true}); + Assert.InRange(deletion.GetProperty("timestamp").GetInt64(),DateTimeOffset.UtcNow.AddDays(29).ToUnixTimeMilliseconds(),DateTimeOffset.UtcNow.AddDays(31).ToUnixTimeMilliseconds()); + Assert.False(user.IsBlocked); + await RequestOk(controller,199,new {delete=false}); + Assert.Null(user.ScheduledDeletionUnixMs); + Assert.False(user.IsBlocked); + await RequestOk(controller,199,new {delete=true}); + user.ScheduledDeletionUnixMs=DateTimeOffset.UtcNow.AddSeconds(-1).ToUnixTimeMilliseconds(); + await db.SaveChangesAsync(); + await controller.DeliverScheduledDeletionsAsync(CancellationToken.None); + Assert.True(user.IsBlocked); + Assert.Equal("Удалённый аккаунт",user.DisplayName); + } + + [Fact] + public async Task AcceptingCall_ReceivesConfirmationForActiveScreen() + { + var store=new QlyraCallCompatibilityStore(Options.Create(new WebRtcOptions())); + var a=Guid.NewGuid(); var b=Guid.NewGuid(); + var conversation=store.Create("accept-confirmation",true,(a,10),(b,20)); + var socket=new ScriptedWebSocket("{\"command\":\"accept-call\",\"sequence\":1}"); + await store.HandleWebSocketAsync(CreateWebSocketContext(conversation.Participants[b].Token,socket)); + Assert.Contains(socket.Sent,text=>JsonDocument.Parse(text).RootElement.TryGetProperty("notification",out var n) && n.GetString()=="accepted-call"); + } + + [Fact] + public async Task HangingUpDirectCall_RevokesBothParticipantTokens() + { + var store=new QlyraCallCompatibilityStore(Options.Create(new WebRtcOptions())); + var a=Guid.NewGuid(); var b=Guid.NewGuid(); + var conversation=store.Create("revoked-call",true,(a,10),(b,20)); + var socket=new ScriptedWebSocket("{\"command\":\"hangup\",\"sequence\":1}"); + await store.HandleWebSocketAsync(CreateWebSocketContext(conversation.Participants[a].Token,socket)); + Assert.Null(store.Find(conversation.Id)); + var context=CreateWebSocketContext(conversation.Participants[b].Token,new ScriptedWebSocket()); + await store.HandleWebSocketAsync(context); + Assert.Equal(404,context.Response.StatusCode); + } + + [Fact] + public async Task RetryingMessage_PreservesIdentityFormattingAndReply() + { + await using var database = new SqliteTestDatabase(); + await using var db = await database.CreateContextAsync(); + var author = new User { Username="author",NormalizedUsername="AUTHOR",DisplayName="Author",PasswordHash="test" }; + var chat = new Chat { Type=ChatType.Group,CreatedById=author.Id }; + chat.Members.Add(new ChatMember { User=author,UserId=author.Id,IsOwner=true }); + db.Chats.Add(chat); await db.SaveChangesAsync(); + var chatId = await new QlyraClientIdMapper(db).GetOrCreateAsync("chat",chat.Id,CancellationToken.None); + var controller = CreateController(db,author.Id); + var original = await RequestOk(controller,64,new {chatId,message=new {text="Original"}}); + var payload = new {chatId,message=new {cid=987654321L,text=" Formatted ",elements=new[]{new {type="STRONG",from=2,length=9}},link=new {type="REPLY",messageId=original.GetProperty("message").GetProperty("id").GetInt32()}}}; + var first = await RequestOk(controller,64,payload); + var second = await RequestOk(controller,64,payload); + Assert.Equal(first.GetProperty("message").GetProperty("id").GetInt32(),second.GetProperty("message").GetProperty("id").GetInt32()); + Assert.Equal(2,await db.Messages.CountAsync()); + var history = await RequestOk(controller,49,new {chatId}); + var result = history.GetProperty("messages").EnumerateArray().Single(x=>x.GetProperty("cid").GetInt64()==987654321L); + Assert.Equal(" Formatted ",result.GetProperty("text").GetString()); + Assert.Single(result.GetProperty("elements").EnumerateArray()); + Assert.Equal("Original",result.GetProperty("link").GetProperty("message").GetProperty("text").GetString()); + } + + [Fact] + public async Task BlockedSender_CannotDeliverDirectMessage() + { + await using var database = new SqliteTestDatabase(); + await using var db = await database.CreateContextAsync(); + var author = new User {Username="blocked",NormalizedUsername="BLOCKED",DisplayName="Blocked",PasswordHash="test"}; + var peer = new User {Username="peer",NormalizedUsername="PEER",DisplayName="Peer",PasswordHash="test"}; + var chat = new Chat {Type=ChatType.Direct,CreatedById=author.Id}; + chat.Members.Add(new ChatMember {User=author,UserId=author.Id}); + chat.Members.Add(new ChatMember {User=peer,UserId=peer.Id}); + db.Chats.Add(chat); + db.UserPrivacySettings.Add(new UserPrivacySettings {UserId=peer.Id,BlockedUserIds=author.Id.ToString("D")}); + await db.SaveChangesAsync(); + var mapper = new QlyraClientIdMapper(db); + var chatId = await mapper.GetOrCreateAsync("user",author.Id,CancellationToken.None) ^ await mapper.GetOrCreateAsync("user",peer.Id,CancellationToken.None); + var response = await CreateController(db,author.Id).Dispatch(64,new QlyraCompatibilityRequest(JsonSerializer.SerializeToElement(new {chatId,message=new {text="Must not be delivered"}})),CancellationToken.None); + Assert.Equal(403,Assert.IsType(response).StatusCode); + Assert.Empty(db.Messages); + } + + [Fact] + public async Task IkarStickerCatalog_HasDownloadableAssets() + { + await using var database = new SqliteTestDatabase(); + await using var db = await database.CreateContextAsync(); + var user = new User {Username="stickers",NormalizedUsername="STICKERS",DisplayName="Stickers",PasswordHash="test"}; + db.Users.Add(user); await db.SaveChangesAsync(); + var controller=CreateController(db,user.Id); + var sections=await RequestOk(controller,27,new {type="STICKER",sync=0}); + var id=sections.GetProperty("sections")[0].GetProperty("stickerSets")[0].GetInt32(); + var sets=await RequestOk(controller,28,new {type="STICKER_SET",ids=new[]{id}}); + var stickers=await RequestOk(controller,28,new {type="STICKER",ids=sets.GetProperty("stickerSets")[0].GetProperty("stickers").EnumerateArray().Select(x=>x.GetInt32()).ToArray()}); + Assert.Equal(8,stickers.GetProperty("stickers").GetArrayLength()); + Assert.All(stickers.GetProperty("stickers").EnumerateArray(),s=>Assert.Contains("/stickers/",s.GetProperty("url").GetString())); + } + + [Fact] + public async Task Transcription_CachedResultIsRestrictedToChatMembers() + { + await using var database = new SqliteTestDatabase(); + await using var db = await database.CreateContextAsync(); + var member = new User {Username="speech",NormalizedUsername="SPEECH",DisplayName="Speech",PasswordHash="test"}; + var outsider = new User {Username="outside",NormalizedUsername="OUTSIDE",DisplayName="Outside",PasswordHash="test"}; + var chat=new Chat {Type=ChatType.Group,CreatedById=member.Id}; + chat.Members.Add(new ChatMember {User=member,UserId=member.Id}); + var message=new Message {Chat=chat,Sender=member,Text="",IkarTranscriptText="Проверка ИКАР"}; + db.Messages.Add(message); db.Users.Add(outsider); await db.SaveChangesAsync(); + var mapper=new QlyraClientIdMapper(db); + var payload=new {chatId=await mapper.GetOrCreateAsync("chat",chat.Id,CancellationToken.None),messageId=await mapper.GetOrCreateAsync("message",message.Id,CancellationToken.None)}; + var transcript=await RequestOk(CreateController(db,member.Id),202,payload); + Assert.Equal("Проверка ИКАР",transcript.GetProperty("transcription").GetString()); + var forbidden=await CreateController(db,outsider.Id).Dispatch(202,new QlyraCompatibilityRequest(JsonSerializer.SerializeToElement(payload)),CancellationToken.None); + Assert.IsType(forbidden); + } + + [Fact] + public async Task MiniApp_LaunchContainsSignedOwnUserData_AndDoesNotExposePhone() + { + await using var database = new SqliteTestDatabase(); + await using var db = await database.CreateContextAsync(); + var user=new User {Username="webuser",NormalizedUsername="WEBUSER",DisplayName="Web User",PasswordHash="test",NormalizedPhoneNumber="+12025550123"}; + var botUser=new User {Username="webbot",NormalizedUsername="WEBBOT",DisplayName="Web Bot",PasswordHash="test",IsBot=true}; + db.Bots.Add(new Bot {User=botUser,OwnerUser=user,TokenHash=new string('A',64),WebAppUrl="https://example.com/app"}); + await db.SaveChangesAsync(); + var botId=await new QlyraClientIdMapper(db).GetOrCreateAsync("user",botUser.Id,CancellationToken.None); + var response=await RequestOk(CreateController(db,user.Id),160,new {botId,startParam="test"}); + var url=response.GetProperty("url").GetString()!; + Assert.StartsWith("https://example.com/app#ikarWebAppData=",url); + Assert.Contains("hash",Uri.UnescapeDataString(url)); + Assert.DoesNotContain("12025550123",Uri.UnescapeDataString(url)); + var contact=await RequestOk(CreateController(db,user.Id),145,new {botId}); + Assert.Contains(contact.GetProperty("contact").GetProperty("options").EnumerateArray(),x=>x.GetString()=="HAS_WEBAPP"); + } +} diff --git a/ikar_server/src/Ikar.Server.Tests/IkarScheduledMessageTests.cs b/ikar_server/src/Ikar.Server.Tests/IkarScheduledMessageTests.cs new file mode 100644 index 0000000..d29b1c3 --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/IkarScheduledMessageTests.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using Ikar.Server.Controllers; +using Ikar.Server.Data.Entities; +using Ikar.Shared; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Tests; + +public sealed partial class QlyraCompatibilityControllerTests +{ + private static async Task RequestOk(QlyraCompatibilityController controller, int opcode, object payload) + { + var result = await controller.Dispatch(opcode, new QlyraCompatibilityRequest(JsonSerializer.SerializeToElement(payload)), CancellationToken.None); + return JsonSerializer.SerializeToElement(Assert.IsType(Assert.IsType(result).Value).Payload); + } + + [Fact] + public async Task ScheduledMessage_IsPrivateUntilDue_AndSurvivesReopeningDatabase() + { + await using var database = new SqliteTestDatabase(); + Guid authorId, peerId, messageId; + int chatId; + await using (var db = await database.CreateContextAsync()) + { + var author = new User { Username="scheduler", NormalizedUsername="SCHEDULER", DisplayName="Scheduler", PasswordHash="test" }; + var peer = new User { Username="receiver", NormalizedUsername="RECEIVER", DisplayName="Receiver", PasswordHash="test" }; + var chat = new Chat { Type=ChatType.Direct, CreatedById=author.Id }; + chat.Members.Add(new ChatMember { User=author, UserId=author.Id, IsOwner=true }); + chat.Members.Add(new ChatMember { User=peer, UserId=peer.Id }); + db.Chats.Add(chat); + await db.SaveChangesAsync(); + var mapper = new Ikar.Server.Infrastructure.QlyraClientIdMapper(db); + chatId = (await mapper.GetOrCreateAsync("user",author.Id,CancellationToken.None)) ^ (await mapper.GetOrCreateAsync("user",peer.Id,CancellationToken.None)); + authorId=author.Id; peerId=peer.Id; + var controller=CreateController(db, author.Id); + await RequestOk(controller,64,new {chatId,message=new {text="Queued",delayedAttributes=new {timeToFire=DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds()}}}); + var regular=await RequestOk(CreateController(db,peer.Id),49,new {chatId}); + Assert.Empty(regular.GetProperty("messages").EnumerateArray()); + var queued=await RequestOk(controller,49,new {chatId,itemType="DELAYED",from=1}); + Assert.Single(queued.GetProperty("messages").EnumerateArray()); + messageId=(await db.Messages.SingleAsync()).Id; + } + await using (var db=await database.CreateContextAsync()) + { + var message=await db.Messages.SingleAsync(x=>x.Id==messageId); + Assert.NotNull(message.ScheduledForUnixMs); + message.ScheduledForUnixMs=DateTimeOffset.UtcNow.AddSeconds(-1).ToUnixTimeMilliseconds(); + await db.SaveChangesAsync(); + await CreateController(db,authorId).DeliverScheduledMessagesAsync(CancellationToken.None); + var history=await RequestOk(CreateController(db,peerId),49,new {chatId}); + Assert.Single(history.GetProperty("messages").EnumerateArray()); + Assert.Null(message.ScheduledForUnixMs); + } + } + + [Fact] + public async Task DeletingScheduledMessageForMe_CancelsServerDelivery() + { + await using var database = new SqliteTestDatabase(); + await using var db=await database.CreateContextAsync(); + var user=new User {Username="cancel",NormalizedUsername="CANCEL",DisplayName="Cancel",PasswordHash="test"}; + var chat=new Chat {Type=ChatType.Group,CreatedById=user.Id}; + chat.Members.Add(new ChatMember {User=user,UserId=user.Id,IsOwner=true}); + db.Chats.Add(chat); await db.SaveChangesAsync(); + var chatId=await new Ikar.Server.Infrastructure.QlyraClientIdMapper(db).GetOrCreateAsync("chat",chat.Id,CancellationToken.None); + var controller=CreateController(db,user.Id); + var sent=await RequestOk(controller,64,new {chatId,message=new {text="Cancel",delayedAttributes=new {timeToFire=DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds()}}}); + await RequestOk(controller,66,new {chatId,messageIds=new[]{sent.GetProperty("message").GetProperty("id").GetInt32()},forMe=true,itemType="DELAYED"}); + Assert.NotNull((await db.Messages.SingleAsync()).DeletedAt); + } +} diff --git a/ikar_server/src/Ikar.Server.Tests/PrivacyAndStoriesControllerTests.cs b/ikar_server/src/Ikar.Server.Tests/PrivacyAndStoriesControllerTests.cs new file mode 100644 index 0000000..7b1de85 --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/PrivacyAndStoriesControllerTests.cs @@ -0,0 +1,437 @@ +using Ikar.Server.Controllers; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Shared; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Mvc; + +namespace Ikar.Server.Tests; + +public sealed class PrivacyAndStoriesControllerTests +{ + [Fact] + public async Task UpdatePrivacySettings_NormalizesVisibilityAndDropsOwnUserId() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + dbContext.Users.AddRange(alice, bob); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateUsersController(dbContext, alice.Id); + var response = await controller.UpdateMyPrivacySettings( + new UpdatePrivacySettingsRequest( + PhoneNumberVisibility: "invalid", + LastSeenVisibility: "nobody", + ProfilePhotoVisibility: "everyone", + BlockedUserIds: [alice.Id, bob.Id, bob.Id], + AlwaysAllowUserIds: [alice.Id], + NeverAllowUserIds: [bob.Id], + PasscodeLockEnabled: true, + ShowMessagePreview: false), + CancellationToken.None); + + var settings = ControllerTestFixture.ValueOf(response); + Assert.Equal("contacts", settings.PhoneNumberVisibility); + Assert.Equal("nobody", settings.LastSeenVisibility); + Assert.Equal([bob.Id], settings.BlockedUserIds); + var blockedUser = Assert.Single(settings.BlockedUsers); + Assert.Equal(bob.Id, blockedUser.Id); + Assert.Equal("bob", blockedUser.DisplayName); + Assert.Empty(settings.AlwaysAllowUserIds); + Assert.Empty(settings.AlwaysAllowUsers); + Assert.Equal([bob.Id], settings.NeverAllowUserIds); + var neverAllowUser = Assert.Single(settings.NeverAllowUsers); + Assert.Equal(bob.Id, neverAllowUser.Id); + Assert.Equal("bob", neverAllowUser.DisplayName); + Assert.True(settings.PasscodeLockEnabled); + Assert.False(settings.ShowMessagePreview); + } + + [Fact] + public async Task GetUser_HidesPrivatePhoneLastSeenAndAvatar() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var phoneNumber = "+10000000001"; + var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(phoneNumber)!; + alice.PhoneNumber = phoneNumber; + alice.NormalizedPhoneNumber = normalizedPhoneNumber; + alice.AvatarStoragePath = "avatar.png"; + alice.AvatarContentType = "image/png"; + alice.PrivacySettings = new UserPrivacySettings + { + UserId = alice.Id, + User = alice, + PhoneNumberVisibility = "nobody", + LastSeenVisibility = "nobody", + ProfilePhotoVisibility = "nobody" + }; + alice.Sessions.Add(new UserSession + { + UserId = alice.Id, + User = alice, + LastSeenAt = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero) + }); + var sharedChat = CreateDirectChat(alice, bob); + dbContext.AddRange(alice, bob, sharedChat); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateUsersController(dbContext, bob.Id); + var response = await controller.GetUser(alice.Id, CancellationToken.None); + + var user = ControllerTestFixture.ValueOf(response); + Assert.Null(user.PhoneNumber); + Assert.Null(user.LastSeenAt); + Assert.Null(user.AvatarPath); + Assert.False(user.IsOnline); + } + + [Fact] + public async Task ResolveContacts_UsesPrivacyFilteredUserDto() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var phoneNumber = "+10000000001"; + var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(phoneNumber)!; + alice.PhoneNumber = phoneNumber; + alice.NormalizedPhoneNumber = normalizedPhoneNumber; + alice.AvatarStoragePath = "avatar.png"; + alice.AvatarContentType = "image/png"; + alice.PrivacySettings = new UserPrivacySettings + { + UserId = alice.Id, + User = alice, + PhoneNumberVisibility = "nobody", + LastSeenVisibility = "nobody", + ProfilePhotoVisibility = "nobody" + }; + alice.Sessions.Add(new UserSession + { + UserId = alice.Id, + User = alice, + LastSeenAt = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero) + }); + dbContext.AddRange(alice, bob); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateUsersController(dbContext, bob.Id); + var response = await controller.ResolveContacts( + new ResolveContactsRequest([phoneNumber]), + CancellationToken.None); + + var match = Assert.Single(ControllerTestFixture.ValueOf(response)); + Assert.Equal(normalizedPhoneNumber, match.PhoneNumber); + Assert.Equal(alice.Id, match.User.Id); + Assert.Null(match.User.PhoneNumber); + Assert.Null(match.User.LastSeenAt); + Assert.Null(match.User.AvatarPath); + } + + [Fact] + public async Task GetStories_ReturnsEveryoneStoriesWithoutSharedChat() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var charlie = CreateUser("charlie"); + var sharedChat = CreateDirectChat(alice, bob); + var peerStory = CreateStory(bob, "peer", StoryVisibility.Everyone); + var publicStory = CreateStory(charlie, "public", StoryVisibility.Everyone); + dbContext.AddRange(alice, bob, charlie, sharedChat, peerStory, publicStory); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateStoriesController(dbContext, alice.Id); + var response = await controller.GetStories(CancellationToken.None); + + var stories = ControllerTestFixture.ValueOf(response); + Assert.Contains(stories, story => story.Id == peerStory.Id); + Assert.Contains(stories, story => story.Id == publicStory.Id); + } + + [Fact] + public async Task GetStories_ContactsOnlyReturnsStoriesFromSharedChats() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var charlie = CreateUser("charlie"); + var sharedChat = CreateDirectChat(alice, bob); + var visibleStory = CreateStory(bob, "visible", StoryVisibility.Contacts); + var hiddenStory = CreateStory(charlie, "hidden", StoryVisibility.Contacts); + dbContext.AddRange(alice, bob, charlie, sharedChat, visibleStory, hiddenStory); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateStoriesController(dbContext, alice.Id); + var response = await controller.GetStories(CancellationToken.None); + + var stories = ControllerTestFixture.ValueOf(response); + var story = Assert.Single(stories); + Assert.Equal(visibleStory.Id, story.Id); + Assert.Equal("visible", story.Text); + } + + [Fact] + public async Task GetStories_PrivateOnlyReturnsOwnStories() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var sharedChat = CreateDirectChat(alice, bob); + var ownStory = CreateStory(alice, "own", StoryVisibility.Private); + var peerStory = CreateStory(bob, "peer", StoryVisibility.Private); + dbContext.AddRange(alice, bob, sharedChat, ownStory, peerStory); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateStoriesController(dbContext, alice.Id); + var response = await controller.GetStories(CancellationToken.None); + + var stories = ControllerTestFixture.ValueOf(response); + var story = Assert.Single(stories); + Assert.Equal(ownStory.Id, story.Id); + Assert.Equal("own", story.Text); + } + + [Fact] + public async Task CreateStory_NotifiesAllClientsForEveryoneStory() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + dbContext.Users.Add(alice); + await dbContext.SaveChangesAsync(); + + var hubContext = new RecordingHubContext(); + var controller = ControllerTestFixture.CreateStoriesController(dbContext, alice.Id, hubContext); + await controller.CreateStory(new CreateStoryRequest("public", StoryVisibility.Everyone), CancellationToken.None); + + var send = Assert.Single(hubContext.Sends, x => x.Method == "StoriesChanged"); + Assert.Equal("All", send.TargetKind); + Assert.Empty(send.Targets); + Assert.Equal(0, send.ArgumentCount); + } + + [Fact] + public async Task CreateStory_NotifiesOnlyAuthorAndPeersForContactsStory() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var charlie = CreateUser("charlie"); + var sharedChat = CreateDirectChat(alice, bob); + dbContext.AddRange(alice, bob, charlie, sharedChat); + await dbContext.SaveChangesAsync(); + + var hubContext = new RecordingHubContext(); + var controller = ControllerTestFixture.CreateStoriesController(dbContext, alice.Id, hubContext); + await controller.CreateStory(new CreateStoryRequest("contacts", StoryVisibility.Contacts), CancellationToken.None); + + var send = Assert.Single(hubContext.Sends, x => x.Method == "StoriesChanged"); + Assert.Equal("Users", send.TargetKind); + Assert.Equal( + new[] { alice.Id.ToString(), bob.Id.ToString() }.OrderBy(x => x).ToArray(), + send.Targets.OrderBy(x => x).ToArray()); + Assert.DoesNotContain(charlie.Id.ToString(), send.Targets); + Assert.Equal(0, send.ArgumentCount); + } + + [Fact] + public async Task CreateMediaStory_PreservesAllSelectedFilesInOrder() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-story-upload-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + dbContext.Users.Add(alice); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateStoriesController( + dbContext, + alice.Id, + new RecordingHubContext(), + ControllerTestFixture.CreateAttachmentStorageService(tempRoot)); + + var response = await controller.CreateMediaStory( + new UploadStoryRequest + { + Text = "trip", + Visibility = StoryVisibility.Everyone, + Files = + [ + CreateFormFile([1, 2, 3], "first.jpg", "image/jpeg"), + CreateFormFile([4, 5, 6], "second.jpg", "image/jpeg"), + CreateFormFile([7, 8, 9], "third.jpg", "image/jpeg") + ] + }, + CancellationToken.None); + + var dto = ControllerTestFixture.ValueOf(response); + Assert.Equal(3, dto.Attachments.Count); + Assert.Collection( + dto.Attachments, + attachment => + { + Assert.Equal("first.jpg", attachment.FileName); + Assert.Equal(0, attachment.SortOrder); + }, + attachment => + { + Assert.Equal("second.jpg", attachment.FileName); + Assert.Equal(1, attachment.SortOrder); + }, + attachment => + { + Assert.Equal("third.jpg", attachment.FileName); + Assert.Equal(2, attachment.SortOrder); + }); + + var storedStory = await dbContext.Stories + .Include(x => x.Attachments) + .SingleAsync(x => x.Id == dto.Id); + var storedAttachments = storedStory.Attachments + .OrderBy(x => x.SortOrder) + .ToList(); + Assert.Equal(3, storedAttachments.Count); + Assert.All(storedAttachments, attachment => Assert.True(File.Exists(attachment.StoragePath))); + } + finally + { + Directory.Delete(tempRoot, recursive: true); + } + } + + [Fact] + public async Task GetAvatar_RespectsProfilePhotoContactsVisibility() + { + var avatarPath = Path.GetTempFileName(); + await File.WriteAllTextAsync(avatarPath, "avatar"); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var charlie = CreateUser("charlie"); + alice.AvatarStoragePath = avatarPath; + alice.AvatarContentType = "image/png"; + alice.PrivacySettings = new UserPrivacySettings + { + UserId = alice.Id, + User = alice, + ProfilePhotoVisibility = "contacts" + }; + var sharedChat = CreateDirectChat(alice, bob); + dbContext.AddRange(alice, bob, charlie, sharedChat); + await dbContext.SaveChangesAsync(); + + var bobController = ControllerTestFixture.CreateUsersController(dbContext, bob.Id); + var bobResponse = await bobController.GetAvatar(alice.Id, Path.GetFileName(avatarPath), CancellationToken.None); + Assert.IsType(bobResponse); + + var charlieController = ControllerTestFixture.CreateUsersController(dbContext, charlie.Id); + var charlieResponse = await charlieController.GetAvatar(alice.Id, Path.GetFileName(avatarPath), CancellationToken.None); + Assert.IsType(charlieResponse); + } + finally + { + File.Delete(avatarPath); + } + } + + [Fact] + public async Task GetStories_HidesContactOnlyAuthorProfileFields_ForNonPeerViewer() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var charlie = CreateUser("charlie"); + alice.PhoneNumber = "+10000000001"; + alice.AvatarStoragePath = "alice-avatar.png"; + alice.PrivacySettings = new UserPrivacySettings + { + UserId = alice.Id, + User = alice, + PhoneNumberVisibility = "contacts", + LastSeenVisibility = "contacts", + ProfilePhotoVisibility = "contacts" + }; + alice.Sessions.Add(new UserSession + { + Id = Guid.NewGuid(), + UserId = alice.Id, + User = alice, + LastSeenAt = DateTimeOffset.UtcNow.AddMinutes(-5) + }); + var story = CreateStory(alice, "public", StoryVisibility.Everyone); + dbContext.AddRange(alice, charlie, story); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateStoriesController(dbContext, charlie.Id); + var response = await controller.GetStories(CancellationToken.None); + + var visibleStory = Assert.Single(ControllerTestFixture.ValueOf(response)); + Assert.Equal(alice.Id, visibleStory.Author.Id); + Assert.Null(visibleStory.Author.PhoneNumber); + Assert.Null(visibleStory.Author.LastSeenAt); + Assert.Null(visibleStory.Author.AvatarPath); + } + + private static User CreateUser(string username) => + new() + { + Id = Guid.NewGuid(), + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = username, + PasswordHash = "test" + }; + + private static Chat CreateDirectChat(User first, User second) + { + var chat = new Chat + { + Id = Guid.NewGuid(), + Type = ChatType.Direct, + CreatedById = first.Id, + CreatedBy = first + }; + chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = first.Id, User = first }); + chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = second.Id, User = second }); + return chat; + } + + private static Story CreateStory(User author, string text, StoryVisibility visibility) => + new() + { + Id = Guid.NewGuid(), + AuthorUserId = author.Id, + AuthorUser = author, + Text = text, + Visibility = visibility, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) + }; + + private static FormFile CreateFormFile(byte[] content, string fileName, string contentType) + { + var stream = new MemoryStream(content); + return new FormFile(stream, 0, stream.Length, "files", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + } +} diff --git a/ikar_server/src/Ikar.Server.Tests/PushNotificationDispatcherTests.cs b/ikar_server/src/Ikar.Server.Tests/PushNotificationDispatcherTests.cs new file mode 100644 index 0000000..9926251 --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/PushNotificationDispatcherTests.cs @@ -0,0 +1,309 @@ +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Push; +using Ikar.Shared; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Ikar.Server.Tests; + +public sealed class PushNotificationDispatcherTests +{ + [Fact] + public async Task DispatchMessageAsync_DoesNotNotifyMutedRecipient() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var senderUser = CreateUser("sender"); + var mutedRecipient = CreateUser("muted"); + var recipientDevice = CreateAndroidDevice(mutedRecipient, "muted-device", notificationsEnabled: true); + var chat = CreateChat(senderUser, mutedRecipient); + chat.Members.Single(member => member.UserId == mutedRecipient.Id).MutedUntil = DateTimeOffset.UtcNow.AddHours(1); + var message = new Message + { + Chat = chat, + ChatId = chat.Id, + Sender = senderUser, + SenderId = senderUser.Id, + Text = "hello" + }; + + dbContext.AddRange(senderUser, mutedRecipient, chat, message, recipientDevice); + await dbContext.SaveChangesAsync(); + + var sender = new RecordingPushNotificationSender(); + var dispatcher = new PushNotificationDispatcher( + dbContext, + sender, + NullLogger.Instance); + + await dispatcher.DispatchMessageAsync(chat, message); + + Assert.Empty(sender.MessageCalls); + } + + [Fact] + public async Task DispatchMessageAsync_NotifiesRecipientWhenMuteExpired() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var senderUser = CreateUser("sender"); + var recipient = CreateUser("recipient"); + var recipientDevice = CreateAndroidDevice(recipient, "recipient-device", notificationsEnabled: true); + var chat = CreateChat(senderUser, recipient); + chat.Members.Single(member => member.UserId == recipient.Id).MutedUntil = DateTimeOffset.UtcNow.AddMinutes(-1); + var message = new Message + { + Chat = chat, + ChatId = chat.Id, + Sender = senderUser, + SenderId = senderUser.Id, + Text = "hello" + }; + + dbContext.AddRange(senderUser, recipient, chat, message, recipientDevice); + await dbContext.SaveChangesAsync(); + + var sender = new RecordingPushNotificationSender(); + var dispatcher = new PushNotificationDispatcher( + dbContext, + sender, + NullLogger.Instance); + + await dispatcher.DispatchMessageAsync(chat, message); + + var call = Assert.Single(sender.MessageCalls); + Assert.Equal(chat.Id, call.Chat.Id); + Assert.Equal(message.Id, call.Message.Id); + var device = Assert.Single(call.Devices); + Assert.Equal(recipientDevice.Id, device.Id); + } + + [Fact] + public async Task DispatchMessageAsync_HidesPreviewAndSenderPhoneForPrivateRecipient() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var senderUser = CreateUser("sender"); + senderUser.PhoneNumber = "+10000000000"; + var recipient = CreateUser("recipient"); + var recipientDevice = CreateAndroidDevice(recipient, "recipient-device", notificationsEnabled: true); + var chat = CreateChat(senderUser, recipient); + var message = new Message + { + Chat = chat, + ChatId = chat.Id, + Sender = senderUser, + SenderId = senderUser.Id, + Text = "secret" + }; + var privacy = new UserPrivacySettings + { + UserId = recipient.Id, + User = recipient, + ShowMessagePreview = false + }; + + dbContext.AddRange(senderUser, recipient, chat, message, recipientDevice, privacy); + await dbContext.SaveChangesAsync(); + + var sender = new RecordingPushNotificationSender(); + var dispatcher = new PushNotificationDispatcher( + dbContext, + sender, + NullLogger.Instance); + + await dispatcher.DispatchMessageAsync(chat, message); + + var call = Assert.Single(sender.MessageCalls); + Assert.Equal(message.Id, call.Message.Id); + Assert.Equal("Новое сообщение", call.Message.Text); + Assert.Null(call.Message.Sender.PhoneNumber); + var device = Assert.Single(call.Devices); + Assert.Equal(recipientDevice.Id, device.Id); + } + + [Fact] + public async Task DispatchMessageReactionAsync_SendsOnlyToMessageAuthorDevices() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var reactor = CreateUser("reactor"); + var ownerDevice = CreateAndroidDevice(owner, "owner-device", notificationsEnabled: true); + var reactorDevice = CreateAndroidDevice(reactor, "reactor-device", notificationsEnabled: true); + var disabledOwnerDevice = CreateAndroidDevice(owner, "disabled-owner-device", notificationsEnabled: false); + var chat = CreateChat(owner, reactor); + var message = new Message + { + Chat = chat, + ChatId = chat.Id, + Sender = owner, + SenderId = owner.Id, + Text = "hello" + }; + + dbContext.AddRange(owner, reactor, chat, message, ownerDevice, reactorDevice, disabledOwnerDevice); + await dbContext.SaveChangesAsync(); + + var sender = new RecordingPushNotificationSender(); + var dispatcher = new PushNotificationDispatcher( + dbContext, + sender, + NullLogger.Instance); + + await dispatcher.DispatchMessageReactionAsync(chat, message, reactor, "+1"); + + var call = Assert.Single(sender.ReactionCalls); + Assert.Equal(chat.Id, call.Chat.Id); + Assert.Equal(message.Id, call.Message.Id); + Assert.Equal(reactor.Id, call.Reactor.Id); + Assert.Equal("+1", call.Emoji); + var device = Assert.Single(call.Devices); + Assert.Equal(ownerDevice.Id, device.Id); + } + + [Fact] + public async Task DispatchMessageReactionAsync_DoesNotNotifySelfReaction() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var ownerDevice = CreateAndroidDevice(owner, "owner-device", notificationsEnabled: true); + var chat = CreateChat(owner); + var message = new Message + { + Chat = chat, + ChatId = chat.Id, + Sender = owner, + SenderId = owner.Id, + Text = "hello" + }; + + dbContext.AddRange(owner, chat, message, ownerDevice); + await dbContext.SaveChangesAsync(); + + var sender = new RecordingPushNotificationSender(); + var dispatcher = new PushNotificationDispatcher( + dbContext, + sender, + NullLogger.Instance); + + await dispatcher.DispatchMessageReactionAsync(chat, message, owner, "+1"); + + Assert.Empty(sender.ReactionCalls); + } + + private static User CreateUser(string username) => + new() + { + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = username, + PasswordHash = "test" + }; + + private static Chat CreateChat(params User[] users) + { + var chat = new Chat + { + Type = users.Length == 1 ? ChatType.Direct : ChatType.Group, + Title = "Test chat", + CreatedBy = users[0], + CreatedById = users[0].Id + }; + + foreach (var user in users) + { + chat.Members.Add(new ChatMember + { + Chat = chat, + ChatId = chat.Id, + User = user, + UserId = user.Id + }); + } + + return chat; + } + + private static PushDevice CreateAndroidDevice(User user, string installationId, bool notificationsEnabled) => + new() + { + User = user, + UserId = user.Id, + Platform = PushPlatform.Android, + InstallationId = installationId, + DeviceToken = $"{installationId}-token", + NotificationsEnabled = notificationsEnabled + }; + + private sealed class RecordingPushNotificationSender : IPushNotificationSender + { + public List MessageCalls { get; } = []; + public List ReactionCalls { get; } = []; + + public Task SendMessageAsync( + Chat chat, + Message message, + IReadOnlyCollection devices, + CancellationToken cancellationToken = default) + { + MessageCalls.Add(new MessagePushCall(chat, message, devices.ToList())); + return Task.CompletedTask; + } + + public Task SendMessageReactionAsync( + Chat chat, + Message message, + User reactor, + string emoji, + IReadOnlyCollection devices, + CancellationToken cancellationToken = default) + { + ReactionCalls.Add(new ReactionPushCall(chat, message, reactor, emoji, devices.ToList())); + return Task.CompletedTask; + } + + public Task SendIncomingCallAsync( + IncomingCallDto call, + IReadOnlyCollection devices, + CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task SendCallEndedAsync( + CallEndedDto call, + IReadOnlyCollection devices, + CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task SendAppUpdateAvailableAsync( + AndroidAppReleaseDto release, + IReadOnlyCollection devices, + CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task SendLoginCodeAsync( + string code, + string challengeId, + DateTimeOffset expiresAt, + IReadOnlyCollection devices, + CancellationToken cancellationToken = default) => + Task.FromResult(true); + } + + private sealed record MessagePushCall( + Chat Chat, + Message Message, + IReadOnlyCollection Devices); + + private sealed record ReactionPushCall( + Chat Chat, + Message Message, + User Reactor, + string Emoji, + IReadOnlyCollection Devices); +} diff --git a/ikar_server/src/Ikar.Server.Tests/QlyraCompatibilityControllerTests.cs b/ikar_server/src/Ikar.Server.Tests/QlyraCompatibilityControllerTests.cs new file mode 100644 index 0000000..067f1db --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/QlyraCompatibilityControllerTests.cs @@ -0,0 +1,938 @@ +using System.Security.Claims; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using Ikar.Server.Controllers; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Calls; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Push; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Shared; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Ikar.Server.Tests; + +public sealed partial class QlyraCompatibilityControllerTests +{ + [Fact] + public async Task EveryClientRequestOpcode_HasACompatibilityRoute() + { + int[] opcodes = + [ + 1, 8, 16, 17, 18, 19, 20, 21, 22, 23, 26, 27, 28, 29, 32, 34, 35, 36, 39, 41, 43, 46, + 48, 49, 50, 51, 52, 53, 54, 55, 57, 58, 59, 60, 64, 65, 66, 67, 68, 73, 75, 76, 77, 78, + 79, 80, 82, 83, 84, 87, 88, 89, 91, 96, 97, 104, 107, 108, 109, 110, 111, 112, + 113, 115, 118, 145, 161, 162, 164, 166, 178, 179, 181, 198, 202, 208, 210, 214, 215, + 259, 272, 273, 274, 275, 276, 290, 302, 304, 306 + ]; + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var controller = CreateAnonymousController(dbContext); + using var document = JsonDocument.Parse("{}"); + + foreach (var opcode in opcodes) + { + var action = await controller.Dispatch(opcode, new QlyraCompatibilityRequest(document.RootElement.Clone()), CancellationToken.None); + var objectResult = Assert.IsAssignableFrom(action); + Assert.NotEqual(StatusCodes.Status501NotImplemented, objectResult.StatusCode); + if (objectResult.Value is QlyraCompatibilityResponse response) Assert.NotEqual("IKAR_OPCODE_NOT_IMPLEMENTED", response.ErrorKey); + } + } + + [Fact] + public async Task ConferenceCall_ReturnsClientCompatibleJoinTokenAndLinkPreview() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var user = new User { Username = "caller", NormalizedUsername = "CALLER", DisplayName = "Caller", PasswordHash = "test" }; + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(); + var callStore = new QlyraCallCompatibilityStore(Options.Create(new WebRtcOptions())); + var controller = CreateController(dbContext, user.Id, qlyraCallStore: callStore); + using var createRequest = JsonDocument.Parse("{\"conversationId\":\"test-conversation\"}"); + + var createAction = await controller.Dispatch(76, new QlyraCompatibilityRequest(createRequest.RootElement.Clone()), CancellationToken.None); + + var createResponse = Assert.IsType(Assert.IsType(createAction).Value); + var createPayload = JsonSerializer.SerializeToElement(createResponse.Payload); + var joinToken = createPayload.GetProperty("joinLink").GetString(); + Assert.False(string.IsNullOrWhiteSpace(joinToken)); + Assert.DoesNotContain('/', joinToken!); + using var previewRequest = JsonDocument.Parse($"{{\"link\":\"joincall/{joinToken}\"}}"); + + var previewAction = await controller.Dispatch(89, new QlyraCompatibilityRequest(previewRequest.RootElement.Clone()), CancellationToken.None); + + var previewResponse = Assert.IsType(Assert.IsType(previewAction).Value); + var conference = JsonSerializer.SerializeToElement(previewResponse.Payload).GetProperty("videoConference"); + Assert.Equal("test-conversation", conference.GetProperty("conferenceId").GetString()); + Assert.Equal(1, conference.GetProperty("participantsCount").GetInt32()); + Assert.Equal("AUDIO", conference.GetProperty("callType").GetString()); + } + + [Fact] + public async Task DirectCall_SendsQlyraIncomingPushToCalleeDevice() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var caller = new User { Username = "call-sender", NormalizedUsername = "CALL-SENDER", DisplayName = "Call Sender", PasswordHash = "test" }; + var callee = new User { Username = "call-recipient", NormalizedUsername = "CALL-RECIPIENT", DisplayName = "Call Recipient", PasswordHash = "test" }; + callee.PushDevices.Add(new PushDevice + { + Platform = PushPlatform.Android, + InstallationId = "call-device", + DeviceToken = "call-device-token", + NotificationsEnabled = true + }); + dbContext.Users.AddRange(caller, callee); + await dbContext.SaveChangesAsync(); + var mapper = new QlyraClientIdMapper(dbContext); + var calleeClientId = await mapper.GetOrCreateAsync("user", callee.Id, CancellationToken.None); + var callStore = new QlyraCallCompatibilityStore(Options.Create(new WebRtcOptions())); + var pushSender = new RecordingQlyraPushSender(); + var controller = CreateController(dbContext, caller.Id, qlyraCallStore: callStore, pushNotificationSender: pushSender); + using var request = JsonDocument.Parse($"{{\"conversationId\":\"direct-call\",\"calleeIds\":[{calleeClientId}],\"isVideo\":true}}"); + + var action = await controller.Dispatch(78, new QlyraCompatibilityRequest(request.RootElement.Clone()), CancellationToken.None); + + Assert.IsType(action); + var sent = Assert.Single(pushSender.Calls); + Assert.Equal("direct-call", sent.Call.ConversationId); + Assert.Equal(calleeClientId, sent.Call.CalleeClientId); + Assert.True(sent.Call.IsVideo); + Assert.StartsWith("{", DecodeVcpJson(sent.Call.Vcp)); + Assert.Single(sent.Devices); + Assert.Equal("call-device-token", sent.Devices[0].DeviceToken); + } + + [Fact] + public async Task CallSignaling_QueuesOfferUntilCalleeConnects() + { + var store = new QlyraCallCompatibilityStore(Options.Create(new WebRtcOptions())); + var callerId = Guid.NewGuid(); + var calleeId = Guid.NewGuid(); + var conversation = store.Create("queued-offer", true, (callerId, 101), (calleeId, 202)); + var caller = conversation.Participants[callerId]; + var callee = conversation.Participants[calleeId]; + var callerSocket = new ScriptedWebSocket( + "{\"command\":\"transmit-data\",\"sequence\":7,\"participantId\":202,\"data\":{\"sdp\":{\"type\":\"offer\",\"sdp\":\"test-offer\"}}}"); + + await store.HandleWebSocketAsync(CreateWebSocketContext(caller.Token, callerSocket)); + + Assert.True(callee.PendingMessages.Count >= 1); + var calleeSocket = new ScriptedWebSocket(); + await store.HandleWebSocketAsync(CreateWebSocketContext(callee.Token, calleeSocket)); + var sent = calleeSocket.Sent.Select(text => JsonDocument.Parse(text).RootElement.Clone()).ToList(); + Assert.Equal("connection", sent[0].GetProperty("notification").GetString()); + var transmitted = Assert.Single(sent, item => item.TryGetProperty("notification", out var notification) && notification.GetString() == "transmitted-data"); + Assert.Equal(101, transmitted.GetProperty("participantId").GetInt32()); + Assert.Equal("test-offer", transmitted.GetProperty("data").GetProperty("sdp").GetProperty("sdp").GetString()); + } + + [Fact] + public async Task WebPushQrFlow_CreatesSessionAndStoresSubscription() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var user = new User { Username = "webpush-user", NormalizedUsername = "WEBPUSH-USER", DisplayName = "Web Push User", PasswordHash = "test" }; + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(); + await using var services = new ServiceCollection().AddSingleton(dbContext).BuildServiceProvider(); + var store = new QlyraWebPushSessionStore(services.GetRequiredService(), Options.Create(new JwtOptions()), Options.Create(new QlyraPublicEndpointOptions())); + var createSocket = new ScriptedWebSocket("{\"seq\":1,\"opcode\":288,\"payload\":null}"); + var createContext = CreateWebSocketContext(createSocket); + + await store.HandleWebSocketAsync(createContext); + + var createPayload = JsonDocument.Parse(createSocket.Sent.Single()).RootElement.GetProperty("payload"); + var trackId = createPayload.GetProperty("trackId").GetString()!; + var qrLink = createPayload.GetProperty("qrLink").GetString()!; + Assert.True(store.Approve(qrLink, user.Id)); + var finishSocket = new ScriptedWebSocket( + $"{{\"seq\":2,\"opcode\":289,\"payload\":{{\"trackId\":\"{trackId}\"}}}}", + $"{{\"seq\":3,\"opcode\":291,\"payload\":{{\"trackId\":\"{trackId}\"}}}}"); + + await store.HandleWebSocketAsync(CreateWebSocketContext(finishSocket)); + + var finishFrames = finishSocket.Sent.Select(text => JsonDocument.Parse(text).RootElement.Clone()).ToList(); + Assert.True(finishFrames[0].GetProperty("payload").GetProperty("status").GetProperty("loginAvailable").GetBoolean()); + var token = finishFrames[1].GetProperty("payload").GetProperty("tokenAttrs").GetProperty("LOGIN").GetProperty("token").GetString()!; + var registerSocket = new ScriptedWebSocket( + $"{{\"seq\":4,\"opcode\":19,\"payload\":{{\"token\":\"{token}\"}}}}", + "{\"seq\":5,\"opcode\":22,\"payload\":{\"subscribe\":true,\"pushToken\":\"https://push.example/subscription\",\"publicKey\":\"public-key\",\"secretKey\":\"auth-key\"}}"); + + await store.HandleWebSocketAsync(CreateWebSocketContext(registerSocket)); + + var subscription = Assert.Single(dbContext.PushDevices); + Assert.Equal(PushPlatform.Web, subscription.Platform); + Assert.Equal("https://push.example/subscription", subscription.DeviceToken); + Assert.Equal("public-key", subscription.WebPushPublicKey); + Assert.Equal("auth-key", subscription.WebPushAuthKey); + Assert.Single(dbContext.UserSessions); + } + + [Fact] + public async Task ContactPresence_ReturnsAuthenticatedUsersPresence() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var user = new User + { + Username = "presence-user", + NormalizedUsername = "PRESENCE-USER", + DisplayName = "Presence User", + PasswordHash = "test" + }; + user.Sessions.Add(new UserSession + { + TokenHash = "presence-token", + LastSeenAt = DateTimeOffset.UtcNow.AddMinutes(-1) + }); + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(); + var mapper = new QlyraClientIdMapper(dbContext); + var clientUserId = await mapper.GetOrCreateAsync("user", user.Id, CancellationToken.None); + var controller = new QlyraCompatibilityController( + dbContext, + new TokenService(dbContext, Options.Create(new JwtOptions())), + new DeviceAuthChallengeStore(), + new PresenceTracker(), + mapper, + ControllerTestFixture.CreateQlyraUploadStore(Path.GetTempPath()), + new NoopHubContext(), + NullLogger.Instance); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())], + "Test")) + } + }; + using var document = JsonDocument.Parse($"{{\"contactIds\":[{clientUserId}]}}"); + + var action = await controller.Dispatch( + 35, + new QlyraCompatibilityRequest(document.RootElement.Clone()), + CancellationToken.None); + + var ok = Assert.IsType(action); + var response = Assert.IsType(ok.Value); + var responsePayload = JsonSerializer.SerializeToElement(response.Payload); + var item = responsePayload.GetProperty("presence").GetProperty(clientUserId.ToString()); + Assert.Equal(1, item.GetProperty("status").GetInt32()); + Assert.True(item.GetProperty("seen").GetInt64() > 0); + } + + [Fact] + public async Task Config_RegistersPushTokenForAuthenticatedDevice() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var user = new User + { + Username = "push-user", + NormalizedUsername = "PUSH-USER", + DisplayName = "Push User", + PasswordHash = "test" + }; + user.DeviceCredentials.Add(new DeviceCredential + { + InstallationId = "00112233445566778899aabbccddeeff", + PublicKey = "test-public-key" + }); + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(); + var controller = CreateController(dbContext, user.Id); + using var document = JsonDocument.Parse("{\"pushToken\":\"fcm-test-token\"}"); + + var action = await controller.Dispatch( + 22, + new QlyraCompatibilityRequest(document.RootElement.Clone()), + CancellationToken.None); + + Assert.IsType(action); + var device = Assert.Single(dbContext.PushDevices); + Assert.Equal(user.Id, device.UserId); + Assert.Equal("00112233445566778899aabbccddeeff", device.InstallationId); + Assert.Equal("fcm-test-token", device.DeviceToken); + Assert.Equal(PushPlatform.Android, device.Platform); + Assert.True(device.NotificationsEnabled); + } + + [Fact] + public async Task ChatMembers_ReturnsMappedContactsAndPresence() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = new User { Username = "owner", NormalizedUsername = "OWNER", DisplayName = "Chat Owner", PasswordHash = "test" }; + var member = new User { Username = "member", NormalizedUsername = "MEMBER", DisplayName = "Chat Member", PasswordHash = "test" }; + var chat = new Chat { Type = ChatType.Group, Title = "Group", CreatedBy = owner }; + chat.Members.Add(new ChatMember { Chat = chat, User = owner, IsOwner = true, Role = ChatMemberRole.Owner }); + chat.Members.Add(new ChatMember { Chat = chat, User = member, Role = ChatMemberRole.Member }); + dbContext.Chats.Add(chat); + await dbContext.SaveChangesAsync(); + var mapper = new QlyraClientIdMapper(dbContext); + var clientChatId = await mapper.GetOrCreateAsync("chat", chat.Id, CancellationToken.None); + var controller = CreateController(dbContext, owner.Id); + using var document = JsonDocument.Parse($"{{\"chatId\":{clientChatId},\"marker\":0,\"count\":50}}"); + + var action = await controller.Dispatch(59, new QlyraCompatibilityRequest(document.RootElement.Clone()), CancellationToken.None); + + var response = Assert.IsType(Assert.IsType(action).Value); + var responsePayload = JsonSerializer.SerializeToElement(response.Payload); + var members = responsePayload.GetProperty("members"); + Assert.Equal(2, members.GetArrayLength()); + Assert.All(members.EnumerateArray(), item => Assert.True(item.GetProperty("contact").GetProperty("id").GetInt32() > 0)); + } + + [Fact] + public async Task MessageSearch_IsLimitedToMemberChats() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var user = new User { Username = "searcher", NormalizedUsername = "SEARCHER", DisplayName = "Searcher", PasswordHash = "test" }; + var chat = new Chat { Type = ChatType.Direct, Title = "Dialog", CreatedBy = user }; + chat.Members.Add(new ChatMember { Chat = chat, User = user, IsOwner = true, Role = ChatMemberRole.Owner }); + chat.Messages.Add(new Message { Chat = chat, Sender = user, Text = "needle in message" }); + dbContext.Chats.Add(chat); + await dbContext.SaveChangesAsync(); + var controller = CreateController(dbContext, user.Id); + using var document = JsonDocument.Parse("{\"query\":\"needle\",\"count\":10}"); + + var action = await controller.Dispatch(68, new QlyraCompatibilityRequest(document.RootElement.Clone()), CancellationToken.None); + + var response = Assert.IsType(Assert.IsType(action).Value); + var result = JsonSerializer.SerializeToElement(response.Payload).GetProperty("result"); + Assert.Single(result.EnumerateArray()); + Assert.Equal("needle in message", result[0].GetProperty("message").GetProperty("text").GetString()); + } + + [Fact] + public async Task DeleteForMe_HidesMessageOnlyFromRequestingUser() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var first = new User { Username = "hide-first", NormalizedUsername = "HIDE-FIRST", DisplayName = "First", PasswordHash = "test" }; + var second = new User { Username = "hide-second", NormalizedUsername = "HIDE-SECOND", DisplayName = "Second", PasswordHash = "test" }; + var chat = new Chat { Type = ChatType.Group, Title = "Private deletion", CreatedBy = first }; + chat.Members.Add(new ChatMember { Chat = chat, User = first, IsOwner = true, Role = ChatMemberRole.Owner }); + chat.Members.Add(new ChatMember { Chat = chat, User = second, Role = ChatMemberRole.Member }); + var message = new Message { Chat = chat, Sender = second, Text = "visible to the peer" }; + chat.Messages.Add(message); + dbContext.Chats.Add(chat); + await dbContext.SaveChangesAsync(); + var mapper = new QlyraClientIdMapper(dbContext); + var clientChatId = await mapper.GetOrCreateAsync("chat", chat.Id, CancellationToken.None); + var clientMessageId = await mapper.GetOrCreateAsync("message", message.Id, CancellationToken.None); + var firstController = CreateController(dbContext, first.Id); + using var delete = JsonDocument.Parse($"{{\"chatId\":{clientChatId},\"messageIds\":[{clientMessageId}],\"forMe\":true}}"); + + Assert.IsType(await firstController.Dispatch(66, new QlyraCompatibilityRequest(delete.RootElement.Clone()), CancellationToken.None)); + + using var history = JsonDocument.Parse($"{{\"chatId\":{clientChatId},\"backward\":50}}"); + var hiddenHistoryAction = await firstController.Dispatch(49, new QlyraCompatibilityRequest(history.RootElement.Clone()), CancellationToken.None); + var hiddenHistory = JsonSerializer.SerializeToElement(Assert.IsType(Assert.IsType(hiddenHistoryAction).Value).Payload); + Assert.Empty(hiddenHistory.GetProperty("messages").EnumerateArray()); + var secondController = CreateController(dbContext, second.Id); + var peerHistoryAction = await secondController.Dispatch(49, new QlyraCompatibilityRequest(history.RootElement.Clone()), CancellationToken.None); + var peerHistory = JsonSerializer.SerializeToElement(Assert.IsType(Assert.IsType(peerHistoryAction).Value).Payload); + Assert.Single(peerHistory.GetProperty("messages").EnumerateArray()); + Assert.Null(message.DeletedAt); + } + + [Fact] + public async Task Poll_CreateFetchAndVote_RoundTripWithClientShapes() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = new User { Username = "poll-owner", NormalizedUsername = "POLL-OWNER", DisplayName = "Poll Owner", PasswordHash = "test" }; + var voter = new User { Username = "poll-voter", NormalizedUsername = "POLL-VOTER", DisplayName = "Poll Voter", PasswordHash = "test" }; + var chat = new Chat { Type = ChatType.Group, Title = "Poll", CreatedBy = owner }; + chat.Members.Add(new ChatMember { Chat = chat, User = owner, IsOwner = true, Role = ChatMemberRole.Owner }); + chat.Members.Add(new ChatMember { Chat = chat, User = voter, Role = ChatMemberRole.Member }); + dbContext.Chats.Add(chat); + await dbContext.SaveChangesAsync(); + var mapper = new QlyraClientIdMapper(dbContext); + var clientChatId = await mapper.GetOrCreateAsync("chat", chat.Id, CancellationToken.None); + var ownerController = CreateController(dbContext, owner.Id); + using var send = JsonDocument.Parse($"{{\"chatId\":{clientChatId},\"message\":{{\"attaches\":[{{\"_type\":\"POLL\",\"title\":\"Выбор\",\"settings\":0,\"answers\":[{{\"text\":\"Да\"}},{{\"text\":\"Нет\"}}]}}]}}}}"); + var sendAction = await ownerController.Dispatch(64, new QlyraCompatibilityRequest(send.RootElement.Clone()), CancellationToken.None); + var sentMessage = JsonSerializer.SerializeToElement(Assert.IsType(Assert.IsType(sendAction).Value).Payload).GetProperty("message"); + var pollId = sentMessage.GetProperty("attaches")[0].GetProperty("pollId").GetInt32(); + var messageId = sentMessage.GetProperty("id").GetInt32(); + var voterController = CreateController(dbContext, voter.Id); + using var vote = JsonDocument.Parse($"{{\"chatId\":{clientChatId},\"messageId\":{messageId},\"pollId\":{pollId},\"answersIds\":[1]}}"); + var voteAction = await voterController.Dispatch(304, new QlyraCompatibilityRequest(vote.RootElement.Clone()), CancellationToken.None); + var state = JsonSerializer.SerializeToElement(Assert.IsType(Assert.IsType(voteAction).Value).Payload).GetProperty("state"); + Assert.Equal(1, state.GetProperty("total").GetInt32()); + Assert.Equal(1, state.GetProperty("result")[0].GetProperty("options").GetInt32()); + using var fetch = JsonDocument.Parse($"{{\"chatId\":{clientChatId},\"polls\":[{{\"messageId\":{messageId},\"pollId\":{pollId}}}]}}"); + var fetchAction = await voterController.Dispatch(306, new QlyraCompatibilityRequest(fetch.RootElement.Clone()), CancellationToken.None); + var fetched = JsonSerializer.SerializeToElement(Assert.IsType(Assert.IsType(fetchAction).Value).Payload).GetProperty("polls"); + Assert.Single(fetched.EnumerateArray()); + Assert.Equal("Выбор", fetched[0].GetProperty("title").GetString()); + } + + [Fact] + public async Task CallHistory_ReturnsCallMessagesAndCanHideEntries() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var user = new User { Username = "call-user", NormalizedUsername = "CALL-USER", DisplayName = "Caller", PasswordHash = "test" }; + var chat = new Chat { Type = ChatType.Group, Title = "Calls", CreatedBy = user }; + chat.Members.Add(new ChatMember { Chat = chat, User = user, IsOwner = true, Role = ChatMemberRole.Owner }); + var message = new Message { Chat = chat, Sender = user, Text = "Аудиозвонок завершён", QlyraAttachesJson = "[{\"_type\":\"CALL\",\"callType\":\"AUDIO\",\"duration\":5000,\"contactIds\":[]}]" }; + chat.Messages.Add(message); + dbContext.Chats.Add(chat); + await dbContext.SaveChangesAsync(); + var mapper = new QlyraClientIdMapper(dbContext); + var clientMessageId = await mapper.GetOrCreateAsync("message", message.Id, CancellationToken.None); + var controller = CreateController(dbContext, user.Id); + using var empty = JsonDocument.Parse("{}"); + var historyAction = await controller.Dispatch(79, new QlyraCompatibilityRequest(empty.RootElement.Clone()), CancellationToken.None); + var history = JsonSerializer.SerializeToElement(Assert.IsType(Assert.IsType(historyAction).Value).Payload).GetProperty("history"); + Assert.Single(history.EnumerateArray()); + Assert.Equal("CALL", history[0].GetProperty("message").GetProperty("attaches")[0].GetProperty("_type").GetString()); + using var remove = JsonDocument.Parse($"{{\"historyIds\":[{clientMessageId}]}}"); + Assert.IsType(await controller.Dispatch(164, new QlyraCompatibilityRequest(remove.RootElement.Clone()), CancellationToken.None)); + var emptyAction = await controller.Dispatch(79, new QlyraCompatibilityRequest(empty.RootElement.Clone()), CancellationToken.None); + var emptyHistory = JsonSerializer.SerializeToElement(Assert.IsType(Assert.IsType(emptyAction).Value).Payload).GetProperty("history"); + Assert.Empty(emptyHistory.EnumerateArray()); + } + + [Fact] + public async Task TwoFactorPassword_EnableInspectVerifyAndRemove_RoundTrip() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var user = new User { Username = "two-factor", NormalizedUsername = "TWO-FACTOR", DisplayName = "Two Factor", PasswordHash = "unrelated-login-secret" }; + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(); + var controller = CreateController(dbContext, user.Id); + using var empty = JsonDocument.Parse("{}"); + var createAction = await controller.Dispatch(112, new QlyraCompatibilityRequest(empty.RootElement.Clone()), CancellationToken.None); + var trackId = JsonSerializer.SerializeToElement(Assert.IsType(Assert.IsType(createAction).Value).Payload).GetProperty("trackId").GetString()!; + using var password = JsonDocument.Parse($"{{\"trackId\":\"{trackId}\",\"password\":\"correct horse battery staple\"}}"); + using var hint = JsonDocument.Parse($"{{\"trackId\":\"{trackId}\",\"hint\":\"four words\"}}"); + Assert.IsType(await controller.Dispatch(107, new QlyraCompatibilityRequest(password.RootElement.Clone()), CancellationToken.None)); + Assert.IsType(await controller.Dispatch(108, new QlyraCompatibilityRequest(hint.RootElement.Clone()), CancellationToken.None)); + using var commit = JsonDocument.Parse($"{{\"trackId\":\"{trackId}\",\"password\":\"correct horse battery staple\",\"hint\":\"four words\",\"expectedCapabilities\":[0,3]}}"); + Assert.IsType(await controller.Dispatch(111, new QlyraCompatibilityRequest(commit.RootElement.Clone()), CancellationToken.None)); + var stored = Assert.Single(dbContext.UserTwoFactorSettings); + Assert.Equal("four words", stored.Hint); + Assert.NotEqual(user.PasswordHash, stored.PasswordHash); + + var secondTrackAction = await controller.Dispatch(112, new QlyraCompatibilityRequest(empty.RootElement.Clone()), CancellationToken.None); + var secondTrack = JsonSerializer.SerializeToElement(Assert.IsType(Assert.IsType(secondTrackAction).Value).Payload).GetProperty("trackId").GetString()!; + using var details = JsonDocument.Parse($"{{\"trackId\":\"{secondTrack}\"}}"); + var detailsAction = await controller.Dispatch(104, new QlyraCompatibilityRequest(details.RootElement.Clone()), CancellationToken.None); + var detailsPayload = JsonSerializer.SerializeToElement(Assert.IsType(Assert.IsType(detailsAction).Value).Payload); + Assert.True(detailsPayload.GetProperty("password").GetProperty("enabled").GetBoolean()); + using var verify = JsonDocument.Parse($"{{\"trackId\":\"{secondTrack}\",\"password\":\"correct horse battery staple\"}}"); + Assert.IsType(await controller.Dispatch(113, new QlyraCompatibilityRequest(verify.RootElement.Clone()), CancellationToken.None)); + using var remove = JsonDocument.Parse($"{{\"trackId\":\"{secondTrack}\",\"remove2fa\":true,\"expectedCapabilities\":[5]}}"); + Assert.IsType(await controller.Dispatch(111, new QlyraCompatibilityRequest(remove.RootElement.Clone()), CancellationToken.None)); + Assert.Empty(dbContext.UserTwoFactorSettings); + } + + [Fact] + public async Task TwoFactorEmail_SendsAndVerifiesCode() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var user = new User { Username = "two-factor-email", NormalizedUsername = "TWO-FACTOR-EMAIL", DisplayName = "Two Factor Email", PasswordHash = "test" }; + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(); + var sender = new RecordingEmailCodeSender(); + var controller = CreateController(dbContext, user.Id, emailCodeSender: sender); + using var empty = JsonDocument.Parse("{}"); + var createAction = await controller.Dispatch(112, new QlyraCompatibilityRequest(empty.RootElement.Clone()), CancellationToken.None); + var trackId = JsonSerializer.SerializeToElement(Assert.IsType(Assert.IsType(createAction).Value).Payload).GetProperty("trackId").GetString()!; + using var request = JsonDocument.Parse($"{{\"trackId\":\"{trackId}\",\"email\":\"USER@EXAMPLE.COM\"}}"); + Assert.IsType(await controller.Dispatch(109, new QlyraCompatibilityRequest(request.RootElement.Clone()), CancellationToken.None)); + Assert.Equal("user@example.com", sender.Email); + Assert.Matches("^[0-9]{6}$", sender.Code); + using var verify = JsonDocument.Parse($"{{\"trackId\":\"{trackId}\",\"verifyCode\":\"{sender.Code}\"}}"); + var verifyAction = await controller.Dispatch(110, new QlyraCompatibilityRequest(verify.RootElement.Clone()), CancellationToken.None); + var verified = JsonSerializer.SerializeToElement(Assert.IsType(Assert.IsType(verifyAction).Value).Payload); + Assert.Equal("user@example.com", verified.GetProperty("email").GetString()); + } + + [Fact] + public async Task SavedMessages_OpenSendAndReopen_PreservesPrivateChatAndZeroId() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = new User { Username = "saved-owner", NormalizedUsername = "SAVED-OWNER", DisplayName = "Saved Owner", PasswordHash = "test" }; + var other = new User { Username = "saved-other", NormalizedUsername = "SAVED-OTHER", DisplayName = "Saved Other", PasswordHash = "test" }; + dbContext.Users.AddRange(owner, other); + await dbContext.SaveChangesAsync(); + var controller = CreateController(dbContext, owner.Id); + using var open = JsonDocument.Parse("{\"chatId\":0,\"subscribe\":true}"); + using var close = JsonDocument.Parse("{\"chatId\":0,\"subscribe\":false}"); + using var text = JsonDocument.Parse("{\"chatId\":0,\"message\":{\"cid\":-101,\"text\":\"my note\",\"attaches\":[]}}"); + using var sticker = JsonDocument.Parse("{\"chatId\":0,\"message\":{\"cid\":-102,\"text\":\"\",\"attaches\":[{\"_type\":\"STICKER\",\"stickerId\":900001}]}}"); + + var subscribed = await controller.Dispatch(75, new QlyraCompatibilityRequest(open.RootElement.Clone()), CancellationToken.None); + var subscribedPayload = JsonSerializer.SerializeToElement(Assert.IsType(Assert.IsType(subscribed).Value).Payload); + Assert.Equal(0, subscribedPayload.GetProperty("chat").GetProperty("id").GetInt32()); + Assert.IsType(await controller.Dispatch(64, new QlyraCompatibilityRequest(text.RootElement.Clone()), CancellationToken.None)); + Assert.IsType(await controller.Dispatch(64, new QlyraCompatibilityRequest(sticker.RootElement.Clone()), CancellationToken.None)); + Assert.IsType(await controller.Dispatch(75, new QlyraCompatibilityRequest(close.RootElement.Clone()), CancellationToken.None)); + var history = await controller.Dispatch(49, new QlyraCompatibilityRequest(open.RootElement.Clone()), CancellationToken.None); + var historyPayload = JsonSerializer.SerializeToElement(Assert.IsType(Assert.IsType(history).Value).Payload); + Assert.Equal(2, historyPayload.GetProperty("messages").GetArrayLength()); + Assert.Contains(historyPayload.GetProperty("messages").EnumerateArray(), message => message.GetProperty("text").GetString() == "my note"); + Assert.Contains(historyPayload.GetProperty("messages").EnumerateArray(), message => message.GetProperty("attaches").GetArrayLength() == 1); + var list = await controller.Dispatch(53, new QlyraCompatibilityRequest(open.RootElement.Clone()), CancellationToken.None); + var listPayload = JsonSerializer.SerializeToElement(Assert.IsType(Assert.IsType(list).Value).Payload); + Assert.Equal(0, listPayload.GetProperty("chats")[0].GetProperty("id").GetInt32()); + Assert.Equal(owner.Id, Assert.Single(dbContext.ChatMembers).UserId); + + var otherController = CreateController(dbContext, other.Id); + var otherHistory = await otherController.Dispatch(49, new QlyraCompatibilityRequest(open.RootElement.Clone()), CancellationToken.None); + var otherPayload = JsonSerializer.SerializeToElement(Assert.IsType(Assert.IsType(otherHistory).Value).Payload); + Assert.Empty(otherPayload.GetProperty("messages").EnumerateArray()); + Assert.Equal(2, dbContext.Chats.Count()); + Assert.Equal(2, dbContext.Messages.Count()); + } + + [Fact] + public async Task ContactDialogBeforeFirstMessage_HasEmptyHistoryAndCanSubscribe() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var sender = new User { Username = "empty-sender", NormalizedUsername = "EMPTY-SENDER", DisplayName = "Sender", PasswordHash = "test" }; + var recipient = new User { Username = "empty-recipient", NormalizedUsername = "EMPTY-RECIPIENT", DisplayName = "Recipient", PasswordHash = "test" }; + dbContext.Users.AddRange(sender, recipient); + await dbContext.SaveChangesAsync(); + var mapper = new QlyraClientIdMapper(dbContext); + var senderId = await mapper.GetOrCreateAsync("user", sender.Id, CancellationToken.None); + var recipientId = await mapper.GetOrCreateAsync("user", recipient.Id, CancellationToken.None); + var controller = CreateController(dbContext, sender.Id); + using var request = JsonDocument.Parse($"{{\"chatId\":{senderId ^ recipientId},\"subscribe\":true}}"); + var history = await controller.Dispatch(49, new QlyraCompatibilityRequest(request.RootElement.Clone()), CancellationToken.None); + var payload = JsonSerializer.SerializeToElement(Assert.IsType(Assert.IsType(history).Value).Payload); + Assert.Empty(payload.GetProperty("messages").EnumerateArray()); + Assert.False(payload.GetProperty("hasMore").GetBoolean()); + Assert.IsType(await controller.Dispatch(75, new QlyraCompatibilityRequest(request.RootElement.Clone()), CancellationToken.None)); + Assert.Empty(dbContext.Chats); + Assert.Empty(dbContext.ChatMembers); + } + + [Fact] + public async Task DirectDialog_SubscribeAndClosePreview_PreservesMembershipAndSending() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var sender = new User { Username = "sub-sender", NormalizedUsername = "SUB-SENDER", DisplayName = "Sender", PasswordHash = "test" }; + var recipient = new User { Username = "sub-recipient", NormalizedUsername = "SUB-RECIPIENT", DisplayName = "Recipient", PasswordHash = "test" }; + dbContext.Users.AddRange(sender, recipient); + await dbContext.SaveChangesAsync(); + var mapper = new QlyraClientIdMapper(dbContext); + var senderId = await mapper.GetOrCreateAsync("user", sender.Id, CancellationToken.None); + var recipientId = await mapper.GetOrCreateAsync("user", recipient.Id, CancellationToken.None); + var chatId = senderId ^ recipientId; + var controller = CreateController(dbContext, sender.Id); + using var send = JsonDocument.Parse($"{{\"chatId\":{chatId},\"message\":{{\"text\":\"hello\",\"attaches\":[]}}}}"); + using var subscribe = JsonDocument.Parse($"{{\"chatId\":{chatId},\"subscribe\":true}}"); + using var unsubscribe = JsonDocument.Parse($"{{\"chatId\":{chatId},\"subscribe\":false}}"); + Assert.IsType(await controller.Dispatch(64, new QlyraCompatibilityRequest(send.RootElement.Clone()), CancellationToken.None)); + Assert.IsType(await controller.Dispatch(75, new QlyraCompatibilityRequest(subscribe.RootElement.Clone()), CancellationToken.None)); + Assert.IsType(await controller.Dispatch(75, new QlyraCompatibilityRequest(unsubscribe.RootElement.Clone()), CancellationToken.None)); + Assert.IsType(await controller.Dispatch(64, new QlyraCompatibilityRequest(send.RootElement.Clone()), CancellationToken.None)); + Assert.Single(dbContext.Chats); + Assert.Equal(2, dbContext.ChatMembers.Count()); + Assert.Equal(2, dbContext.Messages.Count()); + } + + [Fact] + public async Task PublicChannelPreview_SubscribeAndClose_DoesNotJoinOrLeaveChannel() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = new User { Username = "preview-owner", NormalizedUsername = "PREVIEW-OWNER", DisplayName = "Owner", PasswordHash = "test" }; + var viewer = new User { Username = "preview-viewer", NormalizedUsername = "PREVIEW-VIEWER", DisplayName = "Viewer", PasswordHash = "test" }; + var channel = new Chat { Type = ChatType.Channel, CreatedById = owner.Id, Title = "Public channel", PublicUsername = "public-preview" }; + channel.Members.Add(new ChatMember { Chat = channel, User = owner, UserId = owner.Id, IsOwner = true, Role = ChatMemberRole.Owner }); + dbContext.Users.AddRange(owner, viewer); + dbContext.Chats.Add(channel); + await dbContext.SaveChangesAsync(); + var mapper = new QlyraClientIdMapper(dbContext); + var channelId = await mapper.GetOrCreateAsync("chat", channel.Id, CancellationToken.None); + var controller = CreateController(dbContext, viewer.Id); + using var subscribe = JsonDocument.Parse($"{{\"chatId\":{channelId},\"subscribe\":true}}"); + using var unsubscribe = JsonDocument.Parse($"{{\"chatId\":{channelId},\"subscribe\":false}}"); + Assert.IsType(await controller.Dispatch(75, new QlyraCompatibilityRequest(subscribe.RootElement.Clone()), CancellationToken.None)); + Assert.IsType(await controller.Dispatch(75, new QlyraCompatibilityRequest(unsubscribe.RootElement.Clone()), CancellationToken.None)); + Assert.Equal(owner.Id, Assert.Single(dbContext.ChatMembers).UserId); + + var ownerController = CreateController(dbContext, owner.Id); + Assert.IsType(await ownerController.Dispatch(75, new QlyraCompatibilityRequest(unsubscribe.RootElement.Clone()), CancellationToken.None)); + Assert.Equal(owner.Id, Assert.Single(dbContext.ChatMembers).UserId); + } + + [Fact] + public async Task DirectChat_UsesXorIdAndCanBeCreatedBySending() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var sender = new User { Username = "sender", NormalizedUsername = "SENDER", DisplayName = "Sender", PasswordHash = "test" }; + var recipient = new User { Username = "recipient", NormalizedUsername = "RECIPIENT", DisplayName = "Recipient", PasswordHash = "test" }; + dbContext.Users.AddRange(sender, recipient); + await dbContext.SaveChangesAsync(); + var mapper = new QlyraClientIdMapper(dbContext); + var senderClientId = await mapper.GetOrCreateAsync("user", sender.Id, CancellationToken.None); + var recipientClientId = await mapper.GetOrCreateAsync("user", recipient.Id, CancellationToken.None); + var clientChatId = senderClientId ^ recipientClientId; + var controller = CreateController(dbContext, sender.Id); + using var document = JsonDocument.Parse($"{{\"chatId\":{clientChatId},\"message\":{{\"text\":\"hello\",\"attaches\":[]}}}}"); + + var action = await controller.Dispatch(64, new QlyraCompatibilityRequest(document.RootElement.Clone()), CancellationToken.None); + + Assert.IsType(action); + var chat = Assert.Single(dbContext.Chats); + Assert.Equal(ChatType.Direct, chat.Type); + Assert.Equal(2, dbContext.ChatMembers.Count()); + var listAction = await controller.Dispatch(53, new QlyraCompatibilityRequest(document.RootElement.Clone()), CancellationToken.None); + var listResponse = Assert.IsType(Assert.IsType(listAction).Value); + var returnedChat = JsonSerializer.SerializeToElement(listResponse.Payload).GetProperty("chats")[0]; + Assert.Equal(clientChatId, returnedChat.GetProperty("id").GetInt32()); + } + + [Fact] + public async Task NewChatControl_CreatesGroupWithRequestedMembers() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = new User { Username = "creator", NormalizedUsername = "CREATOR", DisplayName = "Creator", PasswordHash = "test" }; + var member = new User { Username = "invited", NormalizedUsername = "INVITED", DisplayName = "Invited", PasswordHash = "test" }; + dbContext.Users.AddRange(owner, member); + await dbContext.SaveChangesAsync(); + var mapper = new QlyraClientIdMapper(dbContext); + var memberClientId = await mapper.GetOrCreateAsync("user", member.Id, CancellationToken.None); + var controller = CreateController(dbContext, owner.Id); + using var document = JsonDocument.Parse($"{{\"message\":{{\"attaches\":[{{\"_type\":\"CONTROL\",\"event\":\"new\",\"chatType\":\"CHAT\",\"title\":\"Team\",\"userIds\":[{memberClientId}]}}]}}}}"); + + var action = await controller.Dispatch(64, new QlyraCompatibilityRequest(document.RootElement.Clone()), CancellationToken.None); + + var response = Assert.IsType(Assert.IsType(action).Value); + var returnedChat = JsonSerializer.SerializeToElement(response.Payload).GetProperty("chat"); + Assert.Equal("CHAT", returnedChat.GetProperty("type").GetString()); + Assert.Equal("Team", returnedChat.GetProperty("title").GetString()); + Assert.Equal(2, dbContext.ChatMembers.Count()); + } + + [Fact] + public async Task UploadedFileToken_IsMaterializedIntoSentMessage() + { + var contentRoot = Path.Combine(Path.GetTempPath(), $"ikar-qlyra-upload-{Guid.NewGuid():N}"); + Directory.CreateDirectory(contentRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var sender = new User { Username = "upload-sender", NormalizedUsername = "UPLOAD-SENDER", DisplayName = "Upload Sender", PasswordHash = "test" }; + var recipient = new User { Username = "upload-recipient", NormalizedUsername = "UPLOAD-RECIPIENT", DisplayName = "Upload Recipient", PasswordHash = "test" }; + dbContext.Users.AddRange(sender, recipient); + await dbContext.SaveChangesAsync(); + var mapper = new QlyraClientIdMapper(dbContext); + var senderClientId = await mapper.GetOrCreateAsync("user", sender.Id, CancellationToken.None); + var recipientClientId = await mapper.GetOrCreateAsync("user", recipient.Id, CancellationToken.None); + var uploadStore = ControllerTestFixture.CreateQlyraUploadStore(contentRoot); + var controller = CreateController(dbContext, sender.Id, uploadStore); + using var empty = JsonDocument.Parse("{\"count\":1}"); + var reserveAction = await controller.Dispatch(87, new QlyraCompatibilityRequest(empty.RootElement.Clone()), CancellationToken.None); + var reserveResponse = Assert.IsType(Assert.IsType(reserveAction).Value); + var info = JsonSerializer.SerializeToElement(reserveResponse.Payload).GetProperty("info")[0]; + var token = Assert.IsType(info.GetProperty("token").GetString()); + await using var bytes = new MemoryStream("hello attachment"u8.ToArray()); + var uploaded = await uploadStore.SaveAsync(token, bytes, "hello.txt", "text/plain", bytes.Length, CancellationToken.None); + Assert.NotNull(uploaded); + + var chatId = senderClientId ^ recipientClientId; + using var send = JsonDocument.Parse($"{{\"chatId\":{chatId},\"message\":{{\"attaches\":[{{\"_type\":\"FILE\",\"token\":\"{token}\"}}]}}}}"); + var sendAction = await controller.Dispatch(64, new QlyraCompatibilityRequest(send.RootElement.Clone()), CancellationToken.None); + + var sendResponse = Assert.IsType(Assert.IsType(sendAction).Value); + var attach = JsonSerializer.SerializeToElement(sendResponse.Payload).GetProperty("message").GetProperty("attaches")[0]; + Assert.Equal("FILE", attach.GetProperty("_type").GetString()); + var stored = Assert.Single(dbContext.MessageAttachments); + Assert.Equal("hello.txt", stored.OriginalFileName); + Assert.Equal("text/plain", stored.ContentType); + Assert.True(File.Exists(stored.StoragePath)); + } + finally + { + if (Directory.Exists(contentRoot)) Directory.Delete(contentRoot, true); + } + } + + [Fact] + public async Task ChunkedVideoUpload_ResumesAndMaterializesCompleteFile() + { + var contentRoot = Path.Combine(Path.GetTempPath(), $"ikar-qlyra-video-{Guid.NewGuid():N}"); + Directory.CreateDirectory(contentRoot); + try + { + var store = ControllerTestFixture.CreateQlyraUploadStore(contentRoot); + var userId = Guid.NewGuid(); + var reservation = store.Reserve(userId, "video.mp4"); + await using var first = new MemoryStream([1, 2, 3]); + var partial = await store.SaveChunkAsync(reservation.Token, first, 0, 2, 6, "video.mp4", "application/x-binary", CancellationToken.None); + Assert.NotNull(partial); + Assert.Null(partial.UploadedAt); + Assert.Equal(3, store.GetChunkOffset(reservation.Token)); + await using var second = new MemoryStream([4, 5, 6]); + var complete = await store.SaveChunkAsync(reservation.Token, second, 3, 5, 6, "video.mp4", "application/x-binary", CancellationToken.None); + Assert.NotNull(complete?.UploadedAt); + Assert.Equal(6, store.GetChunkOffset(reservation.Token)); + var attachment = await store.MaterializeMessageAttachmentAsync(reservation.Token, userId, Guid.NewGuid(), AttachmentKind.File, 0, CancellationToken.None); + Assert.NotNull(attachment); + Assert.Equal("video/mp4", attachment.ContentType); + Assert.Equal(new byte[] { 1, 2, 3, 4, 5, 6 }, await File.ReadAllBytesAsync(attachment.StoragePath)); + } + finally + { + if (Directory.Exists(contentRoot)) Directory.Delete(contentRoot, true); + } + } + + [Fact] + public async Task Folders_CreateReadReorderAndDelete_RoundTripWithClientShapes() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var user = new User { Username = "folders-user", NormalizedUsername = "FOLDERS-USER", DisplayName = "Folders User", PasswordHash = "test" }; + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(); + var controller = CreateController(dbContext, user.Id); + + using var createFirst = JsonDocument.Parse("{\"id\":\"first\",\"title\":\"Работа\",\"include\":[101],\"filters\":[3],\"options\":[0],\"favorites\":[101]}"); + using var createSecond = JsonDocument.Parse("{\"id\":\"second\",\"title\":\"Личное\",\"include\":[],\"filters\":[4],\"options\":[],\"favorites\":[]}"); + var firstAction = await controller.Dispatch(274, new QlyraCompatibilityRequest(createFirst.RootElement.Clone()), CancellationToken.None); + var firstPayload = JsonSerializer.SerializeToElement(Assert.IsType(Assert.IsType(firstAction).Value).Payload); + Assert.Equal("first", firstPayload.GetProperty("folder").GetProperty("id").GetString()); + Assert.Equal(101, firstPayload.GetProperty("folder").GetProperty("include")[0].GetInt32()); + Assert.IsType(await controller.Dispatch(274, new QlyraCompatibilityRequest(createSecond.RootElement.Clone()), CancellationToken.None)); + + using var reorder = JsonDocument.Parse("{\"foldersOrder\":[\"second\",\"first\"]}"); + Assert.IsType(await controller.Dispatch(275, new QlyraCompatibilityRequest(reorder.RootElement.Clone()), CancellationToken.None)); + using var get = JsonDocument.Parse("{\"folderSync\":0}"); + var getAction = await controller.Dispatch(272, new QlyraCompatibilityRequest(get.RootElement.Clone()), CancellationToken.None); + var getPayload = JsonSerializer.SerializeToElement(Assert.IsType(Assert.IsType(getAction).Value).Payload); + Assert.Equal(new[] { "second", "first" }, getPayload.GetProperty("foldersOrder").EnumerateArray().Select(item => item.GetString()).ToArray()); + Assert.Equal("second", getPayload.GetProperty("folders")[0].GetProperty("id").GetString()); + + using var delete = JsonDocument.Parse("{\"folderIds\":[\"second\"]}"); + Assert.IsType(await controller.Dispatch(276, new QlyraCompatibilityRequest(delete.RootElement.Clone()), CancellationToken.None)); + Assert.Single(dbContext.QlyraChatFolders.Where(item => item.UserId == user.Id)); + } + + [Fact] + public async Task StoryUploadFeedAndMark_RoundTripWithClientShapes() + { + var contentRoot = Path.Combine(Path.GetTempPath(), $"ikar-qlyra-story-{Guid.NewGuid():N}"); + Directory.CreateDirectory(contentRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var author = new User { Username = "story-author", NormalizedUsername = "STORY-AUTHOR", DisplayName = "Story Author", PasswordHash = "test" }; + var viewer = new User { Username = "story-viewer", NormalizedUsername = "STORY-VIEWER", DisplayName = "Story Viewer", PasswordHash = "test" }; + var chat = new Chat { Type = ChatType.Direct, Title = "Story peers", CreatedBy = author }; + chat.Members.Add(new ChatMember { Chat = chat, User = author, IsOwner = true, Role = ChatMemberRole.Owner }); + chat.Members.Add(new ChatMember { Chat = chat, User = viewer, Role = ChatMemberRole.Member }); + dbContext.Chats.Add(chat); + await dbContext.SaveChangesAsync(); + var store = ControllerTestFixture.CreateQlyraUploadStore(contentRoot); + var reservation = store.Reserve(author.Id, "photo.jpg"); + await using var bytes = new MemoryStream([0xff, 0xd8, 0xff, 0xd9]); + Assert.NotNull(await store.SaveAsync(reservation.Token, bytes, "story.jpg", "image/jpeg", bytes.Length, CancellationToken.None)); + var authorController = CreateController(dbContext, author.Id, store); + using var publish = JsonDocument.Parse($"{{\"stories\":[{{\"cid\":42,\"settings\":2,\"expiration\":3600000,\"media\":{{\"_type\":\"PHOTO\",\"photoToken\":\"{reservation.Token}\"}}}}]}}"); + + var publishAction = await authorController.Dispatch(215, new QlyraCompatibilityRequest(publish.RootElement.Clone()), CancellationToken.None); + + Assert.True(publishAction is OkObjectResult, JsonSerializer.Serialize((publishAction as ObjectResult)?.Value)); + var publishOk = (OkObjectResult)publishAction; + var publishResponse = Assert.IsType(publishOk.Value); + var publishedStory = JsonSerializer.SerializeToElement(publishResponse.Payload).GetProperty("stories")[0]; + Assert.Equal(42, publishedStory.GetProperty("cid").GetInt32()); + Assert.Equal("PHOTO", publishedStory.GetProperty("media").GetProperty("_type").GetString()); + var clientStoryId = publishedStory.GetProperty("id").GetInt32(); + var viewerController = CreateController(dbContext, viewer.Id); + using var feedRequest = JsonDocument.Parse("{\"count\":20}"); + var feedAction = await viewerController.Dispatch(208, new QlyraCompatibilityRequest(feedRequest.RootElement.Clone()), CancellationToken.None); + var feed = JsonSerializer.SerializeToElement(Assert.IsType(Assert.IsType(feedAction).Value).Payload); + Assert.Single(feed.GetProperty("storiesPreviews").EnumerateArray()); + using var markRequest = JsonDocument.Parse($"{{\"storyId\":{clientStoryId}}}"); + Assert.IsType(await viewerController.Dispatch(214, new QlyraCompatibilityRequest(markRequest.RootElement.Clone()), CancellationToken.None)); + Assert.Single(dbContext.StoryViews.Where(item => item.ViewerUserId == viewer.Id)); + } + finally + { + if (Directory.Exists(contentRoot)) Directory.Delete(contentRoot, true); + } + } + + private static QlyraCompatibilityController CreateController( + Ikar.Server.Data.IkarDbContext dbContext, + Guid userId, + QlyraUploadStore? uploadStore = null, + IEmailCodeSender? emailCodeSender = null, + QlyraCallCompatibilityStore? qlyraCallStore = null, + IPushNotificationSender? pushNotificationSender = null, + VideoPreviewService? videoPreviewService = null) + { + var controller = new QlyraCompatibilityController( + dbContext, + new TokenService(dbContext, Options.Create(new JwtOptions())), + new DeviceAuthChallengeStore(), + new PresenceTracker(), + new QlyraClientIdMapper(dbContext), + uploadStore ?? ControllerTestFixture.CreateQlyraUploadStore(Path.GetTempPath()), + new NoopHubContext(), + NullLogger.Instance, + emailCodeSender, + qlyraCallStore, + pushNotificationSender, + null, + Options.Create(new QlyraPublicEndpointOptions()), + videoPreviewService: videoPreviewService); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.NameIdentifier, userId.ToString()), new Claim("sid", Guid.NewGuid().ToString())], + "Test")) + } + }; + return controller; + } + + private sealed class RecordingEmailCodeSender : IEmailCodeSender + { + public string Email { get; private set; } = string.Empty; + public string Code { get; private set; } = string.Empty; + public Task SendLoginCodeAsync(string email, string code, DateTimeOffset expiresAt, CancellationToken cancellationToken = default) + { + Email = email; + Code = code; + return Task.CompletedTask; + } + } + + private static string DecodeVcpJson(string vcp) + { + var separator = vcp.IndexOf(':'); + var expectedLength = int.Parse(vcp[..separator]); + var block = Convert.FromBase64String(vcp[(separator + 1)..]); + var index = 0; + var token = block[index++]; + var literalLength = token >> 4; + if (literalLength == 15) + { + byte value; + do { value = block[index++]; literalLength += value; } while (value == 255); + } + var json = System.Text.Encoding.UTF8.GetString(block, index, literalLength); + Assert.Equal(expectedLength, System.Text.Encoding.UTF8.GetByteCount(json)); + return json; + } + + private static DefaultHttpContext CreateWebSocketContext(string token, WebSocket socket) + { + var context = new DefaultHttpContext(); + context.Request.QueryString = new QueryString($"?token={token}"); + context.Features.Set(new ScriptedWebSocketFeature(socket)); + return context; + } + + private static DefaultHttpContext CreateWebSocketContext(WebSocket socket) + { + var context = new DefaultHttpContext(); + context.Request.Scheme = "https"; + context.Request.Host = new HostString("ikar.kusoft.xyz"); + context.Features.Set(new ScriptedWebSocketFeature(socket)); + return context; + } + + private sealed class ScriptedWebSocketFeature(WebSocket socket) : IHttpWebSocketFeature + { + public bool IsWebSocketRequest => true; + public Task AcceptAsync(WebSocketAcceptContext context) => Task.FromResult(socket); + } + + private sealed class ScriptedWebSocket(params string[] incoming) : WebSocket + { + private readonly Queue _incoming = new(incoming); + private WebSocketState _state = WebSocketState.Open; + public List Sent { get; } = []; + public override WebSocketCloseStatus? CloseStatus => _state == WebSocketState.Open ? null : WebSocketCloseStatus.NormalClosure; + public override string? CloseStatusDescription => null; + public override WebSocketState State => _state; + public override string? SubProtocol => null; + public override void Abort() => _state = WebSocketState.Aborted; + public override Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken) { _state = WebSocketState.Closed; return Task.CompletedTask; } + public override Task CloseOutputAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken) { _state = WebSocketState.CloseSent; return Task.CompletedTask; } + public override void Dispose() => _state = WebSocketState.Closed; + public override Task ReceiveAsync(ArraySegment buffer, CancellationToken cancellationToken) + { + if (_incoming.TryDequeue(out var text)) + { + var bytes = Encoding.UTF8.GetBytes(text); + Array.Copy(bytes, 0, buffer.Array!, buffer.Offset, bytes.Length); + return Task.FromResult(new WebSocketReceiveResult(bytes.Length, WebSocketMessageType.Text, true)); + } + _state = WebSocketState.CloseReceived; + return Task.FromResult(new WebSocketReceiveResult(0, WebSocketMessageType.Close, true, WebSocketCloseStatus.NormalClosure, null)); + } + public override Task SendAsync(ArraySegment buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken cancellationToken) + { + Sent.Add(Encoding.UTF8.GetString(buffer.Array!, buffer.Offset, buffer.Count)); + return Task.CompletedTask; + } + } + + private sealed class RecordingQlyraPushSender : IPushNotificationSender + { + public List<(QlyraIncomingCallPushDto Call, IReadOnlyList Devices)> Calls { get; } = []; + public Task SendMessageAsync(Chat chat, Message message, IReadOnlyCollection devices, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task SendMessageReactionAsync(Chat chat, Message message, User reactor, string emoji, IReadOnlyCollection devices, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task SendIncomingCallAsync(IncomingCallDto call, IReadOnlyCollection devices, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task SendQlyraIncomingCallAsync(QlyraIncomingCallPushDto call, IReadOnlyCollection devices, CancellationToken cancellationToken = default) + { + Calls.Add((call, devices.ToList())); + return Task.CompletedTask; + } + public Task SendCallEndedAsync(CallEndedDto call, IReadOnlyCollection devices, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task SendAppUpdateAvailableAsync(AndroidAppReleaseDto release, IReadOnlyCollection devices, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task SendLoginCodeAsync(string code, string challengeId, DateTimeOffset expiresAt, IReadOnlyCollection devices, CancellationToken cancellationToken = default) => Task.FromResult(true); + } + + private static QlyraCompatibilityController CreateAnonymousController(Ikar.Server.Data.IkarDbContext dbContext) + { + var controller = new QlyraCompatibilityController( + dbContext, + new TokenService(dbContext, Options.Create(new JwtOptions())), + new DeviceAuthChallengeStore(), + new PresenceTracker(), + new QlyraClientIdMapper(dbContext), + ControllerTestFixture.CreateQlyraUploadStore(Path.GetTempPath()), + new NoopHubContext(), + NullLogger.Instance); + controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; + return controller; + } +} diff --git a/ikar_server/src/Ikar.Server.Tests/ReliabilityAndSecurityTests.cs b/ikar_server/src/Ikar.Server.Tests/ReliabilityAndSecurityTests.cs new file mode 100644 index 0000000..eebbdeb --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/ReliabilityAndSecurityTests.cs @@ -0,0 +1,166 @@ +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Calls; +using Ikar.Server.Infrastructure.Push; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Shared; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Ikar.Server.Tests; + +public sealed class ReliabilityAndSecurityTests +{ + [Fact] + public void EmailChallenge_EnforcesResendDelayAndAttemptLimit() + { + var store = new EmailAuthChallengeStore(); + var challenge = store.Create("user@example.com", "10000000000", isRegistered: true); + + var rateLimit = Assert.Throws(() => + store.Create("user@example.com", "10000000000", isRegistered: true)); + Assert.True(rateLimit.RetryAfter > TimeSpan.Zero); + + for (var attempt = 0; attempt < 5; attempt++) + { + Assert.Null(store.Consume(challenge.Id, challenge.NormalizedEmail, challenge.NormalizedPhoneNumber, "000000")); + } + + Assert.Null(store.Consume(challenge.Id, challenge.NormalizedEmail, challenge.NormalizedPhoneNumber, challenge.Code)); + } + + [Fact] + public void AttachmentInspector_UsesFileSignatureInsteadOfClaimedMimeType() + { + using var fakeImageStream = new MemoryStream("not-an-image"u8.ToArray()); + var fakeImage = new FormFile(fakeImageStream, 0, fakeImageStream.Length, "file", "photo.jpg") + { + Headers = new HeaderDictionary(), + ContentType = "image/jpeg" + }; + Assert.Equal("application/octet-stream", AttachmentContentInspector.Inspect(fakeImage).ContentType); + + using var jpegStream = new MemoryStream([0xFF, 0xD8, 0xFF, 0xE0]); + var jpeg = new FormFile(jpegStream, 0, jpegStream.Length, "file", "photo.bin") + { + Headers = new HeaderDictionary(), + ContentType = "application/octet-stream" + }; + var inspectedJpeg = AttachmentContentInspector.Inspect(jpeg); + Assert.Equal("image/jpeg", inspectedJpeg.ContentType); + Assert.True(inspectedJpeg.IsImage); + } + + [Fact] + public void CallStore_ExpiresUnansweredCalls() + { + var store = new CallSessionStore(); + Assert.True(store.TryCreate( + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + CallMediaType.Audio, + out var created, + out _)); + + var expired = store.DrainExpired( + created!.CreatedAt.AddSeconds(61), + TimeSpan.FromSeconds(60), + TimeSpan.FromHours(8)); + + var ended = Assert.Single(expired); + Assert.Equal("no_answer", ended.EndReason); + Assert.Equal(CallSessionState.Ended, ended.State); + } + + [Fact] + public async Task SendMessage_ReusingClientMessageId_ReturnsOriginalMessage() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var user = new User + { + Username = "reliable_sender", + NormalizedUsername = "RELIABLE_SENDER", + DisplayName = "Reliable Sender", + PasswordHash = "test" + }; + var chat = new Chat + { + Type = ChatType.Group, + Title = "Idempotency", + CreatedBy = user, + CreatedById = user.Id + }; + chat.Members.Add(new ChatMember + { + Chat = chat, + ChatId = chat.Id, + User = user, + UserId = user.Id, + IsOwner = true, + Role = ChatMemberRole.Owner + }); + dbContext.AddRange(user, chat); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, user.Id); + var clientMessageId = Guid.NewGuid(); + var request = new SendMessageRequest("Send once", ClientMessageId: clientMessageId); + var first = ControllerTestFixture.ValueOf(await controller.SendMessage(chat.Id, request, CancellationToken.None)); + var second = ControllerTestFixture.ValueOf(await controller.SendMessage(chat.Id, request, CancellationToken.None)); + + Assert.Equal(first.Id, second.Id); + Assert.Equal(clientMessageId, second.ClientMessageId); + Assert.Equal(1, await dbContext.Messages.CountAsync()); + } + + [Fact] + public async Task PushOutbox_IsCommittedWithTheMessageAndDeduplicated() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var user = new User + { + Username = "outbox_sender", + NormalizedUsername = "OUTBOX_SENDER", + DisplayName = "Outbox Sender", + PasswordHash = "test" + }; + var chat = new Chat + { + Type = ChatType.Group, + Title = "Outbox", + CreatedBy = user, + CreatedById = user.Id + }; + chat.Members.Add(new ChatMember + { + Chat = chat, + ChatId = chat.Id, + User = user, + UserId = user.Id, + IsOwner = true, + Role = ChatMemberRole.Owner + }); + var message = new Message + { + Chat = chat, + ChatId = chat.Id, + Sender = user, + SenderId = user.Id, + Text = "Persist with outbox" + }; + dbContext.Messages.Add(message); + + using var services = new ServiceCollection().BuildServiceProvider(); + var queue = new PushDispatchQueue(services.GetRequiredService()); + queue.StageMessages(dbContext, message, message); + await dbContext.SaveChangesAsync(); + + var outbox = Assert.Single(await dbContext.PushDispatchOutbox.AsNoTracking().ToListAsync()); + Assert.Equal(message.Id, outbox.MessageId); + Assert.Equal($"message:{message.Id:D}", outbox.DeduplicationKey); + } +} diff --git a/ikar_server/src/Ikar.Server.Tests/SearchControllerTests.cs b/ikar_server/src/Ikar.Server.Tests/SearchControllerTests.cs new file mode 100644 index 0000000..1d207db --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/SearchControllerTests.cs @@ -0,0 +1,330 @@ +using Ikar.Server.Data.Entities; +using Ikar.Shared; + +namespace Ikar.Server.Tests; + +public sealed class SearchControllerTests +{ + [Fact] + public async Task SearchPage_FiltersByAuthorDateAndType_AndReturnsJumpFields() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var chat = CreateChat(alice, bob); + var targetMessage = CreateMessage(chat, bob, "alpha image", new DateTimeOffset(2026, 1, 3, 10, 0, 0, TimeSpan.Zero)); + targetMessage.Attachments.Add(new MessageAttachment + { + MessageId = targetMessage.Id, + OriginalFileName = "alpha.png", + ContentType = "image/png", + StoragePath = "alpha.png", + FileSizeBytes = 123 + }); + dbContext.AddRange( + alice, + bob, + chat, + CreateMessage(chat, alice, "alpha document", new DateTimeOffset(2026, 1, 2, 10, 0, 0, TimeSpan.Zero)), + targetMessage, + CreateMessage(chat, bob, "alpha later file", new DateTimeOffset(2026, 1, 4, 10, 0, 0, TimeSpan.Zero))); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, alice.Id); + var response = await controller.SearchPage( + q: "alpha", + type: "media", + page: 1, + limit: 10, + dateFrom: new DateTimeOffset(2026, 1, 3, 0, 0, 0, TimeSpan.Zero), + dateTo: new DateTimeOffset(2026, 1, 3, 23, 59, 59, TimeSpan.Zero), + authorId: bob.Id); + + var page = ControllerTestFixture.ValueOf(response); + var result = Assert.Single(page.Results); + Assert.False(page.HasMore); + Assert.Equal(1, page.Page); + Assert.Equal(targetMessage.Id, result.MessageId); + Assert.Equal(chat.Id, result.ChatId); + Assert.Equal(bob.Id, result.SenderUserId); + Assert.Equal(targetMessage.SentAt, result.MessageSentAt); + Assert.Equal(targetMessage.Id, result.Message?.Id); + } + + [Fact] + public async Task SearchMessagesPage_PaginatesMessageResults() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var chat = CreateChat(alice, bob); + dbContext.AddRange( + alice, + bob, + chat, + CreateMessage(chat, bob, "alpha one", new DateTimeOffset(2026, 1, 1, 10, 0, 0, TimeSpan.Zero)), + CreateMessage(chat, bob, "alpha two", new DateTimeOffset(2026, 1, 2, 10, 0, 0, TimeSpan.Zero)), + CreateMessage(chat, bob, "alpha three", new DateTimeOffset(2026, 1, 3, 10, 0, 0, TimeSpan.Zero))); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, alice.Id); + var firstPageResponse = await controller.SearchMessagesPage(chat.Id, q: "alpha", page: 1, limit: 2); + var secondPageResponse = await controller.SearchMessagesPage(chat.Id, q: "alpha", page: 2, limit: 2); + + var firstPage = ControllerTestFixture.ValueOf(firstPageResponse); + var secondPage = ControllerTestFixture.ValueOf(secondPageResponse); + Assert.True(firstPage.HasMore); + Assert.Equal(["alpha three", "alpha two"], firstPage.Messages.Select(x => x.Text).ToArray()); + Assert.False(secondPage.HasMore); + Assert.Equal(["alpha one"], secondPage.Messages.Select(x => x.Text).ToArray()); + } + + [Fact] + public async Task GetChats_IgnoresExpiredMessagesForPreviewAndUnreadCount() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var chat = CreateChat(alice, bob); + var now = DateTimeOffset.UtcNow; + dbContext.AddRange( + alice, + bob, + chat, + CreateMessage(chat, bob, "visible", now.AddMinutes(-10)), + CreateMessage(chat, bob, "expired", now.AddMinutes(-5), expiresAt: now.AddMinutes(-1))); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, alice.Id); + var response = await controller.GetChats(CancellationToken.None); + + var summary = Assert.Single(ControllerTestFixture.ValueOf(response)); + Assert.Equal("bob: visible", summary.LastMessagePreview); + Assert.Equal(1, summary.UnreadCount); + } + + [Fact] + public async Task MarkChatRead_ClearsUnreadCountForChat() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var chat = CreateChat(alice, bob); + var first = CreateMessage(chat, bob, "first", new DateTimeOffset(2026, 1, 1, 10, 0, 0, TimeSpan.Zero)); + var second = CreateMessage(chat, bob, "second", new DateTimeOffset(2026, 1, 1, 10, 1, 0, TimeSpan.Zero)); + dbContext.AddRange(alice, bob, chat, first, second); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, alice.Id); + var before = Assert.Single(ControllerTestFixture.ValueOf(await controller.GetChats(CancellationToken.None))); + Assert.Equal(2, before.UnreadCount); + + await controller.MarkChatRead(chat.Id, CancellationToken.None); + + var after = Assert.Single(ControllerTestFixture.ValueOf(await controller.GetChats(CancellationToken.None))); + Assert.Equal(0, after.UnreadCount); + Assert.Equal(second.SentAt, chat.Members.First(member => member.UserId == alice.Id).LastReadAt); + } + + [Fact] + public async Task SearchMessagesPage_IgnoresExpiredMessages() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var chat = CreateChat(alice, bob); + var now = DateTimeOffset.UtcNow; + dbContext.AddRange( + alice, + bob, + chat, + CreateMessage(chat, bob, "alpha visible", now.AddMinutes(-10)), + CreateMessage(chat, bob, "alpha expired", now.AddMinutes(-5), expiresAt: now.AddMinutes(-1))); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, alice.Id); + var response = await controller.SearchMessagesPage(chat.Id, q: "alpha", page: 1, limit: 10); + + var page = ControllerTestFixture.ValueOf(response); + var message = Assert.Single(page.Messages); + Assert.Equal("alpha visible", message.Text); + } + + [Fact] + public async Task SearchMessagesPage_FiltersPinnedMessages() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var chat = CreateChat(alice, bob); + var pinned = CreateMessage(chat, bob, "pinned note", new DateTimeOffset(2026, 1, 2, 10, 0, 0, TimeSpan.Zero)); + pinned.PinnedAt = new DateTimeOffset(2026, 1, 2, 10, 5, 0, TimeSpan.Zero); + pinned.PinnedByUserId = alice.Id; + pinned.PinnedByUser = alice; + dbContext.AddRange( + alice, + bob, + chat, + CreateMessage(chat, bob, "regular note", new DateTimeOffset(2026, 1, 3, 10, 0, 0, TimeSpan.Zero)), + pinned); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, alice.Id); + var response = await controller.SearchMessagesPage(chat.Id, q: " ", filter: "pinned", page: 1, limit: 10); + + var page = ControllerTestFixture.ValueOf(response); + var message = Assert.Single(page.Messages); + Assert.Equal(pinned.Id, message.Id); + Assert.NotNull(message.PinnedAt); + } + + [Fact] + public async Task SearchPage_ReturnsPublicChannelForNonMember_ByAtUsername() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var viewer = CreateUser("viewer"); + var owner = CreateUser("owner"); + var publicChannel = CreateChannel(owner, "Daily News", "daily_news", "Public news channel"); + var privateChannel = CreateChannel(owner, "Daily News Private", null, "Private news channel"); + dbContext.AddRange(viewer, owner, publicChannel, privateChannel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id); + var response = await controller.SearchPage(q: "@daily_news", page: 1, limit: 10); + + var page = ControllerTestFixture.ValueOf(response); + var result = Assert.Single(page.Results); + Assert.Equal(publicChannel.Id, result.Chat.Id); + Assert.Equal(ChatType.Channel, result.Chat.Type); + Assert.True(result.Chat.CanSubscribe); + Assert.False(result.Chat.IsSubscribed); + } + + [Fact] + public async Task GetPublicChannels_ReturnsPublicChannelsForNonMember_AndFiltersByTitle() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var viewer = CreateUser("viewer"); + var owner = CreateUser("owner"); + var publicChannel = CreateChannel(owner, "Daily News", "daily_news", "Public news channel"); + var privateChannel = CreateChannel(owner, "Daily News Private", null, "Private news channel"); + dbContext.AddRange(viewer, owner, publicChannel, privateChannel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id); + var response = await controller.GetPublicChannels(q: "daily", limit: 10); + + var channels = ControllerTestFixture.ValueOf(response); + var channel = Assert.Single(channels); + Assert.Equal(publicChannel.Id, channel.Id); + Assert.Equal(ChatType.Channel, channel.Type); + Assert.Equal("daily_news", channel.PublicUsername); + Assert.True(channel.CanSubscribe); + Assert.False(channel.IsSubscribed); + } + + [Fact] + public async Task GetPublicChannels_FindsCyrillicTitleIgnoringCase() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var viewer = CreateUser("viewer"); + var owner = CreateUser("owner"); + var publicChannel = CreateChannel(owner, "РИА новости", "ria_news", "Новости из агрегатора"); + dbContext.AddRange(viewer, owner, publicChannel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id); + var response = await controller.GetPublicChannels(q: "риа", limit: 10); + + var channel = Assert.Single(ControllerTestFixture.ValueOf(response)); + Assert.Equal(publicChannel.Id, channel.Id); + Assert.Equal("ria_news", channel.PublicUsername); + } + + private static User CreateUser(string username) => + new() + { + Id = Guid.NewGuid(), + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = username, + PasswordHash = "test" + }; + + private static Chat CreateChat(User first, User second) + { + var chat = new Chat + { + Id = Guid.NewGuid(), + Type = ChatType.Direct, + CreatedById = first.Id, + CreatedBy = first, + CreatedAt = new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero) + }; + chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = first.Id, User = first }); + chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = second.Id, User = second }); + return chat; + } + + private static Message CreateMessage( + Chat chat, + User sender, + string text, + DateTimeOffset sentAt, + DateTimeOffset? expiresAt = null) + { + var message = new Message + { + Id = Guid.NewGuid(), + ChatId = chat.Id, + Chat = chat, + SenderId = sender.Id, + Sender = sender, + Text = text, + SentAt = sentAt, + ExpiresAt = expiresAt + }; + chat.Messages.Add(message); + return message; + } + + private static Chat CreateChannel(User owner, string title, string? publicUsername, string description) + { + var channel = new Chat + { + Id = Guid.NewGuid(), + Type = ChatType.Channel, + Title = title, + Description = description, + PublicUsername = publicUsername, + CreatedById = owner.Id, + CreatedBy = owner, + CreatedAt = new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero), + LastActivityAt = new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero) + }; + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = owner.Id, + User = owner, + IsOwner = true, + Role = ChatMemberRole.Owner, + CanPinMessages = true, + CanDeleteMessages = true, + CanInviteUsers = true, + CanPostMessages = true + }); + + return channel; + } +} diff --git a/ikar_server/src/Ikar.Server.Tests/StoryReplyControllerTests.cs b/ikar_server/src/Ikar.Server.Tests/StoryReplyControllerTests.cs new file mode 100644 index 0000000..920ccd7 --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/StoryReplyControllerTests.cs @@ -0,0 +1,105 @@ +using Ikar.Server.Data.Entities; +using Ikar.Shared; +using Microsoft.AspNetCore.Mvc; + +namespace Ikar.Server.Tests; + +public sealed class StoryReplyControllerTests +{ + [Fact] + public async Task SendMessage_WithStoryReplyStoryId_PersistsStoryReplySnapshot() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var author = CreateUser("author"); + var viewer = CreateUser("viewer"); + var chat = CreateDirectChat(author, viewer); + var story = new Story + { + Id = Guid.NewGuid(), + AuthorUserId = author.Id, + AuthorUser = author, + Text = "Morning update from the road", + Visibility = StoryVisibility.Everyone, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) + }; + dbContext.AddRange(author, viewer, chat, story); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id); + var response = await controller.SendMessage( + chat.Id, + new SendMessageRequest("Looks good", StoryReplyStoryId: story.Id), + CancellationToken.None); + + var dto = ControllerTestFixture.ValueOf(response); + Assert.Equal("Looks good", dto.Text); + Assert.NotNull(dto.StoryReply); + Assert.Equal(story.Id, dto.StoryReply!.StoryId); + Assert.Equal(author.Id, dto.StoryReply.AuthorUserId); + Assert.Equal("Morning update from the road", dto.StoryReply.PreviewText); + Assert.Equal("text", dto.StoryReply.AttachmentKind); + + var storedMessage = Assert.Single(dbContext.Messages.Where(message => message.ChatId == chat.Id)); + Assert.Equal(story.Id, storedMessage.StoryReplyStoryId); + Assert.Equal(author.Id, storedMessage.StoryReplyAuthorUserId); + Assert.Equal("Morning update from the road", storedMessage.StoryReplyPreviewText); + Assert.Equal("text", storedMessage.StoryReplyAttachmentKind); + } + + [Fact] + public async Task SendMessage_WithStoryReplyStoryId_RejectsWrongDirectChat() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var author = CreateUser("author"); + var viewer = CreateUser("viewer"); + var other = CreateUser("other"); + var wrongChat = CreateDirectChat(viewer, other); + var story = new Story + { + Id = Guid.NewGuid(), + AuthorUserId = author.Id, + AuthorUser = author, + Text = "Morning update", + Visibility = StoryVisibility.Everyone, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) + }; + dbContext.AddRange(author, viewer, other, wrongChat, story); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id); + var response = await controller.SendMessage( + wrongChat.Id, + new SendMessageRequest("Looks good", StoryReplyStoryId: story.Id), + CancellationToken.None); + + Assert.IsType(response.Result); + } + + private static User CreateUser(string username) => + new() + { + Id = Guid.NewGuid(), + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = username, + PasswordHash = "test" + }; + + private static Chat CreateDirectChat(User first, User second) + { + var chat = new Chat + { + Id = Guid.NewGuid(), + Type = ChatType.Direct, + CreatedById = first.Id, + CreatedBy = first + }; + chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = first.Id, User = first }); + chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = second.Id, User = second }); + return chat; + } +} diff --git a/ikar_server/src/Ikar.Server.Tests/VideoDeliveryTests.cs b/ikar_server/src/Ikar.Server.Tests/VideoDeliveryTests.cs new file mode 100644 index 0000000..db9c195 --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/VideoDeliveryTests.cs @@ -0,0 +1,123 @@ +using System.Security.Claims; +using System.Text.Json; +using Ikar.Server.Controllers; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Shared; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Ikar.Server.Tests; + +public sealed partial class QlyraCompatibilityControllerTests +{ + [Fact] + public async Task VideoPlay_WebJsonPreservesSourceKeyAndDoesNotAdvertiseMp4AsHls() + { + await using var database = new SqliteTestDatabase(); + await using var db = await database.CreateContextAsync(); + var (user, chat, attachment) = await AddVideoAsync(db, "missing-test-video.mp4"); + var mapper = new QlyraClientIdMapper(db); + var chatId = await mapper.GetOrCreateAsync("chat", chat.Id, CancellationToken.None); + var videoId = await mapper.GetOrCreateAsync("attachment", attachment.Id, CancellationToken.None); + var controller = CreateController(db, user.Id); + using var request = JsonDocument.Parse($"{{\"chatId\":{chatId},\"videoId\":{videoId}}}"); + var response = Assert.IsType(await controller.Dispatch(83, new(request.RootElement.Clone()), CancellationToken.None)); + var wire = JsonSerializer.SerializeToElement(response.Value, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var sources = wire.GetProperty("payload"); + var url = sources.GetProperty("EXTERNAL").GetString()!; + Assert.Contains($"/attachments/{attachment.Id}/content?access_token=", url); + Assert.False(sources.TryGetProperty("HLS", out _)); + Assert.False(sources.TryGetProperty("external", out _)); + } + + [Fact] + public async Task VideoHistory_UsesPersistentJpegPreviewAndRealMetadata() + { + var directory = Path.Combine(Path.GetTempPath(), $"ikar-video-preview-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var path = Path.Combine(directory, "clip.mp4"); + await WriteCachedPreviewAsync(path); + await using var database = new SqliteTestDatabase(); + await using var db = await database.CreateContextAsync(); + var (user, chat, attachment) = await AddVideoAsync(db, path); + var mapper = new QlyraClientIdMapper(db); + var chatId = await mapper.GetOrCreateAsync("chat", chat.Id, CancellationToken.None); + var previews = new VideoPreviewService(NullLogger.Instance); + var controller = CreateController(db, user.Id, videoPreviewService: previews); + using var request = JsonDocument.Parse($"{{\"chatId\":{chatId}}}"); + var response = Assert.IsType(await controller.Dispatch(49, new(request.RootElement.Clone()), CancellationToken.None)); + var wire = JsonSerializer.SerializeToElement(response.Value, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var video = wire.GetProperty("payload").GetProperty("messages")[0].GetProperty("attaches")[0]; + Assert.Equal(1920, video.GetProperty("width").GetInt32()); + Assert.Equal(1080, video.GetProperty("height").GetInt32()); + Assert.Equal(4200, video.GetProperty("duration").GetInt32()); + var thumbnail = video.GetProperty("thumbnail").GetString(); + Assert.Contains($"/attachments/{attachment.Id}/thumbnail?access_token=", thumbnail); + Assert.Equal(thumbnail, video.GetProperty("baseUrl").GetString()); + Assert.NotEqual(thumbnail, video.GetProperty("mp4Url").GetString()); + + var endpoint = CreatePreviewController(db, user.Id, previews); + var image = Assert.IsType(await endpoint.Get(chat.Id, attachment.Id, CancellationToken.None)); + Assert.Equal("image/jpeg", image.ContentType); + Assert.Equal(VideoPreviewService.ThumbnailPath(path), image.FileName); + var content = Assert.IsType(await ControllerTestFixture.CreateChatsController(db, user.Id) + .DownloadAttachment(chat.Id, attachment.Id, CancellationToken.None)); + Assert.True(content.EnableRangeProcessing); + await content.FileStream.DisposeAsync(); + } + finally { Directory.Delete(directory, true); } + } + + [Theory] + [InlineData("outsider")] + [InlineData("deleted")] + [InlineData("expired")] + public async Task VideoPreview_DoesNotExposeInaccessibleAttachments(string reason) + { + await using var database = new SqliteTestDatabase(); + await using var db = await database.CreateContextAsync(); + var (user, chat, attachment) = await AddVideoAsync(db, "not-accessible.mp4"); + if (reason == "deleted") attachment.Message.DeletedAt = DateTimeOffset.UtcNow; + if (reason == "expired") attachment.Message.ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-1); + await db.SaveChangesAsync(); + var endpoint = CreatePreviewController(db, reason == "outsider" ? Guid.NewGuid() : user.Id, + new VideoPreviewService(NullLogger.Instance)); + Assert.IsType(await endpoint.Get(chat.Id, attachment.Id, CancellationToken.None)); + } + + private static async Task<(User, Chat, MessageAttachment)> AddVideoAsync(IkarDbContext db, string path) + { + var user = new User { Username = "video-member", NormalizedUsername = "VIDEO-MEMBER", DisplayName = "Video member", PasswordHash = "test" }; + var chat = new Chat { Type = ChatType.Group, CreatedById = user.Id }; + chat.Members.Add(new ChatMember { Chat = chat, User = user, IsOwner = true, Role = ChatMemberRole.Owner }); + var message = new Message { Chat = chat, Sender = user }; + var attachment = new MessageAttachment { Message = message, ContentType = "video/mp4", StoragePath = path, OriginalFileName = "clip.mp4" }; + db.Users.Add(user); + db.Chats.Add(chat); + db.Messages.Add(message); + db.MessageAttachments.Add(attachment); + await db.SaveChangesAsync(); + return (user, chat, attachment); + } + + private static async Task WriteCachedPreviewAsync(string path) + { + await File.WriteAllBytesAsync(path, [0, 0, 0, 24, 102, 116, 121, 112]); + await File.WriteAllBytesAsync(VideoPreviewService.ThumbnailPath(path), [255, 216, 255, 217]); + await File.WriteAllTextAsync(VideoPreviewService.MetadataPath(path), JsonSerializer.Serialize(new VideoPreviewMetadata(1920, 1080, 4200))); + } + + private static AttachmentPreviewsController CreatePreviewController(IkarDbContext db, Guid userId, VideoPreviewService previews) + { + var controller = new AttachmentPreviewsController(db, previews); + controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { + User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, userId.ToString())], "test")) } }; + return controller; + } +} diff --git a/ikar_server/src/Ikar.Server/Controllers/AdminController.cs b/ikar_server/src/Ikar.Server/Controllers/AdminController.cs new file mode 100644 index 0000000..f82c731 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Controllers/AdminController.cs @@ -0,0 +1,261 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Ikar.Server.Data; +using Ikar.Server.Infrastructure.Admin; +using Ikar.Shared; + +namespace Ikar.Server.Controllers; + +[ApiController] +[Route("admin/api")] +public sealed class AdminController( + IOptions adminOptions, + AdminService adminService, + IkarDbContext dbContext) : ControllerBase +{ + [HttpGet("session")] + [AllowAnonymous] + public async Task> GetSessionState() + { + var auth = await HttpContext.AuthenticateAsync(AdminAuthConstants.Scheme); + return Ok(new AdminSessionStateDto( + adminOptions.Value.IsConfigured, + auth.Succeeded, + auth.Principal?.Identity?.Name)); + } + + [HttpPost("session/login")] + [AllowAnonymous] + public async Task Login(AdminLoginRequest request) + { + if (!adminOptions.Value.IsConfigured) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, "Admin login is not configured."); + } + + var username = request.Username?.Trim() ?? string.Empty; + var password = request.Password ?? string.Empty; + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + { + return ValidationProblem("Username and password are required."); + } + + if (!SecureEquals(adminOptions.Value.Username, username) || + !SecureEquals(adminOptions.Value.Password, password)) + { + return Unauthorized("Invalid admin credentials."); + } + + var claims = new List + { + new(ClaimTypes.Name, adminOptions.Value.Username), + new(ClaimTypes.Role, "Admin") + }; + + var identity = new ClaimsIdentity(claims, AdminAuthConstants.Scheme); + var principal = new ClaimsPrincipal(identity); + + await HttpContext.SignInAsync(AdminAuthConstants.Scheme, principal); + return NoContent(); + } + + [HttpPost("session/logout")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task Logout() + { + await HttpContext.SignOutAsync(AdminAuthConstants.Scheme); + return NoContent(); + } + + [HttpGet("status")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task> GetStatus(CancellationToken cancellationToken) => + Ok(await adminService.GetStatusAsync(cancellationToken)); + + [HttpGet("app-updates/compatibility-report")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task> GetAndroidUpdateCompatibilityReport(CancellationToken cancellationToken) => + Ok(await adminService.GetAndroidUpdateCompatibilityReportAsync(cancellationToken)); + + [HttpGet("users")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task>> GetUsers([FromQuery] string? q, CancellationToken cancellationToken) => + Ok(await adminService.GetUsersAsync(q, cancellationToken)); + + [HttpPost("users/{userId:guid}/block")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task BlockUser(Guid userId, CancellationToken cancellationToken) + { + var updated = await adminService.SetUserBlockedAsync(userId, true, cancellationToken); + return updated ? NoContent() : NotFound(); + } + + [HttpPost("users/{userId:guid}/unblock")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task UnblockUser(Guid userId, CancellationToken cancellationToken) + { + var updated = await adminService.SetUserBlockedAsync(userId, false, cancellationToken); + return updated ? NoContent() : NotFound(); + } + + [HttpDelete("users/{userId:guid}")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task DeleteUser(Guid userId, CancellationToken cancellationToken) + { + var deleted = await adminService.DeleteUserAsync(userId, cancellationToken); + return deleted ? NoContent() : NotFound(); + } + + [HttpGet("chats")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task>> GetChats([FromQuery] string? q, CancellationToken cancellationToken) => + Ok(await adminService.GetChatsAsync(q, cancellationToken)); + + [HttpGet("chats/{chatId:guid}/details")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task> GetChatDetails(Guid chatId, CancellationToken cancellationToken) + { + var details = await adminService.GetChatDetailsAsync(chatId, cancellationToken); + return details is null ? NotFound() : Ok(details); + } + + [HttpPut("chats/{chatId:guid}/settings")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task> UpdateChatSettings( + Guid chatId, + AdminChatSettingsRequest request, + CancellationToken cancellationToken) + { + try + { + var details = await adminService.UpdateChatSettingsAsync(chatId, request, cancellationToken); + return details is null ? NotFound() : Ok(details); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpPut("chats/{chatId:guid}/members/{memberUserId:guid}/permissions")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task> UpdateChatMemberPermissions( + Guid chatId, + Guid memberUserId, + AdminChatMemberPermissionsRequest request, + CancellationToken cancellationToken) + { + var details = await adminService.UpdateMemberPermissionsAsync(chatId, memberUserId, request, cancellationToken); + return details is null ? NotFound() : Ok(details); + } + + [HttpPost("chats/{chatId:guid}/invite-links")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task> CreateInviteLink( + Guid chatId, + AdminCreateInviteLinkRequest request, + CancellationToken cancellationToken) + { + try + { + var details = await adminService.CreateInviteLinkAsync(chatId, request, cancellationToken); + return details is null ? NotFound() : Ok(details); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpDelete("chats/{chatId:guid}/invite-links/{inviteLinkId:guid}")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task> RevokeInviteLink( + Guid chatId, + Guid inviteLinkId, + CancellationToken cancellationToken) + { + var details = await adminService.RevokeInviteLinkAsync(chatId, inviteLinkId, cancellationToken); + return details is null ? NotFound() : Ok(details); + } + + [HttpPost("chats/{chatId:guid}/join-requests/{joinRequestId:guid}")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task> ResolveJoinRequest( + Guid chatId, + Guid joinRequestId, + AdminResolveJoinRequestRequest request, + CancellationToken cancellationToken) + { + var details = await adminService.ResolveJoinRequestAsync(chatId, joinRequestId, request.Approve, cancellationToken); + return details is null ? NotFound() : Ok(details); + } + + [HttpDelete("chats/{chatId:guid}")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task DeleteChat(Guid chatId, CancellationToken cancellationToken) + { + var deleted = await adminService.DeleteChatAsync(chatId, cancellationToken); + return deleted ? NoContent() : NotFound(); + } + + [HttpGet("attachments")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task>> GetAttachments([FromQuery] string? q, CancellationToken cancellationToken) => + Ok(await adminService.GetAttachmentsAsync(q, cancellationToken)); + + [HttpGet("attachments/{attachmentId:guid}/content")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task DownloadAttachment(Guid attachmentId, CancellationToken cancellationToken) + { + var attachment = await dbContext.MessageAttachments + .AsNoTracking() + .SingleOrDefaultAsync(x => x.Id == attachmentId, cancellationToken); + + if (attachment is null || !System.IO.File.Exists(attachment.StoragePath)) + { + return NotFound(); + } + + var stream = System.IO.File.OpenRead(attachment.StoragePath); + var contentType = string.IsNullOrWhiteSpace(attachment.ContentType) + ? "application/octet-stream" + : attachment.ContentType; + + return File(stream, contentType, attachment.OriginalFileName); + } + + [HttpDelete("attachments/{attachmentId:guid}")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task DeleteAttachment(Guid attachmentId, CancellationToken cancellationToken) + { + var deleted = await adminService.DeleteAttachmentAsync(attachmentId, cancellationToken); + return deleted ? NoContent() : NotFound(); + } + + [HttpGet("moderation-log")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task>> GetModerationLog( + [FromQuery] Guid? chatId, + [FromQuery] int limit = 100, + CancellationToken cancellationToken = default) + => Ok(await adminService.GetModerationLogAsync(chatId, limit, cancellationToken)); + + [HttpPost("messages/cleanup-expired")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task> CleanupExpiredMessages(CancellationToken cancellationToken) => + Ok(await adminService.CleanupExpiredMessagesAsync(cancellationToken)); + + private static bool SecureEquals(string left, string right) + { + var leftBytes = System.Text.Encoding.UTF8.GetBytes(left ?? string.Empty); + var rightBytes = System.Text.Encoding.UTF8.GetBytes(right ?? string.Empty); + return CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes); + } + + public sealed record AdminLoginRequest(string? Username, string? Password); +} diff --git a/ikar_server/src/Ikar.Server/Controllers/AppUpdatesController.cs b/ikar_server/src/Ikar.Server/Controllers/AppUpdatesController.cs new file mode 100644 index 0000000..f7a1e9c --- /dev/null +++ b/ikar_server/src/Ikar.Server/Controllers/AppUpdatesController.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.Mvc; +using Ikar.Server.Infrastructure.Updates; +using Ikar.Shared; + +namespace Ikar.Server.Controllers; + +[ApiController] +[Route("api/app-updates")] +public sealed class AppUpdatesController( + AndroidAppUpdateService androidAppUpdateService, + AndroidUpdateCompatibilityTrackingService trackingService) : ControllerBase +{ + [HttpGet("android/latest")] + [ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)] + [ProducesResponseType(typeof(AndroidAppReleaseDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetLatestAndroidRelease(CancellationToken cancellationToken) + { + ApplyNoCacheHeaders(); + await trackingService.TrackHitAsync(Request, "android/latest", null, cancellationToken); + var release = await androidAppUpdateService.GetLatestReleaseAsync(cancellationToken); + return release is null ? NotFound() : Ok(release); + } + + [HttpGet("android/download/{fileName}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DownloadAndroidRelease(string fileName, CancellationToken cancellationToken) + { + await trackingService.TrackHitAsync(Request, "android/download/file", fileName, cancellationToken); + var package = await androidAppUpdateService.ResolvePackageAsync(fileName, cancellationToken); + if (package is null) + { + return NotFound(); + } + + if (!string.IsNullOrWhiteSpace(package.RedirectUrl)) + { + return Redirect(package.RedirectUrl); + } + + var physicalPath = package.PhysicalPath!; + return PhysicalFile( + physicalPath, + "application/vnd.android.package-archive", + package.FileName, + enableRangeProcessing: true); + } + + [HttpGet("android/download/latest")] + [ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DownloadLatestAndroidRelease(CancellationToken cancellationToken) + { + ApplyNoCacheHeaders(); + await trackingService.TrackHitAsync(Request, "android/download/latest", null, cancellationToken); + var package = await androidAppUpdateService.ResolveLatestPackageAsync(cancellationToken); + if (package is null) + { + return NotFound(); + } + + if (!string.IsNullOrWhiteSpace(package.RedirectUrl)) + { + return Redirect(package.RedirectUrl); + } + + var physicalPath = package.PhysicalPath!; + return PhysicalFile( + physicalPath, + "application/vnd.android.package-archive", + package.FileName, + enableRangeProcessing: true); + } + + private void ApplyNoCacheHeaders() + { + Response.Headers.CacheControl = "no-store, no-cache, must-revalidate, max-age=0"; + Response.Headers.Pragma = "no-cache"; + Response.Headers.Expires = "0"; + } +} diff --git a/ikar_server/src/Ikar.Server/Controllers/AttachmentPreviewsController.cs b/ikar_server/src/Ikar.Server/Controllers/AttachmentPreviewsController.cs new file mode 100644 index 0000000..81f4d29 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Controllers/AttachmentPreviewsController.cs @@ -0,0 +1,28 @@ +using Ikar.Server.Data; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Storage; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Controllers; + +[ApiController] +[Authorize] +[Route("api/chats/{chatId:guid}/attachments/{attachmentId:guid}/thumbnail")] +public sealed class AttachmentPreviewsController(IkarDbContext dbContext, VideoPreviewService previews) : ControllerBase +{ + [HttpGet] + public async Task Get(Guid chatId, Guid attachmentId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var now = DateTimeOffset.UtcNow; + var attachment = await dbContext.MessageAttachments.AsNoTracking() + .SingleOrDefaultAsync(item => item.Id == attachmentId && item.Message.ChatId == chatId && + item.ContentType.StartsWith("video/") && item.Message.DeletedAt == null && + (item.Message.ExpiresAt == null || item.Message.ExpiresAt > now) && + item.Message.Chat.Members.Any(member => member.UserId == userId), cancellationToken); + if (attachment is null || await previews.GetAsync(attachment.StoragePath, cancellationToken) is null) return NotFound(); + return PhysicalFile(VideoPreviewService.ThumbnailPath(attachment.StoragePath), "image/jpeg"); + } +} diff --git a/ikar_server/src/Ikar.Server/Controllers/AuthController.cs b/ikar_server/src/Ikar.Server/Controllers/AuthController.cs new file mode 100644 index 0000000..e3eab23 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Controllers/AuthController.cs @@ -0,0 +1,541 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Push; +using Ikar.Shared; + +namespace Ikar.Server.Controllers; + +[ApiController] +[Route("api/[controller]")] +public sealed class AuthController( + IkarDbContext dbContext, + TokenService tokenService, + PresenceTracker presenceTracker, + PhoneAuthChallengeStore phoneAuthChallengeStore, + EmailAuthChallengeStore emailAuthChallengeStore, + IEmailCodeSender emailCodeSender, + IPushNotificationSender pushNotificationSender) : ControllerBase +{ + private readonly PasswordHasher _passwordHasher = new(); + + [HttpPost("register")] + [AllowAnonymous] + public async Task> Register(RegisterRequest request, CancellationToken cancellationToken) + { + var username = request.Username?.Trim() ?? string.Empty; + var displayName = request.DisplayName?.Trim() ?? string.Empty; + var password = request.Password ?? string.Empty; + var phoneNumber = request.PhoneNumber?.Trim() ?? string.Empty; + var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(phoneNumber); + + if (string.IsNullOrWhiteSpace(username) || + string.IsNullOrWhiteSpace(displayName) || + password.Length < 6 || + normalizedPhoneNumber is null) + { + return ValidationProblem("Username, display name, phone number and a password of at least 6 characters are required."); + } + + var normalizedUsername = username.ToUpperInvariant(); + if (await dbContext.Users.AnyAsync(x => x.NormalizedUsername == normalizedUsername, cancellationToken)) + { + return Conflict("Username is already taken."); + } + + if (await dbContext.Users.AnyAsync(x => x.NormalizedPhoneNumber == normalizedPhoneNumber, cancellationToken)) + { + return Conflict("Phone number is already used."); + } + + var user = new User + { + Username = username, + NormalizedUsername = normalizedUsername, + DisplayName = displayName, + PhoneNumber = phoneNumber, + NormalizedPhoneNumber = normalizedPhoneNumber + }; + + user.PasswordHash = _passwordHasher.HashPassword(user, password); + dbContext.Users.Add(user); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + var usernameTaken = await dbContext.Users + .AsNoTracking() + .AnyAsync(x => x.NormalizedUsername == normalizedUsername, cancellationToken); + if (usernameTaken) + { + return Conflict("Username is already taken."); + } + + var phoneTaken = await dbContext.Users + .AsNoTracking() + .AnyAsync(x => x.NormalizedPhoneNumber == normalizedPhoneNumber, cancellationToken); + if (phoneTaken) + { + return Conflict("Phone number is already used."); + } + + throw; + } + + var session = await tokenService.CreateSessionAsync( + user, + ClientVersionRequestReader.Read(Request), + cancellationToken); + return Ok(session with { User = user.ToDto(presenceTracker) }); + } + + [HttpPost("login")] + [AllowAnonymous] + public async Task> Login(LoginRequest request, CancellationToken cancellationToken) + { + var username = request.Username?.Trim() ?? string.Empty; + var password = request.Password ?? string.Empty; + var normalizedUsername = username.ToUpperInvariant(); + var user = await dbContext.Users.SingleOrDefaultAsync( + x => x.NormalizedUsername == normalizedUsername, + cancellationToken); + + if (user is null) + { + return Unauthorized("Invalid username or password."); + } + + if (user.IsBot) + { + return Unauthorized("Bot accounts cannot sign in through the user client."); + } + + if (user.IsBlocked) + { + return StatusCode(StatusCodes.Status423Locked, "User is blocked."); + } + + var verification = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password); + if (verification == PasswordVerificationResult.Failed) + { + return Unauthorized("Invalid username or password."); + } + + var session = await tokenService.CreateSessionAsync( + user, + ClientVersionRequestReader.Read(Request), + cancellationToken); + return Ok(session with { User = user.ToDto(presenceTracker) }); + } + + [HttpPost("request-code")] + [AllowAnonymous] + public async Task> RequestPhoneCode( + RequestPhoneCodeRequest request, + CancellationToken cancellationToken) + { + var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber); + if (normalizedPhoneNumber is null) + { + return ValidationProblem("Valid phone number is required."); + } + + var user = await dbContext.Users + .AsNoTracking() + .SingleOrDefaultAsync(x => x.NormalizedPhoneNumber == normalizedPhoneNumber, cancellationToken); + if (user is null) + { + return NotFound("Phone code login is available only for existing accounts. Register by email."); + } + + if (user.IsBlocked) + { + return StatusCode(StatusCodes.Status423Locked, "User is blocked."); + } + + var challenge = phoneAuthChallengeStore.Create(normalizedPhoneNumber, isRegistered: true); + var devices = await dbContext.PushDevices + .Where(device => device.UserId == user.Id && device.NotificationsEnabled) + .ToListAsync(cancellationToken); + if (devices.Count == 0) + { + phoneAuthChallengeStore.Remove(challenge.Id); + return Conflict("No trusted IKAR device is available for code delivery."); + } + var delivered = await pushNotificationSender.SendLoginCodeAsync(challenge.Code, challenge.Id, challenge.ExpiresAt, devices, cancellationToken); + if (!delivered) + { + phoneAuthChallengeStore.Remove(challenge.Id); + return StatusCode(StatusCodes.Status503ServiceUnavailable, "IKAR server could not deliver the login code."); + } + return Ok(new PhoneCodeChallengeDto( + challenge.Id, + challenge.ExpiresAt, + null, + challenge.IsRegistered)); + } + + [HttpPost("verify-code")] + [AllowAnonymous] + public async Task> VerifyPhoneCode( + VerifyPhoneCodeRequest request, + CancellationToken cancellationToken) + { + var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber); + if (normalizedPhoneNumber is null) + { + return ValidationProblem("Valid phone number is required."); + } + + var challenge = phoneAuthChallengeStore.Consume( + request.ChallengeId?.Trim() ?? string.Empty, + normalizedPhoneNumber, + request.Code?.Trim() ?? string.Empty); + if (challenge is null) + { + return Unauthorized("Invalid or expired verification code."); + } + + var user = await dbContext.Users.SingleOrDefaultAsync( + x => x.NormalizedPhoneNumber == normalizedPhoneNumber, + cancellationToken); + if (user is null) + { + return NotFound("Phone code login is available only for existing accounts. Register by email."); + } + + if (user.IsBlocked) + { + return StatusCode(StatusCodes.Status423Locked, "User is blocked."); + } + + var session = await tokenService.CreateSessionAsync( + user, + ClientVersionRequestReader.Read(Request), + cancellationToken); + return Ok(session with { User = user.ToDto(presenceTracker) }); + } + + [HttpPost("request-email-code")] + [AllowAnonymous] + public async Task> RequestEmailCode( + RequestEmailCodeRequest request, + CancellationToken cancellationToken) + { + var normalizedEmail = EmailAddressNormalizer.Normalize(request.Email); + if (normalizedEmail is null) + { + return ValidationProblem("Valid email is required."); + } + + var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber); + if (normalizedPhoneNumber is null) + { + return ValidationProblem("Valid phone number is required."); + } + + var email = request.Email.Trim(); + var (user, conflictMessage) = await LoadEmailPhoneUserAsync( + normalizedEmail, + normalizedPhoneNumber, + cancellationToken); + if (conflictMessage is not null) + { + return Conflict(conflictMessage); + } + + if (RequiresAllowedRegistrationDomain(user, normalizedEmail) && + !EmailAddressNormalizer.IsAllowedRegistrationDomain(normalizedEmail)) + { + return BadRequest(EmailAddressNormalizer.UnsupportedRegistrationDomainMessage); + } + + if (user?.IsBlocked == true) + { + return StatusCode(StatusCodes.Status423Locked, "User is blocked."); + } + + EmailAuthChallengeStore.EmailAuthChallenge challenge; + try + { + challenge = emailAuthChallengeStore.Create(normalizedEmail, normalizedPhoneNumber, user is not null); + } + catch (AuthChallengeRateLimitException ex) + { + Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(ex.RetryAfter.TotalSeconds)).ToString(); + return StatusCode( + StatusCodes.Status429TooManyRequests, + new { error = "Please wait before requesting another email code." }); + } + try + { + await emailCodeSender.SendLoginCodeAsync(email, challenge.Code, challenge.ExpiresAt, cancellationToken); + } + catch + { + emailAuthChallengeStore.Remove(challenge.Id); + throw; + } + + return Ok(new EmailCodeChallengeDto( + challenge.Id, + challenge.ExpiresAt, + IsRegistered: false)); + } + + [HttpPost("verify-email-code")] + [AllowAnonymous] + public async Task> VerifyEmailCode( + VerifyEmailCodeRequest request, + CancellationToken cancellationToken) + { + var normalizedEmail = EmailAddressNormalizer.Normalize(request.Email); + if (normalizedEmail is null) + { + return ValidationProblem("Valid email is required."); + } + + var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber); + if (normalizedPhoneNumber is null) + { + return ValidationProblem("Valid phone number is required."); + } + + var challenge = emailAuthChallengeStore.Consume( + request.ChallengeId?.Trim() ?? string.Empty, + normalizedEmail, + normalizedPhoneNumber, + request.Code?.Trim() ?? string.Empty); + if (challenge is null) + { + return Unauthorized("Invalid or expired verification code."); + } + + var (user, conflictMessage) = await LoadEmailPhoneUserAsync( + normalizedEmail, + normalizedPhoneNumber, + cancellationToken); + if (conflictMessage is not null) + { + return Conflict(conflictMessage); + } + + if (RequiresAllowedRegistrationDomain(user, normalizedEmail) && + !EmailAddressNormalizer.IsAllowedRegistrationDomain(normalizedEmail)) + { + return BadRequest(EmailAddressNormalizer.UnsupportedRegistrationDomainMessage); + } + + if (user is null) + { + var displayName = request.DisplayName?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(displayName)) + { + return ValidationProblem("Display name is required for a new account."); + } + + user = await CreateEmailPhoneUserAsync( + normalizedEmail, + request.Email, + normalizedPhoneNumber, + request.PhoneNumber, + displayName, + cancellationToken); + } + + if (user.IsBot) + { + return Unauthorized("Bot accounts cannot sign in through the user client."); + } + + if (user.IsBlocked) + { + return StatusCode(StatusCodes.Status423Locked, "User is blocked."); + } + + await EnsureEmailPhoneLinkedAsync( + user, + normalizedEmail, + request.Email, + normalizedPhoneNumber, + request.PhoneNumber, + cancellationToken); + + var session = await tokenService.CreateSessionAsync( + user, + ClientVersionRequestReader.Read(Request), + cancellationToken); + return Ok(session with { User = user.ToDto(presenceTracker) }); + } + + [HttpPost("refresh")] + [AllowAnonymous] + public async Task> Refresh(RefreshSessionRequest request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.RefreshToken)) + { + return Unauthorized(); + } + + var session = await tokenService.RefreshSessionAsync( + request.RefreshToken, + ClientVersionRequestReader.Read(Request), + cancellationToken); + if (session is null) + { + return Unauthorized(); + } + + return Ok(session with { User = session.User with { IsOnline = presenceTracker.IsOnline(session.User.Id) } }); + } + + [HttpPost("logout")] + [Authorize] + public async Task Logout(CancellationToken cancellationToken) + { + await tokenService.RevokeSessionAsync(User.GetRequiredSessionId(), cancellationToken); + return NoContent(); + } + + private async Task CreatePhoneOnlyUserAsync( + string normalizedPhoneNumber, + string rawPhoneNumber, + string displayName, + CancellationToken cancellationToken) + { + var username = await GenerateInternalUsernameAsync(cancellationToken); + var user = new User + { + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = displayName, + PhoneNumber = rawPhoneNumber.Trim(), + NormalizedPhoneNumber = normalizedPhoneNumber + }; + + user.PasswordHash = _passwordHasher.HashPassword(user, Guid.NewGuid().ToString("N")); + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(cancellationToken); + return user; + } + + private async Task CreateEmailPhoneUserAsync( + string normalizedEmail, + string rawEmail, + string normalizedPhoneNumber, + string rawPhoneNumber, + string displayName, + CancellationToken cancellationToken) + { + var username = await GenerateInternalUsernameAsync(cancellationToken); + var user = new User + { + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = displayName, + Email = rawEmail.Trim(), + NormalizedEmail = normalizedEmail, + PhoneNumber = rawPhoneNumber.Trim(), + NormalizedPhoneNumber = normalizedPhoneNumber + }; + + user.PasswordHash = _passwordHasher.HashPassword(user, Guid.NewGuid().ToString("N")); + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(cancellationToken); + return user; + } + + private async Task<(User? User, string? ConflictMessage)> LoadEmailPhoneUserAsync( + string normalizedEmail, + string normalizedPhoneNumber, + CancellationToken cancellationToken) + { + var users = await dbContext.Users + .Where(x => + x.NormalizedEmail == normalizedEmail || + x.NormalizedPhoneNumber == normalizedPhoneNumber) + .ToListAsync(cancellationToken); + + var emailUser = users.SingleOrDefault(x => x.NormalizedEmail == normalizedEmail); + var phoneUser = users.SingleOrDefault(x => x.NormalizedPhoneNumber == normalizedPhoneNumber); + if (emailUser is not null && phoneUser is not null && emailUser.Id != phoneUser.Id) + { + return (null, "Email and phone number belong to different accounts."); + } + + var user = emailUser ?? phoneUser; + if (user is null) + { + return (null, null); + } + + if (!string.IsNullOrWhiteSpace(user.NormalizedEmail) && + !string.Equals(user.NormalizedEmail, normalizedEmail, StringComparison.Ordinal)) + { + return (null, "Phone number is already linked to another email."); + } + + if (!string.IsNullOrWhiteSpace(user.NormalizedPhoneNumber) && + !string.Equals(user.NormalizedPhoneNumber, normalizedPhoneNumber, StringComparison.Ordinal)) + { + return (null, "Email is already linked to another phone number."); + } + + return (user, null); + } + + private static bool RequiresAllowedRegistrationDomain(User? user, string normalizedEmail) => + user is null || + !string.Equals(user.NormalizedEmail, normalizedEmail, StringComparison.Ordinal); + + private async Task EnsureEmailPhoneLinkedAsync( + User user, + string normalizedEmail, + string rawEmail, + string normalizedPhoneNumber, + string rawPhoneNumber, + CancellationToken cancellationToken) + { + var changed = false; + if (string.IsNullOrWhiteSpace(user.NormalizedEmail)) + { + user.Email = rawEmail.Trim(); + user.NormalizedEmail = normalizedEmail; + changed = true; + } + + if (string.IsNullOrWhiteSpace(user.NormalizedPhoneNumber)) + { + user.PhoneNumber = rawPhoneNumber.Trim(); + user.NormalizedPhoneNumber = normalizedPhoneNumber; + changed = true; + } + + if (changed) + { + await dbContext.SaveChangesAsync(cancellationToken); + } + } + + private async Task GenerateInternalUsernameAsync(CancellationToken cancellationToken) + { + while (true) + { + var candidate = $"user_{Guid.NewGuid():N}".ToLowerInvariant(); + var exists = await dbContext.Users + .AsNoTracking() + .AnyAsync(x => x.NormalizedUsername == candidate.ToUpperInvariant(), cancellationToken); + if (!exists) + { + return candidate; + } + } + } +} diff --git a/ikar_server/src/Ikar.Server/Controllers/BotApiController.cs b/ikar_server/src/Ikar.Server/Controllers/BotApiController.cs new file mode 100644 index 0000000..042a890 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Controllers/BotApiController.cs @@ -0,0 +1,748 @@ +using System.Text.RegularExpressions; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Bots; +using Ikar.Server.Infrastructure.Channels; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Push; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Shared; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Controllers; + +[ApiController] +[AllowAnonymous] +[Route("api/bot/{token}")] +public sealed class BotApiController( + IkarDbContext dbContext, + PresenceTracker presenceTracker, + BotTokenService botTokenService, + BotUpdateService botUpdateService, + IHubContext hubContext, + PushDispatchQueue pushDispatchQueue, + AttachmentStorageService attachmentStorageService, + QlyraCompatibilityController? ikarClient = null) : ControllerBase +{ + private const long MaxBotAttachmentSizeBytes = 25 * 1024 * 1024; + private const long MaxBotMediaGroupSizeBytes = 100 * 1024 * 1024; + private const int MaxBotMediaGroupAttachments = 10; + + [HttpGet("getMe")] + public async Task> GetMe(string token, CancellationToken cancellationToken) + { + var bot = await LoadBotByTokenAsync(token, cancellationToken); + if (bot is null) + { + return Unauthorized(); + } + + if (!bot.IsEnabled) + { + return StatusCode(StatusCodes.Status423Locked, "Bot is disabled."); + } + + return Ok(bot.User.ToDto(presenceTracker)); + } + + [HttpGet("getUpdates")] + public async Task> GetUpdates( + string token, + [FromQuery] long offset = 0, + [FromQuery] int limit = 100, + [FromQuery] int timeoutSeconds = 20, + CancellationToken cancellationToken = default) + { + var bot = await LoadBotByTokenAsync(token, cancellationToken); + if (bot is null) + { + return Unauthorized(); + } + + if (!bot.IsEnabled) + { + return StatusCode(StatusCodes.Status423Locked, "Bot is disabled."); + } + + limit = Math.Clamp(limit, 1, 100); + timeoutSeconds = Math.Clamp(timeoutSeconds, 0, 50); + + var timeoutAt = DateTimeOffset.UtcNow.AddSeconds(timeoutSeconds); + while (true) + { + var updates = await LoadUpdatesAsync(bot, offset, limit, cancellationToken); + if (updates.Count > 0 || timeoutSeconds == 0 || DateTimeOffset.UtcNow >= timeoutAt) + { + var messages = updates + .Select(x => x.Message) + .Where(x => x is not null) + .Cast() + .ToList(); + if (messages.Count > 0) + { + await botUpdateService.MarkMessagesReadByBotAsync(bot.UserId, messages, cancellationToken); + } + + var payload = updates + .Select(update => new BotUpdateDto( + update.Id, + update.Kind, + update.Message?.ToDto(presenceTracker, bot.UserId), + update.Kind == BotUpdateKind.CallbackQuery && update.CallbackUser is not null + ? new BotCallbackQueryDto( + update.CallbackId ?? string.Empty, + update.CallbackPayload, + update.CallbackUser.ToDto(presenceTracker), + update.ChatId, + update.MessageId) + : null)) + .ToList(); + + var nextOffset = payload.Count == 0 ? offset : payload[^1].Id + 1; + return Ok(new BotUpdatesResponseDto(nextOffset, payload)); + } + + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + } + + [HttpGet("getMyCommands")] + public async Task>> GetMyCommands(string token, CancellationToken cancellationToken) + { + var bot = await LoadBotByTokenAsync(token, cancellationToken); + if (bot is null) + { + return Unauthorized(); + } + + return Ok(bot.Commands + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Command, StringComparer.OrdinalIgnoreCase) + .Select(x => new BotCommandDto(x.Command, x.Description)) + .ToList()); + } + + [HttpPost("setMyCommands")] + public async Task>> SetMyCommands( + string token, + SetBotCommandsRequest request, + CancellationToken cancellationToken) + { + var bot = await LoadBotByTokenAsync(token, cancellationToken); + if (bot is null) + { + return Unauthorized(); + } + + if (!bot.IsEnabled) + { + return StatusCode(StatusCodes.Status423Locked, "Bot is disabled."); + } + + var commands = NormalizeCommands(request.Commands); + var existingCommands = await dbContext.BotCommands + .Where(x => x.BotId == bot.Id) + .ToListAsync(cancellationToken); + dbContext.BotCommands.RemoveRange(existingCommands); + await dbContext.SaveChangesAsync(cancellationToken); + + for (var index = 0; index < commands.Count; index++) + { + dbContext.BotCommands.Add(new BotCommand + { + BotId = bot.Id, + Command = commands[index].Command, + Description = commands[index].Description, + SortOrder = index + }); + } + + bot.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + return Ok(commands); + } + + [HttpPost("sendMessage")] + public async Task> SendMessage( + string token, + BotSendMessageRequest request, + CancellationToken cancellationToken) + { + var bot = await LoadBotByTokenAsync(token, cancellationToken); + if (bot is null) + { + return Unauthorized(); + } + + if (!bot.IsEnabled) + { + return StatusCode(StatusCodes.Status423Locked, "Bot is disabled."); + } + + var text = request.Text?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(text)) + { + return ValidationProblem("Message text is required."); + } + + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync( + x => x.Id == request.ChatId && x.Members.Any(member => member.UserId == bot.UserId), + cancellationToken); + if (chat is null) + { + return NotFound("Chat not found."); + } + + if (!DtoMapper.BuildPermissions(chat.Members.FirstOrDefault(member => member.UserId == bot.UserId), chat.Type).CanPostMessages) + { + return StatusCode(StatusCodes.Status403Forbidden, "Bot cannot publish in this chat."); + } + + var message = new Message + { + ChatId = chat.Id, + Chat = chat, + SenderId = bot.UserId, + Sender = bot.User, + PostedAsChannel = chat.Type == ChatType.Channel, + AuthorSignature = chat.Type == ChatType.Channel && chat.ChannelSignaturesEnabled + ? string.IsNullOrWhiteSpace(bot.User.DisplayName) ? bot.User.Username : bot.User.DisplayName + : null, + Text = text, + SentAt = DateTimeOffset.UtcNow + }; + + chat.LastActivityAt = message.SentAt; + dbContext.Messages.Add(message); + var discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync( + dbContext, + attachmentStorageService, + chat, + message, + bot.User, + message.SentAt, + cancellationToken); + if (discussionRootMessage is not null) + { + message.DiscussionMessageId = discussionRootMessage.Id; + } + + pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage); + await dbContext.SaveChangesAsync(cancellationToken); + + await NotifyMessageCreatedAsync(chat, message, cancellationToken); + await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id); + if (discussionRootMessage is not null) + { + await NotifyMessageCreatedAsync(discussionRootMessage.Chat, discussionRootMessage, cancellationToken); + await pushDispatchQueue.EnqueueAsync(discussionRootMessage.ChatId, discussionRootMessage.Id); + } + + return Ok(message.ToDto(presenceTracker, bot.UserId)); + } + + [HttpPost("sendDocument")] + [RequestFormLimits(MultipartBodyLengthLimit = MaxBotAttachmentSizeBytes)] + [RequestSizeLimit(MaxBotAttachmentSizeBytes)] + public Task> SendDocument( + string token, + [FromForm] BotSendAttachmentRequest request, + CancellationToken cancellationToken) => + SendAttachmentAsync(token, request, forceVoiceNote: false, requiredMediaKind: null, cancellationToken: cancellationToken); + + [HttpPost("sendPhoto")] + [RequestFormLimits(MultipartBodyLengthLimit = MaxBotAttachmentSizeBytes)] + [RequestSizeLimit(MaxBotAttachmentSizeBytes)] + public Task> SendPhoto( + string token, + [FromForm] BotSendAttachmentRequest request, + CancellationToken cancellationToken) => + SendAttachmentAsync(token, request, forceVoiceNote: false, requiredMediaKind: BotAttachmentMediaKind.Photo, cancellationToken: cancellationToken); + + [HttpPost("sendVideo")] + [RequestFormLimits(MultipartBodyLengthLimit = MaxBotAttachmentSizeBytes)] + [RequestSizeLimit(MaxBotAttachmentSizeBytes)] + public Task> SendVideo( + string token, + [FromForm] BotSendAttachmentRequest request, + CancellationToken cancellationToken) => + SendAttachmentAsync(token, request, forceVoiceNote: false, requiredMediaKind: BotAttachmentMediaKind.Video, cancellationToken: cancellationToken); + + [HttpPost("sendMediaGroup")] + [RequestFormLimits(MultipartBodyLengthLimit = MaxBotMediaGroupSizeBytes)] + [RequestSizeLimit(MaxBotMediaGroupSizeBytes)] + public async Task> SendMediaGroup( + string token, + [FromForm] BotSendMediaGroupRequest request, + CancellationToken cancellationToken) + { + var bot = await LoadBotByTokenAsync(token, cancellationToken); + if (bot is null) + { + return Unauthorized(); + } + + if (!bot.IsEnabled) + { + return StatusCode(StatusCodes.Status423Locked, "Bot is disabled."); + } + + if (request.ChatId == Guid.Empty) + { + return ValidationProblem("ChatId is required."); + } + + var files = request.Files + .Where(file => file is not null) + .ToList(); + + if (files.Count < 2) + { + return BadRequest("Media group must contain at least 2 files."); + } + + if (files.Count > MaxBotMediaGroupAttachments) + { + return BadRequest($"Media group can contain up to {MaxBotMediaGroupAttachments} files."); + } + + if (files.Any(file => file.Length <= 0)) + { + return BadRequest("Media group contains an empty file."); + } + + if (files.Any(file => file.Length > MaxBotAttachmentSizeBytes) || files.Sum(file => file.Length) > MaxBotMediaGroupSizeBytes) + { + return BadRequest($"Media group exceeds {MaxBotMediaGroupSizeBytes} bytes or contains a file over {MaxBotAttachmentSizeBytes} bytes."); + } + + if (files.Any(file => + !MatchesRequiredMediaKind(file, BotAttachmentMediaKind.Photo) && + !MatchesRequiredMediaKind(file, BotAttachmentMediaKind.Video))) + { + return BadRequest("sendMediaGroup accepts only image and video files."); + } + + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync( + x => x.Id == request.ChatId && x.Members.Any(member => member.UserId == bot.UserId), + cancellationToken); + if (chat is null) + { + return NotFound("Chat not found."); + } + + if (!DtoMapper.BuildPermissions(chat.Members.FirstOrDefault(member => member.UserId == bot.UserId), chat.Type).CanPostMessages) + { + return StatusCode(StatusCodes.Status403Forbidden, "Bot cannot publish in this chat."); + } + + var sentAt = DateTimeOffset.UtcNow; + var message = new Message + { + ChatId = chat.Id, + Chat = chat, + SenderId = bot.UserId, + Sender = bot.User, + PostedAsChannel = chat.Type == ChatType.Channel, + AuthorSignature = chat.Type == ChatType.Channel && chat.ChannelSignaturesEnabled + ? string.IsNullOrWhiteSpace(bot.User.DisplayName) ? bot.User.Username : bot.User.DisplayName + : null, + Text = request.Text?.Trim() ?? string.Empty, + MediaAlbumId = Guid.NewGuid(), + SentAt = sentAt + }; + + var savedAttachments = new List(); + Message? discussionRootMessage = null; + try + { + for (var index = 0; index < files.Count; index++) + { + var attachment = await attachmentStorageService.SaveAsync(message.Id, files[index], cancellationToken, sortOrder: index); + savedAttachments.Add(attachment); + message.Attachments.Add(attachment); + } + + chat.LastActivityAt = message.SentAt; + dbContext.Messages.Add(message); + discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync( + dbContext, + attachmentStorageService, + chat, + message, + bot.User, + sentAt, + cancellationToken); + if (discussionRootMessage is not null) + { + message.DiscussionMessageId = discussionRootMessage.Id; + } + + pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage); + await dbContext.SaveChangesAsync(cancellationToken); + } + catch + { + foreach (var attachment in savedAttachments) + { + attachmentStorageService.DeleteIfExists(attachment.StoragePath); + } + + if (discussionRootMessage is not null) + { + DeleteFiles(discussionRootMessage.Attachments.Select(x => x.StoragePath)); + } + + throw; + } + + await NotifyMessageCreatedAsync(chat, message, cancellationToken); + await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id); + if (discussionRootMessage is not null) + { + await NotifyMessageCreatedAsync(discussionRootMessage.Chat, discussionRootMessage, cancellationToken); + await pushDispatchQueue.EnqueueAsync(discussionRootMessage.ChatId, discussionRootMessage.Id); + } + + return Ok(message.ToDto(presenceTracker, bot.UserId)); + } + + [HttpPost("sendVoice")] + [RequestFormLimits(MultipartBodyLengthLimit = MaxBotAttachmentSizeBytes)] + [RequestSizeLimit(MaxBotAttachmentSizeBytes)] + public Task> SendVoice( + string token, + [FromForm] BotSendAttachmentRequest request, + CancellationToken cancellationToken) => + SendAttachmentAsync(token, request, forceVoiceNote: true, requiredMediaKind: null, cancellationToken: cancellationToken); + + private async Task> SendAttachmentAsync( + string token, + BotSendAttachmentRequest request, + bool forceVoiceNote, + BotAttachmentMediaKind? requiredMediaKind, + CancellationToken cancellationToken) + { + var bot = await LoadBotByTokenAsync(token, cancellationToken); + if (bot is null) + { + return Unauthorized(); + } + + if (!bot.IsEnabled) + { + return StatusCode(StatusCodes.Status423Locked, "Bot is disabled."); + } + + if (request.ChatId == Guid.Empty) + { + return ValidationProblem("ChatId is required."); + } + + if (request.File is null) + { + return BadRequest("File is required."); + } + + if (request.File.Length <= 0) + { + return BadRequest("File is empty."); + } + + if (request.File.Length > MaxBotAttachmentSizeBytes) + { + return BadRequest($"File exceeds {MaxBotAttachmentSizeBytes} bytes."); + } + + if (requiredMediaKind is not null && !MatchesRequiredMediaKind(request.File, requiredMediaKind.Value)) + { + return BadRequest(requiredMediaKind == BotAttachmentMediaKind.Photo + ? "sendPhoto accepts only image files." + : "sendVideo accepts only video files."); + } + + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync( + x => x.Id == request.ChatId && x.Members.Any(member => member.UserId == bot.UserId), + cancellationToken); + if (chat is null) + { + return NotFound("Chat not found."); + } + + if (!DtoMapper.BuildPermissions(chat.Members.FirstOrDefault(member => member.UserId == bot.UserId), chat.Type).CanPostMessages) + { + return StatusCode(StatusCodes.Status403Forbidden, "Bot cannot publish in this chat."); + } + + var sentAt = DateTimeOffset.UtcNow; + var message = new Message + { + ChatId = chat.Id, + Chat = chat, + SenderId = bot.UserId, + Sender = bot.User, + PostedAsChannel = chat.Type == ChatType.Channel, + AuthorSignature = chat.Type == ChatType.Channel && chat.ChannelSignaturesEnabled + ? string.IsNullOrWhiteSpace(bot.User.DisplayName) ? bot.User.Username : bot.User.DisplayName + : null, + Text = request.Text?.Trim() ?? string.Empty, + SentAt = sentAt + }; + + MessageAttachment? attachment = null; + Message? discussionRootMessage = null; + try + { + attachment = await attachmentStorageService.SaveAsync(message.Id, request.File, cancellationToken); + if (forceVoiceNote || IsVoiceLikeAttachment(attachment)) + { + attachment.ContentType = ResolveVoiceContentType(attachment); + attachment.Kind = AttachmentKind.VoiceNote; + } + + message.Attachments.Add(attachment); + chat.LastActivityAt = message.SentAt; + dbContext.Messages.Add(message); + discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync( + dbContext, + attachmentStorageService, + chat, + message, + bot.User, + sentAt, + cancellationToken); + if (discussionRootMessage is not null) + { + message.DiscussionMessageId = discussionRootMessage.Id; + } + + pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage); + await dbContext.SaveChangesAsync(cancellationToken); + } + catch + { + if (attachment is not null) + { + attachmentStorageService.DeleteIfExists(attachment.StoragePath); + } + + if (discussionRootMessage is not null) + { + DeleteFiles(discussionRootMessage.Attachments.Select(x => x.StoragePath)); + } + + throw; + } + + await NotifyMessageCreatedAsync(chat, message, cancellationToken); + await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id); + if (discussionRootMessage is not null) + { + await NotifyMessageCreatedAsync(discussionRootMessage.Chat, discussionRootMessage, cancellationToken); + await pushDispatchQueue.EnqueueAsync(discussionRootMessage.ChatId, discussionRootMessage.Id); + } + + return Ok(message.ToDto(presenceTracker, bot.UserId)); + } + + private async Task LoadBotByTokenAsync(string token, CancellationToken cancellationToken) + { + var tokenHash = botTokenService.HashToken(token); + return await dbContext.Bots + .Include(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.Commands) + .SingleOrDefaultAsync(x => x.TokenHash == tokenHash, cancellationToken); + } + + private async Task> LoadUpdatesAsync(Bot bot, long offset, int limit, CancellationToken cancellationToken) + { + return await dbContext.BotUpdates + .AsNoTracking() + .Where(x => x.BotId == bot.Id && x.Id >= offset) + .OrderBy(x => x.Id) + .Take(limit) + .Include(x => x.Message) + .ThenInclude(x => x.Sender) + .ThenInclude(x => x.Sessions) + .Include(x => x.Message) + .ThenInclude(x => x.Attachments) + .Include(x => x.Message) + .ThenInclude(x => x.Reactions) + .Include(x => x.Message) + .ThenInclude(x => x.Views) + .Include(x => x.Message) + .ThenInclude(x => x.Chat) + .ThenInclude(x => x.Members) + .Include(x => x.CallbackUser) + .ThenInclude(x => x!.Sessions) + .ToListAsync(cancellationToken); + } + + private async Task NotifyMessageCreatedAsync(Chat chat, Message message, CancellationToken cancellationToken) + { + var tasks = chat.Members.Select(async member => + { + var dto = message.ToDto(presenceTracker, member.UserId); + await hubContext.Clients.User(member.UserId.ToString()) + .SendAsync("MessageCreated", dto, cancellationToken); + }); + + await Task.WhenAll(tasks); + if (ikarClient is not null) + { + ikarClient.ControllerContext = new ControllerContext { HttpContext = HttpContext }; + var mapper = new QlyraClientIdMapper(dbContext); + var chatId = chat.Type == ChatType.Direct ? 0 : await mapper.GetOrCreateAsync("chat", chat.Id, cancellationToken); + if (chat.Type == ChatType.Direct) foreach (var member in chat.Members) chatId ^= await mapper.GetOrCreateAsync("user", member.UserId, cancellationToken); + await hubContext.Clients.Users(chat.Members.Select(x => x.UserId.ToString()).ToArray()).SendAsync("QlyraPacket", new { opcode = 128, payload = new { chatId, message = await ikarClient.BuildMessageAsync(message, cancellationToken), unread = 0 } }, cancellationToken); + } + } + + private void DeleteFiles(IEnumerable attachmentPaths) + { + foreach (var path in attachmentPaths) + { + attachmentStorageService.DeleteIfExists(path); + } + } + + private static string ResolveVoiceContentType(MessageAttachment attachment) + { + var normalized = attachment.ContentType + .Split(';', 2, StringSplitOptions.TrimEntries) + .FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(normalized) && + normalized.StartsWith("audio/", StringComparison.OrdinalIgnoreCase)) + { + return attachment.ContentType; + } + + var extension = Path.GetExtension(attachment.OriginalFileName).ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(extension)) + { + var lastToken = attachment.OriginalFileName + .Split([' ', '.', '_', '-', '(', ')', '[', ']'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .LastOrDefault() + ?.ToLowerInvariant(); + extension = string.IsNullOrWhiteSpace(lastToken) ? string.Empty : $".{lastToken}"; + } + + return extension switch + { + ".aac" => "audio/aac", + ".flac" => "audio/flac", + ".m4a" => "audio/mp4", + ".mp3" => "audio/mpeg", + ".oga" or ".ogg" => "audio/ogg", + ".opus" => "audio/opus", + ".wav" => "audio/wav", + ".webm" => "audio/webm", + _ => "audio/ogg" + }; + } + + private static bool IsVoiceLikeAttachment(MessageAttachment attachment) + { + var normalized = attachment.ContentType + .Split(';', 2, StringSplitOptions.TrimEntries) + .FirstOrDefault() + ?.Trim() + .ToLowerInvariant(); + if (normalized is "audio/ogg" or "audio/opus" or "application/ogg" or "application/opus" or "application/x-ogg" or "video/ogg") + { + return true; + } + + var extension = Path.GetExtension(attachment.OriginalFileName).ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(extension)) + { + var lastToken = attachment.OriginalFileName + .Split([' ', '.', '_', '-', '(', ')', '[', ']'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .LastOrDefault() + ?.ToLowerInvariant(); + extension = string.IsNullOrWhiteSpace(lastToken) ? string.Empty : $".{lastToken}"; + } + + return extension is ".ogg" or ".oga" or ".opus"; + } + + private static bool MatchesRequiredMediaKind(IFormFile file, BotAttachmentMediaKind requiredMediaKind) + { + var normalizedContentType = file.ContentType + ?.Split(';', 2, StringSplitOptions.TrimEntries) + .FirstOrDefault() + ?.Trim() + .ToLowerInvariant(); + + if (!string.IsNullOrWhiteSpace(normalizedContentType)) + { + if (requiredMediaKind == BotAttachmentMediaKind.Photo && + normalizedContentType.StartsWith("image/", StringComparison.Ordinal)) + { + return true; + } + + if (requiredMediaKind == BotAttachmentMediaKind.Video && + normalizedContentType.StartsWith("video/", StringComparison.Ordinal)) + { + return true; + } + } + + var extension = Path.GetExtension(file.FileName).ToLowerInvariant(); + return requiredMediaKind switch + { + BotAttachmentMediaKind.Photo => extension is ".jpg" or ".jpeg" or ".png" or ".gif" or ".webp" or ".bmp", + BotAttachmentMediaKind.Video => extension is ".mp4" or ".m4v" or ".mov" or ".webm" or ".mkv", + _ => false + }; + } + + private enum BotAttachmentMediaKind + { + Photo, + Video + } + + private static List NormalizeCommands(IReadOnlyList commands) + { + var normalized = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var command in commands ?? Array.Empty()) + { + var normalizedCommand = (command.Command ?? string.Empty).Trim().TrimStart('/'); + var normalizedDescription = (command.Description ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(normalizedCommand) || string.IsNullOrWhiteSpace(normalizedDescription)) + { + continue; + } + + if (!Regex.IsMatch(normalizedCommand, "^[a-z0-9_]{1,32}$", RegexOptions.IgnoreCase)) + { + continue; + } + + if (!seen.Add(normalizedCommand)) + { + continue; + } + + normalized.Add(new BotCommandDto(normalizedCommand.ToLowerInvariant(), normalizedDescription)); + } + + return normalized; + } +} diff --git a/ikar_server/src/Ikar.Server/Controllers/BotsController.cs b/ikar_server/src/Ikar.Server/Controllers/BotsController.cs new file mode 100644 index 0000000..0570e21 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Controllers/BotsController.cs @@ -0,0 +1,264 @@ +using System.Text.RegularExpressions; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Bots; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Ikar.Shared; + +namespace Ikar.Server.Controllers; + +[ApiController] +[Authorize] +[Route("api/[controller]")] +public sealed class BotsController( + IkarDbContext dbContext, + BotTokenService botTokenService, + BotDeletionService botDeletionService) : ControllerBase +{ + private static readonly Regex BotUsernameRegex = new("^[A-Za-z0-9_]{5,64}$", RegexOptions.Compiled); + + [HttpGet("mine")] + public async Task>> GetMine(CancellationToken cancellationToken) + { + var ownerUserId = User.GetRequiredUserId(); + var bots = await dbContext.Bots + .Include(x => x.User) + .Include(x => x.Commands) + .AsNoTracking() + .Where(x => x.OwnerUserId == ownerUserId) + .OrderBy(x => x.User.DisplayName) + .ToListAsync(cancellationToken); + + return Ok(bots.Select(x => x.ToDto()).ToList()); + } + + [HttpGet("{botId:guid}")] + public async Task> GetBot(Guid botId, CancellationToken cancellationToken) + { + var bot = await LoadOwnedBotAsync(botId, User.GetRequiredUserId(), cancellationToken); + if (bot is null) + { + return NotFound(); + } + + return Ok(bot.ToDto()); + } + + [HttpPost] + public ActionResult CreateBot(CreateBotRequest request) + { + _ = request; + return StatusCode( + StatusCodes.Status410Gone, + "Создание ботов доступно только через чат с @botfatherbot. Напишите /создать_бота Имя бота | usernamebot | описание."); + } + + [HttpPut("{botId:guid}")] + public async Task> UpdateBot( + Guid botId, + UpdateBotRequest request, + CancellationToken cancellationToken) + { + var ownerUserId = User.GetRequiredUserId(); + var bot = await LoadOwnedBotAsync(botId, ownerUserId, cancellationToken); + if (bot is null) + { + return NotFound(); + } + + var validationError = await ValidateBotDefinitionAsync( + bot.User.Username, + request.DisplayName, + request.About, + excludingBotId: botId, + cancellationToken); + if (validationError is not null) + { + return ValidationProblem(validationError); + } + + bot.User.DisplayName = request.DisplayName.Trim(); + bot.User.About = string.IsNullOrWhiteSpace(request.About) ? null : request.About.Trim(); + bot.IsEnabled = request.IsEnabled; + bot.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + + return Ok(bot.ToDto()); + } + + [HttpPut("{botId:guid}/web-app")] + public async Task SetWebApp(Guid botId, SetBotWebAppRequest request, CancellationToken cancellationToken) + { + var bot = await LoadOwnedBotAsync(botId, User.GetRequiredUserId(), cancellationToken); + if (bot is null) return NotFound(); + var url = request.Url?.Trim(); + if (!string.IsNullOrEmpty(url) && (url.Length > 2048 || !Uri.TryCreate(url, UriKind.Absolute, out var parsed) || parsed.Scheme != "https" || !string.IsNullOrEmpty(parsed.UserInfo))) + return BadRequest("Мини-приложение должно иметь адрес HTTPS."); + bot.WebAppUrl = string.IsNullOrEmpty(url) ? null : url; + bot.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + return Ok(new { url = bot.WebAppUrl }); + } + + [HttpPut("{botId:guid}/commands")] + public async Task> SetCommands( + Guid botId, + SetBotCommandsRequest request, + CancellationToken cancellationToken) + { + var ownerUserId = User.GetRequiredUserId(); + var bot = await LoadOwnedBotAsync(botId, ownerUserId, cancellationToken); + if (bot is null) + { + return NotFound(); + } + + var normalizedCommands = NormalizeCommands(request.Commands); + var existingCommands = await dbContext.BotCommands + .Where(x => x.BotId == bot.Id) + .ToListAsync(cancellationToken); + dbContext.BotCommands.RemoveRange(existingCommands); + await dbContext.SaveChangesAsync(cancellationToken); + + for (var index = 0; index < normalizedCommands.Count; index++) + { + dbContext.BotCommands.Add(new BotCommand + { + BotId = bot.Id, + Command = normalizedCommands[index].Command, + Description = normalizedCommands[index].Description, + SortOrder = index + }); + } + + bot.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + var reloaded = await LoadOwnedBotAsync(bot.Id, ownerUserId, cancellationToken) + ?? throw new InvalidOperationException("Updated bot was not found."); + return Ok(reloaded.ToDto()); + } + + [HttpPost("{botId:guid}/regenerate-token")] + public async Task> RegenerateToken(Guid botId, CancellationToken cancellationToken) + { + var ownerUserId = User.GetRequiredUserId(); + var bot = await LoadOwnedBotAsync(botId, ownerUserId, cancellationToken); + if (bot is null) + { + return NotFound(); + } + + var rawToken = botTokenService.GenerateToken(bot.Id); + bot.TokenHash = botTokenService.HashToken(rawToken); + bot.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + + return Ok(new RegenerateBotTokenResponseDto(rawToken)); + } + + [HttpDelete("{botId:guid}")] + public async Task DeleteBot(Guid botId, CancellationToken cancellationToken) + { + var deleted = await botDeletionService.DeleteBotAsync( + botId, + User.GetRequiredUserId(), + cancellationToken); + if (deleted is null) + { + return NotFound(); + } + return NoContent(); + } + + private async Task LoadOwnedBotAsync(Guid botId, Guid ownerUserId, CancellationToken cancellationToken) + { + return await dbContext.Bots + .Include(x => x.User) + .Include(x => x.Commands) + .SingleOrDefaultAsync( + x => x.Id == botId && x.OwnerUserId == ownerUserId, + cancellationToken); + } + + private async Task ValidateBotDefinitionAsync( + string? username, + string? displayName, + string? about, + Guid? excludingBotId, + CancellationToken cancellationToken) + { + var trimmedUsername = username?.Trim() ?? string.Empty; + var trimmedDisplayName = displayName?.Trim() ?? string.Empty; + var normalizedUsername = trimmedUsername.ToUpperInvariant(); + + if (string.IsNullOrWhiteSpace(trimmedDisplayName)) + { + return "Display name is required."; + } + + if (trimmedDisplayName.Length > 120) + { + return "Display name must be 120 characters or fewer."; + } + + if (!string.IsNullOrWhiteSpace(about) && about.Trim().Length > 70) + { + return "About must be 70 characters or fewer."; + } + + if (!BotUsernameRegex.IsMatch(trimmedUsername) || + !trimmedUsername.EndsWith("bot", StringComparison.OrdinalIgnoreCase)) + { + return "Bot username must contain only latin letters, digits or underscore and end with 'bot'."; + } + + var usernameTaken = await dbContext.Users + .AsNoTracking() + .Include(x => x.BotProfile) + .AnyAsync( + x => x.NormalizedUsername == normalizedUsername && + (!excludingBotId.HasValue || x.BotProfile == null || x.BotProfile.Id != excludingBotId.Value), + cancellationToken); + if (usernameTaken) + { + return "Username is already taken."; + } + + return null; + } + + private static List NormalizeCommands(IReadOnlyList commands) + { + var normalized = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var command in commands ?? Array.Empty()) + { + var normalizedCommand = (command.Command ?? string.Empty).Trim().TrimStart('/'); + var normalizedDescription = (command.Description ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(normalizedCommand) || string.IsNullOrWhiteSpace(normalizedDescription)) + { + continue; + } + + if (!Regex.IsMatch(normalizedCommand, "^[a-z0-9_]{1,32}$", RegexOptions.IgnoreCase)) + { + continue; + } + + if (!seen.Add(normalizedCommand)) + { + continue; + } + + normalized.Add(new BotCommandDto(normalizedCommand.ToLowerInvariant(), normalizedDescription)); + } + + return normalized; + } +} diff --git a/ikar_server/src/Ikar.Server/Controllers/ChatsController.cs b/ikar_server/src/Ikar.Server/Controllers/ChatsController.cs new file mode 100644 index 0000000..9e8c56a --- /dev/null +++ b/ikar_server/src/Ikar.Server/Controllers/ChatsController.cs @@ -0,0 +1,4566 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Bots; +using Ikar.Server.Infrastructure.Channels; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Push; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Shared; +using System.Net.Mime; +using System.Security.Cryptography; + +namespace Ikar.Server.Controllers; + +[ApiController] +[Authorize] +[Route("api/[controller]")] +public sealed class ChatsController( + IkarDbContext dbContext, + PresenceTracker presenceTracker, + IHubContext hubContext, + AttachmentStorageService attachmentStorageService, + PushDispatchQueue pushDispatchQueue, + BotUpdateService botUpdateService, + BotCreatorService botCreatorService) : ControllerBase +{ + private const long MaxAttachmentSizeBytes = 25 * 1024 * 1024; + private const long MaxAlbumSizeBytes = 100 * 1024 * 1024; + private const int MaxAlbumAttachments = 10; + private const int DefaultMessagePageSize = 50; + private const int MaxMessagePageSize = 100; + + [HttpGet] + public async Task>> GetChats(CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + + var chats = await dbContext.Chats + .Where(x => x.Members.Any(member => member.UserId == userId)) + .Include(x => x.Members) + .ThenInclude(x => x.User) + .AsNoTracking() + .AsSplitQuery() + .ToListAsync(cancellationToken); + + var chatIds = chats.Select(x => x.Id).ToList(); + var latestMessagesByChatId = await LoadLatestMessagesByChatIdAsync(chatIds, cancellationToken); + var unreadCountsByChatId = await LoadUnreadCountsByChatIdAsync(userId, chatIds, cancellationToken); + + return Ok(chats + .OrderByDescending(x => x.LastActivityAt ?? x.CreatedAt) + .Select(x => x.ToSummaryDto( + userId, + presenceTracker, + latestMessagesByChatId.GetValueOrDefault(x.Id), + unreadCountsByChatId.GetValueOrDefault(x.Id))) + .ToList()); + } + + [HttpGet("folders")] + public async Task>> GetFolders(CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var memberships = await dbContext.ChatMembers + .Where(member => member.UserId == userId) + .Select(member => new + { + member.ChatId, + member.IsArchived, + member.IsPinned, + member.FolderKey, + member.LastReadAt + }) + .ToListAsync(cancellationToken); + + var chatIds = memberships.Select(x => x.ChatId).ToList(); + var unreadCounts = await LoadUnreadCountsByChatIdAsync(userId, chatIds, cancellationToken); + var folders = new List + { + new("all", "All chats", memberships.Count, unreadCounts.Values.Sum()), + new("unread", "Unread", unreadCounts.Count(x => x.Value > 0), unreadCounts.Values.Sum()), + new("pinned", "Pinned", memberships.Count(x => x.IsPinned), memberships.Where(x => x.IsPinned).Sum(x => unreadCounts.GetValueOrDefault(x.ChatId))), + new("archive", "Archive", memberships.Count(x => x.IsArchived), memberships.Where(x => x.IsArchived).Sum(x => unreadCounts.GetValueOrDefault(x.ChatId))) + }; + + folders.AddRange(memberships + .Where(x => !string.IsNullOrWhiteSpace(x.FolderKey)) + .GroupBy(x => x.FolderKey!) + .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .Select(group => new ChatFolderDto( + group.Key, + group.Key, + group.Count(), + group.Sum(x => unreadCounts.GetValueOrDefault(x.ChatId))))); + + return Ok(folders); + } + + [HttpGet("public-channels")] + public async Task>> GetPublicChannels( + [FromQuery] string? q = null, + [FromQuery] int limit = 50, + CancellationToken cancellationToken = default) + { + var userId = User.GetRequiredUserId(); + var channels = await LoadPublicChannelSearchResultsAsync( + q, + cancellationToken, + NormalizeSearchLimit(limit)); + return Ok(channels + .Select(chat => chat.ToSummaryDto(userId, presenceTracker)) + .ToList()); + } + + [HttpGet("public/{username}")] + public async Task> GetPublicChannelByUsername( + string username, + CancellationToken cancellationToken = default) + { + var userId = User.GetRequiredUserId(); + string? publicUsername; + try + { + publicUsername = NormalizePublicUsername(username); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + + if (publicUsername is null) + { + return BadRequest("Public username is required."); + } + + var chatId = await dbContext.Chats + .AsNoTracking() + .Where(chat => + chat.Type == ChatType.Channel && + chat.PublicUsername == publicUsername) + .Select(chat => (Guid?)chat.Id) + .SingleOrDefaultAsync(cancellationToken); + if (chatId is null) + { + return NotFound(); + } + + var chat = await LoadChatMetadata( + chatId.Value, + userId, + cancellationToken, + asNoTracking: false, + allowPublicChannelPreview: true); + if (chat is null) + { + return NotFound(); + } + + var page = await LoadMessagesPageAsync(chat, beforeMessageId: null, DefaultMessagePageSize, cancellationToken); + await MarkChannelMessagesViewedAsync(chat, userId, page.Messages, cancellationToken); + var pinnedMessages = await LoadPinnedMessagesAsync(chat, cancellationToken); + return Ok(chat.ToDetailsDto( + userId, + presenceTracker, + messagesOverride: page.Messages, + hasMoreMessages: page.HasMoreMessages, + pinnedMessagesOverride: pinnedMessages)); + } + + [HttpGet("search")] + public async Task>> Search( + [FromQuery] string q, + [FromQuery] string? filter = null, + [FromQuery] string? type = null, + [FromQuery] int page = 1, + [FromQuery] int limit = 50, + [FromQuery] DateTimeOffset? date = null, + [FromQuery] DateTimeOffset? dateFrom = null, + [FromQuery] DateTimeOffset? dateTo = null, + [FromQuery] Guid? authorId = null, + [FromQuery] string? author = null, + CancellationToken cancellationToken = default) + { + var userId = User.GetRequiredUserId(); + var searchPage = await SearchChatsPageAsync( + userId, + q, + BuildSearchFilters(filter, type, date, dateFrom, dateTo, authorId, author), + page, + limit, + cancellationToken); + WriteSearchPageHeaders(searchPage.Page, searchPage.Limit, searchPage.HasMore); + return Ok(searchPage.Results); + } + + [HttpGet("search/page")] + public async Task> SearchPage( + [FromQuery] string q, + [FromQuery] string? filter = null, + [FromQuery] string? type = null, + [FromQuery] int page = 1, + [FromQuery] int limit = 50, + [FromQuery] DateTimeOffset? date = null, + [FromQuery] DateTimeOffset? dateFrom = null, + [FromQuery] DateTimeOffset? dateTo = null, + [FromQuery] Guid? authorId = null, + [FromQuery] string? author = null, + CancellationToken cancellationToken = default) + { + var userId = User.GetRequiredUserId(); + var searchPage = await SearchChatsPageAsync( + userId, + q, + BuildSearchFilters(filter, type, date, dateFrom, dateTo, authorId, author), + page, + limit, + cancellationToken); + return Ok(searchPage); + } + + [HttpGet("{chatId:guid}")] + public async Task> GetChat(Guid chatId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata( + chatId, + userId, + cancellationToken, + asNoTracking: false, + allowPublicChannelPreview: true); + + if (chat is null) + { + return NotFound(); + } + + var currentMembership = chat.Members.FirstOrDefault(member => member.UserId == userId); + var previousReadAt = currentMembership?.LastReadAt; + var now = DateTimeOffset.UtcNow; + var firstUnreadMessageId = previousReadAt is null + ? null + : await dbContext.Messages + .Where(message => + message.ChatId == chatId && + message.SenderId != userId && + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now) && + message.SentAt > previousReadAt.Value) + .OrderBy(message => message.SentAt) + .Select(message => (Guid?)message.Id) + .FirstOrDefaultAsync(cancellationToken); + + var page = await LoadMessagesPageAsync(chat, beforeMessageId: null, DefaultMessagePageSize, cancellationToken); + await MarkChannelMessagesViewedAsync(chat, userId, page.Messages, cancellationToken); + var pinnedMessages = await LoadPinnedMessagesAsync(chat, cancellationToken); + return Ok(chat.ToDetailsDto( + userId, + presenceTracker, + firstUnreadMessageId, + page.Messages, + page.HasMoreMessages, + pinnedMessages)); + } + + [HttpGet("{chatId:guid}/info")] + public async Task> GetChatInfo(Guid chatId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata( + chatId, + userId, + cancellationToken, + allowPublicChannelPreview: true); + if (chat is null) + { + return NotFound(); + } + + var page = await LoadMessagesPageAsync(chat, beforeMessageId: null, DefaultMessagePageSize, cancellationToken); + var pinnedMessages = await LoadPinnedMessagesAsync(chat, cancellationToken); + var details = chat.ToDetailsDto( + userId, + presenceTracker, + messagesOverride: page.Messages, + hasMoreMessages: page.HasMoreMessages, + pinnedMessagesOverride: pinnedMessages); + + var now = DateTimeOffset.UtcNow; + var mediaCount = await dbContext.MessageAttachments + .CountAsync(x => + x.Message.ChatId == chatId && + x.Message.DeletedAt == null && + (x.Message.ExpiresAt == null || x.Message.ExpiresAt > now) && + (x.ContentType.StartsWith("image/") || x.ContentType.StartsWith("video/")), + cancellationToken); + var fileCount = await dbContext.MessageAttachments + .CountAsync(x => + x.Message.ChatId == chatId && + x.Message.DeletedAt == null && + (x.Message.ExpiresAt == null || x.Message.ExpiresAt > now) && + !x.ContentType.StartsWith("image/") && + !x.ContentType.StartsWith("video/") && + !x.ContentType.StartsWith("audio/"), + cancellationToken); + var voiceCount = await dbContext.MessageAttachments + .CountAsync(x => + x.Message.ChatId == chatId && + x.Message.DeletedAt == null && + (x.Message.ExpiresAt == null || x.Message.ExpiresAt > now) && + x.ContentType.StartsWith("audio/"), + cancellationToken); + var linkCount = await dbContext.Messages + .CountAsync(x => + x.ChatId == chatId && + x.DeletedAt == null && + (x.ExpiresAt == null || x.ExpiresAt > now) && + (x.Text.Contains("http://") || x.Text.Contains("https://")), + cancellationToken); + var pinnedCount = await dbContext.Messages + .CountAsync(x => + x.ChatId == chatId && + x.DeletedAt == null && + (x.ExpiresAt == null || x.ExpiresAt > now) && + x.PinnedAt != null, + cancellationToken); + var canInspectInviteLinks = ResolvePermissions(chat, userId).CanInviteUsers; + var inviteLinks = canInspectInviteLinks + ? await dbContext.ChatInviteLinks + .AsNoTracking() + .Where(x => x.ChatId == chatId) + .OrderByDescending(x => x.CreatedAt) + .Take(20) + .ToListAsync(cancellationToken) + : []; + var topics = await LoadChatTopicDtosAsync(chatId, cancellationToken); + + return Ok(new ChatInfoDto( + details, + mediaCount, + fileCount, + linkCount, + voiceCount, + pinnedCount, + topics.Count, + chat.PublicUsername, + inviteLinks.Select(ToInviteLinkDto).ToList(), + topics, + details.SubscriberCount, + details.AdminCount, + chat.LinkedDiscussionChatId, + chat.ChannelSignaturesEnabled, + details.CanManageChannel, + details.CanPostAsChannel, + canInspectInviteLinks, + chat.Description)); + } + + [HttpGet("{chatId:guid}/invite-links")] + public async Task>> GetInviteLinks(Guid chatId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata(chatId, userId, cancellationToken); + if (chat is null) + { + return NotFound(); + } + + if (!ResolvePermissions(chat, userId).CanInviteUsers) + { + return StatusCode(StatusCodes.Status403Forbidden, "You cannot manage invite links in this chat."); + } + + var links = await dbContext.ChatInviteLinks + .AsNoTracking() + .Where(x => x.ChatId == chatId) + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + return Ok(links.Select(ToInviteLinkDto).ToList()); + } + + [HttpPost("{chatId:guid}/invite-links")] + public async Task> CreateInviteLink( + Guid chatId, + CreateChatInviteLinkRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata(chatId, userId, cancellationToken, asNoTracking: false); + if (chat is null) + { + return NotFound(); + } + + if (!ResolvePermissions(chat, userId).CanInviteUsers) + { + return StatusCode(StatusCodes.Status403Forbidden, "You cannot create invite links in this chat."); + } + + if (chat.Type == ChatType.Direct) + { + return BadRequest("Direct chats do not support invite links."); + } + + DateTimeOffset? expiresAt; + int? maxUses; + int? memberLimit; + try + { + expiresAt = NormalizeInviteExpiresAt(request.ExpiresAt); + maxUses = NormalizeInviteLimit(request.MaxUses, nameof(request.MaxUses)); + memberLimit = NormalizeInviteLimit(request.MemberLimit, nameof(request.MemberLimit)); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + + var link = new ChatInviteLink + { + ChatId = chatId, + CreatedById = userId, + Token = await GenerateInviteTokenAsync(cancellationToken), + Title = TrimToNull(request.Title, 64), + CreatesJoinRequest = request.CreatesJoinRequest, + ExpiresAt = expiresAt, + MaxUses = maxUses, + MemberLimit = memberLimit, + CreatedAt = DateTimeOffset.UtcNow + }; + + dbContext.ChatInviteLinks.Add(link); + await dbContext.SaveChangesAsync(cancellationToken); + return Ok(ToInviteLinkDto(link)); + } + + [HttpDelete("{chatId:guid}/invite-links/{inviteLinkId:guid}")] + public async Task RevokeInviteLink(Guid chatId, Guid inviteLinkId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata(chatId, userId, cancellationToken); + if (chat is null) + { + return NotFound(); + } + + if (!ResolvePermissions(chat, userId).CanInviteUsers) + { + return StatusCode(StatusCodes.Status403Forbidden, "You cannot revoke invite links in this chat."); + } + + var link = await dbContext.ChatInviteLinks + .SingleOrDefaultAsync(x => x.Id == inviteLinkId && x.ChatId == chatId, cancellationToken); + if (link is null) + { + return NotFound(); + } + + link.IsRevoked = true; + link.RevokedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpGet("{chatId:guid}/join-requests")] + public async Task>> GetJoinRequests(Guid chatId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata(chatId, userId, cancellationToken); + if (chat is null) + { + return NotFound(); + } + + if (!CanManageMembers(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only admins can review join requests."); + } + + var requests = await dbContext.ChatJoinRequests + .AsNoTracking() + .Include(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.ResolvedByUser) + .ThenInclude(x => x!.Sessions) + .Where(x => x.ChatId == chatId) + .OrderByDescending(x => x.RequestedAt) + .Take(100) + .ToListAsync(cancellationToken); + + return Ok(requests.Select(ToJoinRequestDto).ToList()); + } + + [HttpPost("{chatId:guid}/join-requests/{joinRequestId:guid}")] + public async Task> ResolveJoinRequest( + Guid chatId, + Guid joinRequestId, + ResolveChatJoinRequestRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .SingleOrDefaultAsync(x => x.Id == chatId && x.Members.Any(member => member.UserId == userId), cancellationToken); + if (chat is null) + { + return NotFound(); + } + + if (!CanManageMembers(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only admins can resolve join requests."); + } + + var joinRequest = await dbContext.ChatJoinRequests + .Include(x => x.InviteLink) + .Include(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.ResolvedByUser) + .ThenInclude(x => x!.Sessions) + .SingleOrDefaultAsync(x => x.Id == joinRequestId && x.ChatId == chatId, cancellationToken); + if (joinRequest is null) + { + return NotFound(); + } + + if (joinRequest.Status != ChatJoinRequestStatus.Pending) + { + return Ok(ToJoinRequestDto(joinRequest)); + } + + joinRequest.Status = request.Approve ? ChatJoinRequestStatus.Approved : ChatJoinRequestStatus.Declined; + joinRequest.ResolvedAt = DateTimeOffset.UtcNow; + joinRequest.ResolvedByUserId = userId; + + if (request.Approve && chat.Members.All(member => member.UserId != joinRequest.UserId)) + { + var availabilityError = ValidateInviteAvailability(joinRequest.InviteLink, DateTimeOffset.UtcNow, chat.Members.Count); + if (availabilityError is not null) + { + return BadRequest(availabilityError); + } + + chat.Members.Add(CreateSubscriberMembership(chat, joinRequest.UserId)); + joinRequest.InviteLink.UsesCount += 1; + chat.LastActivityAt = DateTimeOffset.UtcNow; + } + + await AddModerationLogAsync( + userId, + chatId, + ModerationLogTargetType.JoinRequest, + joinRequest.Id, + request.Approve ? "join_request.approved" : "join_request.declined", + null, + cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(chat.Members.Select(member => member.UserId).Append(joinRequest.UserId), cancellationToken); + return Ok(ToJoinRequestDto(joinRequest)); + } + + [HttpPost("join/{token}")] + public async Task> JoinByInviteLink(string token, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var normalizedToken = token?.Trim() ?? string.Empty; + var invite = await dbContext.ChatInviteLinks + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .SingleOrDefaultAsync(x => x.Token == normalizedToken && !x.IsRevoked, cancellationToken); + if (invite is null) + { + return NotFound(); + } + + if (invite.Chat.Type == ChatType.Direct) + { + return BadRequest("Direct chats do not support invite links."); + } + + var isExistingMember = invite.Chat.Members.Any(x => x.UserId == userId); + if (!isExistingMember) + { + var availabilityError = ValidateInviteAvailability(invite, DateTimeOffset.UtcNow, invite.Chat.Members.Count); + if (availabilityError is not null) + { + return BadRequest(availabilityError); + } + } + + if (invite.CreatesJoinRequest) + { + if (isExistingMember) + { + var existingChat = await LoadChatWithMessages(invite.ChatId, userId, cancellationToken) + ?? throw new InvalidOperationException("Joined chat could not be loaded."); + return Ok(new JoinChatResultDto( + JoinChatResultStatus.Joined, + existingChat.ToDetailsDto(userId, presenceTracker))); + } + + var existingRequest = await dbContext.ChatJoinRequests + .SingleOrDefaultAsync(x => x.InviteLinkId == invite.Id && x.UserId == userId, cancellationToken); + if (existingRequest is null) + { + existingRequest = new ChatJoinRequest + { + ChatId = invite.ChatId, + InviteLinkId = invite.Id, + UserId = userId, + RequestedAt = DateTimeOffset.UtcNow + }; + dbContext.ChatJoinRequests.Add(existingRequest); + await AddModerationLogAsync( + userId, + invite.ChatId, + ModerationLogTargetType.JoinRequest, + existingRequest.Id, + "join_request.created", + null, + cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + + return Accepted(new JoinChatResultDto( + JoinChatResultStatus.PendingApproval, + JoinRequestId: existingRequest.Id, + JoinRequestStatus: existingRequest.Status)); + } + + if (!isExistingMember) + { + invite.Chat.Members.Add(CreateSubscriberMembership(invite.Chat, userId)); + invite.UsesCount += 1; + invite.Chat.LastActivityAt = DateTimeOffset.UtcNow; + await AddModerationLogAsync(userId, invite.ChatId, ModerationLogTargetType.InviteLink, invite.Id, "invite_link.joined", null, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + var joined = await LoadChatWithMessages(invite.ChatId, userId, cancellationToken) + ?? throw new InvalidOperationException("Joined chat could not be loaded."); + await NotifyChatCreatedAsync(joined, cancellationToken); + return Ok(new JoinChatResultDto( + JoinChatResultStatus.Joined, + joined.ToDetailsDto(userId, presenceTracker))); + } + + var loaded = await LoadChatWithMessages(invite.ChatId, userId, cancellationToken) + ?? throw new InvalidOperationException("Joined chat could not be loaded."); + return Ok(new JoinChatResultDto( + JoinChatResultStatus.Joined, + loaded.ToDetailsDto(userId, presenceTracker))); + } + + [HttpPost("{chatId:guid}/subscribe")] + public async Task> SubscribeToChannel(Guid chatId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .SingleOrDefaultAsync(x => x.Id == chatId && x.Type == ChatType.Channel, cancellationToken); + if (chat is null) + { + return NotFound(); + } + + if (chat.Members.Any(member => member.UserId == userId)) + { + var existing = await LoadChatWithMessages(chatId, userId, cancellationToken) + ?? throw new InvalidOperationException("Subscribed channel could not be loaded."); + return Ok(existing.ToDetailsDto(userId, presenceTracker)); + } + + if (string.IsNullOrWhiteSpace(chat.PublicUsername)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Private channels require an invite link."); + } + + chat.Members.Add(CreateSubscriberMembership(chat, userId)); + chat.LastActivityAt = DateTimeOffset.UtcNow; + await AddModerationLogAsync(userId, chatId, ModerationLogTargetType.User, userId, "channel.subscribed", null, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + + var loaded = await LoadChatWithMessages(chatId, userId, cancellationToken) + ?? throw new InvalidOperationException("Subscribed channel could not be loaded."); + await NotifyChatCreatedAsync(loaded, cancellationToken); + return Ok(loaded.ToDetailsDto(userId, presenceTracker)); + } + + [HttpDelete("{chatId:guid}/subscribe")] + public async Task UnsubscribeFromChannel(Guid chatId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync(x => x.Id == chatId && x.Type == ChatType.Channel, cancellationToken); + if (chat is null) + { + return NotFound(); + } + + var membership = chat.Members.FirstOrDefault(member => member.UserId == userId); + if (membership is null) + { + return NoContent(); + } + + if (membership.IsOwner || membership.Role == ChatMemberRole.Owner) + { + return BadRequest("Transfer channel ownership before leaving the channel."); + } + + dbContext.ChatMembers.Remove(membership); + await AddModerationLogAsync(userId, chatId, ModerationLogTargetType.User, userId, "channel.unsubscribed", null, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync([userId], cancellationToken); + return NoContent(); + } + + [HttpGet("{chatId:guid}/topics")] + public async Task>> GetTopics(Guid chatId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata(chatId, userId, cancellationToken); + if (chat is null) + { + return NotFound(); + } + + return Ok(await LoadChatTopicDtosAsync(chatId, cancellationToken)); + } + + [HttpPost("{chatId:guid}/topics")] + public async Task> CreateTopic( + Guid chatId, + CreateChatTopicRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata(chatId, userId, cancellationToken, asNoTracking: false); + if (chat is null) + { + return NotFound(); + } + + if (chat.Type == ChatType.Direct) + { + return BadRequest("Direct chats do not support topics."); + } + + if (!CanManageMembers(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only admins can create topics."); + } + + var title = request.Title?.Trim() ?? string.Empty; + if (title.Length is < 2 or > 80) + { + return ValidationProblem("Topic title must be between 2 and 80 characters."); + } + + var topic = new ChatTopic + { + ChatId = chatId, + CreatedById = userId, + Title = title, + Icon = string.IsNullOrWhiteSpace(request.Icon) ? "💬" : request.Icon.Trim()[..Math.Min(request.Icon.Trim().Length, 8)], + CreatedAt = DateTimeOffset.UtcNow, + LastActivityAt = DateTimeOffset.UtcNow + }; + chat.TopicsEnabled = true; + dbContext.ChatTopics.Add(topic); + await dbContext.SaveChangesAsync(cancellationToken); + return Ok(new ChatTopicDto(topic.Id, topic.ChatId, topic.Title, topic.Icon, topic.IsPinned, topic.IsClosed, topic.CreatedAt, topic.LastActivityAt, 0)); + } + + [HttpGet("{chatId:guid}/messages")] + public async Task> GetMessages( + Guid chatId, + [FromQuery] Guid? beforeMessageId = null, + [FromQuery] Guid? aroundMessageId = null, + [FromQuery] int limit = DefaultMessagePageSize, + CancellationToken cancellationToken = default) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata(chatId, userId, cancellationToken, allowPublicChannelPreview: true); + if (chat is null) + { + return NotFound(); + } + + var page = aroundMessageId is not null + ? await LoadMessagesAroundAsync(chat, aroundMessageId.Value, limit, cancellationToken) + : await LoadMessagesPageAsync(chat, beforeMessageId, limit, cancellationToken); + await MarkChannelMessagesViewedAsync(chat, userId, page.Messages, cancellationToken); + var messages = page.Messages + .Select(message => message.ToDto(presenceTracker, userId)) + .ToList(); + + return Ok(new ChatMessagesPageDto(messages, page.HasMoreMessages)); + } + + [HttpGet("{chatId:guid}/messages/search")] + public async Task>> SearchMessages( + Guid chatId, + [FromQuery] string q, + [FromQuery] string? filter = null, + [FromQuery] string? type = null, + [FromQuery] int page = 1, + [FromQuery] int limit = 50, + [FromQuery] DateTimeOffset? date = null, + [FromQuery] DateTimeOffset? dateFrom = null, + [FromQuery] DateTimeOffset? dateTo = null, + [FromQuery] Guid? authorId = null, + [FromQuery] string? author = null, + CancellationToken cancellationToken = default) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata(chatId, userId, cancellationToken, allowPublicChannelPreview: true); + if (chat is null) + { + return NotFound(); + } + + var searchPage = await SearchMessagesPageAsync( + chat, + userId, + q, + BuildSearchFilters(filter, type, date, dateFrom, dateTo, authorId, author), + page, + limit, + cancellationToken); + WriteSearchPageHeaders(searchPage.Page, searchPage.Limit, searchPage.HasMore); + return Ok(searchPage.Messages); + } + + [HttpGet("{chatId:guid}/messages/search/page")] + public async Task> SearchMessagesPage( + Guid chatId, + [FromQuery] string q, + [FromQuery] string? filter = null, + [FromQuery] string? type = null, + [FromQuery] int page = 1, + [FromQuery] int limit = 50, + [FromQuery] DateTimeOffset? date = null, + [FromQuery] DateTimeOffset? dateFrom = null, + [FromQuery] DateTimeOffset? dateTo = null, + [FromQuery] Guid? authorId = null, + [FromQuery] string? author = null, + CancellationToken cancellationToken = default) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata(chatId, userId, cancellationToken, allowPublicChannelPreview: true); + if (chat is null) + { + return NotFound(); + } + + return Ok(await SearchMessagesPageAsync( + chat, + userId, + q, + BuildSearchFilters(filter, type, date, dateFrom, dateTo, authorId, author), + page, + limit, + cancellationToken)); + } + + [HttpPut("{chatId:guid}/state")] + public async Task> UpdateChatState( + Guid chatId, + ChatStateRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .SingleOrDefaultAsync( + x => x.Id == chatId && x.Members.Any(member => member.UserId == userId), + cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + var membership = chat.Members.First(member => member.UserId == userId); + membership.IsPinned = request.IsPinned ?? membership.IsPinned; + membership.IsArchived = request.IsArchived ?? membership.IsArchived; + membership.MutedUntil = request.IsMuted switch + { + false => null, + true => request.MutedUntil ?? DateTimeOffset.UtcNow.AddYears(1), + _ => request.MutedUntil ?? membership.MutedUntil + }; + + if (request.FolderKey is not null) + { + membership.FolderKey = NormalizeFolderKey(request.FolderKey); + } + + var notifyAllMembers = false; + if (request.Title is not null || + request.Description is not null || + request.ClearDescription || + request.PublicUsername is not null || + request.ClearPublicUsername) + { + if (chat.Type == ChatType.Direct) + { + return BadRequest("Direct chats do not support profile editing."); + } + + if (!CanManageMembers(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only admins can update chat profile."); + } + + if (request.Title is not null) + { + var title = request.Title.Trim(); + if (string.IsNullOrWhiteSpace(title)) + { + return BadRequest("Chat title is required."); + } + + chat.Title = title.Length <= 120 ? title : title[..120]; + } + + if (request.ClearDescription && request.Description is not null) + { + return BadRequest("Description cannot be set and cleared in the same request."); + } + + if (request.ClearDescription) + { + chat.Description = null; + } + else if (request.Description is not null) + { + chat.Description = TrimToNull(request.Description, 255); + } + + if (request.ClearPublicUsername && request.PublicUsername is not null) + { + return BadRequest("Public username cannot be set and cleared in the same request."); + } + + if (request.ClearPublicUsername || request.PublicUsername is not null) + { + if (chat.Type != ChatType.Channel) + { + return BadRequest("Only channels support public usernames."); + } + + string? publicUsername; + try + { + publicUsername = request.ClearPublicUsername + ? null + : NormalizePublicUsername(request.PublicUsername); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + + if (publicUsername is not null) + { + var usernameTaken = await dbContext.Chats + .AnyAsync(x => x.Id != chatId && x.PublicUsername == publicUsername, cancellationToken); + if (usernameTaken) + { + return Conflict("Public username is already taken."); + } + } + + chat.PublicUsername = publicUsername; + } + + notifyAllMembers = true; + await AddModerationLogAsync( + userId, + chatId, + ModerationLogTargetType.Chat, + chatId, + "chat.profile.updated", + null, + cancellationToken); + } + + if (request.HasProtectedContent is not null || request.DefaultMessageTtlSeconds is not null) + { + if (!CanManageMembers(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only admins can update chat security settings."); + } + + chat.HasProtectedContent = request.HasProtectedContent ?? chat.HasProtectedContent; + if (request.DefaultMessageTtlSeconds is not null) + { + chat.DefaultMessageTtlSeconds = NormalizeTtlSeconds(request.DefaultMessageTtlSeconds, allowNull: true); + } + await AddModerationLogAsync( + userId, + chatId, + ModerationLogTargetType.Chat, + chatId, + "chat.security.updated", + null, + cancellationToken); + } + + if (request.ChannelSignaturesEnabled is not null || + request.LinkedDiscussionChatId is not null || + request.ClearLinkedDiscussionChat) + { + if (chat.Type != ChatType.Channel) + { + return BadRequest("Only channels support channel settings."); + } + + if (!CanManageMembers(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only admins can update channel settings."); + } + + if (request.ChannelSignaturesEnabled is not null) + { + chat.ChannelSignaturesEnabled = request.ChannelSignaturesEnabled.Value; + } + + if (request.ClearLinkedDiscussionChat && request.LinkedDiscussionChatId is not null) + { + return BadRequest("Linked discussion chat cannot be set and cleared in the same request."); + } + + if (request.ClearLinkedDiscussionChat) + { + chat.LinkedDiscussionChatId = null; + } + else if (request.LinkedDiscussionChatId is not null) + { + var discussionChatExists = await dbContext.Chats + .AsNoTracking() + .AnyAsync(x => + x.Id == request.LinkedDiscussionChatId.Value && + x.Type == ChatType.Group && + x.Members.Any(member => member.UserId == userId), + cancellationToken); + if (!discussionChatExists) + { + return BadRequest("Linked discussion chat must be an accessible group chat."); + } + + chat.LinkedDiscussionChatId = request.LinkedDiscussionChatId; + } + + await AddModerationLogAsync( + userId, + chatId, + ModerationLogTargetType.Chat, + chatId, + "channel.settings.updated", + null, + cancellationToken); + } + + await dbContext.SaveChangesAsync(cancellationToken); + + var latestMessages = await LoadLatestMessagesByChatIdAsync([chatId], cancellationToken); + var unreadCounts = await LoadUnreadCountsByChatIdAsync(userId, [chatId], cancellationToken); + await NotifyChatsChangedAsync(notifyAllMembers ? chat.Members.Select(member => member.UserId) : [userId], cancellationToken); + return Ok(chat.ToSummaryDto( + userId, + presenceTracker, + latestMessages.GetValueOrDefault(chatId), + unreadCounts.GetValueOrDefault(chatId))); + } + + [HttpPost("{chatId:guid}/members")] + public async Task> AddMembers( + Guid chatId, + AddChatMembersRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var requestedMemberIds = (request.MemberIds ?? Array.Empty()) + .Where(id => id != Guid.Empty) + .Distinct() + .ToList(); + + if (requestedMemberIds.Count == 0) + { + return BadRequest("At least one member is required."); + } + + if (request.Role is not ChatMemberRole.Member and not ChatMemberRole.Admin) + { + return BadRequest("Members can only be added as a member or admin."); + } + + var chat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .SingleOrDefaultAsync( + x => x.Id == chatId && x.Members.Any(member => member.UserId == userId), + cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + if (chat.Type is not ChatType.Group and not ChatType.Channel) + { + return BadRequest("Only groups and channels support adding members with this endpoint."); + } + + if (!CanManageMembers(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only admins can add members."); + } + + var existingMemberIds = chat.Members + .Select(member => member.UserId) + .ToHashSet(); + var newMemberIds = requestedMemberIds + .Where(id => !existingMemberIds.Contains(id)) + .ToList(); + + if (newMemberIds.Count == 0) + { + var existing = await LoadChatWithMessages(chatId, userId, cancellationToken) + ?? throw new InvalidOperationException("The chat could not be reloaded."); + var existingPage = await LoadMessagesPageAsync(existing, beforeMessageId: null, DefaultMessagePageSize, cancellationToken); + var existingPinnedMessages = await LoadPinnedMessagesAsync(existing, cancellationToken); + return Ok(existing.ToDetailsDto( + userId, + presenceTracker, + messagesOverride: existingPage.Messages, + hasMoreMessages: existingPage.HasMoreMessages, + pinnedMessagesOverride: existingPinnedMessages)); + } + + var users = await dbContext.Users + .Where(user => newMemberIds.Contains(user.Id) && !user.IsBlocked) + .Select(user => user.Id) + .ToListAsync(cancellationToken); + + if (users.Count != newMemberIds.Count) + { + return BadRequest("Some members do not exist."); + } + + foreach (var memberId in newMemberIds) + { + if (await IsBlockedByPrivacyAsync(userId, memberId, cancellationToken) || + await IsBlockedByPrivacyAsync(memberId, userId, cancellationToken)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Adding this member is blocked by privacy settings."); + } + } + + var role = request.Role; + var isAdmin = role == ChatMemberRole.Admin; + var defaultCanPostMessages = chat.Type == ChatType.Group && role == ChatMemberRole.Member; + var joinedAt = DateTimeOffset.UtcNow; + + foreach (var memberId in newMemberIds) + { + chat.Members.Add(new ChatMember + { + ChatId = chat.Id, + Chat = chat, + UserId = memberId, + Role = role, + IsOwner = false, + CanPinMessages = isAdmin || request.CanPinMessages, + CanDeleteMessages = isAdmin || request.CanDeleteMessages, + CanInviteUsers = isAdmin || request.CanInviteUsers, + CanPostMessages = isAdmin || request.CanPostMessages || defaultCanPostMessages, + JoinedAt = joinedAt + }); + + await AddModerationLogAsync( + userId, + chatId, + ModerationLogTargetType.User, + memberId, + isAdmin ? "member.admin.added" : "member.added", + null, + cancellationToken); + } + + chat.LastActivityAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(chat.Members.Select(member => member.UserId).Concat(newMemberIds), cancellationToken); + + var loaded = await LoadChatWithMessages(chatId, userId, cancellationToken) + ?? throw new InvalidOperationException("The chat could not be reloaded."); + var page = await LoadMessagesPageAsync(loaded, beforeMessageId: null, DefaultMessagePageSize, cancellationToken); + var pinnedMessages = await LoadPinnedMessagesAsync(loaded, cancellationToken); + return Ok(loaded.ToDetailsDto( + userId, + presenceTracker, + messagesOverride: page.Messages, + hasMoreMessages: page.HasMoreMessages, + pinnedMessagesOverride: pinnedMessages)); + } + + [HttpPut("{chatId:guid}/members/{memberUserId:guid}/permissions")] + public async Task> UpdateMemberPermissions( + Guid chatId, + Guid memberUserId, + UpdateChatMemberPermissionsRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .SingleOrDefaultAsync( + x => x.Id == chatId && + x.Members.Any(member => member.UserId == userId) && + x.Members.Any(member => member.UserId == memberUserId), + cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + if (!CanManageMembers(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only admins can update member permissions."); + } + + var target = chat.Members.First(member => member.UserId == memberUserId); + if (target.IsOwner && target.UserId != userId) + { + return BadRequest("Owner permissions cannot be changed by another member."); + } + + if (request.Role is not ChatMemberRole.Member and not ChatMemberRole.Admin and not ChatMemberRole.Owner) + { + return ValidationProblem("Unsupported member role."); + } + + if (request.Role == ChatMemberRole.Owner && !target.IsOwner) + { + return BadRequest("Ownership can only be transferred by a dedicated owner transfer action."); + } + + if (target.IsOwner && request.Role != ChatMemberRole.Owner) + { + return BadRequest("Owner role cannot be changed through member permissions."); + } + + target.Role = request.Role; + target.IsOwner = request.Role == ChatMemberRole.Owner; + target.CanPinMessages = request.CanPinMessages; + target.CanDeleteMessages = request.CanDeleteMessages; + target.CanInviteUsers = request.CanInviteUsers; + target.CanPostMessages = request.CanPostMessages; + + await AddModerationLogAsync(userId, chatId, ModerationLogTargetType.User, memberUserId, "member.permissions.updated", null, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(chat.Members.Select(member => member.UserId), cancellationToken); + + var loaded = await LoadChatWithMessages(chatId, userId, cancellationToken) + ?? throw new InvalidOperationException("The chat could not be reloaded."); + var page = await LoadMessagesPageAsync(loaded, beforeMessageId: null, DefaultMessagePageSize, cancellationToken); + var pinnedMessages = await LoadPinnedMessagesAsync(loaded, cancellationToken); + return Ok(loaded.ToDetailsDto(userId, presenceTracker, messagesOverride: page.Messages, hasMoreMessages: page.HasMoreMessages, pinnedMessagesOverride: pinnedMessages)); + } + + [HttpPost("{chatId:guid}/members/{memberUserId:guid}/transfer-owner")] + public async Task> TransferOwnership( + Guid chatId, + Guid memberUserId, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .SingleOrDefaultAsync( + x => x.Id == chatId && + x.Members.Any(member => member.UserId == userId) && + x.Members.Any(member => member.UserId == memberUserId), + cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + if (chat.Type is not ChatType.Group and not ChatType.Channel) + { + return BadRequest("Only groups and channels support ownership transfer."); + } + + var current = chat.Members.First(member => member.UserId == userId); + if (!current.IsOwner && current.Role != ChatMemberRole.Owner) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only owner can transfer ownership."); + } + + if (memberUserId == userId) + { + return BadRequest("Ownership target must be another member."); + } + + var target = chat.Members.First(member => member.UserId == memberUserId); + if (target.User.IsBot) + { + return BadRequest("Bot cannot become chat owner."); + } + + foreach (var member in chat.Members) + { + if (member.UserId == memberUserId) + { + member.IsOwner = true; + member.Role = ChatMemberRole.Owner; + member.CanPinMessages = true; + member.CanDeleteMessages = true; + member.CanInviteUsers = true; + member.CanPostMessages = true; + continue; + } + + if (member.IsOwner || member.Role == ChatMemberRole.Owner) + { + member.IsOwner = false; + member.Role = ChatMemberRole.Admin; + member.CanPinMessages = true; + member.CanDeleteMessages = true; + member.CanInviteUsers = true; + member.CanPostMessages = true; + } + } + + chat.CreatedById = memberUserId; + chat.LastActivityAt = DateTimeOffset.UtcNow; + + await AddModerationLogAsync( + userId, + chatId, + ModerationLogTargetType.User, + memberUserId, + "member.owner.transferred", + null, + cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(chat.Members.Select(member => member.UserId), cancellationToken); + + var loaded = await LoadChatWithMessages(chatId, userId, cancellationToken) + ?? throw new InvalidOperationException("The chat could not be reloaded."); + var page = await LoadMessagesPageAsync(loaded, beforeMessageId: null, DefaultMessagePageSize, cancellationToken); + var pinnedMessages = await LoadPinnedMessagesAsync(loaded, cancellationToken); + return Ok(loaded.ToDetailsDto( + userId, + presenceTracker, + messagesOverride: page.Messages, + hasMoreMessages: page.HasMoreMessages, + pinnedMessagesOverride: pinnedMessages)); + } + + [HttpDelete("{chatId:guid}/members/{memberUserId:guid}")] + public async Task> RemoveMember( + Guid chatId, + Guid memberUserId, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .SingleOrDefaultAsync( + x => x.Id == chatId && + x.Members.Any(member => member.UserId == userId) && + x.Members.Any(member => member.UserId == memberUserId), + cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + if (chat.Type is not ChatType.Group and not ChatType.Channel) + { + return BadRequest("Only groups and channels support removing members."); + } + + var current = chat.Members.First(member => member.UserId == userId); + if (!CanManageMembers(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only admins can remove members."); + } + + if (memberUserId == userId) + { + return BadRequest("Use leave chat instead of removing yourself."); + } + + var target = chat.Members.First(member => member.UserId == memberUserId); + if (target.IsOwner || target.Role == ChatMemberRole.Owner) + { + return BadRequest("Owner cannot be removed."); + } + + if (!current.IsOwner && current.Role != ChatMemberRole.Owner && target.Role == ChatMemberRole.Admin) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only owner can remove admins."); + } + + dbContext.ChatMembers.Remove(target); + chat.Members.Remove(target); + chat.LastActivityAt = DateTimeOffset.UtcNow; + await AddModerationLogAsync(userId, chatId, ModerationLogTargetType.User, memberUserId, "member.removed", null, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(chat.Members.Select(member => member.UserId).Append(memberUserId), cancellationToken); + + var loaded = await LoadChatWithMessages(chatId, userId, cancellationToken) + ?? throw new InvalidOperationException("The chat could not be reloaded."); + var page = await LoadMessagesPageAsync(loaded, beforeMessageId: null, DefaultMessagePageSize, cancellationToken); + var pinnedMessages = await LoadPinnedMessagesAsync(loaded, cancellationToken); + return Ok(loaded.ToDetailsDto( + userId, + presenceTracker, + messagesOverride: page.Messages, + hasMoreMessages: page.HasMoreMessages, + pinnedMessagesOverride: pinnedMessages)); + } + + [HttpDelete("{chatId:guid}")] + public async Task DeleteChat( + Guid chatId, + [FromQuery] bool forEveryone = false, + CancellationToken cancellationToken = default) + { + var userId = User.GetRequiredUserId(); + var chat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .Include(x => x.Messages) + .ThenInclude(x => x.Attachments) + .SingleOrDefaultAsync(x => x.Id == chatId && x.Members.Any(member => member.UserId == userId), cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + var affectedUserIds = chat.Members + .Select(x => x.UserId) + .ToHashSet(); + + if (forEveryone) + { + var currentMembership = chat.Members.First(x => x.UserId == userId); + if (chat.Type != ChatType.Direct && !currentMembership.IsOwner) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only chat owners can delete this chat for everyone."); + } + + var attachmentPaths = chat.Messages + .SelectMany(x => x.Attachments) + .Select(x => x.StoragePath) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct() + .ToList(); + + dbContext.Chats.Remove(chat); + await dbContext.SaveChangesAsync(cancellationToken); + + DeleteFiles(attachmentPaths); + await NotifyChatsChangedAsync(affectedUserIds, cancellationToken); + return NoContent(); + } + + var membership = chat.Members.FirstOrDefault(x => x.UserId == userId); + if (membership is null) + { + return NotFound(); + } + + if (chat.Type is ChatType.Group or ChatType.Channel && + (membership.IsOwner || membership.Role == ChatMemberRole.Owner)) + { + return BadRequest("Owner must transfer ownership or delete the chat for everyone."); + } + + dbContext.ChatMembers.Remove(membership); + chat.Members.Remove(membership); + + if (chat.Members.Count == 0) + { + var attachmentPaths = chat.Messages + .SelectMany(x => x.Attachments) + .Select(x => x.StoragePath) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct() + .ToList(); + + dbContext.Chats.Remove(chat); + await dbContext.SaveChangesAsync(cancellationToken); + + DeleteFiles(attachmentPaths); + await NotifyChatsChangedAsync(affectedUserIds, cancellationToken); + return NoContent(); + } + + if (chat.Type == ChatType.Direct && chat.Members.Count == 1) + { + chat.Title = string.IsNullOrWhiteSpace(membership.User.DisplayName) + ? membership.User.PhoneNumber ?? "Dialog" + : membership.User.DisplayName; + } + + if (chat.CreatedById == userId) + { + var replacementOwner = chat.Members + .OrderByDescending(x => x.IsOwner) + .ThenBy(x => x.JoinedAt) + .First(); + + replacementOwner.IsOwner = true; + replacementOwner.Role = ChatMemberRole.Owner; + replacementOwner.CanDeleteMessages = true; + replacementOwner.CanPinMessages = true; + replacementOwner.CanInviteUsers = true; + replacementOwner.CanPostMessages = true; + chat.CreatedById = replacementOwner.UserId; + } + + if (chat.Type == ChatType.Channel && chat.Members.All(x => !x.IsOwner)) + { + var replacementChannelOwner = chat.Members + .OrderBy(x => x.JoinedAt) + .First(); + replacementChannelOwner.IsOwner = true; + replacementChannelOwner.Role = ChatMemberRole.Owner; + replacementChannelOwner.CanDeleteMessages = true; + replacementChannelOwner.CanPinMessages = true; + replacementChannelOwner.CanInviteUsers = true; + replacementChannelOwner.CanPostMessages = true; + } + + chat.LastActivityAt = chat.Messages + .OrderByDescending(x => x.SentAt) + .Select(x => (DateTimeOffset?)x.SentAt) + .FirstOrDefault() ?? chat.CreatedAt; + + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(affectedUserIds, cancellationToken); + return NoContent(); + } + + [HttpDelete("{chatId:guid}/history")] + public async Task ClearChatHistory(Guid chatId, CancellationToken cancellationToken = default) + { + var userId = User.GetRequiredUserId(); + var chat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .Include(x => x.Messages) + .ThenInclude(x => x.Attachments) + .SingleOrDefaultAsync(x => x.Id == chatId && x.Members.Any(member => member.UserId == userId), cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + var hasBotPeer = chat.Type == ChatType.Direct && + chat.Members.Any(member => member.UserId != userId && member.User.IsBot); + if (!hasBotPeer) + { + return StatusCode( + StatusCodes.Status403Forbidden, + "Chat history can only be cleared in direct chats with bots."); + } + + var attachmentPaths = chat.Messages + .SelectMany(x => x.Attachments) + .Select(x => x.StoragePath) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct() + .ToList(); + + var attachments = chat.Messages + .SelectMany(x => x.Attachments) + .ToList(); + var messages = chat.Messages.ToList(); + + dbContext.MessageAttachments.RemoveRange(attachments); + dbContext.Messages.RemoveRange(messages); + chat.LastActivityAt = chat.CreatedAt; + + await dbContext.SaveChangesAsync(cancellationToken); + + DeleteFiles(attachmentPaths); + await NotifyChatsChangedAsync(chat.Members.Select(x => x.UserId), cancellationToken); + return NoContent(); + } + + [HttpPost("direct")] + public async Task> CreateDirectChat(CreateDirectChatRequest request, CancellationToken cancellationToken) + { + var currentUserId = User.GetRequiredUserId(); + if (request.UserId == currentUserId) + { + return BadRequest("You cannot create a direct chat with yourself."); + } + + var otherUserExists = await dbContext.Users.AnyAsync(x => x.Id == request.UserId && !x.IsBlocked, cancellationToken); + if (!otherUserExists) + { + return NotFound("User not found."); + } + + if (await IsBlockedByPrivacyAsync(currentUserId, request.UserId, cancellationToken) || + await IsBlockedByPrivacyAsync(request.UserId, currentUserId, cancellationToken)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Direct chats are blocked by privacy settings."); + } + + var existingChat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .Include(x => x.Messages) + .ThenInclude(x => x.Sender) + .Include(x => x.Messages) + .ThenInclude(x => x.Attachments) + .AsSplitQuery() + .SingleOrDefaultAsync(x => + x.Type == ChatType.Direct && + x.Members.Count == 2 && + x.Members.Any(m => m.UserId == currentUserId) && + x.Members.Any(m => m.UserId == request.UserId), + cancellationToken); + + if (existingChat is not null) + { + return Ok(existingChat.ToDetailsDto(currentUserId, presenceTracker)); + } + + var chat = new Chat + { + Type = ChatType.Direct, + CreatedById = currentUserId, + LastActivityAt = DateTimeOffset.UtcNow, + Members = + [ + new ChatMember { UserId = currentUserId, IsOwner = true, Role = ChatMemberRole.Owner, CanDeleteMessages = true }, + new ChatMember { UserId = request.UserId, IsOwner = false } + ] + }; + + dbContext.Chats.Add(chat); + await dbContext.SaveChangesAsync(cancellationToken); + + var loaded = await LoadChatWithMessages(chat.Id, currentUserId, cancellationToken) + ?? throw new InvalidOperationException("Newly created chat could not be loaded."); + + await NotifyChatCreatedAsync(loaded, cancellationToken); + return Ok(loaded.ToDetailsDto(currentUserId, presenceTracker)); + } + + [HttpPost("group")] + public async Task> CreateGroupChat(CreateGroupChatRequest request, CancellationToken cancellationToken) + { + var currentUserId = User.GetRequiredUserId(); + var title = request.Title?.Trim() ?? string.Empty; + var memberIds = (request.MemberIds ?? Array.Empty()) + .Append(currentUserId) + .Distinct() + .ToList(); + + if (string.IsNullOrWhiteSpace(title) || memberIds.Count < 3) + { + return BadRequest("A title and at least 2 invited members are required for a group chat."); + } + + var users = await dbContext.Users + .Where(x => memberIds.Contains(x.Id) && !x.IsBlocked) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + + if (users.Count != memberIds.Count) + { + return BadRequest("Some group members do not exist."); + } + + var chat = new Chat + { + Type = ChatType.Group, + Title = title, + CreatedById = currentUserId, + LastActivityAt = DateTimeOffset.UtcNow, + Members = memberIds + .Select(x => new ChatMember + { + UserId = x, + IsOwner = x == currentUserId, + Role = x == currentUserId ? ChatMemberRole.Owner : ChatMemberRole.Member, + CanDeleteMessages = x == currentUserId + }) + .ToList() + }; + + dbContext.Chats.Add(chat); + await dbContext.SaveChangesAsync(cancellationToken); + + var loaded = await LoadChatWithMessages(chat.Id, currentUserId, cancellationToken) + ?? throw new InvalidOperationException("Newly created group chat could not be loaded."); + + await NotifyChatCreatedAsync(loaded, cancellationToken); + return Ok(loaded.ToDetailsDto(currentUserId, presenceTracker)); + } + + [HttpPost("channel")] + public async Task> CreateChannel(CreateChannelRequest request, CancellationToken cancellationToken) + { + var currentUserId = User.GetRequiredUserId(); + var title = request.Title?.Trim() ?? string.Empty; + var description = TrimToNull(request.Description, 255); + var memberIds = (request.MemberIds ?? Array.Empty()) + .Append(currentUserId) + .Distinct() + .ToList(); + string? publicUsername; + + if (string.IsNullOrWhiteSpace(title)) + { + return BadRequest("Channel title is required."); + } + + try + { + publicUsername = NormalizePublicUsername(request.PublicUsername); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + + if (publicUsername is not null) + { + var usernameTaken = await dbContext.Chats + .AnyAsync(x => x.PublicUsername == publicUsername, cancellationToken); + if (usernameTaken) + { + return Conflict("Public username is already taken."); + } + } + + var users = await dbContext.Users + .Where(x => memberIds.Contains(x.Id) && !x.IsBlocked) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + + if (users.Count != memberIds.Count) + { + return BadRequest("Some channel members do not exist."); + } + + var chat = new Chat + { + Type = ChatType.Channel, + Title = title, + Description = description, + PublicUsername = publicUsername, + ChannelSignaturesEnabled = request.ChannelSignaturesEnabled, + CreatedById = currentUserId, + LastActivityAt = DateTimeOffset.UtcNow, + Members = memberIds + .Select(x => new ChatMember + { + UserId = x, + IsOwner = x == currentUserId, + Role = x == currentUserId ? ChatMemberRole.Owner : ChatMemberRole.Member, + CanPinMessages = x == currentUserId, + CanDeleteMessages = x == currentUserId, + CanInviteUsers = x == currentUserId, + CanPostMessages = x == currentUserId + }) + .ToList() + }; + + dbContext.Chats.Add(chat); + if (publicUsername is null && request.CreateInviteLink) + { + dbContext.ChatInviteLinks.Add(new ChatInviteLink + { + ChatId = chat.Id, + CreatedById = currentUserId, + Token = await GenerateInviteTokenAsync(cancellationToken), + Title = "Основная ссылка", + CreatedAt = DateTimeOffset.UtcNow + }); + } + + await dbContext.SaveChangesAsync(cancellationToken); + + var loaded = await LoadChatWithMessages(chat.Id, currentUserId, cancellationToken) + ?? throw new InvalidOperationException("Newly created channel could not be loaded."); + + await NotifyChatCreatedAsync(loaded, cancellationToken); + return Ok(loaded.ToDetailsDto(currentUserId, presenceTracker)); + } + + [HttpPost("{chatId:guid}/messages")] + public async Task> SendMessage(Guid chatId, SendMessageRequest request, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var text = request.Text?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(text)) + { + return BadRequest("Message text is required."); + } + + var chat = await dbContext.Chats + .Include(x => x.Members) + .FirstOrDefaultAsync(x => x.Id == chatId && x.Members.Any(m => m.UserId == userId), cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + if (!CanUserPublish(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only channel owners can publish."); + } + + if (request.ClientMessageId is not null) + { + var existingMessage = await dbContext.Messages + .AsNoTracking() + .SingleOrDefaultAsync( + message => message.SenderId == userId && message.ClientMessageId == request.ClientMessageId, + cancellationToken); + if (existingMessage is not null) + { + if (existingMessage.ChatId != chatId) + { + return Conflict("Client message id is already used in another chat."); + } + + var loadedExistingMessage = await LoadMessageForMemberAsync( + chatId, + existingMessage.Id, + userId, + cancellationToken); + if (loadedExistingMessage is null) + { + return NotFound(); + } + + // Re-enqueueing is safe because the durable outbox deduplicates by message id. + // This repairs the narrow case where the original HTTP request committed the + // message but was interrupted before the push job could be persisted. + await pushDispatchQueue.EnqueueAsync(chatId, existingMessage.Id, cancellationToken); + return Ok(loadedExistingMessage.ToDto(presenceTracker, userId)); + } + } + + var storyReply = request.StoryReplyStoryId is null + ? null + : await LoadVisibleStoryForReplyAsync(request.StoryReplyStoryId.Value, userId, cancellationToken); + if (request.StoryReplyStoryId is not null && storyReply is null) + { + return NotFound("The story was not found."); + } + + if (storyReply is not null) + { + if (storyReply.AuthorUserId == userId) + { + return BadRequest("You cannot reply to your own story."); + } + + if (chat.Type != ChatType.Direct || chat.Members.All(member => member.UserId != storyReply.AuthorUserId)) + { + return BadRequest("Story replies can only be sent to the story author."); + } + } + + var replyToMessage = await ResolveReplyTargetAsync(chatId, request.ReplyToMessageId, userId, cancellationToken); + if (request.ReplyToMessageId is not null && replyToMessage is null) + { + return NotFound("The replied message was not found."); + } + + if (request.TopicId is not null) + { + var topic = await dbContext.ChatTopics + .SingleOrDefaultAsync(x => x.Id == request.TopicId.Value && x.ChatId == chatId, cancellationToken); + if (topic is null || topic.IsClosed) + { + return NotFound("The topic was not found."); + } + topic.LastActivityAt = DateTimeOffset.UtcNow; + } + + var sender = await dbContext.Users.SingleAsync(x => x.Id == userId, cancellationToken); + var ttlSeconds = NormalizeTtlSeconds(request.TtlSeconds ?? chat.DefaultMessageTtlSeconds, allowNull: true); + var sentAt = DateTimeOffset.UtcNow; + var commentedChannelPost = replyToMessage is null + ? null + : await LoadCommentedChannelPostAsync(replyToMessage, cancellationToken); + if (commentedChannelPost is not null) + { + commentedChannelPost.CommentCount++; + } + + var message = new Message + { + ChatId = chatId, + Chat = chat, + SenderId = userId, + Sender = sender, + ClientMessageId = request.ClientMessageId, + PostedAsChannel = ShouldPostAsChannel(chat), + AuthorSignature = ResolveChannelAuthorSignature(chat, sender), + Text = text, + ReplyToMessageId = replyToMessage?.Id, + ReplyToMessage = replyToMessage, + TopicId = request.TopicId, + HasProtectedContent = request.HasProtectedContent ?? chat.HasProtectedContent, + TtlSeconds = ttlSeconds, + ExpiresAt = ttlSeconds is null ? null : sentAt.AddSeconds(ttlSeconds.Value), + SentAt = sentAt, + StoryReplyStoryId = storyReply?.Id, + StoryReplyAuthorUserId = storyReply?.AuthorUserId, + StoryReplyPreviewText = storyReply is null ? null : BuildStoryReplyPreview(storyReply), + StoryReplyAttachmentKind = storyReply is null ? null : ResolveStoryReplyAttachmentKind(storyReply) + }; + + chat.LastActivityAt = message.SentAt; + dbContext.Messages.Add(message); + var discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync( + dbContext, + attachmentStorageService, + chat, + message, + sender, + sentAt, + cancellationToken); + if (discussionRootMessage is not null) + { + message.DiscussionMessageId = discussionRootMessage.Id; + } + + pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage); + await dbContext.SaveChangesAsync(cancellationToken); + + var loadedMessage = await LoadMessageForMemberAsync(chatId, message.Id, userId, cancellationToken) + ?? throw new InvalidOperationException("The created message could not be reloaded."); + var loadedDiscussionRootMessage = discussionRootMessage is null + ? null + : await LoadMessageForNotificationAsync(discussionRootMessage, cancellationToken); + var updatedCommentedChannelPost = commentedChannelPost is null + ? null + : await LoadMessageForMemberAsync( + commentedChannelPost.ChatId, + commentedChannelPost.Id, + commentedChannelPost.SenderId, + cancellationToken); + + var dto = loadedMessage.ToDto(presenceTracker, userId); + await NotifyMessageCreatedAsync(loadedMessage.Chat, loadedMessage, cancellationToken); + await botUpdateService.EnqueueIncomingMessageAsync(loadedMessage.Id, cancellationToken); + await botCreatorService.TryHandleIncomingMessageAsync(loadedMessage.Id, cancellationToken); + await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id); + if (loadedDiscussionRootMessage is not null) + { + await NotifyMessageCreatedAsync(loadedDiscussionRootMessage.Chat, loadedDiscussionRootMessage, cancellationToken); + await botUpdateService.EnqueueIncomingMessageAsync(loadedDiscussionRootMessage.Id, cancellationToken); + await pushDispatchQueue.EnqueueAsync(loadedDiscussionRootMessage.ChatId, loadedDiscussionRootMessage.Id); + } + + if (updatedCommentedChannelPost is not null) + { + await NotifyMessageUpdatedAsync(updatedCommentedChannelPost.Chat, [updatedCommentedChannelPost], cancellationToken); + } + + return Ok(dto); + } + + [HttpPost("{chatId:guid}/messages/{messageId:guid}/forward")] + public async Task> ForwardMessage( + Guid chatId, + Guid messageId, + ForwardMessageRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var sourceMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken); + if (sourceMessage is null) + { + return NotFound(); + } + + if (sourceMessage.DeletedAt is not null) + { + return BadRequest("Deleted messages cannot be forwarded."); + } + + if (sourceMessage.HasProtectedContent || sourceMessage.Chat.HasProtectedContent) + { + return BadRequest("Protected messages cannot be forwarded."); + } + + var targetChat = await dbContext.Chats + .Include(x => x.Members) + .FirstOrDefaultAsync( + x => x.Id == request.TargetChatId && + x.Members.Any(member => member.UserId == userId), + cancellationToken); + if (targetChat is null) + { + return NotFound(); + } + + if (!CanUserPublish(targetChat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only channel owners can publish."); + } + + var sender = await dbContext.Users.SingleAsync(x => x.Id == userId, cancellationToken); + var ttlSeconds = NormalizeTtlSeconds(targetChat.DefaultMessageTtlSeconds, allowNull: true); + var sentAt = DateTimeOffset.UtcNow; + var forwardedMessage = new Message + { + ChatId = targetChat.Id, + Chat = targetChat, + SenderId = userId, + Sender = sender, + PostedAsChannel = ShouldPostAsChannel(targetChat), + AuthorSignature = ResolveChannelAuthorSignature(targetChat, sender), + Text = sourceMessage.Text, + ForwardedFromDisplayName = ResolveForwardedFromDisplayName(sourceMessage), + MediaAlbumId = sourceMessage.MediaAlbumId is null ? null : Guid.NewGuid(), + HasProtectedContent = targetChat.HasProtectedContent, + TtlSeconds = ttlSeconds, + ExpiresAt = ttlSeconds is null ? null : sentAt.AddSeconds(ttlSeconds.Value), + SentAt = sentAt + }; + + var clonedAttachments = new List(); + Message? discussionRootMessage = null; + try + { + foreach (var sourceAttachment in sourceMessage.Attachments) + { + var clonedAttachment = await attachmentStorageService.CloneAsync( + forwardedMessage.Id, + sourceAttachment, + cancellationToken); + clonedAttachments.Add(clonedAttachment); + forwardedMessage.Attachments.Add(clonedAttachment); + } + + targetChat.LastActivityAt = forwardedMessage.SentAt; + dbContext.Messages.Add(forwardedMessage); + discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync( + dbContext, + attachmentStorageService, + targetChat, + forwardedMessage, + sender, + sentAt, + cancellationToken); + if (discussionRootMessage is not null) + { + forwardedMessage.DiscussionMessageId = discussionRootMessage.Id; + } + + pushDispatchQueue.StageMessages(dbContext, forwardedMessage, discussionRootMessage); + await dbContext.SaveChangesAsync(cancellationToken); + } + catch + { + foreach (var clonedAttachment in clonedAttachments) + { + attachmentStorageService.DeleteIfExists(clonedAttachment.StoragePath); + } + + if (discussionRootMessage is not null) + { + DeleteFiles(discussionRootMessage.Attachments.Select(x => x.StoragePath)); + } + + throw; + } + + var loadedForwardedMessage = await LoadMessageForMemberAsync(targetChat.Id, forwardedMessage.Id, userId, cancellationToken) + ?? throw new InvalidOperationException("The forwarded message could not be reloaded."); + var loadedDiscussionRootMessage = discussionRootMessage is null + ? null + : await LoadMessageForNotificationAsync(discussionRootMessage, cancellationToken); + + var dto = loadedForwardedMessage.ToDto(presenceTracker, userId); + await NotifyMessageCreatedAsync(loadedForwardedMessage.Chat, loadedForwardedMessage, cancellationToken); + await botUpdateService.EnqueueIncomingMessageAsync(loadedForwardedMessage.Id, cancellationToken); + await pushDispatchQueue.EnqueueAsync(targetChat.Id, forwardedMessage.Id); + if (loadedDiscussionRootMessage is not null) + { + await NotifyMessageCreatedAsync(loadedDiscussionRootMessage.Chat, loadedDiscussionRootMessage, cancellationToken); + await botUpdateService.EnqueueIncomingMessageAsync(loadedDiscussionRootMessage.Id, cancellationToken); + await pushDispatchQueue.EnqueueAsync(loadedDiscussionRootMessage.ChatId, loadedDiscussionRootMessage.Id); + } + + return Ok(dto); + } + + [HttpPost("{chatId:guid}/messages/delivered")] + public Task AcknowledgeDelivered( + Guid chatId, + UpdateMessageReceiptRequest request, + CancellationToken cancellationToken) => + AcknowledgeMessageReceiptAsync(chatId, request.MessageId, User.GetRequiredUserId(), markRead: false, cancellationToken); + + [HttpPost("{chatId:guid}/messages/read")] + public Task AcknowledgeRead( + Guid chatId, + UpdateMessageReceiptRequest request, + CancellationToken cancellationToken) => + AcknowledgeMessageReceiptAsync(chatId, request.MessageId, User.GetRequiredUserId(), markRead: true, cancellationToken); + + [HttpPost("{chatId:guid}/read")] + public async Task MarkChatRead(Guid chatId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync( + x => x.Id == chatId && x.Members.Any(member => member.UserId == userId), + cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + await UpdateReadReceiptForOpenedChatAsync(chat, userId, cancellationToken); + return Ok(); + } + + [HttpPut("{chatId:guid}/messages/{messageId:guid}/reaction")] + public async Task> SetReaction( + Guid chatId, + Guid messageId, + SetMessageReactionRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var emoji = request.Emoji?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(emoji)) + { + return BadRequest("Reaction emoji is required."); + } + + if (emoji.Length > 24) + { + return BadRequest("Reaction emoji is too long."); + } + + var message = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken); + if (message is null) + { + return NotFound(); + } + + if (message.DeletedAt is not null) + { + return BadRequest("Deleted messages cannot be reacted to."); + } + + var shouldNotifyReaction = false; + var existingReaction = message.Reactions.FirstOrDefault(reaction => reaction.UserId == userId); + if (existingReaction is not null && string.Equals(existingReaction.Emoji, emoji, StringComparison.Ordinal)) + { + dbContext.MessageReactions.Remove(existingReaction); + } + else if (existingReaction is not null) + { + existingReaction.Emoji = emoji; + existingReaction.CreatedAt = DateTimeOffset.UtcNow; + shouldNotifyReaction = message.SenderId != userId; + } + else + { + dbContext.MessageReactions.Add(new MessageReaction + { + MessageId = message.Id, + UserId = userId, + Emoji = emoji, + CreatedAt = DateTimeOffset.UtcNow + }); + shouldNotifyReaction = message.SenderId != userId; + } + + if (shouldNotifyReaction) + { + pushDispatchQueue.StageReaction(dbContext, chatId, messageId, userId, emoji); + } + await dbContext.SaveChangesAsync(cancellationToken); + + var updatedMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken) + ?? throw new InvalidOperationException("The reacted message could not be reloaded."); + + var dto = updatedMessage.ToDto(presenceTracker, userId); + await NotifyMessageUpdatedAsync(updatedMessage.Chat, [updatedMessage], cancellationToken); + if (shouldNotifyReaction) + { + await pushDispatchQueue.EnqueueReactionAsync( + chatId, + messageId, + userId, + emoji, + cancellationToken); + } + + return Ok(dto); + } + + [HttpPut("{chatId:guid}/messages/{messageId:guid}/pin")] + public Task> PinMessage(Guid chatId, Guid messageId, CancellationToken cancellationToken) => + SetMessagePinAsync(chatId, messageId, isPinned: true, cancellationToken); + + [HttpDelete("{chatId:guid}/messages/{messageId:guid}/pin")] + public Task> UnpinMessage(Guid chatId, Guid messageId, CancellationToken cancellationToken) => + SetMessagePinAsync(chatId, messageId, isPinned: false, cancellationToken); + + [HttpPost("{chatId:guid}/attachments")] + [RequestFormLimits(MultipartBodyLengthLimit = MaxAttachmentSizeBytes)] + [RequestSizeLimit(MaxAttachmentSizeBytes)] + public async Task> UploadAttachment( + Guid chatId, + [FromForm] UploadAttachmentRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + if (request.File is null) + { + return BadRequest("File is required."); + } + + if (request.File.Length <= 0) + { + return BadRequest("File is empty."); + } + + if (request.File.Length > MaxAttachmentSizeBytes) + { + return BadRequest($"File exceeds {MaxAttachmentSizeBytes} bytes."); + } + + var chat = await dbContext.Chats + .Include(x => x.Members) + .FirstOrDefaultAsync(x => x.Id == chatId && x.Members.Any(m => m.UserId == userId), cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + if (!CanUserPublish(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only channel owners can publish."); + } + + var replyToMessage = await ResolveReplyTargetAsync(chatId, request.ReplyToMessageId, userId, cancellationToken); + if (request.ReplyToMessageId is not null && replyToMessage is null) + { + return NotFound("The replied message was not found."); + } + + if (request.TopicId is not null) + { + var topic = await dbContext.ChatTopics + .SingleOrDefaultAsync(x => x.Id == request.TopicId.Value && x.ChatId == chatId, cancellationToken); + if (topic is null || topic.IsClosed) + { + return NotFound("The topic was not found."); + } + topic.LastActivityAt = DateTimeOffset.UtcNow; + } + + var sender = await dbContext.Users.SingleAsync(x => x.Id == userId, cancellationToken); + var text = request.Text?.Trim() ?? string.Empty; + var ttlSeconds = NormalizeTtlSeconds(request.TtlSeconds ?? chat.DefaultMessageTtlSeconds, allowNull: true); + var sentAt = DateTimeOffset.UtcNow; + var commentedChannelPost = replyToMessage is null + ? null + : await LoadCommentedChannelPostAsync(replyToMessage, cancellationToken); + if (commentedChannelPost is not null) + { + commentedChannelPost.CommentCount++; + } + + var message = new Message + { + ChatId = chatId, + Chat = chat, + SenderId = userId, + Sender = sender, + PostedAsChannel = ShouldPostAsChannel(chat), + AuthorSignature = ResolveChannelAuthorSignature(chat, sender), + Text = text, + ReplyToMessageId = replyToMessage?.Id, + ReplyToMessage = replyToMessage, + TopicId = request.TopicId, + HasProtectedContent = request.HasProtectedContent ?? chat.HasProtectedContent, + TtlSeconds = ttlSeconds, + ExpiresAt = ttlSeconds is null ? null : sentAt.AddSeconds(ttlSeconds.Value), + SentAt = sentAt + }; + + MessageAttachment? attachment = null; + Message? discussionRootMessage = null; + try + { + attachment = await attachmentStorageService.SaveAsync(message.Id, request.File, cancellationToken); + if (request.IsVoiceNote) + { + attachment.Kind = AttachmentKind.VoiceNote; + } + message.Attachments.Add(attachment); + chat.LastActivityAt = message.SentAt; + dbContext.Messages.Add(message); + discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync( + dbContext, + attachmentStorageService, + chat, + message, + sender, + sentAt, + cancellationToken); + if (discussionRootMessage is not null) + { + message.DiscussionMessageId = discussionRootMessage.Id; + } + + pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage); + await dbContext.SaveChangesAsync(cancellationToken); + } + catch + { + if (attachment is not null) + { + attachmentStorageService.DeleteIfExists(attachment.StoragePath); + } + + if (discussionRootMessage is not null) + { + DeleteFiles(discussionRootMessage.Attachments.Select(x => x.StoragePath)); + } + + throw; + } + + var loadedMessage = await LoadMessageForMemberAsync(chatId, message.Id, userId, cancellationToken) + ?? throw new InvalidOperationException("The uploaded message could not be reloaded."); + var loadedDiscussionRootMessage = discussionRootMessage is null + ? null + : await LoadMessageForNotificationAsync(discussionRootMessage, cancellationToken); + var updatedCommentedChannelPost = commentedChannelPost is null + ? null + : await LoadMessageForMemberAsync( + commentedChannelPost.ChatId, + commentedChannelPost.Id, + commentedChannelPost.SenderId, + cancellationToken); + + var dto = loadedMessage.ToDto(presenceTracker, userId); + await NotifyMessageCreatedAsync(loadedMessage.Chat, loadedMessage, cancellationToken); + await botUpdateService.EnqueueIncomingMessageAsync(loadedMessage.Id, cancellationToken); + await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id); + if (loadedDiscussionRootMessage is not null) + { + await NotifyMessageCreatedAsync(loadedDiscussionRootMessage.Chat, loadedDiscussionRootMessage, cancellationToken); + await botUpdateService.EnqueueIncomingMessageAsync(loadedDiscussionRootMessage.Id, cancellationToken); + await pushDispatchQueue.EnqueueAsync(loadedDiscussionRootMessage.ChatId, loadedDiscussionRootMessage.Id); + } + + if (updatedCommentedChannelPost is not null) + { + await NotifyMessageUpdatedAsync(updatedCommentedChannelPost.Chat, [updatedCommentedChannelPost], cancellationToken); + } + + return Ok(dto); + } + + [HttpPost("{chatId:guid}/attachments/album")] + [RequestFormLimits(MultipartBodyLengthLimit = MaxAlbumSizeBytes)] + [RequestSizeLimit(MaxAlbumSizeBytes)] + public async Task> UploadAttachmentAlbum( + Guid chatId, + [FromForm] UploadAttachmentAlbumRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var files = request.Files + .Where(file => file is not null) + .ToList(); + + if (files.Count < 2) + { + return BadRequest("Albums must contain at least 2 files."); + } + + if (files.Count > MaxAlbumAttachments) + { + return BadRequest($"Albums can contain up to {MaxAlbumAttachments} files."); + } + + if (files.Any(file => file.Length <= 0)) + { + return BadRequest("Album contains an empty file."); + } + + if (files.Any(file => file.Length > MaxAttachmentSizeBytes) || files.Sum(file => file.Length) > MaxAlbumSizeBytes) + { + return BadRequest($"Album exceeds {MaxAlbumSizeBytes} bytes or contains a file over {MaxAttachmentSizeBytes} bytes."); + } + + if (files.Any(file => !IsMediaAlbumFile(file))) + { + return BadRequest("Albums accept only image and video files."); + } + + var chat = await dbContext.Chats + .Include(x => x.Members) + .FirstOrDefaultAsync(x => x.Id == chatId && x.Members.Any(m => m.UserId == userId), cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + if (!CanUserPublish(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "You cannot publish in this chat."); + } + + var replyToMessage = await ResolveReplyTargetAsync(chatId, request.ReplyToMessageId, userId, cancellationToken); + if (request.ReplyToMessageId is not null && replyToMessage is null) + { + return NotFound("The replied message was not found."); + } + + if (request.TopicId is not null) + { + var topic = await dbContext.ChatTopics + .SingleOrDefaultAsync(x => x.Id == request.TopicId.Value && x.ChatId == chatId, cancellationToken); + if (topic is null || topic.IsClosed) + { + return NotFound("The topic was not found."); + } + topic.LastActivityAt = DateTimeOffset.UtcNow; + } + + var sender = await dbContext.Users.SingleAsync(x => x.Id == userId, cancellationToken); + var ttlSeconds = NormalizeTtlSeconds(request.TtlSeconds ?? chat.DefaultMessageTtlSeconds, allowNull: true); + var sentAt = DateTimeOffset.UtcNow; + var commentedChannelPost = replyToMessage is null + ? null + : await LoadCommentedChannelPostAsync(replyToMessage, cancellationToken); + if (commentedChannelPost is not null) + { + commentedChannelPost.CommentCount++; + } + + var message = new Message + { + ChatId = chatId, + Chat = chat, + SenderId = userId, + Sender = sender, + PostedAsChannel = ShouldPostAsChannel(chat), + AuthorSignature = ResolveChannelAuthorSignature(chat, sender), + Text = request.Text?.Trim() ?? string.Empty, + ReplyToMessageId = replyToMessage?.Id, + ReplyToMessage = replyToMessage, + TopicId = request.TopicId, + MediaAlbumId = Guid.NewGuid(), + HasProtectedContent = request.HasProtectedContent ?? chat.HasProtectedContent, + TtlSeconds = ttlSeconds, + ExpiresAt = ttlSeconds is null ? null : sentAt.AddSeconds(ttlSeconds.Value), + SentAt = sentAt + }; + + var savedAttachments = new List(); + Message? discussionRootMessage = null; + try + { + for (var index = 0; index < files.Count; index++) + { + var attachment = await attachmentStorageService.SaveAsync(message.Id, files[index], cancellationToken, sortOrder: index); + savedAttachments.Add(attachment); + message.Attachments.Add(attachment); + } + + chat.LastActivityAt = message.SentAt; + dbContext.Messages.Add(message); + discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync( + dbContext, + attachmentStorageService, + chat, + message, + sender, + sentAt, + cancellationToken); + if (discussionRootMessage is not null) + { + message.DiscussionMessageId = discussionRootMessage.Id; + } + + pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage); + await dbContext.SaveChangesAsync(cancellationToken); + } + catch + { + foreach (var attachment in savedAttachments) + { + attachmentStorageService.DeleteIfExists(attachment.StoragePath); + } + + if (discussionRootMessage is not null) + { + DeleteFiles(discussionRootMessage.Attachments.Select(x => x.StoragePath)); + } + + throw; + } + + var loadedMessage = await LoadMessageForMemberAsync(chatId, message.Id, userId, cancellationToken) + ?? throw new InvalidOperationException("The uploaded album could not be reloaded."); + var loadedDiscussionRootMessage = discussionRootMessage is null + ? null + : await LoadMessageForNotificationAsync(discussionRootMessage, cancellationToken); + var updatedCommentedChannelPost = commentedChannelPost is null + ? null + : await LoadMessageForMemberAsync( + commentedChannelPost.ChatId, + commentedChannelPost.Id, + commentedChannelPost.SenderId, + cancellationToken); + + var dto = loadedMessage.ToDto(presenceTracker, userId); + await NotifyMessageCreatedAsync(loadedMessage.Chat, loadedMessage, cancellationToken); + await botUpdateService.EnqueueIncomingMessageAsync(loadedMessage.Id, cancellationToken); + await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id); + if (loadedDiscussionRootMessage is not null) + { + await NotifyMessageCreatedAsync(loadedDiscussionRootMessage.Chat, loadedDiscussionRootMessage, cancellationToken); + await botUpdateService.EnqueueIncomingMessageAsync(loadedDiscussionRootMessage.Id, cancellationToken); + await pushDispatchQueue.EnqueueAsync(loadedDiscussionRootMessage.ChatId, loadedDiscussionRootMessage.Id); + } + + if (updatedCommentedChannelPost is not null) + { + await NotifyMessageUpdatedAsync(updatedCommentedChannelPost.Chat, [updatedCommentedChannelPost], cancellationToken); + } + + return Ok(dto); + } + + [HttpPut("{chatId:guid}/messages/{messageId:guid}")] + public async Task> UpdateMessage( + Guid chatId, + Guid messageId, + UpdateMessageRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var message = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken); + if (message is null) + { + return NotFound(); + } + + if (!CanEditMessage(message, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "You cannot edit this message."); + } + + if (message.DeletedAt is not null) + { + return BadRequest("Deleted messages cannot be edited."); + } + + var text = request.Text?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(text) && message.Attachments.Count == 0) + { + return BadRequest("Message text is required."); + } + + message.Text = text; + message.EditedAt = DateTimeOffset.UtcNow; + var discussionRootMessage = await LoadLinkedDiscussionRootForMutationAsync(message, cancellationToken); + if (discussionRootMessage is not null) + { + discussionRootMessage.Text = ChannelDiscussionMessageFactory.BuildDiscussionRootText(message); + discussionRootMessage.EditedAt = message.EditedAt; + } + + await dbContext.SaveChangesAsync(cancellationToken); + + var updatedMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken) + ?? throw new InvalidOperationException("The updated message could not be reloaded."); + var updatedDiscussionRootMessage = discussionRootMessage is null + ? null + : await LoadMessageForNotificationAsync(discussionRootMessage, cancellationToken); + + var dto = updatedMessage.ToDto(presenceTracker, userId); + await NotifyMessageUpdatedAsync(updatedMessage.Chat, [updatedMessage], cancellationToken); + if (updatedDiscussionRootMessage is not null) + { + await NotifyMessageUpdatedAsync(updatedDiscussionRootMessage.Chat, [updatedDiscussionRootMessage], cancellationToken); + } + + return Ok(dto); + } + + [HttpDelete("{chatId:guid}/messages/{messageId:guid}")] + public async Task> DeleteMessage(Guid chatId, Guid messageId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var message = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken); + if (message is null) + { + return NotFound(); + } + + if (message.DeletedAt is not null) + { + return Ok(message.ToDto(presenceTracker, userId)); + } + + if (message.SenderId != userId && !ResolvePermissions(message.Chat, userId).CanDeleteMessages) + { + return StatusCode(StatusCodes.Status403Forbidden, "You cannot delete this message."); + } + + var discussionRootMessage = await LoadLinkedDiscussionRootForMutationAsync(message, cancellationToken); + var attachments = message.Attachments.ToList(); + foreach (var attachment in attachments) + { + attachmentStorageService.DeleteIfExists(attachment.StoragePath); + } + + if (message.Reactions.Count > 0) + { + dbContext.MessageReactions.RemoveRange(message.Reactions); + message.Reactions.Clear(); + } + + dbContext.MessageAttachments.RemoveRange(attachments); + message.Attachments.Clear(); + message.Text = string.Empty; + message.EditedAt = null; + message.DeletedAt = DateTimeOffset.UtcNow; + message.PinnedAt = null; + message.PinnedByUserId = null; + message.PinnedByUser = null; + if (discussionRootMessage is not null && discussionRootMessage.DeletedAt is null) + { + var discussionAttachments = discussionRootMessage.Attachments.ToList(); + foreach (var attachment in discussionAttachments) + { + attachmentStorageService.DeleteIfExists(attachment.StoragePath); + } + + if (discussionRootMessage.Reactions.Count > 0) + { + dbContext.MessageReactions.RemoveRange(discussionRootMessage.Reactions); + discussionRootMessage.Reactions.Clear(); + } + + if (discussionRootMessage.Views.Count > 0) + { + dbContext.MessageViews.RemoveRange(discussionRootMessage.Views); + discussionRootMessage.Views.Clear(); + } + + dbContext.MessageAttachments.RemoveRange(discussionAttachments); + discussionRootMessage.Attachments.Clear(); + discussionRootMessage.Text = string.Empty; + discussionRootMessage.EditedAt = null; + discussionRootMessage.DeletedAt = message.DeletedAt; + discussionRootMessage.PinnedAt = null; + discussionRootMessage.PinnedByUserId = null; + discussionRootMessage.PinnedByUser = null; + } + + await AddModerationLogAsync(userId, chatId, ModerationLogTargetType.Message, messageId, "message.deleted", null, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + + var dto = message.ToDto(presenceTracker, userId); + await NotifyMessageUpdatedAsync(message.Chat, [message], cancellationToken); + if (discussionRootMessage is not null) + { + await NotifyMessageUpdatedAsync(discussionRootMessage.Chat, [discussionRootMessage], cancellationToken); + } + + return Ok(dto); + } + + [HttpGet("{chatId:guid}/attachments/{attachmentId:guid}/content")] + public async Task DownloadAttachment(Guid chatId, Guid attachmentId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var now = DateTimeOffset.UtcNow; + var attachment = await dbContext.MessageAttachments + .Include(x => x.Message) + .ThenInclude(x => x.Chat) + .ThenInclude(x => x.Members) + .SingleOrDefaultAsync( + x => x.Id == attachmentId && + x.Message.ChatId == chatId && + x.Message.DeletedAt == null && + (x.Message.ExpiresAt == null || x.Message.ExpiresAt > now) && + x.Message.Chat.Members.Any(m => m.UserId == userId), + cancellationToken); + + if (attachment is null || !System.IO.File.Exists(attachment.StoragePath)) + { + return NotFound(); + } + + var stream = System.IO.File.OpenRead(attachment.StoragePath); + return File(stream, string.IsNullOrWhiteSpace(attachment.ContentType) ? MediaTypeNames.Application.Octet : attachment.ContentType, attachment.OriginalFileName, enableRangeProcessing: true); + } + + [HttpPost("messages/cleanup-expired")] + public async Task> CleanupExpiredMessages(CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var now = DateTimeOffset.UtcNow; + var messages = await dbContext.Messages + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .Include(x => x.Attachments) + .Include(x => x.Reactions) + .Include(x => x.Views) + .Where(x => + x.ExpiresAt != null && + x.ExpiresAt <= now && + x.DeletedAt == null && + x.Chat.Members.Any(member => member.UserId == userId)) + .Take(250) + .ToListAsync(cancellationToken); + + var affectedChats = messages + .Select(x => x.Chat) + .DistinctBy(x => x.Id) + .ToList(); + + foreach (var message in messages) + { + DeleteFiles(message.Attachments.Select(x => x.StoragePath)); + dbContext.MessageAttachments.RemoveRange(message.Attachments); + dbContext.MessageReactions.RemoveRange(message.Reactions); + message.Attachments.Clear(); + message.Reactions.Clear(); + message.Text = string.Empty; + message.EditedAt = null; + message.DeletedAt = now; + message.PinnedAt = null; + message.PinnedByUserId = null; + } + + await dbContext.SaveChangesAsync(cancellationToken); + + foreach (var chat in affectedChats) + { + await NotifyMessageUpdatedAsync(chat, messages.Where(x => x.ChatId == chat.Id).ToList(), cancellationToken); + } + + return Ok(new ExpiredMessagesCleanupDto(messages.Count, now)); + } + + [HttpGet("{chatId:guid}/moderation-log")] + public async Task>> GetModerationLog( + Guid chatId, + [FromQuery] int limit = 100, + CancellationToken cancellationToken = default) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata(chatId, userId, cancellationToken); + if (chat is null) + { + return NotFound(); + } + + if (!CanManageMembers(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only admins can view moderation logs."); + } + + var take = Math.Clamp(limit, 1, 250); + var entries = await dbContext.ModerationLogEntries + .AsNoTracking() + .Include(x => x.ActorUser) + .ThenInclude(x => x.Sessions) + .Where(x => x.ChatId == chatId) + .OrderByDescending(x => x.CreatedAt) + .Take(take) + .ToListAsync(cancellationToken); + + return Ok(entries.Select(x => x.ToDto(presenceTracker)).ToList()); + } + + private async Task> SetMessagePinAsync( + Guid chatId, + Guid messageId, + bool isPinned, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var message = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken); + if (message is null) + { + return NotFound(); + } + + if (message.DeletedAt is not null) + { + return BadRequest("Deleted messages cannot be pinned."); + } + + if (!ResolvePermissions(message.Chat, userId).CanPinMessages) + { + return StatusCode(StatusCodes.Status403Forbidden, "You cannot pin messages in this chat."); + } + + if (isPinned) + { + message.PinnedAt = DateTimeOffset.UtcNow; + message.PinnedByUserId = userId; + } + else + { + message.PinnedAt = null; + message.PinnedByUserId = null; + message.PinnedByUser = null; + } + + await AddModerationLogAsync( + userId, + chatId, + ModerationLogTargetType.Message, + messageId, + isPinned ? "message.pinned" : "message.unpinned", + null, + cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + + var updatedMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken) + ?? throw new InvalidOperationException("The pinned message could not be reloaded."); + + var dto = updatedMessage.ToDto(presenceTracker, userId); + await NotifyMessageUpdatedAsync(updatedMessage.Chat, [updatedMessage], cancellationToken); + return Ok(dto); + } + + private async Task AcknowledgeMessageReceiptAsync( + Guid chatId, + Guid messageId, + Guid userId, + bool markRead, + CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var targetMessage = await dbContext.Messages + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.PrivacySettings) + .Include(x => x.Sender) + .ThenInclude(x => x.Sessions) + .Include(x => x.Sender) + .ThenInclude(x => x.PrivacySettings) + .Include(x => x.PinnedByUser) + .ThenInclude(x => x!.Sessions) + .Include(x => x.PinnedByUser) + .ThenInclude(x => x!.PrivacySettings) + .Include(x => x.Attachments) + .Include(x => x.Reactions) + .Include(x => x.Views) + .Include(x => x.ReplyToMessage) + .ThenInclude(x => x!.Sender) + .ThenInclude(x => x.Sessions) + .Include(x => x.ReplyToMessage) + .ThenInclude(x => x!.Sender) + .ThenInclude(x => x.PrivacySettings) + .Include(x => x.ReplyToMessage) + .ThenInclude(x => x!.Attachments) + .SingleOrDefaultAsync( + x => x.Id == messageId && + x.ChatId == chatId && + x.DeletedAt == null && + (x.ExpiresAt == null || x.ExpiresAt > now) && + x.Chat.Members.Any(member => member.UserId == userId), + cancellationToken); + + if (targetMessage is null) + { + return NotFound(); + } + + if (targetMessage.SenderId == userId) + { + return Ok(); + } + + var membership = targetMessage.Chat.Members.First(member => member.UserId == userId); + var previousDeliveredAt = membership.LastDeliveredAt; + var previousReadAt = membership.LastReadAt; + + var changed = false; + if (membership.LastDeliveredAt is null || membership.LastDeliveredAt.Value < targetMessage.SentAt) + { + membership.LastDeliveredAt = targetMessage.SentAt; + changed = true; + } + + if (markRead && (membership.LastReadAt is null || membership.LastReadAt.Value < targetMessage.SentAt)) + { + membership.LastReadAt = targetMessage.SentAt; + changed = true; + } + + if (!changed) + { + return Ok(); + } + + await dbContext.SaveChangesAsync(cancellationToken); + + var changedAfter = markRead ? previousReadAt : previousDeliveredAt; + var changedMessages = await LoadReceiptAffectedMessagesAsync( + chatId, + userId, + changedAfter, + targetMessage.SentAt, + cancellationToken); + + if (changedMessages.Count > 0) + { + await NotifyMessageUpdatedAsync(targetMessage.Chat, changedMessages, cancellationToken); + } + + await NotifyChatsChangedAsync([userId], cancellationToken); + + return Ok(); + } + + private async Task UpdateReadReceiptForOpenedChatAsync(Chat chat, Guid userId, CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var latestIncomingAt = await dbContext.Messages + .Where(message => + message.ChatId == chat.Id && + message.SenderId != userId && + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now)) + .Select(message => (DateTimeOffset?)message.SentAt) + .MaxAsync(cancellationToken); + + if (latestIncomingAt is null) + { + return; + } + + var membership = chat.Members.First(member => member.UserId == userId); + var previousReadAt = membership.LastReadAt; + var previousDeliveredAt = membership.LastDeliveredAt; + + var changed = false; + if (membership.LastDeliveredAt is null || membership.LastDeliveredAt.Value < latestIncomingAt.Value) + { + membership.LastDeliveredAt = latestIncomingAt.Value; + changed = true; + } + + if (membership.LastReadAt is null || membership.LastReadAt.Value < latestIncomingAt.Value) + { + membership.LastReadAt = latestIncomingAt.Value; + changed = true; + } + + if (!changed) + { + return; + } + + await dbContext.SaveChangesAsync(cancellationToken); + + var changedAfter = previousReadAt ?? previousDeliveredAt; + var changedMessages = await LoadReceiptAffectedMessagesAsync( + chat.Id, + userId, + changedAfter, + latestIncomingAt.Value, + cancellationToken); + + if (changedMessages.Count > 0) + { + await NotifyMessageUpdatedAsync(chat, changedMessages, cancellationToken); + } + + await NotifyChatsChangedAsync([userId], cancellationToken); + } + + private async Task MarkChannelMessagesViewedAsync( + Chat chat, + Guid userId, + IReadOnlyCollection messages, + CancellationToken cancellationToken) + { + if (chat.Type != ChatType.Channel || messages.Count == 0) + { + return; + } + + var messageIds = messages + .Where(message => message.PostedAsChannel && message.DeletedAt is null) + .Select(message => message.Id) + .Distinct() + .ToList(); + if (messageIds.Count == 0) + { + return; + } + + var existingViewedMessageIds = await dbContext.MessageViews + .AsNoTracking() + .Where(view => view.ViewerUserId == userId && messageIds.Contains(view.MessageId)) + .Select(view => view.MessageId) + .ToListAsync(cancellationToken); + var existingSet = existingViewedMessageIds.ToHashSet(); + var viewedAt = DateTimeOffset.UtcNow; + var changed = false; + + foreach (var message in messages.Where(message => messageIds.Contains(message.Id))) + { + if (!existingSet.Add(message.Id)) + { + continue; + } + + var view = new MessageView + { + MessageId = message.Id, + ViewerUserId = userId, + ViewedAt = viewedAt + }; + dbContext.MessageViews.Add(view); + message.Views.Add(view); + changed = true; + } + + if (changed) + { + await dbContext.SaveChangesAsync(cancellationToken); + } + } + + private async Task> LoadReceiptAffectedMessagesAsync( + Guid chatId, + Guid recipientUserId, + DateTimeOffset? changedAfter, + DateTimeOffset changedBeforeOrAt, + CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var messages = await dbContext.Messages + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.PrivacySettings) + .Include(x => x.Sender) + .ThenInclude(x => x.Sessions) + .Include(x => x.Sender) + .ThenInclude(x => x.PrivacySettings) + .Include(x => x.PinnedByUser) + .ThenInclude(x => x!.Sessions) + .Include(x => x.PinnedByUser) + .ThenInclude(x => x!.PrivacySettings) + .Include(x => x.Attachments) + .Include(x => x.Reactions) + .Include(x => x.Views) + .Include(x => x.ReplyToMessage) + .ThenInclude(x => x!.Sender) + .ThenInclude(x => x.Sessions) + .Include(x => x.ReplyToMessage) + .ThenInclude(x => x!.Sender) + .ThenInclude(x => x.PrivacySettings) + .Include(x => x.ReplyToMessage) + .ThenInclude(x => x!.Attachments) + .Where(x => + x.ChatId == chatId && + x.SenderId != recipientUserId && + x.DeletedAt == null && + (x.ExpiresAt == null || x.ExpiresAt > now)) + .ToListAsync(cancellationToken); + + return messages + .Where(x => + x.SentAt <= changedBeforeOrAt && + (changedAfter is null || x.SentAt > changedAfter.Value)) + .OrderBy(x => x.SentAt) + .ToList(); + } + + private async Task> LoadLatestMessagesByChatIdAsync( + IReadOnlyCollection chatIds, + CancellationToken cancellationToken) + { + if (chatIds.Count == 0) + { + return new Dictionary(); + } + + var now = DateTimeOffset.UtcNow; + var latestSentAtByChatQuery = dbContext.Messages + .Where(message => + chatIds.Contains(message.ChatId) && + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now)) + .GroupBy(message => message.ChatId) + .Select(group => new + { + ChatId = group.Key, + SentAt = group.Max(message => message.SentAt) + }); + + var latestMessages = await dbContext.Messages + .Where(message => + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now)) + .Join( + latestSentAtByChatQuery, + message => new { message.ChatId, message.SentAt }, + latest => new { latest.ChatId, latest.SentAt }, + (message, _) => message) + .Include(message => message.Sender) + .Include(message => message.PinnedByUser) + .Include(message => message.Attachments) + .AsNoTracking() + .ToListAsync(cancellationToken); + + return latestMessages + .GroupBy(message => message.ChatId) + .ToDictionary( + group => group.Key, + group => group.OrderByDescending(message => message.SentAt).First()); + } + + private async Task> LoadUnreadCountsByChatIdAsync( + Guid userId, + IReadOnlyCollection chatIds, + CancellationToken cancellationToken) + { + if (chatIds.Count == 0) + { + return new Dictionary(); + } + + var now = DateTimeOffset.UtcNow; + var unreadCounts = await dbContext.Messages + .Where(message => + chatIds.Contains(message.ChatId) && + message.SenderId != userId && + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now)) + .Join( + dbContext.ChatMembers.Where(member => member.UserId == userId), + message => message.ChatId, + member => member.ChatId, + (message, member) => new + { + message.ChatId, + message.SentAt, + member.LastReadAt + }) + .Where(x => x.LastReadAt == null || x.SentAt > x.LastReadAt.Value) + .GroupBy(x => x.ChatId) + .Select(group => new + { + ChatId = group.Key, + Count = group.Count() + }) + .ToListAsync(cancellationToken); + + return unreadCounts.ToDictionary(x => x.ChatId, x => x.Count); + } + + private async Task> LoadPinnedMessagesAsync(Chat chat, CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var messages = await dbContext.Messages + .Where(message => + message.ChatId == chat.Id && + message.PinnedAt != null && + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now)) + .OrderByDescending(message => message.PinnedAt) + .Take(5) + .Include(message => message.Sender) + .ThenInclude(user => user.Sessions) + .Include(message => message.Sender) + .ThenInclude(user => user.PrivacySettings) + .Include(message => message.PinnedByUser) + .ThenInclude(user => user!.Sessions) + .Include(message => message.PinnedByUser) + .ThenInclude(user => user!.PrivacySettings) + .Include(message => message.Attachments) + .Include(message => message.Reactions) + .Include(message => message.Views) + .Include(message => message.ReplyToMessage) + .ThenInclude(message => message!.Sender) + .ThenInclude(user => user.Sessions) + .Include(message => message.ReplyToMessage) + .ThenInclude(message => message!.Sender) + .ThenInclude(user => user.PrivacySettings) + .Include(message => message.ReplyToMessage) + .ThenInclude(message => message!.Attachments) + .AsNoTracking() + .ToListAsync(cancellationToken); + + foreach (var message in messages) + { + message.Chat = chat; + } + + return messages; + } + + private sealed record MessagesPage(IReadOnlyList Messages, bool HasMoreMessages); + + private async Task LoadMessagesPageAsync( + Chat chat, + Guid? beforeMessageId, + int requestedLimit, + CancellationToken cancellationToken) + { + var limit = Math.Clamp(requestedLimit, 1, MaxMessagePageSize); + var now = DateTimeOffset.UtcNow; + var query = dbContext.Messages.Where(message => + message.ChatId == chat.Id && + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now)); + + if (beforeMessageId is not null) + { + var boundary = await dbContext.Messages + .AsNoTracking() + .Where(message => + message.ChatId == chat.Id && + message.Id == beforeMessageId.Value && + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now)) + .Select(message => new { message.SentAt }) + .SingleOrDefaultAsync(cancellationToken); + + if (boundary is null) + { + return new MessagesPage([], HasMoreMessages: false); + } + + query = query.Where(message => message.SentAt < boundary.SentAt); + } + + var messages = await query + .OrderByDescending(message => message.SentAt) + .Take(limit + 1) + .Include(message => message.Sender) + .ThenInclude(user => user.Sessions) + .Include(message => message.Sender) + .ThenInclude(user => user.PrivacySettings) + .Include(message => message.PinnedByUser) + .ThenInclude(user => user!.Sessions) + .Include(message => message.PinnedByUser) + .ThenInclude(user => user!.PrivacySettings) + .Include(message => message.Attachments) + .Include(message => message.Reactions) + .Include(message => message.Views) + .Include(message => message.ReplyToMessage) + .ThenInclude(message => message!.Sender) + .ThenInclude(user => user.Sessions) + .Include(message => message.ReplyToMessage) + .ThenInclude(message => message!.Sender) + .ThenInclude(user => user.PrivacySettings) + .Include(message => message.ReplyToMessage) + .ThenInclude(message => message!.Attachments) + .AsNoTracking() + .ToListAsync(cancellationToken); + + var hasMoreMessages = messages.Count > limit; + var pageMessages = messages + .Take(limit) + .OrderBy(message => message.SentAt) + .ToList(); + + foreach (var message in pageMessages) + { + message.Chat = chat; + } + + return new MessagesPage(pageMessages, hasMoreMessages); + } + + private async Task LoadMessagesAroundAsync( + Chat chat, + Guid aroundMessageId, + int requestedLimit, + CancellationToken cancellationToken) + { + var limit = Math.Clamp(requestedLimit, 1, MaxMessagePageSize); + var now = DateTimeOffset.UtcNow; + var boundary = await dbContext.Messages + .AsNoTracking() + .Where(message => + message.ChatId == chat.Id && + message.Id == aroundMessageId && + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now)) + .Select(message => new { message.SentAt }) + .SingleOrDefaultAsync(cancellationToken); + + if (boundary is null) + { + return new MessagesPage([], HasMoreMessages: false); + } + + var messages = await dbContext.Messages + .Where(message => + message.ChatId == chat.Id && + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now) && + message.SentAt >= boundary.SentAt) + .OrderBy(message => message.SentAt) + .Take(limit) + .Include(message => message.Sender) + .ThenInclude(user => user.Sessions) + .Include(message => message.Sender) + .ThenInclude(user => user.PrivacySettings) + .Include(message => message.PinnedByUser) + .ThenInclude(user => user!.Sessions) + .Include(message => message.PinnedByUser) + .ThenInclude(user => user!.PrivacySettings) + .Include(message => message.Attachments) + .Include(message => message.Reactions) + .Include(message => message.Views) + .Include(message => message.ReplyToMessage) + .ThenInclude(message => message!.Sender) + .ThenInclude(user => user.Sessions) + .Include(message => message.ReplyToMessage) + .ThenInclude(message => message!.Sender) + .ThenInclude(user => user.PrivacySettings) + .Include(message => message.ReplyToMessage) + .ThenInclude(message => message!.Attachments) + .AsNoTracking() + .ToListAsync(cancellationToken); + + foreach (var message in messages) + { + message.Chat = chat; + } + + var hasMoreMessages = await dbContext.Messages + .AnyAsync( + message => + message.ChatId == chat.Id && + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now) && + message.SentAt < boundary.SentAt, + cancellationToken); + + return new MessagesPage(messages, hasMoreMessages); + } + + private async Task LoadChatMetadata( + Guid chatId, + Guid userId, + CancellationToken cancellationToken, + bool asNoTracking = true, + bool allowPublicChannelPreview = false) + { + var query = dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.PrivacySettings) + .AsSplitQuery(); + + if (asNoTracking) + { + query = query.AsNoTracking(); + } + + return await query.SingleOrDefaultAsync( + x => x.Id == chatId && + (x.Members.Any(m => m.UserId == userId) || + (allowPublicChannelPreview && + x.Type == ChatType.Channel && + x.PublicUsername != null && + x.PublicUsername != string.Empty)), + cancellationToken); + } + + private async Task LoadChatWithMessages(Guid chatId, Guid userId, CancellationToken cancellationToken, bool asNoTracking = true) + { + var query = dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.PrivacySettings) + .Include(x => x.Messages) + .ThenInclude(x => x.Sender) + .ThenInclude(x => x.Sessions) + .Include(x => x.Messages) + .ThenInclude(x => x.Sender) + .ThenInclude(x => x.PrivacySettings) + .Include(x => x.Messages) + .ThenInclude(x => x.PinnedByUser) + .ThenInclude(x => x!.Sessions) + .Include(x => x.Messages) + .ThenInclude(x => x.PinnedByUser) + .ThenInclude(x => x!.PrivacySettings) + .Include(x => x.Messages) + .ThenInclude(x => x.Attachments) + .Include(x => x.Messages) + .ThenInclude(x => x.Reactions) + .Include(x => x.Messages) + .ThenInclude(x => x.Views) + .Include(x => x.Messages) + .ThenInclude(x => x.ReplyToMessage) + .ThenInclude(x => x!.Sender) + .ThenInclude(x => x.Sessions) + .Include(x => x.Messages) + .ThenInclude(x => x.ReplyToMessage) + .ThenInclude(x => x!.Sender) + .ThenInclude(x => x.PrivacySettings) + .Include(x => x.Messages) + .ThenInclude(x => x.ReplyToMessage) + .ThenInclude(x => x!.Attachments) + .AsSplitQuery(); + + if (asNoTracking) + { + query = query.AsNoTracking(); + } + + return await query.SingleOrDefaultAsync(x => x.Id == chatId && x.Members.Any(m => m.UserId == userId), cancellationToken); + } + + private async Task LoadVisibleStoryForReplyAsync( + Guid storyId, + Guid userId, + CancellationToken cancellationToken) + { + var peerAuthorIds = await LoadStoryPeerAuthorIdsAsync(userId, cancellationToken); + var now = DateTimeOffset.UtcNow; + return await dbContext.Stories + .AsNoTracking() + .Include(x => x.Attachments) + .Where(x => + x.Id == storyId && + x.DeletedAt == null && + x.ExpiresAt > now) + .WhereVisibleTo(userId, peerAuthorIds) + .SingleOrDefaultAsync(cancellationToken); + } + + private async Task> LoadStoryPeerAuthorIdsAsync(Guid userId, CancellationToken cancellationToken) => + await dbContext.ChatMembers + .AsNoTracking() + .Where(member => member.UserId == userId) + .Join( + dbContext.ChatMembers.AsNoTracking().Where(member => member.UserId != userId), + mine => mine.ChatId, + peer => peer.ChatId, + (_, peer) => peer.UserId) + .Distinct() + .ToListAsync(cancellationToken); + + private static string BuildStoryReplyPreview(Story story) + { + var textPreview = story.Text + .Trim() + .ReplaceLineEndings(" ") + .Trim() + .Take(160) + .ToArray(); + var normalizedTextPreview = new string(textPreview); + if (!string.IsNullOrWhiteSpace(normalizedTextPreview)) + { + return normalizedTextPreview; + } + + if (story.Attachments.Count > 1) + { + return $"{story.Attachments.Count} медиа"; + } + + var contentType = story.Attachments.FirstOrDefault()?.ContentType?.Trim().ToLowerInvariant(); + return contentType switch + { + not null when contentType.StartsWith("image/", StringComparison.Ordinal) => "Фото сторис", + not null when contentType.StartsWith("video/", StringComparison.Ordinal) => "Видео сторис", + not null when contentType.StartsWith("audio/", StringComparison.Ordinal) => "Аудио сторис", + not null => "Медиа сторис", + _ => "Сторис" + }; + } + + private static string ResolveStoryReplyAttachmentKind(Story story) + { + if (story.Attachments.Count == 0) + { + return "text"; + } + + if (story.Attachments.Count > 1) + { + return "media"; + } + + var contentType = story.Attachments.First().ContentType.Trim().ToLowerInvariant(); + return contentType switch + { + _ when contentType.StartsWith("image/", StringComparison.Ordinal) => "image", + _ when contentType.StartsWith("video/", StringComparison.Ordinal) => "video", + _ when contentType.StartsWith("audio/", StringComparison.Ordinal) => "audio", + _ => "file" + }; + } + + private async Task LoadMessageForMemberAsync(Guid chatId, Guid messageId, Guid userId, CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + return await dbContext.Messages + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.PrivacySettings) + .Include(x => x.Sender) + .ThenInclude(x => x.Sessions) + .Include(x => x.Sender) + .ThenInclude(x => x.PrivacySettings) + .Include(x => x.PinnedByUser) + .ThenInclude(x => x!.Sessions) + .Include(x => x.PinnedByUser) + .ThenInclude(x => x!.PrivacySettings) + .Include(x => x.Attachments) + .Include(x => x.Reactions) + .Include(x => x.Views) + .Include(x => x.ReplyToMessage) + .ThenInclude(x => x!.Sender) + .ThenInclude(x => x.Sessions) + .Include(x => x.ReplyToMessage) + .ThenInclude(x => x!.Sender) + .ThenInclude(x => x.PrivacySettings) + .Include(x => x.ReplyToMessage) + .ThenInclude(x => x!.Attachments) + .SingleOrDefaultAsync( + x => x.Id == messageId && + x.ChatId == chatId && + x.DeletedAt == null && + (x.ExpiresAt == null || x.ExpiresAt > now) && + x.Chat.Members.Any(m => m.UserId == userId), + cancellationToken); + } + + private Task LoadMessageForNotificationAsync(Message message, CancellationToken cancellationToken) + { + var viewerUserId = message.Chat.Members + .OrderByDescending(x => x.IsOwner) + .ThenBy(x => x.JoinedAt) + .Select(x => x.UserId) + .FirstOrDefault(); + if (viewerUserId == Guid.Empty) + { + viewerUserId = message.SenderId; + } + + return LoadMessageForMemberAsync(message.ChatId, message.Id, viewerUserId, cancellationToken); + } + + private async Task LoadCommentedChannelPostAsync(Message replyToMessage, CancellationToken cancellationToken) + { + var currentMessageId = (Guid?)replyToMessage.Id; + var visitedMessageIds = new HashSet(); + + while (currentMessageId is not null && visitedMessageIds.Add(currentMessageId.Value)) + { + var channelPost = await LoadChannelPostForDiscussionRootAsync(currentMessageId.Value, cancellationToken); + if (channelPost is not null) + { + return channelPost; + } + + currentMessageId = await dbContext.Messages + .Where(x => x.Id == currentMessageId.Value && x.ChatId == replyToMessage.ChatId) + .Select(x => x.ReplyToMessageId) + .SingleOrDefaultAsync(cancellationToken); + } + + return null; + } + + private async Task LoadLinkedDiscussionRootForMutationAsync(Message channelPost, CancellationToken cancellationToken) + { + if (channelPost.Chat.Type != ChatType.Channel || + !channelPost.PostedAsChannel || + channelPost.DiscussionMessageId is null) + { + return null; + } + + return await dbContext.Messages + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.PrivacySettings) + .Include(x => x.Sender) + .ThenInclude(x => x.Sessions) + .Include(x => x.Sender) + .ThenInclude(x => x.PrivacySettings) + .Include(x => x.PinnedByUser) + .ThenInclude(x => x!.Sessions) + .Include(x => x.PinnedByUser) + .ThenInclude(x => x!.PrivacySettings) + .Include(x => x.Attachments) + .Include(x => x.Reactions) + .Include(x => x.Views) + .SingleOrDefaultAsync( + x => x.Id == channelPost.DiscussionMessageId.Value && + x.ChatId == channelPost.Chat.LinkedDiscussionChatId && + x.Chat.Type == ChatType.Group, + cancellationToken); + } + + private async Task CreateLinkedDiscussionMessageAsync( + Chat channel, + Message channelMessage, + User sender, + DateTimeOffset sentAt, + CancellationToken cancellationToken) + { + if (channel.Type != ChatType.Channel || + !channelMessage.PostedAsChannel || + channel.LinkedDiscussionChatId is null) + { + return null; + } + + var discussionChat = await dbContext.Chats + .Include(x => x.Members) + .FirstOrDefaultAsync( + x => x.Id == channel.LinkedDiscussionChatId.Value && + x.Type == ChatType.Group && + x.Members.Any(member => member.UserId == sender.Id), + cancellationToken); + if (discussionChat is null) + { + return null; + } + + var rootMessage = new Message + { + ChatId = discussionChat.Id, + Chat = discussionChat, + SenderId = sender.Id, + Sender = sender, + Text = BuildDiscussionRootText(channelMessage), + SentAt = sentAt, + ForwardedFromDisplayName = string.IsNullOrWhiteSpace(channel.Title) ? "Канал" : channel.Title, + HasProtectedContent = channelMessage.HasProtectedContent, + TtlSeconds = channelMessage.TtlSeconds, + ExpiresAt = channelMessage.ExpiresAt + }; + + discussionChat.LastActivityAt = sentAt; + dbContext.Messages.Add(rootMessage); + return rootMessage; + } + + private Task LoadChannelPostForDiscussionRootAsync(Guid discussionMessageId, CancellationToken cancellationToken) => + dbContext.Messages + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .FirstOrDefaultAsync( + x => x.DiscussionMessageId == discussionMessageId && + x.Chat.Type == ChatType.Channel && + x.DeletedAt == null, + cancellationToken); + + private static string BuildDiscussionRootText(Message channelMessage) + { + var text = channelMessage.Text.Trim(); + return string.IsNullOrWhiteSpace(text) ? "Публикация" : text; + } + + private Task ResolveReplyTargetAsync( + Guid chatId, + Guid? replyToMessageId, + Guid userId, + CancellationToken cancellationToken) + { + if (replyToMessageId is null) + { + return Task.FromResult(null); + } + + var now = DateTimeOffset.UtcNow; + return dbContext.Messages + .Include(x => x.Sender) + .Include(x => x.Attachments) + .SingleOrDefaultAsync( + x => x.Id == replyToMessageId.Value && + x.ChatId == chatId && + x.DeletedAt == null && + (x.ExpiresAt == null || x.ExpiresAt > now) && + x.Chat.Members.Any(member => member.UserId == userId), + cancellationToken); + } + + private async Task> LoadChatSummariesForUserAsync(Guid userId, CancellationToken cancellationToken) => + await dbContext.Chats + .Where(x => x.Members.Any(member => member.UserId == userId)) + .Include(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.PrivacySettings) + .AsNoTracking() + .AsSplitQuery() + .ToListAsync(cancellationToken); + + private async Task SearchChatsPageAsync( + Guid userId, + string? rawQuery, + SearchFilters filters, + int requestedPage, + int requestedLimit, + CancellationToken cancellationToken) + { + var query = rawQuery?.Trim() ?? string.Empty; + var page = NormalizeSearchPage(requestedPage); + var take = NormalizeSearchLimit(requestedLimit); + if (query.Length < 2 && !filters.HasMessageFilter) + { + return new ChatSearchPageDto([], page, take, HasMore: false); + } + + var skip = (page - 1) * take; + var chats = await LoadChatSummariesForUserAsync(userId, cancellationToken); + var chatIds = chats.Select(x => x.Id).ToList(); + var latestMessagesByChatId = await LoadLatestMessagesByChatIdAsync(chatIds, cancellationToken); + var unreadCountsByChatId = await LoadUnreadCountsByChatIdAsync(userId, chatIds, cancellationToken); + var summariesByChatId = chats.ToDictionary( + x => x.Id, + x => x.ToSummaryDto( + userId, + presenceTracker, + latestMessagesByChatId.GetValueOrDefault(x.Id), + unreadCountsByChatId.GetValueOrDefault(x.Id))); + + var chatMatches = query.Length >= 2 && !filters.HasMessageFilter + ? chats + .Concat(await LoadPublicChannelSearchResultsAsync(query, cancellationToken)) + .GroupBy(chat => chat.Id) + .Select(group => group.First()) + .Where(chat => MatchesChatSearch(chat, query)) + .OrderByDescending(chat => chat.LastActivityAt ?? chat.CreatedAt) + .Select(chat => + { + var summary = summariesByChatId.TryGetValue(chat.Id, out var memberSummary) + ? memberSummary + : chat.ToSummaryDto(userId, presenceTracker); + return new ChatSearchResultDto( + summary, + null, + summary.Title, + summary.LastActivityAt, + summary.Id); + }) + .ToList() + : []; + + var selectedChatMatches = chatMatches + .Skip(skip) + .Take(take) + .ToList(); + var messageSkip = Math.Max(0, skip - chatMatches.Count); + var messageTake = take - selectedChatMatches.Count; + var results = new List(selectedChatMatches); + var messageHasMore = false; + + if (messageTake > 0) + { + var messageMatches = await LoadSearchMessagesAsync( + chatIds, + query, + filters, + messageSkip, + messageTake + 1, + cancellationToken); + messageHasMore = messageMatches.Count > messageTake; + + foreach (var message in messageMatches.Take(messageTake)) + { + if (!summariesByChatId.TryGetValue(message.ChatId, out var summary)) + { + continue; + } + + results.Add(ToSearchResult(summary, message, userId)); + } + } + + var hasMore = skip + take < chatMatches.Count || messageHasMore; + return new ChatSearchPageDto(results, page, take, hasMore); + } + + private async Task> LoadPublicChannelSearchResultsAsync( + string? query, + CancellationToken cancellationToken, + int? limit = null) + { + var normalizedQuery = query?.Trim() ?? string.Empty; + var take = limit is null ? (int?)null : Math.Max(limit.Value, 1); + var candidateTake = take is null ? (int?)null : Math.Min(take.Value * 3, 300); + var channelsQuery = dbContext.Chats + .Where(chat => + chat.Type == ChatType.Channel && + chat.PublicUsername != null && + chat.PublicUsername != string.Empty); + + if (!string.IsNullOrWhiteSpace(normalizedQuery)) + { + var queryPattern = BuildLikePattern(normalizedQuery); + var upperQueryPattern = BuildLikePattern(normalizedQuery.ToUpperInvariant()); + var lowerQueryPattern = BuildLikePattern(normalizedQuery.ToLowerInvariant()); + var usernameQuery = NormalizePublicSearchUsername(normalizedQuery); + var usernamePattern = string.IsNullOrWhiteSpace(usernameQuery) + ? null + : BuildLikePattern(usernameQuery); + + channelsQuery = channelsQuery.Where(chat => + EF.Functions.Like(chat.Title, queryPattern, "\\") || + EF.Functions.Like(chat.Title, upperQueryPattern, "\\") || + EF.Functions.Like(chat.Title, lowerQueryPattern, "\\") || + EF.Functions.Like(chat.PublicUsername!, queryPattern, "\\") || + EF.Functions.Like(chat.PublicUsername!, upperQueryPattern, "\\") || + EF.Functions.Like(chat.PublicUsername!, lowerQueryPattern, "\\") || + (usernamePattern != null && EF.Functions.Like(chat.PublicUsername!, usernamePattern, "\\")) || + (chat.Description != null && EF.Functions.Like(chat.Description, queryPattern, "\\")) || + (chat.Description != null && EF.Functions.Like(chat.Description, upperQueryPattern, "\\")) || + (chat.Description != null && EF.Functions.Like(chat.Description, lowerQueryPattern, "\\"))); + } + + channelsQuery = channelsQuery + .OrderByDescending(chat => chat.LastActivityAt ?? chat.CreatedAt) + .ThenBy(chat => chat.Title); + + if (candidateTake is not null) + { + channelsQuery = channelsQuery.Take(candidateTake.Value); + } + + var channels = await channelsQuery + .Include(chat => chat.Members) + .ThenInclude(member => member.User) + .AsNoTracking() + .AsSplitQuery() + .ToListAsync(cancellationToken); + + IEnumerable matches = channels; + if (!string.IsNullOrWhiteSpace(normalizedQuery)) + { + matches = matches.Where(chat => MatchesPublicChannelSearch(chat, normalizedQuery)); + } + + if (take is not null) + { + matches = matches.Take(take.Value); + } + + return matches.ToList(); + } + + private async Task SearchMessagesPageAsync( + Chat chat, + Guid userId, + string? rawQuery, + SearchFilters filters, + int requestedPage, + int requestedLimit, + CancellationToken cancellationToken) + { + var query = rawQuery?.Trim() ?? string.Empty; + var page = NormalizeSearchPage(requestedPage); + var take = NormalizeSearchLimit(requestedLimit); + if (query.Length < 2 && !filters.HasMessageFilter) + { + return new MessageSearchPageDto([], page, take, HasMore: false); + } + + var messages = await LoadSearchMessagesAsync( + [chat.Id], + query, + filters, + (page - 1) * take, + take + 1, + cancellationToken); + var hasMore = messages.Count > take; + var dtos = messages + .Take(take) + .Select(message => + { + message.Chat = chat; + return message.ToDto(presenceTracker, userId); + }) + .ToList(); + + return new MessageSearchPageDto(dtos, page, take, hasMore); + } + + private ChatSearchResultDto ToSearchResult(ChatSummaryDto summary, Message message, Guid userId) + { + var messageDto = message.ToDto(presenceTracker, userId); + return new ChatSearchResultDto( + summary, + messageDto, + BuildSearchMatchText(message), + message.SentAt, + summary.Id, + message.Id, + message.SenderId, + message.SentAt); + } + + private async Task> LoadSearchMessagesAsync( + IReadOnlyCollection chatIds, + string query, + SearchFilters filters, + int skip, + int limit, + CancellationToken cancellationToken) + { + if (chatIds.Count == 0) + { + return []; + } + + var now = DateTimeOffset.UtcNow; + var messagesQuery = dbContext.Messages + .Where(message => + chatIds.Contains(message.ChatId) && + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now)); + + if (!string.IsNullOrWhiteSpace(query)) + { + var queryPattern = BuildLikePattern(query); + messagesQuery = messagesQuery.Where(message => + EF.Functions.Like(message.Text, queryPattern, "\\") || + message.Attachments.Any(attachment => EF.Functions.Like(attachment.OriginalFileName, queryPattern, "\\"))); + } + + if (filters.DateFrom is not null) + { + messagesQuery = messagesQuery.Where(message => message.SentAt >= filters.DateFrom.Value); + } + + if (filters.DateTo is not null) + { + messagesQuery = messagesQuery.Where(message => message.SentAt <= filters.DateTo.Value); + } + + if (filters.AuthorId is not null) + { + messagesQuery = messagesQuery.Where(message => message.SenderId == filters.AuthorId.Value); + } + + if (!string.IsNullOrWhiteSpace(filters.AuthorQuery)) + { + var authorPattern = BuildLikePattern(filters.AuthorQuery); + messagesQuery = messagesQuery.Where(message => + EF.Functions.Like(message.Sender.DisplayName, authorPattern, "\\") || + EF.Functions.Like(message.Sender.Username, authorPattern, "\\") || + (message.Sender.PhoneNumber != null && EF.Functions.Like(message.Sender.PhoneNumber, authorPattern, "\\"))); + } + + messagesQuery = filters.Type switch + { + "text" => messagesQuery.Where(message => message.Text != ""), + "attachments" => messagesQuery.Where(message => message.Attachments.Any()), + "media" => messagesQuery.Where(message => message.Attachments.Any(attachment => attachment.ContentType.StartsWith("image/") || attachment.ContentType.StartsWith("video/"))), + "files" => messagesQuery.Where(message => message.Attachments.Any(attachment => + !attachment.ContentType.StartsWith("image/") && + !attachment.ContentType.StartsWith("video/") && + !attachment.ContentType.StartsWith("audio/"))), + "links" => messagesQuery.Where(message => message.Text.Contains("http://") || message.Text.Contains("https://")), + "voice" => messagesQuery.Where(message => message.Attachments.Any(attachment => attachment.ContentType.StartsWith("audio/"))), + "pinned" => messagesQuery.Where(message => message.PinnedAt != null), + _ => messagesQuery + }; + + return await messagesQuery + .OrderByDescending(message => message.SentAt) + .Skip(skip) + .Take(limit) + .Include(message => message.Chat) + .ThenInclude(chat => chat.Members) + .ThenInclude(member => member.User) + .Include(message => message.Sender) + .Include(message => message.PinnedByUser) + .Include(message => message.Attachments) + .Include(message => message.Reactions) + .Include(message => message.Views) + .Include(message => message.ReplyToMessage) + .ThenInclude(message => message!.Sender) + .Include(message => message.ReplyToMessage) + .ThenInclude(message => message!.Attachments) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + private static bool MatchesChatSearch(Chat chat, string query) + { + if (!string.IsNullOrWhiteSpace(chat.Title) && chat.Title.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(chat.PublicUsername) && + chat.PublicUsername.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var usernameQuery = NormalizePublicSearchUsername(query); + if (!string.IsNullOrWhiteSpace(usernameQuery) && + !string.IsNullOrWhiteSpace(chat.PublicUsername) && + chat.PublicUsername.Contains(usernameQuery, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(chat.Description) && + chat.Description.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return chat.Members.Any(member => + member.User.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase) || + member.User.Username.Contains(query, StringComparison.OrdinalIgnoreCase) || + (member.User.PhoneNumber?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false)); + } + + private static bool MatchesPublicChannelSearch(Chat chat, string query) + { + if (!string.IsNullOrWhiteSpace(chat.Title) && chat.Title.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(chat.PublicUsername) && + chat.PublicUsername.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var usernameQuery = NormalizePublicSearchUsername(query); + if (!string.IsNullOrWhiteSpace(usernameQuery) && + !string.IsNullOrWhiteSpace(chat.PublicUsername) && + chat.PublicUsername.Contains(usernameQuery, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return !string.IsNullOrWhiteSpace(chat.Description) && + chat.Description.Contains(query, StringComparison.OrdinalIgnoreCase); + } + + private static string BuildSearchMatchText(Message message) + { + if (!string.IsNullOrWhiteSpace(message.Text)) + { + return message.Text.Length <= 160 ? message.Text : message.Text[..160]; + } + + var attachment = message.Attachments + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.OriginalFileName) + .FirstOrDefault(); + + return attachment is null ? "Message" : attachment.OriginalFileName; + } + + private static SearchFilters BuildSearchFilters( + string? legacyFilter, + string? type, + DateTimeOffset? date, + DateTimeOffset? dateFrom, + DateTimeOffset? dateTo, + Guid? authorId, + string? author) + { + var normalizedType = NormalizeSearchType(type) ?? NormalizeSearchType(legacyFilter); + var effectiveDateFrom = dateFrom; + var effectiveDateTo = dateTo; + if (date is not null) + { + var dateStart = new DateTimeOffset(date.Value.Date, date.Value.Offset); + effectiveDateFrom ??= dateStart; + effectiveDateTo ??= dateStart.AddDays(1).AddTicks(-1); + } + + return new SearchFilters( + normalizedType, + effectiveDateFrom, + effectiveDateTo, + authorId, + string.IsNullOrWhiteSpace(author) ? null : author.Trim()); + } + + private static string? NormalizeSearchType(string? value) + { + var normalized = value?.Trim().ToLowerInvariant(); + return normalized switch + { + "attachment" or "attachments" => "attachments", + "file" or "files" or "document" or "documents" => "files", + "image" or "images" or "video" or "videos" or "media" => "media", + "link" or "links" or "url" or "urls" => "links", + "audio" or "voice" or "voice-note" or "voice_note" or "voice-notes" or "voice_notes" => "voice", + "pin" or "pinned" or "pinned-message" or "pinned_message" or "pinned-messages" or "pinned_messages" => "pinned", + "message" or "messages" or "text" => "text", + _ => null + }; + } + + private static int NormalizeSearchPage(int page) => Math.Clamp(page, 1, 10_000); + + private static int NormalizeSearchLimit(int limit) => Math.Clamp(limit, 1, 100); + + private static string BuildLikePattern(string value) + { + var escaped = value + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("%", "\\%", StringComparison.Ordinal) + .Replace("_", "\\_", StringComparison.Ordinal); + return $"%{escaped}%"; + } + + private static string? NormalizePublicSearchUsername(string value) + { + var normalized = value.Trim().TrimStart('@'); + return string.IsNullOrWhiteSpace(normalized) ? null : normalized; + } + + private void WriteSearchPageHeaders(int page, int limit, bool hasMore) + { + Response.Headers["X-Search-Page"] = page.ToString(System.Globalization.CultureInfo.InvariantCulture); + Response.Headers["X-Search-Limit"] = limit.ToString(System.Globalization.CultureInfo.InvariantCulture); + Response.Headers["X-Search-Has-More"] = hasMore ? "true" : "false"; + } + + private sealed record SearchFilters( + string? Type, + DateTimeOffset? DateFrom, + DateTimeOffset? DateTo, + Guid? AuthorId, + string? AuthorQuery) + { + public bool HasMessageFilter => + Type is not null || + DateFrom is not null || + DateTo is not null || + AuthorId is not null || + !string.IsNullOrWhiteSpace(AuthorQuery); + } + + private static string? NormalizeFolderKey(string? value) + { + var normalized = value?.Trim().ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(normalized)) + { + return null; + } + + return new string(normalized + .Where(ch => char.IsLetterOrDigit(ch) || ch is '-' or '_') + .Take(48) + .ToArray()); + } + + private static string? NormalizePublicUsername(string? value) + { + var trimmed = value?.Trim().TrimStart('@'); + if (string.IsNullOrWhiteSpace(trimmed)) + { + return null; + } + + var normalized = new string(trimmed + .Where(ch => ch is >= 'a' and <= 'z' || ch is >= 'A' and <= 'Z' || char.IsDigit(ch) || ch == '_') + .Take(32) + .ToArray()) + .ToLowerInvariant(); + + if (normalized.Length < 5) + { + throw new InvalidOperationException("Public username must contain at least 5 characters."); + } + + return normalized; + } + + private ChatInviteLinkDto ToInviteLinkDto(ChatInviteLink link) => + new( + link.Id, + link.ChatId, + link.Token, + $"{Request.Scheme}://{Request.Host}/join/{Uri.EscapeDataString(link.Token)}", + link.Title, + link.CreatesJoinRequest, + link.IsRevoked, + link.CreatedAt, + link.RevokedAt, + link.ExpiresAt, + link.MaxUses, + link.UsesCount, + link.MemberLimit); + + private static string? ValidateInviteAvailability(ChatInviteLink invite, DateTimeOffset now, int currentMemberCount) + { + if (invite.ExpiresAt is not null && invite.ExpiresAt <= now) + { + return "Invite link has expired."; + } + + if (invite.MaxUses is not null && invite.UsesCount >= invite.MaxUses.Value) + { + return "Invite link usage limit reached."; + } + + if (invite.MemberLimit is not null && currentMemberCount >= invite.MemberLimit.Value) + { + return "Chat member limit reached."; + } + + return null; + } + + private static DateTimeOffset? NormalizeInviteExpiresAt(DateTimeOffset? expiresAt) + { + if (expiresAt is null) + { + return null; + } + + if (expiresAt.Value <= DateTimeOffset.UtcNow) + { + throw new InvalidOperationException("Invite link expiration must be in the future."); + } + + return expiresAt.Value; + } + + private static int? NormalizeInviteLimit(int? value, string fieldName) + { + if (value is null) + { + return null; + } + + if (value.Value <= 0) + { + throw new InvalidOperationException($"{fieldName} must be greater than zero."); + } + + return value.Value; + } + + private ChatJoinRequestDto ToJoinRequestDto(ChatJoinRequest request) => + new( + request.Id, + request.ChatId, + request.InviteLinkId, + request.User.ToDto(presenceTracker), + request.Status, + request.RequestedAt, + request.ResolvedAt, + request.ResolvedByUser?.ToDto(presenceTracker)); + + private async Task AddModerationLogAsync( + Guid actorUserId, + Guid? chatId, + ModerationLogTargetType targetType, + Guid? targetId, + string action, + string? reason, + CancellationToken cancellationToken) + { + var actorExists = await dbContext.Users.AnyAsync(x => x.Id == actorUserId, cancellationToken); + if (!actorExists) + { + return; + } + + dbContext.ModerationLogEntries.Add(new ModerationLogEntry + { + ActorUserId = actorUserId, + ChatId = chatId, + TargetType = targetType, + TargetId = targetId, + Action = action, + Reason = TrimToNull(reason, 256), + CreatedAt = DateTimeOffset.UtcNow + }); + } + + private static int? NormalizeTtlSeconds(int? ttlSeconds, bool allowNull) + { + if (ttlSeconds is null || ttlSeconds <= 0) + { + return allowNull ? null : 86400; + } + + return Math.Clamp(ttlSeconds.Value, 5, 31 * 24 * 60 * 60); + } + + private async Task IsBlockedByPrivacyAsync(Guid ownerUserId, Guid candidateUserId, CancellationToken cancellationToken) + { + var rawBlocked = await dbContext.UserPrivacySettings + .AsNoTracking() + .Where(x => x.UserId == ownerUserId) + .Select(x => x.BlockedUserIds) + .SingleOrDefaultAsync(cancellationToken); + + return ContainsSerializedUserId(rawBlocked, candidateUserId); + } + + private static bool ContainsSerializedUserId(string? value, Guid userId) => + !string.IsNullOrWhiteSpace(value) && + value + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Any(x => Guid.TryParse(x, out var parsed) && parsed == userId); + + private async Task GenerateInviteTokenAsync(CancellationToken cancellationToken) + { + while (true) + { + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(18)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + var exists = await dbContext.ChatInviteLinks + .AnyAsync(x => x.Token == token, cancellationToken); + if (!exists) + { + return token; + } + } + } + + private static string? TrimToNull(string? value, int maxLength) + { + var trimmed = value?.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) + { + return null; + } + + return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength]; + } + + private async Task> LoadChatTopicDtosAsync(Guid chatId, CancellationToken cancellationToken) + { + var messageCounts = await dbContext.Messages + .AsNoTracking() + .Where(x => x.ChatId == chatId && x.TopicId != null && x.DeletedAt == null) + .GroupBy(x => x.TopicId!.Value) + .Select(group => new { TopicId = group.Key, Count = group.Count() }) + .ToDictionaryAsync(x => x.TopicId, x => x.Count, cancellationToken); + + var topics = await dbContext.ChatTopics + .AsNoTracking() + .Where(x => x.ChatId == chatId) + .OrderByDescending(x => x.IsPinned) + .ThenByDescending(x => x.LastActivityAt) + .ToListAsync(cancellationToken); + + return topics + .Select(x => new ChatTopicDto( + x.Id, + x.ChatId, + x.Title, + x.Icon, + x.IsPinned, + x.IsClosed, + x.CreatedAt, + x.LastActivityAt, + messageCounts.GetValueOrDefault(x.Id))) + .ToList(); + } + + private static ChatPermissionsDto ResolvePermissions(Chat chat, Guid userId) => + DtoMapper.BuildPermissions(chat.Members.FirstOrDefault(member => member.UserId == userId), chat.Type); + + private static bool CanManageMembers(Chat chat, Guid userId) => + ResolvePermissions(chat, userId).CanManageMembers; + + private static bool CanUserPublish(Chat chat, Guid userId) => + ResolvePermissions(chat, userId).CanPostMessages; + + private static bool CanEditMessage(Message message, Guid userId) => + message.SenderId == userId || + (message.Chat.Type == ChatType.Channel && ResolvePermissions(message.Chat, userId).CanPostMessages); + + private static bool ShouldPostAsChannel(Chat chat) => + chat.Type == ChatType.Channel; + + private static bool IsMediaAlbumFile(IFormFile file) + { + var contentInfo = AttachmentContentInspector.Inspect(file); + return contentInfo.IsImage || contentInfo.IsVideo; + } + + private static string? ResolveChannelAuthorSignature(Chat chat, User sender) + { + if (chat.Type != ChatType.Channel || !chat.ChannelSignaturesEnabled) + { + return null; + } + + return string.IsNullOrWhiteSpace(sender.DisplayName) ? sender.Username : sender.DisplayName; + } + + private static ChatMember CreateSubscriberMembership(Chat chat, Guid userId) + { + var isChannel = chat.Type == ChatType.Channel; + return new ChatMember + { + ChatId = chat.Id, + UserId = userId, + Role = ChatMemberRole.Member, + CanPinMessages = !isChannel, + CanDeleteMessages = false, + CanInviteUsers = !isChannel, + CanPostMessages = !isChannel, + JoinedAt = DateTimeOffset.UtcNow + }; + } + + private static string ResolveForwardedFromDisplayName(Message sourceMessage) + { + var forwardedFromDisplayName = sourceMessage.ForwardedFromDisplayName; + if (!string.IsNullOrWhiteSpace(forwardedFromDisplayName)) + { + return forwardedFromDisplayName; + } + + if (sourceMessage.Chat.Type == ChatType.Channel && sourceMessage.PostedAsChannel) + { + return string.IsNullOrWhiteSpace(sourceMessage.Chat.Title) ? "Channel" : sourceMessage.Chat.Title!; + } + + return string.IsNullOrWhiteSpace(sourceMessage.Sender.DisplayName) + ? sourceMessage.Sender.Username + : sourceMessage.Sender.DisplayName; + } + + private async Task NotifyMessageCreatedAsync(Chat chat, Message message, CancellationToken cancellationToken) + { + foreach (var member in chat.Members) + { + var dto = message.ToDto(presenceTracker, member.UserId); + await hubContext.Clients.User(member.UserId.ToString()) + .SendAsync("MessageCreated", dto, cancellationToken); + } + + await NotifyChatsChangedAsync(chat.Members.Select(member => member.UserId), cancellationToken); + } + + private async Task NotifyMessageUpdatedAsync(Chat chat, IReadOnlyCollection messages, CancellationToken cancellationToken) + { + foreach (var member in chat.Members) + { + foreach (var message in messages.OrderBy(x => x.SentAt)) + { + var dto = message.ToDto(presenceTracker, member.UserId); + await hubContext.Clients.User(member.UserId.ToString()) + .SendAsync("MessageUpdated", dto, cancellationToken); + } + } + } + + private async Task NotifyChatCreatedAsync(Chat chat, CancellationToken cancellationToken) + { + foreach (var member in chat.Members) + { + var summary = chat.ToSummaryDto(member.UserId, presenceTracker); + await hubContext.Clients.User(member.UserId.ToString()) + .SendAsync("ChatCreated", summary, cancellationToken); + } + + await hubContext.Clients.Users(chat.Members.Select(x => x.UserId.ToString())) + .SendAsync("ChatsChanged", cancellationToken); + } + + private Task NotifyChatsChangedAsync(IEnumerable userIds, CancellationToken cancellationToken) => + hubContext.Clients.Users(userIds.Select(x => x.ToString()).Distinct()) + .SendAsync("ChatsChanged", cancellationToken); + + private void DeleteFiles(IEnumerable attachmentPaths) + { + foreach (var path in attachmentPaths) + { + attachmentStorageService.DeleteIfExists(path); + } + } +} diff --git a/ikar_server/src/Ikar.Server/Controllers/IkarAdditionalFeatures.cs b/ikar_server/src/Ikar.Server/Controllers/IkarAdditionalFeatures.cs new file mode 100644 index 0000000..1dce274 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Controllers/IkarAdditionalFeatures.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Controllers; + +public sealed partial class QlyraCompatibilityController +{ + private async Task DeleteOwnAccountAsync(int opcode, JsonElement payload, CancellationToken ct) + { + var user = await RequireCurrentUserAsync(ct); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "Войдите в ИКАР.")); + if (payload.TryGetProperty("delete", out var deletion) && deletion.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + user.ScheduledDeletionUnixMs = deletion.GetBoolean() ? user.ScheduledDeletionUnixMs ?? DateTimeOffset.UtcNow.AddDays(30).ToUnixTimeMilliseconds() : null; + await dbContext.SaveChangesAsync(ct); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true, timestamp = user.ScheduledDeletionUnixMs ?? 0 })); + } + await DeleteUserDataAsync(user, ct); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true })); + } + + [NonAction] + public async Task DeliverScheduledDeletionsAsync(CancellationToken ct) + { + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var due = await dbContext.Users.Where(x => !x.IsBlocked && x.ScheduledDeletionUnixMs != null && x.ScheduledDeletionUnixMs <= now).Take(20).ToListAsync(ct); + foreach (var user in due) await DeleteUserDataAsync(user, ct); + } + + private async Task DeleteUserDataAsync(User user, CancellationToken ct) + { + user.ScheduledDeletionUnixMs = null; + user.IsBlocked = true; + user.BlockedAt = DateTimeOffset.UtcNow; + user.DisplayName = "Удалённый аккаунт"; + user.Username = "deleted_" + user.Id.ToString("N"); + user.NormalizedUsername = user.Username.ToUpperInvariant(); + user.PhoneNumber = user.NormalizedPhoneNumber = user.Email = user.NormalizedEmail = user.About = null; + user.PasswordHash = string.Empty; + user.AvatarStoragePath = user.AvatarContentType = null; + dbContext.DeviceCredentials.RemoveRange(await dbContext.DeviceCredentials.Where(x => x.UserId == user.Id).ToListAsync(ct)); + dbContext.PushDevices.RemoveRange(await dbContext.PushDevices.Where(x => x.UserId == user.Id).ToListAsync(ct)); + dbContext.UserSessions.RemoveRange(await dbContext.UserSessions.Where(x => x.UserId == user.Id).ToListAsync(ct)); + var stories = await dbContext.Stories.Where(x => x.AuthorUserId == user.Id && x.DeletedAt == null).ToListAsync(ct); + foreach (var story in stories) story.DeletedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(ct); + } + + private async Task DeleteOwnStoriesAsync(int opcode, JsonElement payload, CancellationToken ct) + { + var user = await RequireCurrentUserAsync(ct); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "Войдите в ИКАР.")); + var ids = ReadIntList(payload, "storyIds").Distinct().ToList(); + var entities = new List(); + foreach (var id in ids) + { + var entityId = await clientIdMapper.FindEntityIdAsync("story", id, ct); + var story = entityId is null ? null : await dbContext.Stories.FindAsync([entityId.Value], ct); + if (story is null) continue; + if (story.AuthorUserId != user.Id) return StatusCode(403, QlyraCompatibilityResponse.Error(opcode, "STORY_FORBIDDEN", "Можно удалить только свои истории.")); + entities.Add(story); + } + foreach (var story in entities) story.DeletedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(ct); + await PublishPacketAsync([user.Id], 216, new { ownerId = await clientIdMapper.GetOrCreateAsync("user", user.Id, ct), deletedStoryIds = ids }, ct); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true })); + } + + private async Task ReactionSettingsAsync(int opcode, JsonElement payload, CancellationToken ct) + { + var user = await RequireCurrentUserAsync(ct); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "Войдите в ИКАР.")); + if (opcode == 258) + { + var settings = new List(); + foreach (var id in ReadIntList(payload, "chatIds").Distinct()) + { + var chat = await ResolveMemberChatAsync(user.Id, id, ct); + if (chat is not null) settings.Add(MapReactionSettings(chat, id)); + } + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { chatReactionsSettings = settings })); + } + var chatId = ReadInt(payload, "chatId"); + var target = await ResolveMemberChatAsync(user.Id, chatId, ct); + if (target is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Чат не найден.")); + var member = target.Members.Single(x => x.UserId == user.Id); + if (target.Type != Ikar.Shared.ChatType.Direct && !IsChatManager(member)) + return StatusCode(403, QlyraCompatibilityResponse.Error(opcode, "CHAT_FORBIDDEN", "Нужны права администратора.")); + var ids = payload.TryGetProperty("reactionIds", out var raw) && raw.ValueKind == JsonValueKind.Array + ? raw.EnumerateArray().Select(x => x.GetString() ?? string.Empty).Where(x => x.Length <= 64).Distinct().Take(100).ToArray() : []; + target.ReactionSettingsJson = JsonSerializer.Serialize(new { isActive = ReadBool(payload, "value") != false, count = Math.Clamp(ReadInt(payload, "count") ?? 8, 1, 8), included = ReadBool(payload, "included") == true, reactionIds = ids }); + await dbContext.SaveChangesAsync(ct); + var mapped = MapReactionSettings(target, chatId!.Value); + await PublishPacketAsync(target.Members.Select(x => x.UserId), 132, new { chat = (await BuildChatsAsync([target], ct)).Single() }, ct); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { chatReactionsSettings = mapped })); + } + + private static object MapReactionSettings(Chat chat, int chatId) + { + using var doc = JsonDocument.Parse(chat.ReactionSettingsJson); + var settings = doc.RootElement; + return new { chatId, isActive = ReadBool(settings, "isActive") != false, count = ReadInt(settings, "count") ?? 8, included = ReadBool(settings, "included") == true, reactionIds = settings.TryGetProperty("reactionIds", out var ids) ? ids.Clone() : JsonSerializer.SerializeToElement(Array.Empty()) }; + } + private async Task ReactionAllowedAsync(Chat chat, Guid messageId, string emoji, CancellationToken ct) + { + using var doc = JsonDocument.Parse(chat.ReactionSettingsJson); + var settings = doc.RootElement; + if (ReadBool(settings, "isActive") == false) return false; + var listed = settings.TryGetProperty("reactionIds", out var ids) && ids.EnumerateArray().Any(x => x.GetString() == emoji); + if (listed != (ReadBool(settings, "included") == true)) return false; + var present = await dbContext.MessageReactions.Where(x => x.MessageId == messageId).Select(x => x.Emoji).Distinct().ToListAsync(ct); + return present.Contains(emoji) || present.Count < (ReadInt(settings, "count") ?? 8); + } +} diff --git a/ikar_server/src/Ikar.Server/Controllers/IkarMiniAppCompatibility.cs b/ikar_server/src/Ikar.Server/Controllers/IkarMiniAppCompatibility.cs new file mode 100644 index 0000000..01595e8 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Controllers/IkarMiniAppCompatibility.cs @@ -0,0 +1,50 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Controllers; + +public sealed partial class QlyraCompatibilityController +{ + private async Task MiniAppAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var botId = ReadInt(payload, "botId") ?? 0; + string? startParam = ReadString(payload, "startParam"); + if (opcode == 105) + { + if (!Uri.TryCreate(ReadString(payload, "url"), UriKind.Absolute, out var callback) || + !(callback.Scheme == "ikar" || callback.Scheme == "https" && callback.Host == "ikar.kusoft.xyz")) + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "CALLBACK_INVALID", "Некорректная ссылка ИКАР.")); + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(callback.Query); + _ = int.TryParse(query["botId"], out botId); + startParam = query["startParam"].ToString(); + } + var entityId = await clientIdMapper.FindEntityIdAsync("user", botId, cancellationToken); + var bot = entityId is null ? null : await dbContext.Bots.SingleOrDefaultAsync(x => x.UserId == entityId && x.IsEnabled, cancellationToken); + if (bot is null || string.IsNullOrWhiteSpace(bot.WebAppUrl)) return NotFound(QlyraCompatibilityResponse.Error(opcode, "WEB_APP_UNAVAILABLE", "Мини-приложение не настроено владельцем бота.")); + if (opcode == 105) return Ok(QlyraCompatibilityResponse.Ok(opcode, new { botId, startParam })); + var userId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken); + var authDate = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + string Sign(string text) => Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(bot.TokenHash), Encoding.UTF8.GetBytes(text))).ToLowerInvariant(); + if (opcode == 106) + { + var phone = user.NormalizedPhoneNumber ?? string.Empty; + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { phone, authDate, hash = Sign($"authDate={authDate}\nbotId={botId}\nphone={phone}\nuserId={userId}") })); + } + var init = new SortedDictionary(StringComparer.Ordinal) { + ["auth_date"] = authDate.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["bot_id"] = botId.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["query_id"] = Guid.NewGuid().ToString("N"), + ["user"] = JsonSerializer.Serialize(new { id = userId, name = user.DisplayName, username = user.Username }) + }; + if (!string.IsNullOrWhiteSpace(startParam)) init["start_param"] = startParam[..Math.Min(startParam.Length, 512)]; + var signature = Sign(string.Join('\n', init.Select(x => $"{x.Key}={x.Value}"))); + var data = string.Join('&', init.Append(new KeyValuePair("hash", signature)).Select(x => $"{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value)}")); + var launch = new UriBuilder(bot.WebAppUrl) { Fragment = $"ikarWebAppData={Uri.EscapeDataString(data)}" }; + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { url = launch.Uri.ToString(), query_id = init["query_id"] })); + } +} diff --git a/ikar_server/src/Ikar.Server/Controllers/IkarScheduledMessages.cs b/ikar_server/src/Ikar.Server/Controllers/IkarScheduledMessages.cs new file mode 100644 index 0000000..97940b6 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Controllers/IkarScheduledMessages.cs @@ -0,0 +1,46 @@ +using System.Security.Claims; +using System.Text.Json; +using Ikar.Server.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Controllers; + +public sealed partial class QlyraCompatibilityController +{ + private static long? ReadScheduledTime(JsonElement payload, string? parent = null) + { + if (parent is not null && (!payload.TryGetProperty(parent, out payload) || payload.ValueKind != JsonValueKind.Object)) return null; + return payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("delayedAttributes", out var attrs) ? ReadLong(attrs, "timeToFire") : null; + } + + [Microsoft.AspNetCore.Mvc.NonAction] + public async Task DeliverScheduledMessagesAsync(CancellationToken ct) + { + var now = DateTimeOffset.UtcNow; + var due = await dbContext.Messages.Include(x => x.Sender).Include(x => x.Chat).ThenInclude(x => x.Members) + .Where(x => x.ScheduledForUnixMs != null && x.ScheduledForUnixMs <= now.ToUnixTimeMilliseconds() && x.DeletedAt == null) + .OrderBy(x => x.ScheduledForUnixMs).Take(20).ToListAsync(ct); + foreach (var message in due) + { + if (message.Sender.IsBlocked || !message.Chat.Members.Any(x => x.UserId == message.SenderId)) + { + message.DeletedAt = now; + await dbContext.SaveChangesAsync(ct); + continue; + } + var session = await dbContext.UserSessions.Where(x => x.UserId == message.SenderId).OrderByDescending(x => x.CreatedAt).FirstOrDefaultAsync(ct); + HttpContext.User = session is null ? new ClaimsPrincipal() : new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(ClaimTypes.NameIdentifier, message.SenderId.ToString()), new Claim("sid", session.Id.ToString())], "scheduled")); + message.ScheduledForUnixMs = null; + message.SentAt = now; + message.Chat.LastActivityAt = now; + pushDispatchQueue?.StageMessages(dbContext, message); + await dbContext.SaveChangesAsync(ct); + var mapped = await BuildMessageAsync(message, ct); + var chatId = await GetClientChatIdAsync(message.Chat, ct); + await PublishPacketAsync(message.Chat.Members.Select(x => x.UserId), 128, new { chatId, message = mapped, unread = 0 }, ct); + await PublishPacketAsync([message.SenderId], 154, new { chatId, messageId = await clientIdMapper.GetOrCreateAsync("message", message.Id, ct), delivered = true }, ct); + if (pushDispatchQueue is not null) await pushDispatchQueue.EnqueueAsync(message.ChatId, message.Id, ct); + } + } +} diff --git a/ikar_server/src/Ikar.Server/Controllers/IkarUpdateManifestController.cs b/ikar_server/src/Ikar.Server/Controllers/IkarUpdateManifestController.cs new file mode 100644 index 0000000..e20349e --- /dev/null +++ b/ikar_server/src/Ikar.Server/Controllers/IkarUpdateManifestController.cs @@ -0,0 +1,25 @@ +using System.Security.Cryptography; +using Ikar.Server.Infrastructure.Updates; +using Microsoft.AspNetCore.Mvc; + +namespace Ikar.Server.Controllers; + +[ApiController] +[Route("api/apps/ikar/manifest")] +public sealed class IkarUpdateManifestController(AndroidAppUpdateService updates) : ControllerBase +{ + [HttpGet] + [ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)] + public async Task Manifest(CancellationToken ct) + { + var release = await updates.GetLatestReleaseAsync(ct); + var package = await updates.ResolveLatestPackageAsync(ct); + if (release is null || package?.PhysicalPath is null) return NotFound(new { error = "RELEASE_NOT_PUBLISHED" }); + await using var stream = System.IO.File.OpenRead(package.PhysicalPath); + var hash = Convert.ToHexStringLower(await SHA256.HashDataAsync(stream, ct)); + return Ok(new { app = new { slug = "ikar", name = "ИКАР", packageName = "ru.ikar.app" }, release = new { + version = $"{release.VersionName}+{release.VersionCode}", downloadPath = release.DownloadPath, + originalFileName = package.FileName, packageSizeBytes = stream.Length, sha256 = hash, notes = release.Notes + }}); + } +} diff --git a/ikar_server/src/Ikar.Server/Controllers/PushController.cs b/ikar_server/src/Ikar.Server/Controllers/PushController.cs new file mode 100644 index 0000000..e51af44 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Controllers/PushController.cs @@ -0,0 +1,113 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Shared; + +namespace Ikar.Server.Controllers; + +[ApiController] +[Authorize] +[Route("api/[controller]")] +public sealed class PushController(IkarDbContext dbContext) : ControllerBase +{ + [HttpGet("devices")] + public async Task>> GetDevices(CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var devices = await dbContext.PushDevices + .AsNoTracking() + .Where(x => x.UserId == userId) + .ToListAsync(cancellationToken); + + return Ok(devices + .OrderByDescending(x => x.UpdatedAt) + .Select(x => x.ToDto()) + .ToList()); + } + + [HttpPost("devices")] + public async Task> RegisterDevice( + RegisterPushDeviceRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var installationId = request.InstallationId?.Trim() ?? string.Empty; + var deviceToken = request.DeviceToken?.Trim() ?? string.Empty; + if (request.Platform == PushPlatform.Unknown || + string.IsNullOrWhiteSpace(installationId) || + string.IsNullOrWhiteSpace(deviceToken)) + { + return ValidationProblem("Platform, installationId and deviceToken are required."); + } + + var clientVersion = ClientVersionRequestReader.Read(Request); + var device = await dbContext.PushDevices + .SingleOrDefaultAsync(x => x.InstallationId == installationId, cancellationToken); + + if (device is null) + { + device = new PushDevice + { + UserId = userId, + Platform = request.Platform, + InstallationId = installationId, + DeviceToken = deviceToken, + DeviceName = NormalizeDeviceName(request.DeviceName), + NotificationsEnabled = request.NotificationsEnabled, + UpdatedAt = DateTimeOffset.UtcNow + }; + ClientVersionRequestReader.Apply(device, clientVersion); + + dbContext.PushDevices.Add(device); + } + else + { + device.UserId = userId; + device.Platform = request.Platform; + device.DeviceToken = deviceToken; + device.DeviceName = NormalizeDeviceName(request.DeviceName); + device.NotificationsEnabled = request.NotificationsEnabled; + device.UpdatedAt = DateTimeOffset.UtcNow; + ClientVersionRequestReader.Apply(device, clientVersion); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return Ok(device.ToDto()); + } + + [HttpDelete("devices/{installationId}")] + public async Task UnregisterDevice(string installationId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var normalizedInstallationId = installationId?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(normalizedInstallationId)) + { + return NotFound(); + } + + var device = await dbContext.PushDevices + .SingleOrDefaultAsync( + x => x.InstallationId == normalizedInstallationId && + x.UserId == userId, + cancellationToken); + + if (device is null) + { + return NotFound(); + } + + dbContext.PushDevices.Remove(device); + await dbContext.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + private static string? NormalizeDeviceName(string? deviceName) + { + var normalized = deviceName?.Trim(); + return string.IsNullOrWhiteSpace(normalized) ? null : normalized; + } +} diff --git a/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs b/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs new file mode 100644 index 0000000..a506fe8 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs @@ -0,0 +1,3232 @@ +using System.Collections.Concurrent; +using System.Net.Mail; +using System.Text; +using System.Text.Json; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Bots; +using Ikar.Server.Infrastructure.Calls; +using Ikar.Server.Infrastructure.Push; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Shared; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Options; + +namespace Ikar.Server.Controllers; + +[ApiController] +[AllowAnonymous] +[Route("api/ikar/requests")] +public sealed partial class QlyraCompatibilityController( + IkarDbContext dbContext, + TokenService tokenService, + DeviceAuthChallengeStore deviceAuthChallengeStore, + PresenceTracker presenceTracker, + QlyraClientIdMapper clientIdMapper, + QlyraUploadStore uploadStore, + IHubContext hubContext, +ILogger logger, +IEmailCodeSender? emailCodeSender = null, +QlyraCallCompatibilityStore? qlyraCallStore = null, +IPushNotificationSender? pushNotificationSender = null, +QlyraWebPushSessionStore? webPushSessionStore = null, +IOptions? publicEndpointOptions = null, +BotUpdateService? botUpdateService = null, +PushDispatchQueue? pushDispatchQueue = null, +IkarTranscriptionService? transcriptionService = null, +BotCreatorService? botCreatorService = null, +VideoPreviewService? videoPreviewService = null) : ControllerBase +{ + private static readonly ConcurrentDictionary TwoFactorTracks = new(StringComparer.Ordinal); + [HttpPost("{opcode:int}")] + public async Task Dispatch(int opcode, QlyraCompatibilityRequest request, CancellationToken cancellationToken) + { + try + { + return opcode switch + { + 1 => Ok(QlyraCompatibilityResponse.Ok(opcode, new { server = "ikar" })), + 17 => await RequestPhoneCodeAsync(opcode, request.Payload, cancellationToken), + 18 => await VerifyPhoneCodeAsync(opcode, request.Payload, cancellationToken), + 19 => await LoginAsync(opcode, cancellationToken), + 20 => await LogoutAsync(opcode, cancellationToken), + 21 => await SyncAsync(opcode, cancellationToken), + 22 => await ConfigureAsync(opcode, request.Payload, cancellationToken), + 23 => await CompleteRegistrationAsync(opcode, request.Payload, cancellationToken), + 26 or 27 or 28 or 29 or 259 => await GetAssetsAsync(opcode, request.Payload, cancellationToken), + 8 => await GetContactsAsync(opcode, cancellationToken), + 16 => await UpdateProfileAsync(opcode, request.Payload, cancellationToken), + 32 => await GetContactInfoAsync(opcode, request.Payload, cancellationToken), + 34 => await UpdateContactAsync(opcode, request.Payload, cancellationToken), + 35 => await GetContactPresenceAsync(opcode, request.Payload, cancellationToken), + 36 => await GetBlockedContactsAsync(opcode, cancellationToken), + 39 => await GetContactPhotosAsync(opcode, request.Payload, cancellationToken), + 41 => await AddContactByPhoneAsync(opcode, request.Payload, cancellationToken), + 46 => await GetContactByPhoneAsync(opcode, request.Payload, cancellationToken), + 43 => await RemoveProfilePhotoAsync(opcode, cancellationToken), + 48 => await GetChatInfoAsync(opcode, request.Payload, cancellationToken), + 49 => await GetHistoryAsync(opcode, request.Payload, cancellationToken), + 50 => await MarkReadAsync(opcode, request.Payload, cancellationToken), + 51 => await GetChatMediaAsync(opcode, request.Payload, cancellationToken), + 52 => await DeleteChatAsync(opcode, request.Payload, cancellationToken), + 53 => await GetChatsAsync(opcode, request.Payload, cancellationToken), + 54 => await ClearChatAsync(opcode, request.Payload, cancellationToken), + 55 => await UpdateChatAsync(opcode, request.Payload, cancellationToken), + 57 => await JoinChatAsync(opcode, request.Payload, cancellationToken), + 58 => await LeaveChatAsync(opcode, request.Payload, cancellationToken), + 59 => await GetChatMembersAsync(opcode, request.Payload, cancellationToken), + 60 => await PublicSearchAsync(opcode, request.Payload, cancellationToken), + 64 => await SendMessageAsync(opcode, request.Payload, cancellationToken), + 65 => await SendTypingAsync(opcode, request.Payload, cancellationToken), + 66 => await DeleteMessagesAsync(opcode, request.Payload, cancellationToken), + 67 => await EditMessageAsync(opcode, request.Payload, cancellationToken), + 68 => await SearchMessagesAsync(opcode, request.Payload, false, cancellationToken), + 73 => await SearchMessagesAsync(opcode, request.Payload, true, cancellationToken), + 75 => await SubscribeChatAsync(opcode, request.Payload, cancellationToken), + 76 or 78 or 84 or 166 => await CallFeatureAsync(opcode, request.Payload, cancellationToken), + 77 => await UpdateChatMembersAsync(opcode, request.Payload, cancellationToken), + 79 or 164 => await CallHistoryAsync(opcode, request.Payload, cancellationToken), + 80 or 82 or 87 => await RequestUploadAsync(opcode, request.Payload, cancellationToken), + 83 or 88 => await ResolveMediaAsync(opcode, request.Payload, cancellationToken), + 89 => await GetLinkInfoAsync(opcode, request.Payload, cancellationToken), + 91 => await GetCommentsInfoAsync(opcode, request.Payload, cancellationToken), + 96 => await GetSessionsAsync(opcode, cancellationToken), + 97 => await CloseOtherSessionsAsync(opcode, cancellationToken), + 104 or 107 or 108 or 109 or 110 or 111 or 112 or 113 or 115 => await TwoFactorAsync(opcode, request.Payload, cancellationToken), + 105 or 106 or 160 => await MiniAppAsync(opcode, request.Payload, cancellationToken), + 118 => await BotCallbackAsync(opcode, request.Payload, cancellationToken), + 145 => await GetBotInfoAsync(opcode, request.Payload, cancellationToken), + 161 => await SubmitComplaintAsync(opcode, request.Payload, cancellationToken), + 199 => await DeleteOwnAccountAsync(opcode, request.Payload, cancellationToken), + 218 => await DeleteOwnStoriesAsync(opcode, request.Payload, cancellationToken), + 257 or 258 => await ReactionSettingsAsync(opcode, request.Payload, cancellationToken), + 162 => await GetComplaintReasonsAsync(opcode, cancellationToken), + 178 => await SetReactionAsync(opcode, request.Payload, cancellationToken), + 179 => await CancelReactionAsync(opcode, request.Payload, cancellationToken), + 181 => await GetDetailedReactionsAsync(opcode, request.Payload, cancellationToken), + 198 => await GetCommonChatsAsync(opcode, request.Payload, cancellationToken), + 202 => await GetTranscriptionAsync(opcode, request.Payload, cancellationToken), + 208 or 210 => await GetStoriesAsync(opcode, request.Payload, cancellationToken), + 214 => await MarkStoryAsync(opcode, request.Payload, cancellationToken), + 215 => await SendStoryAsync(opcode, request.Payload, cancellationToken), + 272 or 273 or 274 or 275 or 276 => await HandleFoldersAsync(opcode, request.Payload, cancellationToken), + 290 => await ApproveQrLoginAsync(opcode, request.Payload, cancellationToken), + 302 => await GetBannersAsync(opcode, cancellationToken), + 304 => await SendPollVoteAsync(opcode, request.Payload, cancellationToken), + 306 => await GetPollUpdatesAsync(opcode, request.Payload, cancellationToken), + _ => StatusCode( + StatusCodes.Status501NotImplemented, + QlyraCompatibilityResponse.Error( + opcode, + "IKAR_OPCODE_NOT_IMPLEMENTED", + $"IKAR compatibility endpoint for opcode {opcode} is not implemented.")) + }; + } + catch (InvalidOperationException exception) when ( + exception.Message == "Authenticated user id claim is missing.") + { + return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", exception.Message)); + } + } + + private async Task RequestPhoneCodeAsync( + int opcode, + JsonElement payload, + CancellationToken cancellationToken) + { + var phone = ReadString(payload, "phone"); + var normalizedPhone = PhoneNumberNormalizer.Normalize(phone); + if (normalizedPhone is null) + { + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "INVALID_PHONE", "Valid phone number is required.")); + } + + var user = await dbContext.Users.AsNoTracking().SingleOrDefaultAsync( + x => x.NormalizedPhoneNumber == normalizedPhone, + cancellationToken); + var installationId = ReadString(payload, "installationId")?.Trim().ToLowerInvariant(); + var publicKey = ReadString(payload, "devicePublicKey")?.Trim(); + var publicKeyIsValid = !string.IsNullOrWhiteSpace(publicKey) && + publicKey.Length <= 2048 && + DeviceKeyVerifier.IsValidPublicKey(publicKey); + if (!publicKeyIsValid) + { + logger.LogWarning( + "Rejected IKAR device key. JsonKind={JsonKind}, EncodedLength={EncodedLength}", + payload.TryGetProperty("devicePublicKey", out var rawPublicKey) ? rawPublicKey.ValueKind : JsonValueKind.Undefined, + publicKey?.Length ?? 0); + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "DEVICE_KEY_REQUIRED", "A valid IKAR P-256 public key is required.")); + } + + if (!IsInstallationIdValid(installationId)) + { + installationId = DeviceKeyVerifier.DeriveInstallationId(publicKey!); + } + + if (user?.IsBlocked == true) + { + return StatusCode(StatusCodes.Status423Locked, QlyraCompatibilityResponse.Error(opcode, "USER_BLOCKED", "User is blocked.")); + } + + var credential = await dbContext.DeviceCredentials.AsNoTracking() + .SingleOrDefaultAsync(device => device.InstallationId == installationId, cancellationToken); + var bindCredential = false; + if (user is null) + { + if (credential is not null) + { + return Conflict(QlyraCompatibilityResponse.Error(opcode, "DEVICE_ALREADY_BOUND", "This IKAR device is already bound to another account.")); + } + + bindCredential = true; + } + else if (credential is null) + { + var trustedLegacyDevice = await dbContext.PushDevices.AsNoTracking().AnyAsync( + device => device.UserId == user.Id && device.InstallationId == installationId, + cancellationToken); + if (!trustedLegacyDevice) + { + return Conflict(QlyraCompatibilityResponse.Error(opcode, "DEVICE_NOT_TRUSTED", "This device is not linked to the IKAR account.")); + } + + bindCredential = true; + } + else if (credential.UserId != user.Id || + !CryptographicOperations.FixedTimeEquals( + SHA256.HashData(Convert.FromBase64String(credential.PublicKey)), + SHA256.HashData(Convert.FromBase64String(publicKey!)))) + { + return Conflict(QlyraCompatibilityResponse.Error(opcode, "DEVICE_KEY_MISMATCH", "The device key does not match the registered IKAR device.")); + } + + var challenge = deviceAuthChallengeStore.Create( + normalizedPhone, + installationId!, + publicKey!, + isRegistered: user is not null, + bindCredential); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new + { + token = challenge.Id, + expiresAt = challenge.ExpiresAt.ToUnixTimeMilliseconds(), + isRegistered = challenge.IsRegistered, + signaturePayload = challenge.SignaturePayload, + authMethod = "DEVICE_KEY" + })); + } + + private async Task VerifyPhoneCodeAsync( + int opcode, + JsonElement payload, + CancellationToken cancellationToken) + { + var challengeId = ReadString(payload, "token") ?? string.Empty; + var challenge = deviceAuthChallengeStore.Get(challengeId); + if (challenge is null) + { + return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "INVALID_DEVICE_CHALLENGE", "The device challenge is invalid or expired.")); + } + var signature = ReadString(payload, "deviceSignature") ?? string.Empty; + if (!DeviceKeyVerifier.Verify(challenge.PublicKey, challenge.SignaturePayload, signature)) + { + return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "INVALID_DEVICE_SIGNATURE", "The device signature is invalid.")); + } + if (!deviceAuthChallengeStore.Consume(challengeId, challenge)) + { + return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "DEVICE_CHALLENGE_USED", "The device challenge has already been used.")); + } + + var user = await dbContext.Users.SingleOrDefaultAsync( + x => x.NormalizedPhoneNumber == challenge.NormalizedPhoneNumber, + cancellationToken); + if (!challenge.IsRegistered) + { + if (user is not null || await dbContext.DeviceCredentials.AnyAsync( + device => device.InstallationId == challenge.InstallationId, + cancellationToken)) + { + return Conflict(QlyraCompatibilityResponse.Error(opcode, "REGISTRATION_CONFLICT", "The phone number or device is no longer available for registration.")); + } + + user = await CreatePhoneRegistrationUserAsync(challenge.NormalizedPhoneNumber, cancellationToken); + dbContext.DeviceCredentials.Add(new DeviceCredential + { + UserId = user.Id, + InstallationId = challenge.InstallationId, + PublicKey = challenge.PublicKey + }); + await dbContext.SaveChangesAsync(cancellationToken); + + var registrationSession = await tokenService.CreateSessionAsync( + user, + ClientVersionRequestReader.Read(Request), + cancellationToken); + var registrationAccountId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new + { + tokenAttrs = new Dictionary { ["REGISTER"] = new { token = registrationSession.AccessToken, refreshToken = registrationSession.RefreshToken } }, + accountId = registrationAccountId, + presetAvatars = Array.Empty() + })); + } + if (user is null || user.IsBlocked) + { + return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "Account is unavailable.")); + } + + var registeredCredential = await dbContext.DeviceCredentials.SingleOrDefaultAsync( + device => device.InstallationId == challenge.InstallationId && device.UserId == user.Id, + cancellationToken); + if (registeredCredential is null) + { + if (!challenge.BindCredential) + { + return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "DEVICE_NOT_TRUSTED", "The IKAR device credential is unavailable.")); + } + + registeredCredential = new DeviceCredential + { + UserId = user.Id, + InstallationId = challenge.InstallationId, + PublicKey = challenge.PublicKey + }; + dbContext.DeviceCredentials.Add(registeredCredential); + } + else if (!string.Equals(registeredCredential.PublicKey, challenge.PublicKey, StringComparison.Ordinal)) + { + return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "DEVICE_KEY_MISMATCH", "The registered IKAR device key has changed.")); + } + registeredCredential.LastUsedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + + var session = await tokenService.CreateSessionAsync( + user, + ClientVersionRequestReader.Read(Request), + cancellationToken); + var accountId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new + { + tokenAttrs = new Dictionary { ["LOGIN"] = new { token = session.AccessToken, refreshToken = session.RefreshToken } }, + accountId, + profile = await BuildProfileAsync(user, cancellationToken) + })); + } + + private async Task LoginAsync(int opcode, CancellationToken cancellationToken) + { + if (User.Identity?.IsAuthenticated != true) + { + return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + } + + var userId = User.GetRequiredUserId(); + var user = await dbContext.Users.SingleOrDefaultAsync(x => x.Id == userId, cancellationToken); + if (user is null || user.IsBlocked) + { + return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "Account is unavailable.")); + } + + var accountId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken); + var payload = await BuildLoginPayloadAsync(user, accountId, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, payload)); + } + + private async Task CompleteRegistrationAsync( + int opcode, + JsonElement payload, + CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null || user.IsBlocked) + { + return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR registration token is required.")); + } + + var firstName = ReadString(payload, "firstName")?.Trim(); + var lastName = ReadString(payload, "lastName")?.Trim(); + if (string.IsNullOrWhiteSpace(firstName) || firstName.Length > 120 || (lastName?.Length ?? 0) > 120) + { + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "PROFILE_NAME_INVALID", "A valid first name is required.")); + } + + user.DisplayName = string.Join(' ', new[] { firstName, lastName }.Where(value => !string.IsNullOrWhiteSpace(value))); + await dbContext.SaveChangesAsync(cancellationToken); + var issued = await tokenService.CreateSessionAsync(user, ClientVersionRequestReader.Read(Request), cancellationToken); + await tokenService.RevokeSessionAsync(User.GetRequiredSessionId(), cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new + { + token = issued.AccessToken, + refreshToken = issued.RefreshToken, + tokenType = "LOGIN", + accountId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken), + profile = await BuildProfileAsync(user, cancellationToken) + })); + } + + private async Task LogoutAsync(int opcode, CancellationToken cancellationToken) + { + if (User.Identity?.IsAuthenticated != true) + { + return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + } + + await tokenService.RevokeSessionAsync(User.GetRequiredSessionId(), cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { })); + } + + private async Task SyncAsync(int opcode, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var accountId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, await BuildLoginPayloadAsync(user, accountId, cancellationToken))); + } + + private async Task ConfigureAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var pushToken = ReadString(payload, "pushToken")?.Trim(); + if (!string.IsNullOrWhiteSpace(pushToken)) + { + var credential = await dbContext.DeviceCredentials + .Where(device => device.UserId == user.Id) + .OrderByDescending(device => device.LastUsedAt) + .FirstOrDefaultAsync(cancellationToken); + if (credential is null) + { + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "WRONG_DEVICE_TOKEN", "The IKAR device is not registered.")); + } + + var device = await dbContext.PushDevices.SingleOrDefaultAsync( + item => item.InstallationId == credential.InstallationId, + cancellationToken); + if (device is null) + { + device = new PushDevice + { + UserId = user.Id, + Platform = PushPlatform.Android, + InstallationId = credential.InstallationId, + DeviceToken = pushToken, + NotificationsEnabled = true, + UpdatedAt = DateTimeOffset.UtcNow + }; + dbContext.PushDevices.Add(device); + } + else + { + device.UserId = user.Id; + device.Platform = PushPlatform.Android; + device.DeviceToken = pushToken; + device.NotificationsEnabled = true; + device.UpdatedAt = DateTimeOffset.UtcNow; + } + ClientVersionRequestReader.Apply(device, ClientVersionRequestReader.Read(Request)); + await dbContext.SaveChangesAsync(cancellationToken); + } + + var privacy = await GetOrCreatePrivacySettingsAsync(user.Id, cancellationToken); + var userConfig = BuildUserConfig(privacy); + if (payload.ValueKind == JsonValueKind.Object && + payload.TryGetProperty("settings", out var requestedSettings) && + requestedSettings.ValueKind == JsonValueKind.Object) + { + if (requestedSettings.TryGetProperty("user", out var requestedUser) && requestedUser.ValueKind == JsonValueKind.Object) + { + foreach (var property in requestedUser.EnumerateObject()) + { + if (property.Value.ValueKind is JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False or JsonValueKind.Null) + { + userConfig[property.Name] = ReadConfigValue(property.Value); + } + } + ApplyPrivacyConfig(privacy, userConfig); + privacy.QlyraConfigJson = JsonSerializer.Serialize(userConfig); + privacy.UpdatedAt = DateTimeOffset.UtcNow; + } + + if (requestedSettings.TryGetProperty("chats", out var requestedChats) && requestedChats.ValueKind == JsonValueKind.Object) + { + foreach (var property in requestedChats.EnumerateObject()) + { + if (!int.TryParse(property.Name, out var clientChatId) || property.Value.ValueKind != JsonValueKind.Object) continue; + var membershipChat = await ResolveMemberChatAsync(user.Id, clientChatId, cancellationToken); + if (membershipChat is null) continue; + var membership = membershipChat.Members.Single(member => member.UserId == user.Id); + var dontDisturbUntil = ReadLong(property.Value, "dontDisturbUntil"); + if (dontDisturbUntil is null) continue; + membership.MutedUntil = ToMutedUntil(dontDisturbUntil.Value); + } + } + await dbContext.SaveChangesAsync(cancellationToken); + } + + return Ok(QlyraCompatibilityResponse.Ok(opcode, new + { + hash = "ikar", + user = userConfig, + chats = await BuildChatConfigAsync(user.Id, cancellationToken) + })); + } + + private async Task GetAssetsAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) + { + return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + } + + var settings = await dbContext.UserPrivacySettings.SingleOrDefaultAsync(item => item.UserId == user.Id, cancellationToken); + var favorites = DeserializeStoredIntList(settings?.QlyraFavoriteStickerSetIdsJson ?? "[]").Where(id => id > 0).Distinct().ToList(); + if (opcode is 29 or 259) + { + if (!string.Equals(ReadString(payload, "type"), "FAVORITE_STICKER_SET", StringComparison.OrdinalIgnoreCase)) + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "ASSET_TYPE_INVALID", "Only favorite sticker sets can be modified.")); + + var changedIds = opcode == 29 + ? (ReadInt(payload, "id") is int requestedSetId && requestedSetId > 0 ? new[] { requestedSetId } : []) + : ReadIntList(payload, "ids").Where(id => id > 0).Distinct().ToArray(); + if (changedIds.Length == 0) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "ASSET_ID_REQUIRED", "At least one sticker set id is required.")); + + settings ??= new UserPrivacySettings { UserId = user.Id }; + if (dbContext.Entry(settings).State == EntityState.Detached) dbContext.UserPrivacySettings.Add(settings); + if (opcode == 29) + { + foreach (var changedId in changedIds) if (!favorites.Contains(changedId)) favorites.Add(changedId); + } + else + { + favorites.RemoveAll(changedIds.Contains); + } + settings.QlyraFavoriteStickerSetIdsJson = JsonSerializer.Serialize(favorites); + settings.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + foreach (var changedId in changedIds) + { + await PublishPacketAsync([user.Id], 150, new + { + type = "FAVORITE_STICKER_SET", + id = changedId, + updateType = opcode == 29 ? "ADDED" : "REMOVED" + }, cancellationToken); + } + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true })); + } + + var sections = opcode == 27 && string.Equals(ReadString(payload, "type"), "FAVORITE_STICKER", StringComparison.OrdinalIgnoreCase) + ? new object[] { new { id = "FAVORITE_STICKER_SETS", stickerSets = favorites.ToArray(), marker = 0 } } + : opcode == 27 && string.Equals(ReadString(payload, "type"), "STICKER", StringComparison.OrdinalIgnoreCase) + ? [new { id = "NEW_STICKER_SETS", stickerSets = new[] { 900000 }, marker = 0 }] + : []; + var stickerCodes = new[] { "1f600", "1f60a", "1f607", "1f44b", "1f44d", "1f44c", "1f49b", "1f525" }; + var stickerTags = new[] { "😀", "😊", "😇", "👋", "👍", "👌", "💛", "🔥" }; + var requested = ReadIntList(payload, "ids"); + object[] stickerSets = opcode == 28 && ReadString(payload, "type") == "STICKER_SET" && requested.Contains(900000) + ? [new { id = 900000, name = "ИКАР: эмоции", iconUrl = BuildPublicUrl("/stickers/1f600.png"), stickers = Enumerable.Range(900001, 8).ToArray() }] + : []; + var stickers = opcode == 28 && ReadString(payload, "type") == "STICKER" + ? Enumerable.Range(0, 8).Where(index => requested.Contains(900001 + index)).Select(index => new { + id = 900001 + index, setId = 900000, url = BuildPublicUrl($"/stickers/{stickerCodes[index]}.png"), width = 128, height = 128, tags = new[] { stickerTags[index] } + }).Cast().ToArray() + : []; + return Ok(QlyraCompatibilityResponse.Ok(opcode, new + { + sections, + stickerSets, + stickers, + emojis = Array.Empty(), + marker = 0, + success = true + })); + } + + private async Task HandleFoldersAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) + { + return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + } + + var now = DateTimeOffset.UtcNow; + if (opcode == 274) + { + var id = ReadString(payload, "id")?.Trim(); + var title = ReadString(payload, "title")?.Trim(); + if (string.IsNullOrWhiteSpace(id) || id.Length > 64 || string.IsNullOrWhiteSpace(title) || title.Length > 20) + { + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "FOLDER_INVALID", "Folder id and a title up to 20 characters are required.")); + } + var folder = await dbContext.QlyraChatFolders.SingleOrDefaultAsync(item => item.UserId == user.Id && item.Id == id, cancellationToken); + if (folder is null) + { + var nextOrder = await dbContext.QlyraChatFolders.Where(item => item.UserId == user.Id).Select(item => (int?)item.SortOrder).MaxAsync(cancellationToken) ?? -1; + folder = new QlyraChatFolder { UserId = user.Id, Id = id, SortOrder = nextOrder + 1 }; + dbContext.QlyraChatFolders.Add(folder); + } + folder.Title = title; + folder.Emoji = ReadString(payload, "emoji")?.Trim(); + folder.IncludeJson = JsonSerializer.Serialize(ReadFlexibleIntList(payload, "include").Distinct()); + folder.FiltersJson = JsonSerializer.Serialize(ReadFlexibleIntList(payload, "filters").Distinct()); + folder.OptionsJson = JsonSerializer.Serialize(ReadFlexibleIntList(payload, "options").Distinct()); + folder.FavoritesJson = JsonSerializer.Serialize(ReadFlexibleIntList(payload, "favorites").Distinct()); + folder.UpdatedAt = now; + await dbContext.SaveChangesAsync(cancellationToken); + var mapped = BuildQlyraFolder(folder); + var responsePayload = new { folder = mapped, folderSync = now.ToUnixTimeMilliseconds() }; + await PublishPacketAsync([user.Id], 277, responsePayload, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, responsePayload)); + } + + if (opcode == 275) + { + var requestedOrder = ReadStringList(payload, "foldersOrder"); + var folders = await dbContext.QlyraChatFolders.Where(item => item.UserId == user.Id).ToListAsync(cancellationToken); + var byId = folders.ToDictionary(item => item.Id, StringComparer.Ordinal); + var ordered = requestedOrder.Where(byId.ContainsKey).Distinct(StringComparer.Ordinal).ToList(); + ordered.AddRange(folders.OrderBy(item => item.SortOrder).Select(item => item.Id).Where(id => !ordered.Contains(id, StringComparer.Ordinal))); + for (var index = 0; index < ordered.Count; index++) + { + byId[ordered[index]].SortOrder = index; + byId[ordered[index]].UpdatedAt = now; + } + await dbContext.SaveChangesAsync(cancellationToken); + var responsePayload = new { foldersOrder = ordered, folderSync = now.ToUnixTimeMilliseconds() }; + await PublishPacketAsync([user.Id], 277, responsePayload, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, responsePayload)); + } + + if (opcode == 276) + { + var ids = ReadStringList(payload, "folderIds").ToHashSet(StringComparer.Ordinal); + var folders = await dbContext.QlyraChatFolders.Where(item => item.UserId == user.Id && ids.Contains(item.Id)).ToListAsync(cancellationToken); + dbContext.QlyraChatFolders.RemoveRange(folders); + await dbContext.SaveChangesAsync(cancellationToken); + var remainingOrder = await dbContext.QlyraChatFolders.Where(item => item.UserId == user.Id).OrderBy(item => item.SortOrder).Select(item => item.Id).ToListAsync(cancellationToken); + var responsePayload = new { foldersOrder = remainingOrder, folderSync = now.ToUnixTimeMilliseconds() }; + await PublishPacketAsync([user.Id], 277, responsePayload, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, responsePayload)); + } + + var query = dbContext.QlyraChatFolders.AsNoTracking().Where(item => item.UserId == user.Id); + if (opcode == 273) + { + var ids = ReadStringList(payload, "folderIds").ToHashSet(StringComparer.Ordinal); + query = query.Where(item => ids.Contains(item.Id)); + } + var stored = await query.OrderBy(item => item.SortOrder).ToListAsync(cancellationToken); + var allOrder = await dbContext.QlyraChatFolders.AsNoTracking().Where(item => item.UserId == user.Id).OrderBy(item => item.SortOrder).Select(item => item.Id).ToListAsync(cancellationToken); + var sync = stored.Count == 0 ? 0 : stored.Max(item => item.UpdatedAt.ToUnixTimeMilliseconds()); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { folders = stored.Select(BuildQlyraFolder).ToArray(), foldersOrder = allOrder, folderSync = sync })); + } + + private async Task GetBannersAsync(int opcode, CancellationToken cancellationToken) + { + if (await RequireCurrentUserAsync(cancellationToken) is null) + { + return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + } + return Ok(QlyraCompatibilityResponse.Ok(opcode, new + { + banners = Array.Empty(), + bannersSync = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + })); + } + + private async Task GetStoriesAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var peerIds = await LoadStoryPeerAuthorIdsAsync(user.Id, cancellationToken); + var now = DateTimeOffset.UtcNow; + var query = dbContext.Stories + .AsNoTracking() + .Include(story => story.Attachments) + .Include(story => story.Views) + .Where(story => story.DeletedAt == null && story.ExpiresAt > now) + .WhereVisibleTo(user.Id, peerIds); + if (opcode == 210) + { + var requestedOwnerIds = new HashSet(); + if (payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("owners", out var owners) && owners.ValueKind == JsonValueKind.Array) + { + foreach (var owner in owners.EnumerateArray()) + { + var clientOwnerId = ReadInt(owner, "ownerId"); + if (clientOwnerId is null) continue; + var ownerId = await clientIdMapper.FindEntityIdAsync("user", clientOwnerId.Value, cancellationToken); + if (ownerId is not null) requestedOwnerIds.Add(ownerId.Value); + } + } + if (requestedOwnerIds.Count > 0) query = query.Where(story => requestedOwnerIds.Contains(story.AuthorUserId)); + } + var count = Math.Clamp(ReadInt(payload, "count") ?? 100, 1, 100); + var stories = await query.OrderByDescending(story => story.CreatedAt).Take(count).ToListAsync(cancellationToken); + var previews = await BuildStoryPreviewsAsync(stories, user.Id, cancellationToken); + if (opcode == 208) + { + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { storiesPreviews = previews, cursor = string.Empty })); + } + var peers = new List(); + foreach (var group in stories.GroupBy(story => story.AuthorUserId)) + { + var owner = await BuildStoryOwnerAsync(group.Key, cancellationToken); + var mappedStories = new List(); + foreach (var story in group.OrderBy(story => story.CreatedAt)) mappedStories.Add(await BuildStoryAsync(story, cancellationToken)); + peers.Add(new { owner, stories = mappedStories }); + } + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { storiesPreviews = previews, peerStories = peers })); + } + + private async Task MarkStoryAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var clientStoryId = ReadInt(payload, "storyId"); + var storyId = clientStoryId is null ? null : await clientIdMapper.FindEntityIdAsync("story", clientStoryId.Value, cancellationToken); + if (storyId is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "STORY_NOT_FOUND", "Story was not found.")); + var peerIds = await LoadStoryPeerAuthorIdsAsync(user.Id, cancellationToken); + var now = DateTimeOffset.UtcNow; + var story = await dbContext.Stories.Include(item => item.Views) + .Where(item => item.Id == storyId && item.DeletedAt == null && item.ExpiresAt > now) + .WhereVisibleTo(user.Id, peerIds) + .SingleOrDefaultAsync(cancellationToken); + if (story is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "STORY_NOT_FOUND", "Story was not found.")); + if (story.AuthorUserId != user.Id && story.Views.All(view => view.ViewerUserId != user.Id)) + { + story.Views.Add(new StoryView { StoryId = story.Id, ViewerUserId = user.Id, ViewedAt = DateTimeOffset.UtcNow }); + await dbContext.SaveChangesAsync(cancellationToken); + } + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true })); + } + + private async Task CallFeatureAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + if (qlyraCallStore is null) return StatusCode(503, QlyraCompatibilityResponse.Error(opcode, "CALL_TRANSPORT_UNAVAILABLE", "Call transport is unavailable.")); + var callerClientId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken); + var conversationId = ReadString(payload, "conversationId") ?? Guid.NewGuid().ToString("D"); + QlyraCallConversation conversation; + QlyraCallParticipant participant; + if (opcode == 78) + { + var calleeClientId = ReadIntList(payload, "calleeIds").FirstOrDefault(); + var calleeId = await clientIdMapper.FindEntityIdAsync("user", calleeClientId, cancellationToken); + if (calleeId is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CALL_USER_NOT_FOUND", "Call recipient was not found.")); + conversation = qlyraCallStore.Create(conversationId, ReadBool(payload, "isVideo") == true, (user.Id, callerClientId), (calleeId.Value, calleeClientId)); + participant = conversation.Participants[user.Id]; + var callee = conversation.Participants[calleeId.Value]; + var vcp = BuildCallVcp(callee.Token, conversation.IsVideo, callee.ClientId); + await PublishPacketAsync([calleeId.Value], 137, new { vcp, conversationId, callerId = callerClientId, type = conversation.IsVideo ? "VIDEO" : "AUDIO", country = "RU", isContact = true }, cancellationToken); + if (pushNotificationSender is not null) + { + var devices = await dbContext.PushDevices.AsNoTracking() + .Where(device => device.UserId == calleeId.Value && device.NotificationsEnabled) + .ToListAsync(cancellationToken); + await pushNotificationSender.SendQlyraIncomingCallAsync( + new QlyraIncomingCallPushDto(vcp, conversationId, callerClientId, user.DisplayName, callee.ClientId, conversation.IsVideo), + devices, + cancellationToken); + } + } + else if (opcode == 76) + { + conversation = qlyraCallStore.Create(conversationId, false, (user.Id, callerClientId)); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { conversationId, joinLink = conversation.JoinToken, callName = (string?)null })); + } + else if (opcode == 84) + { + conversation = qlyraCallStore.Find(conversationId)!; + if (conversation is null || !conversation.Participants.ContainsKey(user.Id)) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CALL_NOT_FOUND", "Call was not found.")); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { joinLink = conversation.JoinToken })); + } + else + { + var joinToken = (ReadString(payload, "joinLink") ?? string.Empty).Trim().TrimEnd('/').Split('/').Last(); + conversation = qlyraCallStore.FindByJoinToken(joinToken)!; + if (conversation is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CALL_LINK_NOT_FOUND", "Call link was not found.")); + participant = qlyraCallStore.AddParticipant(conversation, user.Id, callerClientId); + } + var endpoint = BuildCallEndpoint(participant.Token, conversation.Id, participant.ClientId); + var internalParams = JsonSerializer.Serialize(new { endpoint, id = new { @internal = participant.ClientId, external = participant.ClientId } }); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { conversationId = conversation.Id, internalCallerParams = internalParams, internalParams, joinLink = conversation.JoinToken })); + } + + private async Task CallHistoryAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var memberChatIds = dbContext.ChatMembers.Where(member => member.UserId == user.Id).Select(member => member.ChatId); + if (opcode == 164) + { + var clientIds = ReadIntList(payload, "historyIds"); + var hidden = await dbContext.MessageHiddenForUsers.Where(item => item.UserId == user.Id).Select(item => item.MessageId).ToListAsync(cancellationToken); + var hiddenSet = hidden.ToHashSet(); + foreach (var clientId in clientIds) + { + var messageId = await clientIdMapper.FindEntityIdAsync("message", clientId, cancellationToken); + if (messageId is null || !hiddenSet.Add(messageId.Value)) continue; + var belongs = await dbContext.Messages.AnyAsync(message => message.Id == messageId && memberChatIds.Contains(message.ChatId), cancellationToken); + if (belongs) dbContext.MessageHiddenForUsers.Add(new MessageHiddenForUser { MessageId = messageId.Value, UserId = user.Id }); + } + await dbContext.SaveChangesAsync(cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true })); + } + var hiddenIds = dbContext.MessageHiddenForUsers.Where(item => item.UserId == user.Id).Select(item => item.MessageId); + var messages = await dbContext.Messages.AsNoTracking().Include(message => message.Sender).Include(message => message.Attachments) + .Where(message => memberChatIds.Contains(message.ChatId) && message.DeletedAt == null && !hiddenIds.Contains(message.Id) && message.QlyraAttachesJson != null && message.QlyraAttachesJson.Contains("\"CALL\"")) + .OrderByDescending(message => message.SentAt).Take(100).ToListAsync(cancellationToken); + var history = new List(); + foreach (var message in messages) history.Add(new { message = await BuildMessageAsync(message, cancellationToken) }); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { history, success = true })); + } + + private async Task RequestUploadAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var count = Math.Clamp(ReadInt(payload, "count") ?? 1, 1, 10); + var mediaKind = opcode switch + { + 80 => "photo.jpg", + 82 when ReadInt(payload, "type") == 2 => "audio.ogg", + 82 when ReadInt(payload, "type") == 1 => "video-note.mp4", + 82 => "video.mp4", + _ => "file" + }; + var reservations = Enumerable.Range(0, count).Select(_ => uploadStore.Reserve(user.Id, mediaKind)).ToArray(); + string Url(UploadReservation reservation) => BuildPublicUrl($"/api/ikar/uploads/{reservation.Token}"); + if (opcode == 80) + { + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { url = Url(reservations[0]) })); + } + var info = new List(reservations.Length); + foreach (var reservation in reservations) + { + var uploadId = await clientIdMapper.GetOrCreateAsync("upload", reservation.Id, cancellationToken); + info.Add(opcode == 87 + ? new { url = Url(reservation), fileId = uploadId, token = reservation.Token } + : new { url = Url(reservation), videoId = uploadId, token = reservation.Token }); + } + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { info })); + } + + private async Task ResolveMediaAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found.")); + MessageAttachment? attachment = null; + var clientAttachmentId = ReadInt(payload, opcode == 83 ? "videoId" : "fileId"); + if (clientAttachmentId is not null) + { + var attachmentId = await clientIdMapper.FindEntityIdAsync("attachment", clientAttachmentId.Value, cancellationToken); + if (attachmentId is not null) attachment = await dbContext.MessageAttachments.SingleOrDefaultAsync(item => item.Id == attachmentId && item.Message.ChatId == chat.Id, cancellationToken); + } + if (attachment is null && ReadInt(payload, "messageId") is int clientMessageId) + { + var message = await ResolveMessageAsync(chat.Id, clientMessageId, cancellationToken); + if (message is not null) attachment = await dbContext.MessageAttachments.Where(item => item.MessageId == message.Id).OrderBy(item => item.SortOrder).FirstOrDefaultAsync(cancellationToken); + } + if (attachment is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "MEDIA_NOT_FOUND", "Media was not found.")); + var url = BuildPublicUrl($"/api/chats/{chat.Id}/attachments/{attachment.Id}/content"); + // Dictionary keys retain the protocol's spelling under ASP.NET's camel-case serializer. + // The original uploaded file is a direct source, not an HLS playlist. + return Ok(QlyraCompatibilityResponse.Ok(opcode, opcode == 83 + ? new Dictionary { ["EXTERNAL"] = url } + : new Dictionary { ["url"] = url })); + } + + private async Task GetLinkInfoAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + if (await RequireCurrentUserAsync(cancellationToken) is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var rawLink = ReadString(payload, "link")?.Trim(); + if (string.IsNullOrWhiteSpace(rawLink)) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "LINK_REQUIRED", "A link is required.")); + var key = ExtractLinkKey(rawLink); + var call = qlyraCallStore?.FindByJoinToken(key); + if (call is not null) + { + return Ok(QlyraCompatibilityResponse.Ok(opcode, new + { + videoConference = new + { + conferenceId = call.Id, + callName = (string?)null, + participantsCount = call.Participants.Count, + callType = call.IsVideo ? "VIDEO" : "AUDIO" + } + })); + } + var chat = await dbContext.Chats.Include(item => item.Members).ThenInclude(member => member.User).Include(item => item.Messages).ThenInclude(message => message.Sender).Include(item => item.InviteLinks) + .SingleOrDefaultAsync(item => item.PublicUsername == key || item.InviteLinks.Any(link => link.Token == key && !link.IsRevoked), cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "LINK_NOT_FOUND", "Link was not found.")); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { chat = (await BuildChatsAsync([chat], cancellationToken)).Single() })); + } + + private async Task GetCommentsInfoAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found.")); + var updates = new List(); + foreach (var clientPostId in ReadFlexibleIntList(payload, "postIds")) + { + var post = await ResolveMessageAsync(chat.Id, clientPostId, cancellationToken); + updates.Add(new { postId = clientPostId, commentsInfo = new { totalCount = post?.CommentCount ?? 0 } }); + } + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { commentsInfoUpdates = updates })); + } + + private async Task TwoFactorAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + RemoveExpiredTwoFactorTracks(); + if (opcode == 112) + { + var created = new QlyraTwoFactorTrack(user.Id); + TwoFactorTracks[created.Id] = created; + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { trackId = created.Id })); + } + + var trackId = ReadString(payload, "trackId"); + if (string.IsNullOrWhiteSpace(trackId) || !TwoFactorTracks.TryGetValue(trackId, out var track) || track.UserId != user.Id || track.ExpiresAt <= DateTimeOffset.UtcNow) + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "TRACK_INVALID", "The two-factor setup track is invalid or expired.")); + var settings = await dbContext.UserTwoFactorSettings.SingleOrDefaultAsync(item => item.UserId == user.Id, cancellationToken); + if (opcode == 104) return Ok(QlyraCompatibilityResponse.Ok(opcode, new { password = new { enabled = settings is not null, email = settings?.Email, hint = settings?.Hint } })); + + if (opcode == 107) + { + var password = ReadString(payload, "password") ?? string.Empty; + if (password.Length < 8) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "PASSWORD_TOO_SHORT", "Password must contain at least 8 characters.")); + track.CandidatePasswordHash = new PasswordHasher().HashPassword(settings ?? new UserTwoFactorSettings { UserId = user.Id }, password); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true })); + } + if (opcode == 108) + { + var hint = ReadString(payload, "hint")?.Trim(); + if (hint is { Length: > 100 }) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "HINT_TOO_LONG", "Password hint is too long.")); + track.Hint = hint; + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true })); + } + if (opcode == 109) return await SendTwoFactorEmailCodeAsync(opcode, payload, track, cancellationToken); + if (opcode == 110) + { + var code = ReadString(payload, "verifyCode") ?? string.Empty; + if (!track.IsValidEmailCode(code)) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "EMAIL_CODE_INVALID", "The email verification code is invalid or expired.")); + track.EmailVerified = true; + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { email = track.PendingEmail })); + } + if (opcode is 113 or 115) + { + if (settings is null) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "TWO_FACTOR_DISABLED", "Two-factor password is not enabled.")); + var password = ReadString(payload, "password") ?? string.Empty; + var valid = new PasswordHasher().VerifyHashedPassword(settings, settings.PasswordHash, password) != PasswordVerificationResult.Failed; + if (!valid) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "WRONG_PASSWORD", "The password is incorrect.")); + track.PasswordVerified = true; + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true })); + } + if (opcode != 111) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "TWO_FACTOR_REQUEST_INVALID", "Unsupported two-factor operation.")); + + if (ReadBool(payload, "remove2fa") == true) + { + if (settings is null || !track.PasswordVerified) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "PASSWORD_REQUIRED", "Verify the current password before removing two-factor protection.")); + dbContext.UserTwoFactorSettings.Remove(settings); + } + else + { + settings ??= new UserTwoFactorSettings { UserId = user.Id }; + if (dbContext.Entry(settings).State == EntityState.Detached) dbContext.UserTwoFactorSettings.Add(settings); + if (!string.IsNullOrWhiteSpace(track.CandidatePasswordHash)) settings.PasswordHash = track.CandidatePasswordHash; + if (string.IsNullOrWhiteSpace(settings.PasswordHash)) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "PASSWORD_REQUIRED", "Validate a password before enabling two-factor protection.")); + settings.Hint = ReadString(payload, "hint")?.Trim() ?? track.Hint ?? settings.Hint; + if (track.EmailVerified) settings.Email = track.PendingEmail; + settings.UpdatedAt = DateTimeOffset.UtcNow; + } + await dbContext.SaveChangesAsync(cancellationToken); + TwoFactorTracks.TryRemove(track.Id, out _); + var profile = await BuildProfileAsync(user, cancellationToken); + await PublishPacketAsync([user.Id], 159, new { profile }, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { profile })); + } + + private async Task BotCallbackAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found.")); + var message = await ResolveMessageAsync(chat.Id, ReadInt(payload, "messageId"), cancellationToken); + if (message is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "MESSAGE_NOT_FOUND", "Message not found.")); + var callbackId = ReadString(payload, "callbackId")?.Trim(); + var callbackPayload = ReadString(payload, "payload"); + if (string.IsNullOrWhiteSpace(callbackId)) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "CALLBACK_ID_REQUIRED", "Callback id is required.")); + if (!ContainsInlineKeyboardCallback(message.QlyraAttachesJson, callbackId, callbackPayload)) + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "CALLBACK_INVALID", "The callback does not belong to this message.")); + + var senderBot = await dbContext.Bots + .SingleOrDefaultAsync(item => item.UserId == message.SenderId && item.IsEnabled, cancellationToken); + if (senderBot is null) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "CALLBACK_BOT_UNAVAILABLE", "The message bot is unavailable.")); + + await (botUpdateService ?? new BotUpdateService(dbContext)) + .EnqueueCallbackAsync(senderBot, message, user, callbackId, callbackPayload, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true, callbackId })); + } + + private static bool ContainsInlineKeyboardCallback(string? attachesJson, string callbackId, string? callbackPayload) + { + if (string.IsNullOrWhiteSpace(attachesJson)) return false; + try + { + using var document = JsonDocument.Parse(attachesJson); + if (document.RootElement.ValueKind != JsonValueKind.Array) return false; + foreach (var attach in document.RootElement.EnumerateArray()) + { + if (!string.Equals(ReadString(attach, "_type"), "INLINE_KEYBOARD", StringComparison.OrdinalIgnoreCase) || + !string.Equals(ReadString(attach, "callbackId"), callbackId, StringComparison.Ordinal)) continue; + if (!attach.TryGetProperty("keyboard", out var keyboard) || + keyboard.ValueKind != JsonValueKind.Object || + !keyboard.TryGetProperty("buttons", out var rows) || + rows.ValueKind != JsonValueKind.Array) return false; + foreach (var row in rows.EnumerateArray()) + { + if (row.ValueKind != JsonValueKind.Array) continue; + foreach (var button in row.EnumerateArray()) + { + if (button.ValueKind != JsonValueKind.Object) continue; + if (callbackPayload is null || string.Equals(ReadString(button, "payload"), callbackPayload, StringComparison.Ordinal)) return true; + } + } + } + } + catch (JsonException) + { + return false; + } + return false; + } + + private async Task SendTwoFactorEmailCodeAsync(int opcode, JsonElement payload, QlyraTwoFactorTrack track, CancellationToken cancellationToken) + { + var email = ReadString(payload, "email")?.Trim().ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(email) || !MailAddress.TryCreate(email, out _)) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "EMAIL_INVALID", "A valid email address is required.")); + if (emailCodeSender is null) return Conflict(QlyraCompatibilityResponse.Error(opcode, "EMAIL_CODE_UNAVAILABLE", "Email verification is not configured.")); + var code = RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6"); + track.SetEmailCode(email, code); + try + { + await emailCodeSender.SendLoginCodeAsync(email, code, track.EmailCodeExpiresAt!.Value, cancellationToken); + } + catch (InvalidOperationException exception) + { + return Conflict(QlyraCompatibilityResponse.Error(opcode, "EMAIL_CODE_UNAVAILABLE", exception.Message)); + } + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { blockingDuration = 60 })); + } + + private static void RemoveExpiredTwoFactorTracks() + { + var now = DateTimeOffset.UtcNow; + foreach (var item in TwoFactorTracks) + if (item.Value.ExpiresAt <= now) TwoFactorTracks.TryRemove(item.Key, out _); + } + + private async Task GetBotInfoAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + if (await RequireCurrentUserAsync(cancellationToken) is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var botUserId = await clientIdMapper.FindEntityIdAsync("user", ReadInt(payload, "botId") ?? 0, cancellationToken); + var bot = botUserId is null ? null : await dbContext.Bots.Include(item => item.User).Include(item => item.Commands).SingleOrDefaultAsync(item => item.UserId == botUserId && item.IsEnabled, cancellationToken); + if (bot is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "BOT_NOT_FOUND", "Bot was not found.")); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { contact = await BuildContactAsync(bot.User, cancellationToken), commands = bot.Commands.OrderBy(item => item.SortOrder).Select(item => new { name = item.Command.TrimStart('/'), description = item.Description }).ToArray() })); + } + + private async Task SubmitComplaintAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var reasonId = ReadInt(payload, "reasonId"); + var typeId = ReadInt(payload, "typeId"); + if (reasonId is null || typeId is null) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "COMPLAINT_INVALID", "Complaint reason and type are required.")); + dbContext.ModerationLogEntries.Add(new ModerationLogEntry { ActorUserId = user.Id, TargetType = ModerationLogTargetType.User, Action = "complaint.submitted", Reason = JsonSerializer.Serialize(new { reasonId, typeId, ids = ReadFlexibleIntList(payload, "ids"), parentId = ReadInt(payload, "parentId") }) }); + await dbContext.SaveChangesAsync(cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true })); + } + + private async Task GetComplaintReasonsAsync(int opcode, CancellationToken cancellationToken) + { + if (await RequireCurrentUserAsync(cancellationToken) is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var reasons = new[] { new { reasonId = 1, reasonTitle = "Спам" }, new { reasonId = 2, reasonTitle = "Оскорбления" }, new { reasonId = 3, reasonTitle = "Мошенничество" }, new { reasonId = 4, reasonTitle = "Другое" } }; + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { complains = new[] { new { typeId = 6, reasons }, new { typeId = 1, reasons }, new { typeId = 2, reasons } } })); + } + + private async Task GetTranscriptionAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user=await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var chat=await ResolveMemberChatAsync(user.Id,ReadInt(payload,"chatId"),cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode,"CHAT_NOT_FOUND","Чат не найден.")); + var message=await ResolveMessageAsync(chat.Id,ReadInt(payload,"messageId"),cancellationToken); + if (message is null || message.DeletedAt is not null) return NotFound(QlyraCompatibilityResponse.Error(opcode,"MESSAGE_NOT_FOUND","Сообщение не найдено.")); + if (message.IkarTranscriptText is not null) return Ok(QlyraCompatibilityResponse.Ok(opcode,new {transcriptionStatus=1,transcription=message.IkarTranscriptText})); + var audio=await dbContext.MessageAttachments.Where(x=>x.MessageId==message.Id && (x.Kind==AttachmentKind.VoiceNote || x.ContentType.StartsWith("audio/"))).FirstOrDefaultAsync(cancellationToken); + var queued=audio is not null && transcriptionService?.Enqueue(message.Id,audio.StoragePath)==true; + return Ok(QlyraCompatibilityResponse.Ok(opcode,new {transcriptionStatus=queued?0:-1,transcription=string.Empty})); + } + + private async Task SendStoryAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty("stories", out var requestedStories) || requestedStories.ValueKind != JsonValueKind.Array || requestedStories.GetArrayLength() == 0) + { + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "STORY_REQUIRED", "At least one story is required.")); + } + var created = new List(); + var cids = new Dictionary(); + foreach (var requested in requestedStories.EnumerateArray().Take(10)) + { + if (!requested.TryGetProperty("media", out var media) || media.ValueKind != JsonValueKind.Object) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "STORY_MEDIA_REQUIRED", "Story media is required.")); + var token = ReadString(media, "token") ?? ReadString(media, "photoToken"); + if (string.IsNullOrWhiteSpace(token)) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "STORY_MEDIA_REQUIRED", "Story media token is required.")); + var ttlMs = Math.Clamp(ReadLong(requested, "expiration") ?? 86_400_000L, 60_000L, 604_800_000L); + var story = new Story + { + AuthorUserId = user.Id, + Text = ReadString(requested, "text")?.Trim() ?? string.Empty, + Visibility = ReadInt(requested, "settings") == 2 ? StoryVisibility.Contacts : StoryVisibility.Everyone, + CreatedAt = DateTimeOffset.UtcNow, + ExpiresAt = DateTimeOffset.UtcNow.AddMilliseconds(ttlMs) + }; + var attachment = await uploadStore.MaterializeStoryAttachmentAsync(token, user.Id, story.Id, 0, cancellationToken); + if (attachment is null) return Conflict(QlyraCompatibilityResponse.Error(opcode, "ATTACHMENT_NOT_READY", "The story upload token is invalid or not ready.")); + story.Attachments.Add(attachment); + created.Add(story); + cids[story.Id] = ReadInt(requested, "cid") ?? 0; + } + dbContext.Stories.AddRange(created); + await dbContext.SaveChangesAsync(cancellationToken); + var mappedStories = new List(); + foreach (var story in created) mappedStories.Add(await BuildStoryAsync(story, cancellationToken, cids[story.Id])); + var now = DateTimeOffset.UtcNow; + var activeOwnStories = await dbContext.Stories.AsNoTracking().Include(story => story.Attachments).Include(story => story.Views) + .Where(story => story.AuthorUserId == user.Id && story.DeletedAt == null && story.ExpiresAt > now) + .ToListAsync(cancellationToken); + var preview = (await BuildStoryPreviewsAsync(activeOwnStories, user.Id, cancellationToken)).Single(); + var publicStories = activeOwnStories.Where(story => story.Visibility == StoryVisibility.Everyone).ToList(); + if (publicStories.Count > 0) + { + var publicPreview = (await BuildStoryPreviewsAsync(publicStories, user.Id, cancellationToken)).Single(); + await hubContext.Clients.All.SendAsync("QlyraPacket", new { opcode = 216, payload = new { storiesPreview = publicPreview } }, cancellationToken); + } + await PublishPacketAsync((await LoadStoryPeerAuthorIdsAsync(user.Id, cancellationToken)).Append(user.Id), 216, new { storiesPreview = preview }, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { storiesPreview = preview, stories = mappedStories })); + } + + private async Task SendPollVoteAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found.")); + var pollId = await clientIdMapper.FindEntityIdAsync("poll", ReadInt(payload, "pollId") ?? 0, cancellationToken); + var poll = pollId is null ? null : await dbContext.QlyraPolls.Include(item => item.Answers).Include(item => item.Votes).SingleOrDefaultAsync(item => item.Id == pollId && item.Message.ChatId == chat.Id, cancellationToken); + if (poll is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "POLL_NOT_FOUND", "Poll was not found.")); + var selected = ReadIntList(payload, "answersIds").Where(id => poll.Answers.Any(answer => answer.AnswerId == id)).Distinct().ToList(); + if ((poll.Settings & 1) == 0 && selected.Count > 1) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "POLL_SINGLE_CHOICE", "Only one answer can be selected.")); + dbContext.QlyraPollVotes.RemoveRange(poll.Votes.Where(vote => vote.UserId == user.Id)); + foreach (var answerId in selected) dbContext.QlyraPollVotes.Add(new QlyraPollVote { PollId = poll.Id, AnswerId = answerId, UserId = user.Id }); + poll.Version++; + await dbContext.SaveChangesAsync(cancellationToken); + poll = await dbContext.QlyraPolls.AsNoTracking().Include(item => item.Answers).Include(item => item.Votes).SingleAsync(item => item.Id == poll.Id, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { state = await BuildPollStateAsync(poll, user.Id, cancellationToken) })); + } + + private async Task GetPollUpdatesAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found.")); + var requestedIds = new List(); + if (payload.TryGetProperty("polls", out var requested) && requested.ValueKind == JsonValueKind.Array) + { + requestedIds.AddRange(requested.EnumerateArray().Select(item => ReadInt(item, "pollId") ?? 0).Where(id => id > 0)); + } + var entityIds = new List(); + foreach (var requestedId in requestedIds.Distinct()) + { + var id = await clientIdMapper.FindEntityIdAsync("poll", requestedId, cancellationToken); + if (id is not null) entityIds.Add(id.Value); + } + var entities = await dbContext.QlyraPolls.AsNoTracking().Include(item => item.Answers).Include(item => item.Votes).Where(item => entityIds.Contains(item.Id) && item.Message.ChatId == chat.Id).ToListAsync(cancellationToken); + var polls = new List(); + foreach (var poll in entities) + { + polls.Add(new { pollId = await clientIdMapper.GetOrCreateAsync("poll", poll.Id, cancellationToken), title = poll.Title, settings = poll.Settings, version = poll.Version, answers = poll.Answers.OrderBy(answer => answer.AnswerId).Select(answer => new { answerId = answer.AnswerId, text = answer.Text }).ToArray(), state = await BuildPollStateAsync(poll, user.Id, cancellationToken) }); + } + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { polls })); + } + + private async Task BuildPollStateAsync(QlyraPoll poll, Guid currentUserId, CancellationToken cancellationToken) + { + var totalVoters = poll.Votes.Select(vote => vote.UserId).Distinct().Count(); + var anonymous = (poll.Settings & 4) != 0; + var voterPreviewIds = new List(); + if (!anonymous) + { + foreach (var voterId in poll.Votes.Select(vote => vote.UserId).Distinct().Take(3)) voterPreviewIds.Add(await clientIdMapper.GetOrCreateAsync("user", voterId, cancellationToken)); + } + var result = new List(); + foreach (var answer in poll.Answers.OrderBy(answer => answer.AnswerId)) + { + var votes = poll.Votes.Where(vote => vote.AnswerId == answer.AnswerId).ToList(); + var voterIds = new List(); + if (!anonymous) + { + foreach (var vote in votes) voterIds.Add(await clientIdMapper.GetOrCreateAsync("user", vote.UserId, cancellationToken)); + } + result.Add(new { answerId = answer.AnswerId, voteCount = votes.Count, rate = totalVoters == 0 ? 0d : (double)votes.Count / totalVoters, votes = voterIds, options = votes.Any(vote => vote.UserId == currentUserId) ? 1 : 0 }); + } + return new { total = totalVoters, result, voterPreviewIds }; + } + + private async Task FeatureUnavailableAsync(int opcode, string errorKey, string message, CancellationToken cancellationToken) + { + if (await RequireCurrentUserAsync(cancellationToken) is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + return StatusCode(StatusCodes.Status409Conflict, QlyraCompatibilityResponse.Error(opcode, errorKey, message)); + } + + private async Task ApproveQrLoginAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + if (webPushSessionStore is null) return StatusCode(StatusCodes.Status503ServiceUnavailable, QlyraCompatibilityResponse.Error(opcode, "QR_LOGIN_UNAVAILABLE", "QR login service is unavailable.")); + var qrLink = ReadString(payload, "qrLink"); + if (string.IsNullOrWhiteSpace(qrLink)) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "QR_LINK_REQUIRED", "QR link is required.")); + if (!webPushSessionStore.Approve(qrLink, user.Id)) return NotFound(QlyraCompatibilityResponse.Error(opcode, "QR_TRACK_NOT_FOUND", "QR session was not found or has expired.")); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true })); + } + + private static Dictionary BuildUserConfig(UserPrivacySettings? settings = null) + { + var config = new Dictionary + { + ["SEARCH_BY_PHONE"] = "ALL", + ["INCOMING_CALL"] = "CONTACTS", + ["PHONE_NUMBER_PRIVACY"] = "ALL", + ["CHATS_INVITE"] = "CONTACTS", + ["CHATS_PUSH_NOTIFICATION"] = "ON", + ["M_CALL_PUSH_NOTIFICATION"] = "ON", + ["PUSH_DETAILS"] = false, + ["PUSH_NEW_CONTACTS"] = false, + ["SHOW_READ_MARK"] = true, + ["AUDIO_TRANSCRIPTION_ENABLED"] = true, + ["hash"] = "ikar" + }; + if (settings is not null && !string.IsNullOrWhiteSpace(settings.QlyraConfigJson)) + { + try + { + var stored = JsonSerializer.Deserialize>(settings.QlyraConfigJson); + if (stored is not null) + { + foreach (var property in stored) config[property.Key] = ReadConfigValue(property.Value); + } + } + catch (JsonException) + { + // Keep safe defaults if an older row contains malformed compatibility data. + } + config["PHONE_NUMBER_PRIVACY"] = ToQlyraPrivacy(settings.PhoneNumberVisibility); + config["PUSH_DETAILS"] = settings.ShowMessagePreview; + } + return config; + } + + private static object? ReadConfigValue(JsonElement value) => value.ValueKind switch + { + JsonValueKind.String => value.GetString(), + JsonValueKind.Number when value.TryGetInt64(out var number) => number, + JsonValueKind.Number => value.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => value.Clone() + }; + + private static void ApplyPrivacyConfig(UserPrivacySettings settings, IReadOnlyDictionary config) + { + if (config.TryGetValue("PHONE_NUMBER_PRIVACY", out var privacy) && privacy is string privacyValue) + { + settings.PhoneNumberVisibility = privacyValue.Trim().ToUpperInvariant() switch + { + "ALL" => "everyone", + "CONTACTS" => "contacts", + "NOBODY" => "nobody", + _ => settings.PhoneNumberVisibility + }; + } + if (config.TryGetValue("PUSH_DETAILS", out var preview) && preview is bool showPreview) + { + settings.ShowMessagePreview = showPreview; + } + } + + private static string ToQlyraPrivacy(string value) => value.Trim().ToLowerInvariant() switch + { + "everyone" => "ALL", + "contacts" => "CONTACTS", + "nobody" => "NOBODY", + _ => "ALL" + }; + + private static DateTimeOffset? ToMutedUntil(long value) + { + if (value == 0) return null; + if (value < 0 || value > DateTimeOffset.MaxValue.ToUnixTimeMilliseconds()) return DateTimeOffset.MaxValue; + return DateTimeOffset.FromUnixTimeMilliseconds(value); + } + + private async Task GetContactsAsync(int opcode, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var contacts = await BuildContactsAsync(user.Id, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { contacts })); + } + + private async Task UpdateProfileAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var firstName = ReadString(payload, "firstName")?.Trim(); + var lastName = ReadString(payload, "lastName")?.Trim(); + if (!string.IsNullOrWhiteSpace(firstName)) + { + var displayName = string.Join(' ', new[] { firstName, lastName }.Where(value => !string.IsNullOrWhiteSpace(value))); + if (displayName.Length > 120) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "PROFILE_NAME_INVALID", "Display name is too long.")); + user.DisplayName = displayName; + } + var photoToken = ReadString(payload, "photoToken")?.Trim(); + if (!string.IsNullOrWhiteSpace(photoToken)) + { + var uploaded = await uploadStore.MaterializeStandaloneAsync(photoToken, user.Id, cancellationToken); + if (uploaded is null) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "UPLOAD_TOKEN_INVALID", "Profile photo upload token is invalid or incomplete.")); + if (!uploaded.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + try { System.IO.File.Delete(uploaded.StoragePath); } catch (IOException) { } + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "PROFILE_PHOTO_INVALID", "Profile photo must be an image.")); + } + var previousAvatar = user.AvatarStoragePath; + user.AvatarStoragePath = uploaded.StoragePath; + user.AvatarContentType = uploaded.ContentType; + if (!string.IsNullOrWhiteSpace(previousAvatar) && !string.Equals(previousAvatar, uploaded.StoragePath, StringComparison.OrdinalIgnoreCase)) + { + try { System.IO.File.Delete(previousAvatar); } catch (IOException) { } + } + } + await dbContext.SaveChangesAsync(cancellationToken); + var profile = await BuildProfileAsync(user, cancellationToken); + await PublishPacketAsync([user.Id], 159, profile, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { profile })); + } + + private async Task GetChatsAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var chats = await LoadMemberChatsAsync(user.Id, cancellationToken); + var marker = Math.Max(ReadInt(payload, "marker") ?? 0, 0); + var count = Math.Clamp(ReadInt(payload, "count") ?? 50, 1, 100); + var page = chats.Skip(marker).Take(count).ToList(); + var nextMarker = marker + page.Count < chats.Count ? marker + page.Count : 0; + return Ok(QlyraCompatibilityResponse.Ok(opcode, new + { + chats = await BuildChatsAsync(page, cancellationToken), + marker = nextMarker, + chatMarker = nextMarker + })); + } + + private async Task GetContactInfoAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var ids = ReadIntList(payload, "contactIds"); + if (ids.Count == 0) return Ok(QlyraCompatibilityResponse.Ok(opcode, new { contacts = Array.Empty() })); + var users = await dbContext.Users.Join( + dbContext.QlyraClientIdMaps.Where(map => map.EntityKind == "user" && ids.Contains(map.Id)), + entity => entity.Id, + map => map.EntityId, + (entity, _) => entity) + .Where(entity => !entity.IsBlocked) + .ToListAsync(cancellationToken); + var userIds = users.Select(contact => contact.Id).ToList(); + var customNames = await dbContext.UserContacts + .Where(contact => contact.OwnerUserId == user.Id && userIds.Contains(contact.ContactUserId)) + .ToDictionaryAsync(contact => contact.ContactUserId, cancellationToken); + var contacts = new List(users.Count); + foreach (var contact in users) + { + customNames.TryGetValue(contact.Id, out var customName); + contacts.Add(await BuildContactAsync( + contact, + cancellationToken, + customName?.CustomFirstName, + customName?.CustomLastName)); + } + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { contacts })); + } + + private async Task GetContactPresenceAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var currentUser = await RequireCurrentUserAsync(cancellationToken); + if (currentUser is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var ids = ReadIntList(payload, "contactIds").Distinct().ToList(); + if (ids.Count == 0) + { + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { presence = new Dictionary() })); + } + + var mappings = await dbContext.QlyraClientIdMaps + .Where(map => map.EntityKind == "user" && ids.Contains(map.Id)) + .ToListAsync(cancellationToken); + var entityIds = mappings.Select(map => map.EntityId).ToList(); + var users = await dbContext.Users + .Include(user => user.Sessions) + .Include(user => user.PrivacySettings) + .Where(user => entityIds.Contains(user.Id) && !user.IsBlocked) + .ToDictionaryAsync(user => user.Id, cancellationToken); + var currentChatIds = dbContext.ChatMembers + .Where(member => member.UserId == currentUser.Id) + .Select(member => member.ChatId); + var sharedUserIds = await dbContext.ChatMembers + .Where(member => entityIds.Contains(member.UserId) && currentChatIds.Contains(member.ChatId)) + .Select(member => member.UserId) + .Distinct() + .ToHashSetAsync(cancellationToken); + + var presence = new Dictionary(); + foreach (var mapping in mappings) + { + if (!users.TryGetValue(mapping.EntityId, out var user)) continue; + var visible = user.ToDtoForViewer( + presenceTracker, + currentUser.Id, + user.Id == currentUser.Id || sharedUserIds.Contains(user.Id)); + var isOnline = user.Id == currentUser.Id || visible.IsOnline; + presence[mapping.Id.ToString()] = new + { + status = isOnline ? 1 : 0, + seen = visible.LastSeenAt?.ToUnixTimeSeconds() ?? 0 + }; + } + + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { presence })); + } + + private async Task GetContactByPhoneAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var currentUser = await RequireCurrentUserAsync(cancellationToken); + if (currentUser is null) + { + return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + } + var normalizedPhone = PhoneNumberNormalizer.Normalize(ReadString(payload, "phone")); + if (normalizedPhone is null) + { + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "INVALID_PHONE", "Valid phone number is required.")); + } + var contact = await dbContext.Users.SingleOrDefaultAsync( + user => user.NormalizedPhoneNumber == normalizedPhone && !user.IsBlocked, + cancellationToken); + if (contact is null) + { + return NotFound(QlyraCompatibilityResponse.Error(opcode, "user.not.found", "Пользователь не найден.")); + } + var relation = await dbContext.UserContacts.SingleOrDefaultAsync( + item => item.OwnerUserId == currentUser.Id && item.ContactUserId == contact.Id, + cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new + { + contact = await BuildContactAsync( + contact, + cancellationToken, + relation?.CustomFirstName, + relation?.CustomLastName) + })); + } + + private async Task AddContactByPhoneAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var currentUser = await RequireCurrentUserAsync(cancellationToken); + if (currentUser is null) + { + return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + } + + var normalizedPhone = PhoneNumberNormalizer.Normalize(ReadString(payload, "phone")); + if (normalizedPhone is null) + { + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "INVALID_PHONE", "Valid phone number is required.")); + } + + var target = await dbContext.Users.SingleOrDefaultAsync( + user => user.NormalizedPhoneNumber == normalizedPhone && !user.IsBlocked, + cancellationToken); + if (target is null || target.Id == currentUser.Id) + { + return NotFound(QlyraCompatibilityResponse.Error(opcode, "user.not.found", "Пользователь не найден.")); + } + + var firstName = ReadString(payload, "firstName")?.Trim(); + var lastName = ReadString(payload, "lastName")?.Trim(); + if ((firstName?.Length ?? 0) > 120 || (lastName?.Length ?? 0) > 120) + { + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "CONTACT_NAME_INVALID", "Contact name is too long.")); + } + + var relation = await UpsertContactAsync( + currentUser.Id, + target.Id, + firstName, + lastName, + cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new + { + contact = await BuildContactAsync(target, cancellationToken, relation.CustomFirstName, relation.CustomLastName) + })); + } + + private async Task UpdateContactAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var currentUser = await RequireCurrentUserAsync(cancellationToken); + if (currentUser is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var targetId = await clientIdMapper.FindEntityIdAsync("user", ReadInt(payload, "contactId") ?? 0, cancellationToken); + if (targetId is null) + { + return NotFound(QlyraCompatibilityResponse.Error(opcode, "user.not.found", "Пользователь не найден.")); + } + var target = await dbContext.Users.SingleOrDefaultAsync(user => user.Id == targetId.Value && !user.IsBlocked, cancellationToken); + if (target is null) + { + return NotFound(QlyraCompatibilityResponse.Error(opcode, "user.not.found", "Пользователь не найден.")); + } + var action = ReadString(payload, "action")?.Trim().ToUpperInvariant(); + var relation = await dbContext.UserContacts.SingleOrDefaultAsync( + contact => contact.OwnerUserId == currentUser.Id && contact.ContactUserId == target.Id, + cancellationToken); + if (action is "ADD" or "UPDATE") + { + if (action == "UPDATE" && relation is null) + { + return NotFound(QlyraCompatibilityResponse.Error(opcode, "contact.not.found", "Контакт не найден. Добавьте его в контакты.")); + } + var firstName = ReadString(payload, "firstName")?.Trim(); + var lastName = ReadString(payload, "lastName")?.Trim(); + if ((firstName?.Length ?? 0) > 120 || (lastName?.Length ?? 0) > 120) + { + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "CONTACT_NAME_INVALID", "Contact name is too long.")); + } + relation = await UpsertContactAsync( + currentUser.Id, + target.Id, + firstName, + lastName, + cancellationToken); + } + else if (action == "REMOVE") + { + if (relation is not null) dbContext.UserContacts.Remove(relation); + relation = null; + } + if (action is "BLOCK" or "UNBLOCK") + { + var settings = await GetOrCreatePrivacySettingsAsync(currentUser.Id, cancellationToken); + var blocked = ParseUserIds(settings.BlockedUserIds); + if (action == "BLOCK") blocked.Add(target.Id); else blocked.Remove(target.Id); + settings.BlockedUserIds = string.Join(',', blocked.OrderBy(id => id).Select(id => id.ToString("D"))); + settings.UpdatedAt = DateTimeOffset.UtcNow; + } + else if (action is not ("ADD" or "UPDATE" or "REMOVE")) + { + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "CONTACT_ACTION_INVALID", "A supported contact action is required.")); + } + await dbContext.SaveChangesAsync(cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new + { + contact = await BuildContactAsync(target, cancellationToken, relation?.CustomFirstName, relation?.CustomLastName), + success = true + })); + } + + private async Task GetBlockedContactsAsync(int opcode, CancellationToken cancellationToken) + { + var currentUser = await RequireCurrentUserAsync(cancellationToken); + if (currentUser is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var settings = await GetOrCreatePrivacySettingsAsync(currentUser.Id, cancellationToken); + var blockedIds = ParseUserIds(settings.BlockedUserIds); + var blockedUsers = await dbContext.Users.Where(user => blockedIds.Contains(user.Id)).ToListAsync(cancellationToken); + var customNames = await dbContext.UserContacts + .Where(contact => contact.OwnerUserId == currentUser.Id && blockedIds.Contains(contact.ContactUserId)) + .ToDictionaryAsync(contact => contact.ContactUserId, cancellationToken); + var contacts = new List(blockedUsers.Count); + foreach (var user in blockedUsers) + { + customNames.TryGetValue(user.Id, out var customName); + contacts.Add(await BuildContactAsync(user, cancellationToken, customName?.CustomFirstName, customName?.CustomLastName)); + } + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { contacts })); + } + + private async Task GetContactPhotosAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + if (await RequireCurrentUserAsync(cancellationToken) is null) + { + return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + } + var targetId = await clientIdMapper.FindEntityIdAsync("user", ReadInt(payload, "contactId") ?? 0, cancellationToken); + var user = targetId is null ? null : await dbContext.Users.SingleOrDefaultAsync(item => item.Id == targetId.Value, cancellationToken); + var urls = user is null || string.IsNullOrWhiteSpace(user.AvatarStoragePath) + ? Array.Empty() + : new[] { $"/api/users/{user.Id}/avatar/{Uri.EscapeDataString(Path.GetFileName(user.AvatarStoragePath))}" }; + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { urls, total = urls.Length })); + } + + private async Task RemoveProfilePhotoAsync(int opcode, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var previousAvatar = user.AvatarStoragePath; + user.AvatarStoragePath = null; + user.AvatarContentType = null; + await dbContext.SaveChangesAsync(cancellationToken); + if (!string.IsNullOrWhiteSpace(previousAvatar)) + { + try { System.IO.File.Delete(previousAvatar); } catch (IOException) { } + } + var profile = await BuildProfileAsync(user, cancellationToken); + await PublishPacketAsync([user.Id], 159, new { profile }, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { profile })); + } + + private async Task GetSessionsAsync(int opcode, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var currentSessionId = User.GetRequiredSessionId(); + var entities = await dbContext.UserSessions + .Where(session => session.UserId == user.Id) + .OrderByDescending(session => session.LastSeenAt) + .ToListAsync(cancellationToken); + var sessions = new List(entities.Count); + foreach (var session in entities) + { + sessions.Add(new + { + id = await clientIdMapper.GetOrCreateAsync("session", session.Id, cancellationToken), + client = session.ClientVersionName ?? session.ClientPlatform ?? "IKAR", + location = string.Empty, + current = session.Id == currentSessionId, + time = session.LastSeenAt.ToUnixTimeSeconds(), + info = session.Id == currentSessionId ? "Это устройство" : "Сессия IKAR" + }); + } + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { sessions })); + } + + private async Task CloseOtherSessionsAsync(int opcode, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var currentSessionId = User.GetRequiredSessionId(); + var sessions = await dbContext.UserSessions + .Where(session => session.UserId == user.Id && session.Id != currentSessionId) + .ToListAsync(cancellationToken); + dbContext.UserSessions.RemoveRange(sessions); + await dbContext.SaveChangesAsync(cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true })); + } + + private async Task GetChatInfoAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var ids = ReadIntList(payload, "chatIds"); + if (ids.Count == 0) return Ok(QlyraCompatibilityResponse.Ok(opcode, new { chats = Array.Empty() })); + var chats = new List(); + foreach (var id in ids.Distinct()) + { + var chat = await ResolveMemberChatAsync(user.Id, id, cancellationToken); + if (chat is not null && chats.All(existing => existing.Id != chat.Id)) chats.Add(chat); + } + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { chats = await BuildChatsAsync(chats, cancellationToken) })); + } + + private async Task GetHistoryAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var requestedChatId = ReadInt(payload, "chatId"); + var chat = await ResolveMemberChatAsync(user.Id, requestedChatId, cancellationToken); + if (chat is null) + { + // A contact dialog exists in the client before its first message. + if (requestedChatId is > 0 && await ResolveDirectPeerAsync(user.Id, requestedChatId.Value, cancellationToken) is not null) + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { messages = Array.Empty(), hasMore = false })); + return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Чат не найден.")); + } + var backward = Math.Clamp(ReadInt(payload, "backward") ?? 50, 0, 200); + var forward = Math.Clamp(ReadInt(payload, "forward") ?? 0, 0, 200); + if (backward == 0 && forward == 0) backward = 50; + var from = ReadLong(payload, "from") ?? DateTimeOffset.MaxValue.ToUnixTimeMilliseconds(); + DateTimeOffset anchor; + try + { + anchor = DateTimeOffset.FromUnixTimeMilliseconds(from); + } + catch (ArgumentOutOfRangeException) + { + anchor = from < 0 ? DateTimeOffset.MinValue : DateTimeOffset.MaxValue; + } + var hiddenMessageIds = await dbContext.MessageHiddenForUsers.AsNoTracking() + .Where(item => item.UserId == user.Id && item.Message.ChatId == chat.Id) + .Select(item => item.MessageId) + .ToListAsync(cancellationToken); + var delayed = ReadString(payload, "itemType") == "DELAYED"; + var visibleMessages = chat.Messages + .Where(message => message.DeletedAt is null && !hiddenMessageIds.Contains(message.Id)) + .Where(message => delayed ? message.ScheduledForUnixMs != null && message.SenderId == user.Id : message.ScheduledForUnixMs == null); + if (delayed) anchor = DateTimeOffset.MaxValue; + if (ReadInt(payload, "postId") is int clientPostId) + { + var parent = await ResolveMessageAsync(chat.Id, clientPostId, cancellationToken); + if (parent is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "POST_NOT_FOUND", "Comment post was not found.")); + visibleMessages = visibleMessages.Where(message => message.DiscussionMessageId == parent.Id); + } + var before = visibleMessages.Where(message => message.SentAt < anchor).OrderByDescending(message => message.SentAt).ToList(); + var after = visibleMessages.Where(message => message.SentAt >= anchor).OrderBy(message => message.SentAt).ToList(); + var hasMore = (backward > 0 && before.Count > backward) || (forward > 0 && after.Count > forward); + var messages = before.Take(backward) + .Concat(after.Take(forward)) + .DistinctBy(message => message.Id) + .OrderBy(message => message.SentAt) + .ToList(); + var mapped = new List(messages.Count); + foreach (var message in messages) + { + mapped.Add(await BuildMessageAsync(message, cancellationToken)); + } + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { messages = mapped, hasMore })); + } + + private async Task GetChatMediaAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found.")); + var count = Math.Clamp((ReadInt(payload, "forward") ?? 0) + (ReadInt(payload, "backward") ?? 60), 1, 100); + var hiddenMessageIds = dbContext.MessageHiddenForUsers.Where(item => item.UserId == user.Id).Select(item => item.MessageId); + var entities = await dbContext.Messages + .Include(message => message.Sender) + .Include(message => message.Attachments) + .Where(message => message.ChatId == chat.Id && message.DeletedAt == null && message.ScheduledForUnixMs == null && !hiddenMessageIds.Contains(message.Id) && message.Attachments.Count > 0) + .OrderByDescending(message => message.SentAt) + .Take(count) + .ToListAsync(cancellationToken); + var messages = new List(entities.Count); + foreach (var message in entities) messages.Add(await BuildMessageAsync(message, cancellationToken)); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { messages, total = entities.Count })); + } + + private async Task DeleteChatAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found.")); + var membership = chat.Members.Single(member => member.UserId == user.Id); + var forAll = ReadBool(payload, "forAll") == true; + if (forAll && chat.Type != ChatType.Direct && !IsChatManager(membership)) + { + return StatusCode(StatusCodes.Status403Forbidden, QlyraCompatibilityResponse.Error(opcode, "CHAT_DELETE_FORBIDDEN", "Only a chat owner can delete this chat for everyone.")); + } + if (!forAll && chat.Type is (ChatType.Group or ChatType.Channel) && membership.IsOwner) + { + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "OWNER_CANNOT_LEAVE", "Transfer ownership or delete the chat for everyone.")); + } + var recipients = chat.Members.Select(member => member.UserId).ToArray(); + if (forAll || chat.Members.Count == 1) + { + dbContext.Chats.Remove(chat); + } + else + { + dbContext.ChatMembers.Remove(membership); + } + await dbContext.SaveChangesAsync(cancellationToken); + await PublishPacketAsync(recipients, 131, new { chatId = ReadInt(payload, "chatId") }, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true })); + } + + private async Task ClearChatAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found.")); + if (ReadBool(payload, "forAll") != true) + { + var alreadyHidden = dbContext.MessageHiddenForUsers.Where(item => item.UserId == user.Id).Select(item => item.MessageId); + var visibleIds = chat.Messages.Where(message => message.DeletedAt == null).Select(message => message.Id).Where(messageId => !alreadyHidden.Contains(messageId)).ToArray(); + dbContext.MessageHiddenForUsers.AddRange(visibleIds.Select(messageId => new MessageHiddenForUser { MessageId = messageId, UserId = user.Id })); + if (visibleIds.Length > 0) await dbContext.SaveChangesAsync(cancellationToken); + await PublishPacketAsync([user.Id], 143, new { chatId = ReadInt(payload, "chatId") }, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true })); + } + var membership = chat.Members.Single(member => member.UserId == user.Id); + if (chat.Type != ChatType.Direct && !IsChatManager(membership)) + { + return StatusCode(StatusCodes.Status403Forbidden, QlyraCompatibilityResponse.Error(opcode, "CHAT_CLEAR_FORBIDDEN", "Only a chat owner can clear history for everyone.")); + } + foreach (var message in chat.Messages.Where(message => message.DeletedAt is null)) + { + message.Text = string.Empty; + message.DeletedAt = DateTimeOffset.UtcNow; + message.EditedAt = null; + } + chat.LastActivityAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + await PublishPacketAsync(chat.Members.Select(member => member.UserId), 143, new { chatId = ReadInt(payload, "chatId") }, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true })); + } + + private async Task UpdateChatAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found.")); + var membership = chat.Members.Single(member => member.UserId == user.Id); + var title = ReadString(payload, "theme") ?? ReadString(payload, "title"); + if (!string.IsNullOrWhiteSpace(title)) + { + if (!IsChatManager(membership)) return StatusCode(StatusCodes.Status403Forbidden, QlyraCompatibilityResponse.Error(opcode, "CHAT_UPDATE_FORBIDDEN", "Only chat administrators can update the title.")); + chat.Title = title.Trim().Length <= 120 ? title.Trim() : title.Trim()[..120]; + } + var photoToken = ReadString(payload, "photoToken")?.Trim(); + if (!string.IsNullOrWhiteSpace(photoToken)) + { + if (!IsChatManager(membership)) return StatusCode(StatusCodes.Status403Forbidden, QlyraCompatibilityResponse.Error(opcode, "CHAT_UPDATE_FORBIDDEN", "Only chat administrators can update the photo.")); + var uploaded = await uploadStore.MaterializeStandaloneAsync(photoToken, user.Id, cancellationToken); + if (uploaded is null) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "UPLOAD_TOKEN_INVALID", "Chat photo upload token is invalid or incomplete.")); + if (!uploaded.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + try { System.IO.File.Delete(uploaded.StoragePath); } catch (IOException) { } + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "CHAT_PHOTO_INVALID", "Chat photo must be an image.")); + } + var previousAvatar = chat.AvatarStoragePath; + chat.AvatarStoragePath = uploaded.StoragePath; + chat.AvatarContentType = uploaded.ContentType; + if (!string.IsNullOrWhiteSpace(previousAvatar) && !string.Equals(previousAvatar, uploaded.StoragePath, StringComparison.OrdinalIgnoreCase)) + { + try { System.IO.File.Delete(previousAvatar); } catch (IOException) { } + } + } + if (ReadInt(payload, "pinMessageId") is int pinnedClientId) + { + if (!membership.CanPinMessages && !IsChatManager(membership)) return StatusCode(StatusCodes.Status403Forbidden, QlyraCompatibilityResponse.Error(opcode, "CHAT_PIN_FORBIDDEN", "This member cannot pin messages.")); + foreach (var item in chat.Messages.Where(message => message.PinnedAt != null)) + { + item.PinnedAt = null; + item.PinnedByUserId = null; + } + if (pinnedClientId != 0) + { + var message = await ResolveMessageAsync(chat.Id, pinnedClientId, cancellationToken); + if (message is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "MESSAGE_NOT_FOUND", "Message not found.")); + message.PinnedAt = DateTimeOffset.UtcNow; + message.PinnedByUserId = user.Id; + } + } + chat.LastActivityAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + var mapped = (await BuildChatsAsync([chat], cancellationToken)).Single(); + await PublishPacketAsync(chat.Members.Select(member => member.UserId), 132, new { chat = mapped }, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { chat = mapped })); + } + + private async Task JoinChatAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var rawLink = ReadString(payload, "link")?.Trim(); + if (string.IsNullOrWhiteSpace(rawLink)) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "LINK_REQUIRED", "A chat link is required.")); + var key = ExtractLinkKey(rawLink); + var chat = await dbContext.Chats + .Include(item => item.Members).ThenInclude(member => member.User) + .Include(item => item.Messages).ThenInclude(message => message.Sender) + .Include(item => item.InviteLinks) + .SingleOrDefaultAsync(item => item.PublicUsername == key || item.InviteLinks.Any(link => link.Token == key && !link.IsRevoked), cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "LINK_NOT_FOUND", "Chat link was not found.")); + var invite = chat.InviteLinks.FirstOrDefault(link => link.Token == key && !link.IsRevoked); + if (invite is not null && (invite.ExpiresAt <= DateTimeOffset.UtcNow || invite.MaxUses is int maxUses && invite.UsesCount >= maxUses || invite.MemberLimit is int memberLimit && chat.Members.Count >= memberLimit)) + { + return Conflict(QlyraCompatibilityResponse.Error(opcode, "LINK_EXPIRED", "Chat link is no longer available.")); + } + if (!chat.Members.Any(member => member.UserId == user.Id)) + { + if (invite?.CreatesJoinRequest == true) return Conflict(QlyraCompatibilityResponse.Error(opcode, "JOIN_REQUEST_REQUIRED", "This chat requires administrator approval.")); + chat.Members.Add(CreateChatMember(chat, user.Id)); + if (invite is not null) invite.UsesCount += 1; + chat.LastActivityAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + } + var mapped = (await BuildChatsAsync([chat], cancellationToken)).Single(); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { chat = mapped })); + } + + private async Task LeaveChatAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found.")); + var membership = chat.Members.Single(member => member.UserId == user.Id); + if (chat.Type is (ChatType.Group or ChatType.Channel) && membership.IsOwner) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "OWNER_CANNOT_LEAVE", "Transfer ownership or delete the chat for everyone.")); + dbContext.ChatMembers.Remove(membership); + await dbContext.SaveChangesAsync(cancellationToken); + await PublishPacketAsync(chat.Members.Where(member => member.UserId != user.Id).Select(member => member.UserId), 131, new { chatId = ReadInt(payload, "chatId") }, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true })); + } + + private async Task GetChatMembersAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found.")); + var marker = Math.Max(ReadInt(payload, "marker") ?? 0, 0); + var count = Math.Clamp(ReadInt(payload, "count") ?? 50, 1, 100); + var selected = chat.Members.OrderByDescending(IsChatManager).ThenBy(member => member.JoinedAt).Skip(marker).Take(count).ToList(); + var members = new List(selected.Count); + foreach (var member in selected) + { + var dto = member.User.ToDtoForViewer(presenceTracker, user.Id, true); + members.Add(new { contact = await BuildContactAsync(member.User, cancellationToken), presence = new { status = dto.IsOnline ? 1 : 0, seen = dto.LastSeenAt?.ToUnixTimeSeconds() ?? 0 } }); + } + var nextMarker = marker + selected.Count; + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { members, marker = nextMarker >= chat.Members.Count ? 0 : nextMarker })); + } + + private async Task PublicSearchAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var query = ReadString(payload, "query")?.Trim() ?? string.Empty; + var count = Math.Clamp(ReadInt(payload, "count") ?? 20, 1, 100); + var result = new List(); + if (int.TryParse(query, out var clientUserId)) + { + var entityId = await clientIdMapper.FindEntityIdAsync("user", clientUserId, cancellationToken); + var exact = entityId is null ? null : await dbContext.Users.SingleOrDefaultAsync(item => item.Id == entityId && !item.IsBlocked, cancellationToken); + if (exact is not null) result.Add(new { contact = await BuildContactAsync(exact, cancellationToken) }); + } + if (result.Count < count && query.Length > 0) + { + var users = await dbContext.Users.Where(item => item.Id != user.Id && !item.IsBlocked && (item.DisplayName.Contains(query) || item.Username.Contains(query))).Take(count - result.Count).ToListAsync(cancellationToken); + foreach (var found in users) result.Add(new { contact = await BuildContactAsync(found, cancellationToken) }); + var normalized = query.TrimStart('@'); + var chats = await dbContext.Chats.Include(item => item.Members).ThenInclude(member => member.User).Include(item => item.Messages).ThenInclude(message => message.Sender) + .Where(item => item.PublicUsername != null && (item.PublicUsername.Contains(normalized) || item.Title != null && item.Title.Contains(query))).Take(count - result.Count).ToListAsync(cancellationToken); + foreach (var found in chats) + { + var mapped = (await BuildChatsAsync([found], cancellationToken)).Single(); + result.Add(new { chat = mapped }); + } + } + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { result = result.Take(count).ToArray() })); + } + + private async Task SearchMessagesAsync(int opcode, JsonElement payload, bool requireChat, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var query = ReadString(payload, "query")?.Trim() ?? string.Empty; + var count = Math.Clamp(ReadInt(payload, "count") ?? 50, 1, 100); + var memberChatIds = dbContext.ChatMembers.Where(member => member.UserId == user.Id).Select(member => member.ChatId); + var hiddenMessageIds = dbContext.MessageHiddenForUsers.Where(item => item.UserId == user.Id).Select(item => item.MessageId); + var messagesQuery = dbContext.Messages.Include(message => message.Sender).Where(message => memberChatIds.Contains(message.ChatId) && message.DeletedAt == null && message.ScheduledForUnixMs == null && !hiddenMessageIds.Contains(message.Id) && message.Text.Contains(query)); + if (requireChat) + { + var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found.")); + messagesQuery = messagesQuery.Where(message => message.ChatId == chat.Id); + } + var entities = await messagesQuery.OrderByDescending(message => message.SentAt).Take(count).ToListAsync(cancellationToken); + var result = new List(entities.Count); + foreach (var message in entities) + { + result.Add(new { chatId = await GetClientChatIdAsync(message.ChatId, cancellationToken), message = await BuildMessageAsync(message, cancellationToken) }); + } + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { result, messages = result.Select(item => item).ToArray() })); + } + + private async Task SubscribeChatAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + // This opcode subscribes a preview to updates. Joining/leaving a chat is + // handled by opcodes 57/58; closing a preview must preserve membership. + if (ReadBool(payload, "subscribe") == false) + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true })); + var requestedChatId = ReadInt(payload, "chatId"); + var memberChat = await ResolveMemberChatAsync(user.Id, requestedChatId, cancellationToken); + if (memberChat is not null) + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { chat = (await BuildChatsAsync([memberChat], cancellationToken)).Single() })); + if (requestedChatId is > 0 && await ResolveDirectPeerAsync(user.Id, requestedChatId.Value, cancellationToken) is not null) + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true })); + var chatId = await clientIdMapper.FindEntityIdAsync("chat", requestedChatId ?? 0, cancellationToken); + var chat = chatId is null ? null : await dbContext.Chats.Include(item => item.Members).ThenInclude(member => member.User).Include(item => item.Messages).ThenInclude(message => message.Sender).SingleOrDefaultAsync(item => item.Id == chatId, cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Чат не найден.")); + if (chat.Type != ChatType.Channel || string.IsNullOrWhiteSpace(chat.PublicUsername)) return StatusCode(StatusCodes.Status403Forbidden, QlyraCompatibilityResponse.Error(opcode, "CHAT_PRIVATE", "Для доступа к приватному чату требуется приглашение.")); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { chat = (await BuildChatsAsync([chat], cancellationToken)).Single() })); + } + + private async Task UpdateChatMembersAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found.")); + if (!IsChatManager(chat.Members.Single(member => member.UserId == user.Id))) return StatusCode(StatusCodes.Status403Forbidden, QlyraCompatibilityResponse.Error(opcode, "CHAT_MEMBERS_FORBIDDEN", "Only chat administrators can update members.")); + var entityIds = new List(); + foreach (var clientUserId in ReadIntList(payload, "userIds")) + { + var entityId = await clientIdMapper.FindEntityIdAsync("user", clientUserId, cancellationToken); + if (entityId is not null) entityIds.Add(entityId.Value); + } + if (entityIds.Count == 0) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "MEMBERS_REQUIRED", "At least one valid member is required.")); + var operation = ReadString(payload, "operation")?.ToLowerInvariant() ?? "add"; + if (operation == "remove") + { + dbContext.ChatMembers.RemoveRange(chat.Members.Where(member => entityIds.Contains(member.UserId) && !member.IsOwner)); + } + else + { + var validIds = await dbContext.Users.Where(item => entityIds.Contains(item.Id) && !item.IsBlocked).Select(item => item.Id).ToListAsync(cancellationToken); + foreach (var entityId in validIds.Where(entityId => chat.Members.All(member => member.UserId != entityId))) chat.Members.Add(CreateChatMember(chat, entityId)); + } + chat.LastActivityAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { chat = (await BuildChatsAsync([chat], cancellationToken)).Single() })); + } + + private async Task SendMessageAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var attaches = ReadNestedArray(payload, "message", "attaches"); + var firstAttach = attaches.ValueKind == JsonValueKind.Array && attaches.GetArrayLength() > 0 ? attaches[0] : default; + if (firstAttach.ValueKind == JsonValueKind.Object && + string.Equals(ReadString(firstAttach, "_type"), "CONTROL", StringComparison.OrdinalIgnoreCase) && + string.Equals(ReadString(firstAttach, "event"), "new", StringComparison.OrdinalIgnoreCase)) + { + return await CreateChatFromControlAsync(opcode, user, firstAttach, cancellationToken); + } + + var requestedChatId = ReadInt(payload, "chatId"); + var chat = await ResolveMemberChatAsync(user.Id, requestedChatId, cancellationToken); + if (chat is null && requestedChatId is > 0) chat = await CreateDirectChatFromClientIdAsync(user, requestedChatId.Value, cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found.")); + if (chat.Type == ChatType.Channel && !chat.Members.Any(member => member.UserId == user.Id && (member.IsOwner || member.Role is ChatMemberRole.Owner or ChatMemberRole.Admin || member.CanPostMessages))) + { + return StatusCode(StatusCodes.Status403Forbidden, QlyraCompatibilityResponse.Error(opcode, "CHAT_READ_ONLY", "Chat is read-only.")); + } + + var clientCid = payload.TryGetProperty("message", out var cidPayload) ? ReadLong(cidPayload, "cid") : null; + if (clientCid is not null) + { + var duplicate = await dbContext.Messages.Include(x => x.Attachments).SingleOrDefaultAsync(x => x.ChatId == chat.Id && x.SenderId == user.Id && x.IkarClientCid == clientCid, cancellationToken); + if (duplicate is not null) return Ok(QlyraCompatibilityResponse.Ok(opcode, new { message = await BuildMessageAsync(duplicate, cancellationToken) })); + } + + if (chat.Type == ChatType.Direct) + { + var peers = chat.Members.Where(member => member.UserId != user.Id).Select(member => member.UserId).ToList(); + var peerSettings = await dbContext.UserPrivacySettings.Where(settings => peers.Contains(settings.UserId)).ToListAsync(cancellationToken); + if (peerSettings.Any(settings => ParseUserIds(settings.BlockedUserIds).Contains(user.Id))) + return StatusCode(403, QlyraCompatibilityResponse.Error(opcode, "USER_BLOCKED_SEND", "Получатель заблокировал сообщения от этого аккаунта.")); + } + var text = ReadNestedString(payload, "message", "text") ?? string.Empty; + if (string.IsNullOrWhiteSpace(text) && (attaches.ValueKind != JsonValueKind.Array || attaches.GetArrayLength() == 0)) + { + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "EMPTY_MESSAGE", "Message text or an attachment is required.")); + } + var message = new Message + { + ChatId = chat.Id, + SenderId = user.Id, + Text = text, + SentAt = DateTimeOffset.UtcNow, + IkarClientCid = clientCid, + IkarElementsJson = payload.TryGetProperty("message", out var formatted) && formatted.TryGetProperty("elements", out var elements) && elements.ValueKind == JsonValueKind.Array ? elements.GetRawText() : null, + QlyraAttachesJson = null + }; + Message? discussionPost = null; + if (ReadInt(payload, "postId") is int clientPostId) + { + discussionPost = await ResolveMessageAsync(chat.Id, clientPostId, cancellationToken); + if (discussionPost is null || discussionPost.DeletedAt is not null) + { + return NotFound(QlyraCompatibilityResponse.Error(opcode, "POST_NOT_FOUND", "Comment post was not found.")); + } + message.DiscussionMessageId = discussionPost.Id; + discussionPost.CommentCount += 1; + } + if (attaches.ValueKind == JsonValueKind.Array) + { + var protocolAttaches = new List(); + var sortOrder = 0; + foreach (var attach in attaches.EnumerateArray()) + { + var type = ReadString(attach, "_type")?.ToUpperInvariant(); + if (type == "POLL") + { + if (attaches.GetArrayLength() != 1) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "POLL_INVALID", "A poll must be the only message attachment.")); + var title = ReadString(attach, "title")?.Trim(); + if (string.IsNullOrWhiteSpace(title) || !attach.TryGetProperty("answers", out var requestedAnswers) || requestedAnswers.ValueKind != JsonValueKind.Array) + { + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "POLL_INVALID", "Poll title and answers are required.")); + } + var answerTexts = requestedAnswers.EnumerateArray().Select(answer => ReadString(answer, "text")?.Trim()).Where(answer => !string.IsNullOrWhiteSpace(answer)).Select(answer => answer!).Take(10).ToArray(); + if (answerTexts.Length < 2) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "POLL_INVALID", "A poll requires at least two answers.")); + var poll = new QlyraPoll { MessageId = message.Id, Title = title.Length <= 255 ? title : title[..255], Settings = ReadInt(attach, "settings") ?? 0 }; + for (var index = 0; index < answerTexts.Length; index++) poll.Answers.Add(new QlyraPollAnswer { PollId = poll.Id, AnswerId = index + 1, Text = answerTexts[index].Length <= 255 ? answerTexts[index] : answerTexts[index][..255] }); + dbContext.Messages.Add(message); + dbContext.QlyraPolls.Add(poll); + var pollId = await clientIdMapper.GetOrCreateAsync("poll", poll.Id, cancellationToken); + protocolAttaches.Add(JsonSerializer.SerializeToElement(new { _type = "POLL", pollId, title = poll.Title, settings = poll.Settings, version = poll.Version, answers = poll.Answers.Select(answer => new { answerId = answer.AnswerId, text = answer.Text }).ToArray() })); + continue; + } + var token = ReadString(attach, "token") ?? ReadString(attach, "photoToken"); + if (type is "PHOTO" or "VIDEO" or "AUDIO" or "FILE" && !string.IsNullOrWhiteSpace(token)) + { + var materialized = await uploadStore.MaterializeMessageAttachmentAsync( + token, + user.Id, + message.Id, + type == "AUDIO" ? AttachmentKind.VoiceNote : AttachmentKind.File, + sortOrder++, + cancellationToken); + if (materialized is null) + { + return Conflict(QlyraCompatibilityResponse.Error(opcode, "ATTACHMENT_NOT_READY", "The uploaded attachment token is invalid or not ready.")); + } + message.Attachments.Add(materialized); + continue; + } + protocolAttaches.Add(attach.Clone()); + } + if (protocolAttaches.Count > 0) message.QlyraAttachesJson = JsonSerializer.Serialize(protocolAttaches); + } + if (payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("message", out var messagePayload) && messagePayload.ValueKind == JsonValueKind.Object && messagePayload.TryGetProperty("link", out var link) && link.ValueKind == JsonValueKind.Object && ReadInt(link, "messageId") is int replyClientId) + { + message.ReplyToMessageId = (await ResolveMessageAsync(chat.Id, replyClientId, cancellationToken))?.Id; + } + var scheduled = ReadScheduledTime(payload, "message"); + if (scheduled is not null && (scheduled <= DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() || scheduled > DateTimeOffset.UtcNow.AddYears(1).ToUnixTimeMilliseconds())) + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "SCHEDULE_INVALID", "Укажите время отправки в пределах года.")); + message.ScheduledForUnixMs = scheduled; + if (scheduled is null) chat.LastActivityAt = message.SentAt; + if (dbContext.Entry(message).State == EntityState.Detached) dbContext.Messages.Add(message); + if (scheduled is null) pushDispatchQueue?.StageMessages(dbContext, message); + await dbContext.SaveChangesAsync(cancellationToken); + message.Sender = user; + var mappedMessage = await BuildMessageAsync(message, cancellationToken); + var clientChatId = await GetClientChatIdAsync(chat, cancellationToken); + await PublishPacketAsync( + message.ScheduledForUnixMs is not null ? [user.Id] : chat.Members.Select(member => member.UserId), + message.ScheduledForUnixMs is not null ? 154 : 128, + new { chatId = clientChatId, message = mappedMessage, unread = 0 }, + cancellationToken); + if (message.ScheduledForUnixMs is null && pushDispatchQueue is not null) await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id, cancellationToken); + if (message.ScheduledForUnixMs is null) await (botUpdateService ?? new BotUpdateService(dbContext)).EnqueueIncomingMessageAsync(message.Id, cancellationToken); + if (message.ScheduledForUnixMs is null && botCreatorService is not null && await botCreatorService.TryHandleIncomingMessageAsync(message.Id, cancellationToken)) + { + var replies = await dbContext.Messages.Include(x => x.Attachments).Where(x => x.ChatId == chat.Id && x.SenderId != user.Id && x.SentAt > message.SentAt && x.DeletedAt == null).ToListAsync(cancellationToken); + foreach (var reply in replies) await PublishPacketAsync(chat.Members.Select(x => x.UserId), 128, new { chatId = clientChatId, message = await BuildMessageAsync(reply, cancellationToken), unread = 0 }, cancellationToken); + } + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { message = mappedMessage })); + } + + private async Task CreateChatFromControlAsync(int opcode, User user, JsonElement control, CancellationToken cancellationToken) + { + var chatType = ReadString(control, "chatType")?.ToUpperInvariant(); + if (chatType is not ("CHAT" or "CHANNEL")) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "CHAT_TYPE_INVALID", "Only group chats and channels can be created with this request.")); + var title = ReadString(control, "title")?.Trim(); + if (string.IsNullOrWhiteSpace(title)) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "CHAT_TITLE_REQUIRED", "Chat title is required.")); + var requestedClientIds = ReadIntList(control, "userIds"); + var memberIds = new HashSet { user.Id }; + foreach (var clientUserId in requestedClientIds) + { + var memberId = await clientIdMapper.FindEntityIdAsync("user", clientUserId, cancellationToken); + if (memberId is not null) memberIds.Add(memberId.Value); + } + if (memberIds.Count != requestedClientIds.Distinct().Count() + 1) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "MEMBER_NOT_FOUND", "One or more members were not found.")); + var validUsers = await dbContext.Users.Where(item => memberIds.Contains(item.Id) && !item.IsBlocked).Select(item => item.Id).ToListAsync(cancellationToken); + if (validUsers.Count != memberIds.Count) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "MEMBER_NOT_FOUND", "One or more members were not found.")); + var chat = new Chat + { + Type = chatType == "CHANNEL" ? ChatType.Channel : ChatType.Group, + Title = title.Length <= 120 ? title : title[..120], + CreatedById = user.Id, + CreatedAt = DateTimeOffset.UtcNow, + LastActivityAt = DateTimeOffset.UtcNow + }; + foreach (var memberId in memberIds) + { + var isOwner = memberId == user.Id; + chat.Members.Add(new ChatMember + { + Chat = chat, + UserId = memberId, + IsOwner = isOwner, + Role = isOwner ? ChatMemberRole.Owner : ChatMemberRole.Member, + CanPinMessages = isOwner, + CanDeleteMessages = isOwner, + CanInviteUsers = isOwner || chat.Type == ChatType.Group, + CanPostMessages = isOwner || chat.Type == ChatType.Group, + JoinedAt = DateTimeOffset.UtcNow + }); + } + dbContext.Chats.Add(chat); + await dbContext.SaveChangesAsync(cancellationToken); + await dbContext.Entry(chat).Collection(item => item.Members).Query().Include(member => member.User).LoadAsync(cancellationToken); + var mapped = (await BuildChatsAsync([chat], cancellationToken)).Single(); + await PublishPacketAsync(memberIds, 131, new { chat = mapped }, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { chat = mapped })); + } + + private async Task CreateDirectChatFromClientIdAsync(User currentUser, int clientChatId, CancellationToken cancellationToken) + { + var peer = await ResolveDirectPeerAsync(currentUser.Id, clientChatId, cancellationToken); + if (peer is null) return null; + var chat = new Chat { Type = ChatType.Direct, Title = peer.DisplayName, CreatedById = currentUser.Id, LastActivityAt = DateTimeOffset.UtcNow }; + chat.Members.Add(new ChatMember { Chat = chat, User = currentUser, UserId = currentUser.Id, IsOwner = true, Role = ChatMemberRole.Owner, CanPinMessages = true, CanDeleteMessages = true, CanInviteUsers = false, CanPostMessages = true }); + chat.Members.Add(new ChatMember { Chat = chat, User = peer, UserId = peer.Id, Role = ChatMemberRole.Member, CanPinMessages = true, CanDeleteMessages = false, CanInviteUsers = false, CanPostMessages = true }); + dbContext.Chats.Add(chat); + await dbContext.SaveChangesAsync(cancellationToken); + return chat; + } + + private async Task ResolveDirectPeerAsync(Guid currentUserId, int clientChatId, CancellationToken cancellationToken) + { + if (clientChatId <= 0) return null; + var currentClientId = await clientIdMapper.GetOrCreateAsync("user", currentUserId, cancellationToken); + var peerClientId = clientChatId ^ currentClientId; + if (peerClientId <= 0 || peerClientId == currentClientId) return null; + var peerId = await clientIdMapper.FindEntityIdAsync("user", peerClientId, cancellationToken); + return peerId is null ? null : await dbContext.Users.SingleOrDefaultAsync(item => item.Id == peerId && !item.IsBlocked, cancellationToken); + } + + private async Task GetOrCreateSavedChatAsync(Guid userId, CancellationToken cancellationToken) + { + // Use the owner's UUID as the saved-chat UUID to guarantee one per user, + // without confusing it with a direct chat left by its other member. + var chat = await dbContext.Chats + .Include(item => item.Members).ThenInclude(member => member.User) + .Include(item => item.Messages).ThenInclude(message => message.Sender) + .SingleOrDefaultAsync(item => item.Id == userId, cancellationToken); + if (chat is not null) return IsSavedChat(chat) ? chat : null; + var user = await dbContext.Users.SingleOrDefaultAsync(item => item.Id == userId && !item.IsBlocked, cancellationToken); + if (user is null) return null; + chat = new Chat { Id = userId, Type = ChatType.Direct, Title = "Избранное", CreatedById = userId }; + chat.Members.Add(new ChatMember { Chat = chat, User = user, UserId = userId, IsOwner = true, Role = ChatMemberRole.Owner, CanPostMessages = true, CanDeleteMessages = true, CanPinMessages = true, CanInviteUsers = false }); + dbContext.Chats.Add(chat); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + // Opening history and subscribing can race on different requests. + // The primary key picks one creator; reload that same saved chat. + foreach (var member in chat.Members) dbContext.Entry(member).State = EntityState.Detached; + dbContext.Entry(chat).State = EntityState.Detached; + var concurrent = await dbContext.Chats + .Include(item => item.Members).ThenInclude(member => member.User) + .Include(item => item.Messages).ThenInclude(message => message.Sender) + .SingleOrDefaultAsync(item => item.Id == userId, cancellationToken); + if (concurrent is null || !IsSavedChat(concurrent)) throw; + return concurrent; + } + return chat; + } + + private static bool IsSavedChat(Chat chat) => + chat.Type == ChatType.Direct && chat.Id == chat.CreatedById && + chat.Members.Count == 1 && chat.Members.Single().UserId == chat.CreatedById; + + private async Task MarkReadAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found.")); + var messageId = ReadInt(payload, "messageId"); + if (messageId is null) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "MESSAGE_ID_REQUIRED", "Message id is required.")); + var message = await dbContext.Messages.Join( + dbContext.QlyraClientIdMaps.Where(map => map.EntityKind == "message" && map.Id == messageId.Value), + entity => entity.Id, + map => map.EntityId, + (entity, _) => entity) + .SingleOrDefaultAsync(entity => entity.ChatId == chat.Id && entity.DeletedAt == null, cancellationToken); + if (message is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "MESSAGE_NOT_FOUND", "Message not found.")); + var membership = chat.Members.Single(member => member.UserId == user.Id); + if (membership.LastReadAt is null || membership.LastReadAt < message.SentAt) + { + membership.LastReadAt = message.SentAt; + await dbContext.SaveChangesAsync(cancellationToken); + var clientChatId = await GetClientChatIdAsync(chat, cancellationToken); + var clientUserId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken); + await PublishPacketAsync( + chat.Members.Where(member => member.UserId != user.Id).Select(member => member.UserId), + 130, + new { chatId = clientChatId, userId = clientUserId, mark = message.SentAt.ToUnixTimeMilliseconds() }, + cancellationToken); + } + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { })); + } + + private async Task SendTypingAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found.")); + var clientChatId = await GetClientChatIdAsync(chat, cancellationToken); + var clientUserId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken); + await PublishPacketAsync( + chat.Members.Where(member => member.UserId != user.Id).Select(member => member.UserId), + 129, + new { chatId = clientChatId, userId = clientUserId, type = ReadString(payload, "type") ?? "TEXT" }, + cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { })); + } + + private async Task EditMessageAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found.")); + var message = await ResolveMessageAsync(chat.Id, ReadInt(payload, "messageId"), cancellationToken); + if (message is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "MESSAGE_NOT_FOUND", "Message not found.")); + if (message.SenderId != user.Id || message.DeletedAt is not null) + { + return StatusCode(StatusCodes.Status403Forbidden, QlyraCompatibilityResponse.Error(opcode, "MESSAGE_EDIT_FORBIDDEN", "Message cannot be edited.")); + } + var text = ReadString(payload, "text") ?? string.Empty; + if (string.IsNullOrWhiteSpace(text)) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "EMPTY_MESSAGE", "Message text is required.")); + message.Text = text; + if (payload.TryGetProperty("elements", out var editedElements) && editedElements.ValueKind == JsonValueKind.Array) message.IkarElementsJson = editedElements.GetRawText(); + if (message.ScheduledForUnixMs is not null && ReadScheduledTime(payload) is long rescheduled) + { + if (rescheduled <= DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() || rescheduled > DateTimeOffset.UtcNow.AddYears(1).ToUnixTimeMilliseconds()) + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "SCHEDULE_INVALID", "Некорректное время отправки.")); + message.ScheduledForUnixMs = rescheduled; + } + message.EditedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + message.Sender = user; + var clientChatId = await GetClientChatIdAsync(chat, cancellationToken); + var mappedMessage = await BuildMessageAsync(message, cancellationToken); + await PublishPacketAsync( + message.ScheduledForUnixMs is not null ? [user.Id] : chat.Members.Select(member => member.UserId), + message.ScheduledForUnixMs is not null ? 154 : 128, + new { chatId = clientChatId, message = mappedMessage, unread = 0 }, + cancellationToken); + if (message.ScheduledForUnixMs is null && pushDispatchQueue is not null) await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { message = mappedMessage })); + } + + private async Task DeleteMessagesAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found.")); + var messageIds = ReadIntList(payload, "messageIds"); + if (messageIds.Count == 0) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "MESSAGE_ID_REQUIRED", "Message ids are required.")); + if (ReadBool(payload, "forMe") == true && ReadString(payload, "itemType") != "DELAYED") + { + var hiddenMessageIds = await dbContext.MessageHiddenForUsers.Where(item => item.UserId == user.Id).Select(item => item.MessageId).ToListAsync(cancellationToken); + var hiddenSet = hiddenMessageIds.ToHashSet(); + var newlyHidden = new List(); + foreach (var messageId in messageIds) + { + var message = await ResolveMessageAsync(chat.Id, messageId, cancellationToken); + if (message is null || !hiddenSet.Add(message.Id)) continue; + dbContext.MessageHiddenForUsers.Add(new MessageHiddenForUser { MessageId = message.Id, UserId = user.Id }); + newlyHidden.Add(messageId); + } + if (newlyHidden.Count > 0) await dbContext.SaveChangesAsync(cancellationToken); + var clientChatIdForUser = await GetClientChatIdAsync(chat, cancellationToken); + await PublishPacketAsync([user.Id], 142, new { chatId = clientChatIdForUser, messageIds = newlyHidden }, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { })); + } + var messages = new List(); + foreach (var messageId in messageIds) + { + var message = await ResolveMessageAsync(chat.Id, messageId, cancellationToken); + if (message is null) continue; + if (message.SenderId != user.Id) return StatusCode(StatusCodes.Status403Forbidden, QlyraCompatibilityResponse.Error(opcode, "MESSAGE_DELETE_FORBIDDEN", "Message cannot be deleted.")); + if (message.DeletedAt is null) + { + message.Text = string.Empty; + message.DeletedAt = DateTimeOffset.UtcNow; + message.EditedAt = null; + messages.Add(message); + } + } + if (messages.Count > 0) await dbContext.SaveChangesAsync(cancellationToken); + var clientChatId = await GetClientChatIdAsync(chat, cancellationToken); + var clientMessageIds = await Task.WhenAll(messages.Select(message => clientIdMapper.GetOrCreateAsync("message", message.Id, cancellationToken))); + await PublishPacketAsync( + chat.Members.Select(member => member.UserId), + 142, + new { chatId = clientChatId, messageIds = clientMessageIds }, + cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { })); + } + + private async Task SetReactionAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found.")); + var message = await ResolveMessageAsync(chat.Id, ReadInt(payload, "messageId"), cancellationToken); + if (message is null || message.DeletedAt is not null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "MESSAGE_NOT_FOUND", "Message not found.")); + var emoji = ReadNestedString(payload, "reaction", "id"); + if (string.IsNullOrWhiteSpace(emoji)) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "REACTION_REQUIRED", "Reaction is required.")); + if (!await ReactionAllowedAsync(chat, message.Id, emoji, cancellationToken)) + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "REACTION_FORBIDDEN", "Эта реакция недоступна в чате.")); + var existing = await dbContext.MessageReactions.SingleOrDefaultAsync(reaction => reaction.MessageId == message.Id && reaction.UserId == user.Id, cancellationToken); + if (existing is null) + { + dbContext.MessageReactions.Add(new MessageReaction { MessageId = message.Id, UserId = user.Id, Emoji = emoji }); + } + else + { + existing.Emoji = emoji; + } + await dbContext.SaveChangesAsync(cancellationToken); + var reactionInfo = await BuildReactionInfoAsync(message.Id, user.Id, cancellationToken); + await PublishReactionChangedAsync(chat, message.Id, reactionInfo, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { reactionInfo })); + } + + private async Task CancelReactionAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found.")); + var message = await ResolveMessageAsync(chat.Id, ReadInt(payload, "messageId"), cancellationToken); + if (message is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "MESSAGE_NOT_FOUND", "Message not found.")); + var existing = await dbContext.MessageReactions.SingleOrDefaultAsync(reaction => reaction.MessageId == message.Id && reaction.UserId == user.Id, cancellationToken); + if (existing is not null) + { + dbContext.MessageReactions.Remove(existing); + await dbContext.SaveChangesAsync(cancellationToken); + } + var reactionInfo = await BuildReactionInfoAsync(message.Id, user.Id, cancellationToken); + await PublishReactionChangedAsync(chat, message.Id, reactionInfo, cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { reactionInfo })); + } + + private async Task GetDetailedReactionsAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken); + if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found.")); + var message = await ResolveMessageAsync(chat.Id, ReadInt(payload, "messageId"), cancellationToken); + if (message is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "MESSAGE_NOT_FOUND", "Message not found.")); + var count = Math.Clamp(ReadInt(payload, "count") ?? 100, 1, 100); + var entities = await dbContext.MessageReactions.Where(reaction => reaction.MessageId == message.Id).OrderBy(reaction => reaction.CreatedAt).Take(count).ToListAsync(cancellationToken); + var reactions = new List(entities.Count); + foreach (var reaction in entities) reactions.Add(new { userId = await clientIdMapper.GetOrCreateAsync("user", reaction.UserId, cancellationToken), reaction = reaction.Emoji }); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { reactions })); + } + + private async Task GetCommonChatsAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) + { + var user = await RequireCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); + var requested = ReadIntList(payload, "userIds"); + if (requested.Count == 0) return Ok(QlyraCompatibilityResponse.Ok(opcode, new { commonChats = Array.Empty() })); + var targetId = await clientIdMapper.FindEntityIdAsync("user", requested[0], cancellationToken); + if (targetId is null) return Ok(QlyraCompatibilityResponse.Ok(opcode, new { commonChats = Array.Empty() })); + var chats = await dbContext.Chats.Include(chat => chat.Members).ThenInclude(member => member.User).Include(chat => chat.Messages).ThenInclude(message => message.Sender) + .Where(chat => chat.Members.Any(member => member.UserId == user.Id) && chat.Members.Any(member => member.UserId == targetId.Value)).ToListAsync(cancellationToken); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { commonChats = await BuildChatsAsync(chats, cancellationToken) })); + } + + private async Task> BuildLoginPayloadAsync(User user, int accountId, CancellationToken cancellationToken) + { + var chats = await LoadMemberChatsAsync(user.Id, cancellationToken); + const int loginChatPageSize = 50; + var initialChats = chats.Take(loginChatPageSize).ToList(); + var contacts = await BuildContactsAsync(user.Id, cancellationToken); + var privacy = await GetOrCreatePrivacySettingsAsync(user.Id, cancellationToken); + return new Dictionary + { + ["time"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + ["accountId"] = accountId, + ["profile"] = await BuildProfileAsync(user, cancellationToken), + ["contacts"] = contacts, + ["chats"] = await BuildChatsAsync(initialChats, cancellationToken), + ["chatMarker"] = chats.Count > initialChats.Count ? initialChats.Count : 0, + ["presence"] = new Dictionary(), + ["config"] = new + { + hash = "ikar", + user = BuildUserConfig(privacy), + chats = await BuildChatConfigAsync(user.Id, cancellationToken) + } + }; + } + + private async Task> BuildChatConfigAsync(Guid userId, CancellationToken cancellationToken) + { + var memberships = await dbContext.ChatMembers + .Include(member => member.Chat) + .ThenInclude(chat => chat.Members) + .Where(member => member.UserId == userId) + .ToListAsync(cancellationToken); + var result = new Dictionary(); + foreach (var membership in memberships) + { + var chatId = await GetClientChatIdAsync(membership.Chat, cancellationToken); + var dontDisturbUntil = membership.MutedUntil is null + ? 0L + : membership.MutedUntil.Value.Year >= 9999 + ? -1L + : membership.MutedUntil.Value.ToUnixTimeMilliseconds(); + result[chatId.ToString()] = new + { + dontDisturbUntil, + favIndex = membership.IsPinned ? 1 : (int?)null + }; + } + return result; + } + + private Task RequireCurrentUserAsync(CancellationToken cancellationToken) + { + if (User.Identity?.IsAuthenticated != true) return Task.FromResult(null); + var userId = User.GetRequiredUserId(); + return dbContext.Users.SingleOrDefaultAsync(x => x.Id == userId && !x.IsBlocked, cancellationToken); + } + + private async Task GetOrCreatePrivacySettingsAsync(Guid userId, CancellationToken cancellationToken) + { + var settings = await dbContext.UserPrivacySettings.SingleOrDefaultAsync( + item => item.UserId == userId, + cancellationToken); + if (settings is not null) return settings; + settings = new UserPrivacySettings { UserId = userId }; + dbContext.UserPrivacySettings.Add(settings); + await dbContext.SaveChangesAsync(cancellationToken); + return settings; + } + + private static HashSet ParseUserIds(string? value) => + string.IsNullOrWhiteSpace(value) + ? [] + : value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(item => Guid.TryParse(item, out var parsed) ? parsed : Guid.Empty) + .Where(item => item != Guid.Empty) + .ToHashSet(); + + private Task> LoadMemberChatsAsync(Guid userId, CancellationToken cancellationToken) => + dbContext.Chats + .Include(chat => chat.Members).ThenInclude(member => member.User) + .Include(chat => chat.Messages).ThenInclude(message => message.Sender) + .Where(chat => chat.Members.Any(member => member.UserId == userId)) + .OrderByDescending(chat => chat.LastActivityAt ?? chat.CreatedAt) + .ToListAsync(cancellationToken); + + private async Task ResolveMemberChatAsync(Guid userId, int? clientChatId, CancellationToken cancellationToken) + { + if (clientChatId is null) return null; + if (clientChatId == 0) return await GetOrCreateSavedChatAsync(userId, cancellationToken); + var mapped = await dbContext.Chats + .Include(chat => chat.Members).ThenInclude(member => member.User) + .Include(chat => chat.Messages).ThenInclude(message => message.Sender) + .Where(chat => chat.Members.Any(member => member.UserId == userId)) + .Join( + dbContext.QlyraClientIdMaps.Where(map => map.EntityKind == "chat" && map.Id == clientChatId.Value), + chat => chat.Id, + map => map.EntityId, + (chat, _) => chat) + .SingleOrDefaultAsync(cancellationToken); + if (mapped is not null) return mapped; + var currentClientId = await clientIdMapper.GetOrCreateAsync("user", userId, cancellationToken); + var peerClientId = clientChatId.Value ^ currentClientId; + var peerId = await clientIdMapper.FindEntityIdAsync("user", peerClientId, cancellationToken); + if (peerId is null) return null; + return await dbContext.Chats + .Include(chat => chat.Members).ThenInclude(member => member.User) + .Include(chat => chat.Messages).ThenInclude(message => message.Sender) + .SingleOrDefaultAsync(chat => chat.Type == ChatType.Direct && chat.Members.Count == 2 && chat.Members.Any(member => member.UserId == userId) && chat.Members.Any(member => member.UserId == peerId.Value), cancellationToken); + } + + private async Task> BuildContactsAsync(Guid currentUserId, CancellationToken cancellationToken) + { + var relations = await dbContext.UserContacts + .Include(contact => contact.ContactUser) + .Where(contact => contact.OwnerUserId == currentUserId && !contact.ContactUser.IsBlocked) + .OrderBy(contact => contact.CustomFirstName ?? contact.ContactUser.DisplayName) + .ToListAsync(cancellationToken); + var contacts = new List(relations.Count); + foreach (var relation in relations) + { + contacts.Add(await BuildContactAsync( + relation.ContactUser, + cancellationToken, + relation.CustomFirstName, + relation.CustomLastName)); + } + return contacts; + } + + private async Task> BuildChatsAsync(IEnumerable chats, CancellationToken cancellationToken) + { + var chatList = chats.ToList(); + var hiddenMessageIds = new HashSet(); + if (User.Identity?.IsAuthenticated == true) + { + var currentUserId = User.GetRequiredUserId(); + var chatIds = chatList.Select(chat => chat.Id).ToArray(); + hiddenMessageIds = (await dbContext.MessageHiddenForUsers.AsNoTracking() + .Where(item => item.UserId == currentUserId && chatIds.Contains(item.Message.ChatId)) + .Select(item => item.MessageId) + .ToListAsync(cancellationToken)).ToHashSet(); + } + var result = new List(); + foreach (var chat in chatList) + { + var participants = new Dictionary(); + foreach (var member in chat.Members) + { + var participantId = await clientIdMapper.GetOrCreateAsync("user", member.UserId, cancellationToken); + participants[participantId.ToString()] = (member.LastReadAt ?? DateTimeOffset.UnixEpoch).ToUnixTimeMilliseconds(); + } + var chatId = await GetClientChatIdAsync(chat, cancellationToken); + var lastMessage = chat.Messages + .Where(message => message.DeletedAt is null && message.ScheduledForUnixMs == null && !hiddenMessageIds.Contains(message.Id)) + .OrderByDescending(message => message.SentAt) + .FirstOrDefault(); + var pinnedMessage = chat.Messages + .Where(message => message.DeletedAt is null && message.ScheduledForUnixMs == null && message.PinnedAt is not null && !hiddenMessageIds.Contains(message.Id)) + .OrderByDescending(message => message.PinnedAt) + .FirstOrDefault(); + var owner = chat.Members.FirstOrDefault(member => member.IsOwner || member.Role == ChatMemberRole.Owner); + var currentUserId = User.Identity?.IsAuthenticated == true ? User.GetRequiredUserId() : Guid.Empty; + var currentMembership = chat.Members.FirstOrDefault(member => member.UserId == currentUserId); + var unreadCount = currentMembership is null + ? 0 + : chat.Messages.Count(message => + message.DeletedAt is null && message.ScheduledForUnixMs == null && + message.SenderId != currentUserId && + !hiddenMessageIds.Contains(message.Id) && + (currentMembership.LastReadAt is null || message.SentAt > currentMembership.LastReadAt)); + var options = new Dictionary + { + ["MESSAGE_COPY_NOT_ALLOWED"] = chat.HasProtectedContent, + ["ONLY_OWNER_CAN_CHANGE_ICON_TITLE"] = chat.Type is ChatType.Group or ChatType.Channel + }; + result.Add(new + { + id = chatId, + type = chat.Type switch { ChatType.Direct => "DIALOG", ChatType.Group => "CHAT", _ => "CHANNEL" }, + title = chat.Title, + baseIconUrl = string.IsNullOrWhiteSpace(chat.AvatarStoragePath) + ? null + : BuildPublicUrl($"/api/ikar/chats/{chat.Id:D}/avatar/{Uri.EscapeDataString(Path.GetFileName(chat.AvatarStoragePath))}"), + participants, + participantsCount = participants.Count, + owner = owner is null ? (int?)null : await clientIdMapper.GetOrCreateAsync("user", owner.UserId, cancellationToken), + admins = (await Task.WhenAll(chat.Members.Where(member => member.IsOwner || member.Role is ChatMemberRole.Owner or ChatMemberRole.Admin).Select(member => clientIdMapper.GetOrCreateAsync("user", member.UserId, cancellationToken)))).ToArray(), + options, + newMessages = unreadCount, + lastEventTime = (chat.LastActivityAt ?? chat.CreatedAt).ToUnixTimeMilliseconds(), + lastMessage = lastMessage is null ? null : await BuildMessageAsync(lastMessage, cancellationToken), + pinnedMessage = pinnedMessage is null ? null : await BuildMessageAsync(pinnedMessage, cancellationToken) + }); + } + return result; + } + + private async Task GetClientChatIdAsync(Guid chatId, CancellationToken cancellationToken) + { + var chat = await dbContext.Chats.Include(item => item.Members).SingleAsync(item => item.Id == chatId, cancellationToken); + return await GetClientChatIdAsync(chat, cancellationToken); + } + + private async Task GetClientChatIdAsync(Chat chat, CancellationToken cancellationToken) + { + if (IsSavedChat(chat)) return 0; + if (chat.Type != ChatType.Direct || chat.Members.Count != 2) return await clientIdMapper.GetOrCreateAsync("chat", chat.Id, cancellationToken); + var ids = new List(2); + foreach (var member in chat.Members) ids.Add(await clientIdMapper.GetOrCreateAsync("user", member.UserId, cancellationToken)); + return ids[0] ^ ids[1]; + } + + private async Task BuildContactAsync( + User user, + CancellationToken cancellationToken, + string? customFirstName = null, + string? customLastName = null) + { + var userId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken); + var names = SplitDisplayName(user.DisplayName, user.Username); + var botWebApp = user.IsBot ? await dbContext.Bots.Where(x => x.UserId == user.Id && x.IsEnabled).Select(x => x.WebAppUrl).SingleOrDefaultAsync(cancellationToken) : null; + var nameEntries = new List(); + if (!string.IsNullOrWhiteSpace(customFirstName) || !string.IsNullOrWhiteSpace(customLastName)) + { + nameEntries.Add(new + { + type = "CUSTOM", + name = string.Join(' ', new[] { customFirstName, customLastName }.Where(value => !string.IsNullOrWhiteSpace(value))), + firstName = customFirstName ?? string.Empty, + lastName = customLastName + }); + } + nameEntries.Add(new { type = "ONEME", name = user.DisplayName, firstName = names.FirstName, lastName = names.LastName }); + return new + { + id = userId, + phone = PhoneAsNumber(user.NormalizedPhoneNumber), + country = "RU", + accountStatus = 0, + options = user.IsBot ? (string.IsNullOrWhiteSpace(botWebApp) ? new[] { "BOT" } : new[] { "BOT", "HAS_WEBAPP" }) : Array.Empty(), + updateTime = user.CreatedAt.ToUnixTimeMilliseconds(), + names = nameEntries, + baseUrl = string.IsNullOrWhiteSpace(user.AvatarStoragePath) ? null : $"/api/users/{user.Id}/avatar/{Uri.EscapeDataString(Path.GetFileName(user.AvatarStoragePath))}" + }; + } + + private async Task UpsertContactAsync( + Guid ownerUserId, + Guid contactUserId, + string? firstName, + string? lastName, + CancellationToken cancellationToken) + { + var normalizedFirstName = NormalizeContactName(firstName); + var normalizedLastName = NormalizeContactName(lastName); + var relation = await dbContext.UserContacts.SingleOrDefaultAsync( + contact => contact.OwnerUserId == ownerUserId && contact.ContactUserId == contactUserId, + cancellationToken); + if (relation is null) + { + relation = new UserContact + { + OwnerUserId = ownerUserId, + ContactUserId = contactUserId, + CreatedAt = DateTimeOffset.UtcNow + }; + dbContext.UserContacts.Add(relation); + } + relation.CustomFirstName = normalizedFirstName; + relation.CustomLastName = normalizedLastName; + relation.UpdatedAt = DateTimeOffset.UtcNow; + return relation; + } + + private static string? NormalizeContactName(string? value) + { + var normalized = value?.Trim(); + if (string.IsNullOrEmpty(normalized)) return null; + return normalized; + } + + private static bool IsInstallationIdValid(string? installationId) => + !string.IsNullOrWhiteSpace(installationId) && + installationId.Length == 32 && + installationId.All(Uri.IsHexDigit); + + private async Task CreatePhoneRegistrationUserAsync(string normalizedPhoneNumber, CancellationToken cancellationToken) + { + string username; + do + { + username = $"user{Convert.ToHexString(RandomNumberGenerator.GetBytes(6)).ToLowerInvariant()}"; + } + while (await dbContext.Users.AnyAsync(user => user.NormalizedUsername == username.ToUpperInvariant(), cancellationToken)); + + var user = new User + { + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = "Новый пользователь", + PhoneNumber = $"+{normalizedPhoneNumber}", + NormalizedPhoneNumber = normalizedPhoneNumber + }; + user.PasswordHash = new PasswordHasher().HashPassword(user, Convert.ToHexString(RandomNumberGenerator.GetBytes(32))); + dbContext.Users.Add(user); + return user; + } + + private async Task> LoadStoryPeerAuthorIdsAsync(Guid userId, CancellationToken cancellationToken) + { + var peerIds = await dbContext.ChatMembers.AsNoTracking() + .Where(member => member.UserId == userId) + .Join( + dbContext.ChatMembers.AsNoTracking(), + mine => mine.ChatId, + other => other.ChatId, + (_, other) => other.UserId) + .Where(peerId => peerId != userId) + .Distinct() + .ToListAsync(cancellationToken); + return peerIds; + } + + private async Task BuildStoryOwnerAsync(Guid userId, CancellationToken cancellationToken) => + new { ownerId = await clientIdMapper.GetOrCreateAsync("user", userId, cancellationToken), type = 0 }; + + private async Task> BuildStoryPreviewsAsync(IEnumerable stories, Guid viewerId, CancellationToken cancellationToken) + { + var result = new List(); + foreach (var group in stories.GroupBy(story => story.AuthorUserId)) + { + var list = group.OrderBy(story => story.CreatedAt).ToList(); + var owner = await BuildStoryOwnerAsync(group.Key, cancellationToken); + var readCount = group.Key == viewerId ? list.Count : list.Count(story => story.Views.Any(view => view.ViewerUserId == viewerId)); + result.Add(new + { + owner, + updateTime = list.Max(story => story.CreatedAt).ToUnixTimeMilliseconds(), + totalCount = list.Count, + readCount, + lastStoryExpirationTime = list.Max(story => story.ExpiresAt).ToUnixTimeMilliseconds() + }); + } + return result; + } + + private async Task BuildStoryAsync(Story story, CancellationToken cancellationToken, int cid = 0) + { + var storyId = await clientIdMapper.GetOrCreateAsync("story", story.Id, cancellationToken); + var owner = await BuildStoryOwnerAsync(story.AuthorUserId, cancellationToken); + object? media = null; + var attachment = story.Attachments.OrderBy(item => item.SortOrder).FirstOrDefault(); + if (attachment is not null) + { + var url = BuildPublicUrl($"/api/stories/{story.Id}/attachments/{attachment.Id}/content"); + media = attachment.ContentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase) + ? new { _type = "VIDEO", mp4Url = url, videoUrl = url, baseUrl = url, duration = 0 } + : (object)new { _type = "PHOTO", photoUrl = url, baseUrl = url, url }; + } + return new + { + id = storyId, + cid, + owner, + settings = story.Visibility == StoryVisibility.Everyone ? 1 : 2, + time = story.CreatedAt.ToUnixTimeMilliseconds(), + updateTime = story.CreatedAt.ToUnixTimeMilliseconds(), + expiration = story.ExpiresAt.ToUnixTimeMilliseconds(), + media + }; + } + + internal async Task BuildMessageAsync(Message message, CancellationToken cancellationToken) + { + var messageId = await clientIdMapper.GetOrCreateAsync("message", message.Id, cancellationToken); + var senderId = await clientIdMapper.GetOrCreateAsync("user", message.SenderId, cancellationToken); + var attachments = message.Attachments.Count > 0 + ? message.Attachments.OrderBy(attachment => attachment.SortOrder).ToList() + : await dbContext.MessageAttachments.Where(attachment => attachment.MessageId == message.Id).OrderBy(attachment => attachment.SortOrder).ToListAsync(cancellationToken); + var mappedAttachments = new List(attachments.Count); + if (!string.IsNullOrWhiteSpace(message.QlyraAttachesJson)) + { + using var protocolDocument = JsonDocument.Parse(message.QlyraAttachesJson); + foreach (var item in protocolDocument.RootElement.EnumerateArray()) mappedAttachments.Add(item.Clone()); + } + foreach (var attachment in attachments) + { + var attachmentId = await clientIdMapper.GetOrCreateAsync("attachment", attachment.Id, cancellationToken); + var url = BuildPublicUrl($"/api/chats/{message.ChatId}/attachments/{attachment.Id}/content"); + var token = attachment.Id.ToString("N"); + if (attachment.Kind == AttachmentKind.VoiceNote || attachment.ContentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase)) + { + mappedAttachments.Add(new { _type = "AUDIO", audioId = attachmentId, token, baseUrl = url, url, size = attachment.FileSizeBytes, name = attachment.OriginalFileName }); + } + else if (attachment.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + mappedAttachments.Add(new { _type = "PHOTO", photoId = attachmentId, photoToken = token, token, baseUrl = url, photoUrl = url, url, size = attachment.FileSizeBytes, name = attachment.OriginalFileName }); + } + else if (attachment.ContentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase)) + { + var metadata = videoPreviewService is null ? null : await videoPreviewService.GetAsync(attachment.StoragePath, cancellationToken); + var thumbnail = metadata is null ? null : BuildPublicUrl($"/api/chats/{message.ChatId}/attachments/{attachment.Id}/thumbnail"); + mappedAttachments.Add(new { _type = "VIDEO", videoId = attachmentId, token, baseUrl = thumbnail, thumbnail, + previewData = (string?)null, width = metadata?.Width, height = metadata?.Height, duration = metadata?.Duration, + mp4Url = url, videoUrl = url, url, size = attachment.FileSizeBytes, name = attachment.OriginalFileName }); + } + else + { + mappedAttachments.Add(new { _type = "FILE", fileId = attachmentId, token, baseUrl = url, url, size = attachment.FileSizeBytes, name = attachment.OriginalFileName, contentType = attachment.ContentType }); + } + } + object? replyLink = null; + if (message.ReplyToMessageId is Guid replyId) + { + var reply = await dbContext.Messages.AsNoTracking().SingleOrDefaultAsync(x => x.Id == replyId && x.ChatId == message.ChatId, cancellationToken); + if (reply is not null) replyLink = new { type = "REPLY", messageId = await clientIdMapper.GetOrCreateAsync("message", reply.Id, cancellationToken), message = new { + id = await clientIdMapper.GetOrCreateAsync("message", reply.Id, cancellationToken), sender = await clientIdMapper.GetOrCreateAsync("user", reply.SenderId, cancellationToken), + text = reply.DeletedAt is null ? reply.Text : string.Empty, time = reply.SentAt.ToUnixTimeMilliseconds(), attaches = Array.Empty() + }}; + } + return new + { + id = messageId, + cid = message.IkarClientCid ?? 0, + link = replyLink, + elements = string.IsNullOrWhiteSpace(message.IkarElementsJson) ? JsonSerializer.SerializeToElement(Array.Empty()) : JsonSerializer.Deserialize(message.IkarElementsJson), + sender = senderId, + text = message.DeletedAt is null ? message.Text : string.Empty, + time = message.SentAt.ToUnixTimeMilliseconds(), + status = message.DeletedAt is not null ? "REMOVED" : message.EditedAt is not null ? "EDITED" : "SENT", + delayedAttributes = message.ScheduledForUnixMs is null ? null : new { timeToFire = message.ScheduledForUnixMs.Value, notifySender = true }, + attaches = mappedAttachments + }; + } + + private static bool IsChatManager(ChatMember member) => + member.IsOwner || member.Role is ChatMemberRole.Owner or ChatMemberRole.Admin; + + private static ChatMember CreateChatMember(Chat chat, Guid userId) + { + var isChannel = chat.Type == ChatType.Channel; + return new ChatMember + { + ChatId = chat.Id, + UserId = userId, + Role = ChatMemberRole.Member, + CanPostMessages = !isChannel, + CanInviteUsers = !isChannel, + CanPinMessages = false, + CanDeleteMessages = false, + JoinedAt = DateTimeOffset.UtcNow + }; + } + + private static string ExtractLinkKey(string link) + { + if (Uri.TryCreate(link, UriKind.Absolute, out var uri)) + { + var segment = uri.Segments.LastOrDefault()?.Trim('/'); + if (!string.IsNullOrWhiteSpace(segment)) return segment.TrimStart('@'); + } + return link.Trim().TrimEnd('/').Split('/').Last().TrimStart('@'); + } + + private string BuildCallEndpoint(string token, string conversationId, int userId) + { + var baseUri = GetPublicBaseUri(); + var builder = new UriBuilder(baseUri) { Scheme = baseUri.Scheme == Uri.UriSchemeHttps ? "wss" : "ws", Path = "/api/ikar/calls/ws2", Query = $"token={Uri.EscapeDataString(token)}&conversationId={Uri.EscapeDataString(conversationId)}&userId={userId}" }; + return builder.Uri.ToString(); + } + + private string BuildCallVcp(string token, bool isVideo, int clientId) + { + var baseUri = GetPublicBaseUri(); + var scheme = baseUri.Scheme == Uri.UriSchemeHttps ? "wss" : "ws"; + var servers = qlyraCallStore?.IceServers ?? []; + var stun = servers.SelectMany(server => server.Urls).FirstOrDefault(url => url.StartsWith("stun:", StringComparison.OrdinalIgnoreCase)); + var turn = servers.SelectMany(server => server.Urls).Where(url => url.StartsWith("turn:", StringComparison.OrdinalIgnoreCase)).ToArray(); + var raw = JsonSerializer.SerializeToUtf8Bytes(new { tkn = token, wse = $"{scheme}://{baseUri.Authority}/api/ikar/calls/ws2", stne = stun, trne = turn.Length == 0 ? null : string.Join(',', turn), trnu = $"ikar:{clientId}", trnp = (string?)null, et = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(), iv = isVideo }); + var compressed = EncodeLz4LiteralBlock(raw); + return $"{raw.Length}:{Convert.ToBase64String(compressed)}"; + } + + private static byte[] EncodeLz4LiteralBlock(byte[] source) + { + using var output = new MemoryStream(source.Length + 8); + var literalLength = source.Length; + output.WriteByte((byte)(Math.Min(literalLength, 15) << 4)); + if (literalLength >= 15) + { + var remaining = literalLength - 15; + while (remaining >= 255) { output.WriteByte(255); remaining -= 255; } + output.WriteByte((byte)remaining); + } + output.Write(source); + return output.ToArray(); + } + + private Uri GetPublicBaseUri() + { + var configured = publicEndpointOptions?.Value.BaseUrl; + if (!string.IsNullOrWhiteSpace(configured) && Uri.TryCreate(configured, UriKind.Absolute, out var uri)) return uri; + return new Uri($"{Request.Scheme}://{Request.Host}"); + } + + private string BuildPublicUrl(string path) + { + var url = new Uri(GetPublicBaseUri(), path).ToString(); + if (path.Contains("/attachments/", StringComparison.Ordinal) && User.Identity?.IsAuthenticated == true) + { + var token = tokenService.CreateMediaToken(User.GetRequiredUserId(), User.GetRequiredSessionId(), path); + url += "?access_token=" + Uri.EscapeDataString(token); + } + return url; + } + + private async Task BuildProfileAsync(User user, CancellationToken cancellationToken) + { + var userId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken); + var names = SplitDisplayName(user.DisplayName, user.Username); + return new + { + contact = new + { + id = userId, + phone = PhoneAsNumber(user.NormalizedPhoneNumber), + country = "RU", + accountStatus = user.IsBlocked ? 1 : 0, + updateTime = user.CreatedAt.ToUnixTimeMilliseconds(), + names = new[] { new { type = "ONEME", firstName = names.FirstName, lastName = names.LastName } }, + baseUrl = string.IsNullOrWhiteSpace(user.AvatarStoragePath) + ? null + : $"/api/users/{user.Id}/avatar/{Uri.EscapeDataString(Path.GetFileName(user.AvatarStoragePath))}", + about = user.About, + online = presenceTracker.IsOnline(user.Id) + }, + profileOptions = Array.Empty() + }; + } + + private Task PublishPacketAsync( + IEnumerable userIds, + int opcode, + object payload, + CancellationToken cancellationToken) => + hubContext.Clients.Users(userIds.Select(userId => userId.ToString()).Distinct()) + .SendAsync("QlyraPacket", new { opcode, payload }, cancellationToken); + + private async Task BuildReactionInfoAsync(Guid messageId, Guid currentUserId, CancellationToken cancellationToken) + { + var reactions = await dbContext.MessageReactions.Where(reaction => reaction.MessageId == messageId).ToListAsync(cancellationToken); + return new QlyraReactionInfo( + reactions.GroupBy(reaction => reaction.Emoji).OrderBy(group => group.Key).Select(group => (object)new { reaction = group.Key, count = group.Count() }).ToArray(), + reactions.SingleOrDefault(reaction => reaction.UserId == currentUserId)?.Emoji, + reactions.Count); + } + + private async Task PublishReactionChangedAsync(Chat chat, Guid messageId, QlyraReactionInfo reactionInfo, CancellationToken cancellationToken) + { + var clientChatId = await GetClientChatIdAsync(chat, cancellationToken); + var clientMessageId = await clientIdMapper.GetOrCreateAsync("message", messageId, cancellationToken); + await PublishPacketAsync( + chat.Members.Select(member => member.UserId), + 155, + new { chatId = clientChatId, messageId = clientMessageId, counters = reactionInfo.Counters, totalCount = reactionInfo.TotalCount }, + cancellationToken); + } + + private static string? ReadString(JsonElement payload, string property) => + payload.ValueKind == JsonValueKind.Object && + payload.TryGetProperty(property, out var value) && + value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private static int? ReadInt(JsonElement payload, string property) => + payload.ValueKind == JsonValueKind.Object && + payload.TryGetProperty(property, out var value) && + value.TryGetInt32(out var number) + ? number + : null; + + private static long? ReadLong(JsonElement payload, string property) => + payload.ValueKind == JsonValueKind.Object && + payload.TryGetProperty(property, out var value) && + value.TryGetInt64(out var number) + ? number + : null; + + private static bool? ReadBool(JsonElement payload, string property) => + payload.ValueKind == JsonValueKind.Object && + payload.TryGetProperty(property, out var value) && + (value.ValueKind == JsonValueKind.True || value.ValueKind == JsonValueKind.False) + ? value.GetBoolean() + : null; + + private static List ReadIntList(JsonElement payload, string property) + { + if (payload.ValueKind != JsonValueKind.Object || + !payload.TryGetProperty(property, out var value) || + value.ValueKind != JsonValueKind.Array) return []; + return value.EnumerateArray().Where(item => item.TryGetInt32(out _)).Select(item => item.GetInt32()).ToList(); + } + + private static List ReadFlexibleIntList(JsonElement payload, string property) + { + if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.Array) return []; + var result = new List(); + foreach (var item in value.EnumerateArray()) + { + if (item.TryGetInt32(out var number)) result.Add(number); + else if (item.ValueKind == JsonValueKind.String && int.TryParse(item.GetString(), out number)) result.Add(number); + } + return result; + } + + private static List ReadStringList(JsonElement payload, string property) + { + if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.Array) return []; + return value.EnumerateArray() + .Where(item => item.ValueKind == JsonValueKind.String) + .Select(item => item.GetString()?.Trim()) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Select(item => item!) + .ToList(); + } + + private static object BuildQlyraFolder(QlyraChatFolder folder) => new + { + id = folder.Id, + title = folder.Title, + emoji = folder.Emoji, + include = DeserializeStoredIntList(folder.IncludeJson), + filters = DeserializeStoredIntList(folder.FiltersJson), + options = DeserializeStoredIntList(folder.OptionsJson), + favorites = DeserializeStoredIntList(folder.FavoritesJson), + widgets = Array.Empty(), + updateTime = folder.UpdatedAt.ToUnixTimeMilliseconds() + }; + + private static int[] DeserializeStoredIntList(string json) + { + try + { + return JsonSerializer.Deserialize(json) ?? []; + } + catch (JsonException) + { + return []; + } + } + + private Task ResolveMessageAsync(Guid chatId, int? clientMessageId, CancellationToken cancellationToken) + { + if (clientMessageId is null) return Task.FromResult(null); + return dbContext.Messages.Join( + dbContext.QlyraClientIdMaps.Where(map => map.EntityKind == "message" && map.Id == clientMessageId.Value), + entity => entity.Id, + map => map.EntityId, + (entity, _) => entity) + .SingleOrDefaultAsync(entity => entity.ChatId == chatId && (entity.ScheduledForUnixMs == null || entity.SenderId == User.GetRequiredUserId()), cancellationToken); + } + + private static string? ReadNestedString(JsonElement payload, string parent, string property) => + payload.ValueKind == JsonValueKind.Object && + payload.TryGetProperty(parent, out var parentValue) && + parentValue.ValueKind == JsonValueKind.Object && + parentValue.TryGetProperty(property, out var value) && + value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private static JsonElement ReadNestedArray(JsonElement payload, string parent, string property) + { + if (payload.ValueKind == JsonValueKind.Object && + payload.TryGetProperty(parent, out var parentValue) && + parentValue.ValueKind == JsonValueKind.Object && + parentValue.TryGetProperty(property, out var value) && + value.ValueKind == JsonValueKind.Array) return value.Clone(); + return default; + } + + private static long PhoneAsNumber(string? normalizedPhone) + { + var digits = new string((normalizedPhone ?? string.Empty).Where(char.IsDigit).ToArray()); + return long.TryParse(digits, out var value) ? value : 0; + } + + private static (string FirstName, string? LastName) SplitDisplayName(string displayName, string fallback) + { + var values = displayName.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return values.Length switch + { + 0 => (fallback, null), + 1 => (values[0], null), + _ => (values[0], string.Join(' ', values.Skip(1))) + }; + } +} + +public sealed record QlyraCompatibilityRequest(JsonElement Payload); + +public sealed record QlyraReactionInfo(object[] Counters, string? YourReaction, int TotalCount); + +public sealed class QlyraTwoFactorTrack(Guid userId) +{ + public string Id { get; } = Guid.NewGuid().ToString("N"); + public Guid UserId { get; } = userId; + public DateTimeOffset ExpiresAt { get; } = DateTimeOffset.UtcNow.AddMinutes(20); + public string? CandidatePasswordHash { get; set; } + public string? Hint { get; set; } + public string? PendingEmail { get; private set; } + public string? EmailCodeHash { get; private set; } + public DateTimeOffset? EmailCodeExpiresAt { get; private set; } + public bool EmailVerified { get; set; } + public bool PasswordVerified { get; set; } + + public void SetEmailCode(string email, string code) + { + PendingEmail = email; + EmailCodeHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(code))); + EmailCodeExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10); + EmailVerified = false; + } + + public bool IsValidEmailCode(string code) + { + if (EmailCodeExpiresAt <= DateTimeOffset.UtcNow || EmailCodeHash is null) return false; + return CryptographicOperations.FixedTimeEquals(Convert.FromHexString(EmailCodeHash), SHA256.HashData(Encoding.UTF8.GetBytes(code))); + } +} + +public sealed record QlyraCompatibilityResponse(int Cmd, int Opcode, object Payload, string? ErrorKey = null) +{ + public static QlyraCompatibilityResponse Ok(int opcode, object payload) => new(1, opcode, payload); + + public static QlyraCompatibilityResponse Error(int opcode, string errorKey, string message) => + new(3, opcode, new { message, error = errorKey, localizedMessage = message }, errorKey); +} diff --git a/ikar_server/src/Ikar.Server/Controllers/QlyraUploadsController.cs b/ikar_server/src/Ikar.Server/Controllers/QlyraUploadsController.cs new file mode 100644 index 0000000..aee3560 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Controllers/QlyraUploadsController.cs @@ -0,0 +1,99 @@ +using Ikar.Server.Infrastructure.Storage; +using Ikar.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Ikar.Server.Controllers; + +[ApiController] +[AllowAnonymous] +[Route("api/ikar/uploads")] +public sealed class QlyraUploadsController(QlyraUploadStore uploadStore, IkarDbContext dbContext) : ControllerBase +{ + [HttpGet("/api/ikar/chats/{chatId:guid}/avatar/{fileName}")] + public async Task GetChatAvatar(Guid chatId, string fileName, CancellationToken cancellationToken) + { + var avatar = await dbContext.Chats.AsNoTracking() + .Where(chat => chat.Id == chatId) + .Select(chat => new { chat.AvatarStoragePath, chat.AvatarContentType }) + .SingleOrDefaultAsync(cancellationToken); + if (avatar is null || string.IsNullOrWhiteSpace(avatar.AvatarStoragePath)) return NotFound(); + if (!string.Equals(Path.GetFileName(avatar.AvatarStoragePath), fileName, StringComparison.OrdinalIgnoreCase) || + !System.IO.File.Exists(avatar.AvatarStoragePath)) return NotFound(); + return PhysicalFile( + avatar.AvatarStoragePath, + string.IsNullOrWhiteSpace(avatar.AvatarContentType) ? "application/octet-stream" : avatar.AvatarContentType); + } + + [HttpGet("{token}")] + public IActionResult GetResumeOffset(string token) + { + var offset = uploadStore.GetChunkOffset(token); + return offset is null ? NotFound(new { error = "UPLOAD_TOKEN_INVALID" }) : Content(offset.Value.ToString(), "text/plain"); + } + + [HttpPost("{token}")] + [HttpPut("{token}")] + [RequestSizeLimit(100L * 1024L * 1024L)] + public async Task Upload(string token, CancellationToken cancellationToken) + { + try + { + UploadReservation? saved; + var contentRange = Request.Headers["Content-Range"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(contentRange)) + { + if (!TryParseContentRange(contentRange, out var start, out var end, out var total)) + { + return BadRequest(new { error = "UPLOAD_RANGE_INVALID" }); + } + saved = await uploadStore.SaveChunkAsync( + token, + Request.Body, + start, + end, + total, + null, + Request.ContentType, + cancellationToken); + } + else if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(cancellationToken); + var file = form.Files.FirstOrDefault(); + if (file is null) return BadRequest(new { error = "UPLOAD_FILE_REQUIRED" }); + await using var stream = file.OpenReadStream(); + saved = await uploadStore.SaveAsync(token, stream, file.FileName, file.ContentType, file.Length, cancellationToken); + } + else + { + saved = await uploadStore.SaveAsync( + token, + Request.Body, + Request.Query["filename"].FirstOrDefault() ?? Request.Headers.ContentDisposition.FirstOrDefault(), + Request.ContentType, + Request.ContentLength, + cancellationToken); + } + if (saved is null) return NotFound(new { error = "UPLOAD_TOKEN_INVALID" }); + var item = new { token = saved.Token, photoToken = saved.Token, videoToken = saved.Token }; + return Ok(new { item.token, item.photoToken, item.videoToken, photos = new Dictionary { ["0"] = item } }); + } + catch (InvalidOperationException exception) + { + return BadRequest(new { error = "UPLOAD_REJECTED", error_msg = exception.Message }); + } + } + + private static bool TryParseContentRange(string value, out long start, out long end, out long total) + { + start = end = total = 0; + if (!value.StartsWith("bytes ", StringComparison.OrdinalIgnoreCase)) return false; + var parts = value[6..].Split(['-', '/'], StringSplitOptions.TrimEntries); + return parts.Length == 3 && + long.TryParse(parts[0], out start) && + long.TryParse(parts[1], out end) && + long.TryParse(parts[2], out total); + } +} diff --git a/ikar_server/src/Ikar.Server/Controllers/StoriesController.cs b/ikar_server/src/Ikar.Server/Controllers/StoriesController.cs new file mode 100644 index 0000000..d23c922 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Controllers/StoriesController.cs @@ -0,0 +1,407 @@ +using System.Net.Mime; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Shared; + +namespace Ikar.Server.Controllers; + +[ApiController] +[Authorize] +[Route("api/[controller]")] +public sealed class StoriesController( + IkarDbContext dbContext, + PresenceTracker presenceTracker, + IHubContext hubContext, + AttachmentStorageService attachmentStorageService) : ControllerBase +{ + private const int MaxStoryAttachmentCount = 10; + private const long MaxStoryUploadSizeBytes = 100L * 1024L * 1024L; + + [HttpGet] + public async Task>> GetStories(CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var peerAuthorIds = await LoadStoryPeerAuthorIdsAsync(userId, cancellationToken); + var now = DateTimeOffset.UtcNow; + + var stories = await dbContext.Stories + .AsNoTracking() + .Include(x => x.AuthorUser) + .ThenInclude(x => x.Sessions) + .Include(x => x.AuthorUser) + .ThenInclude(x => x.PrivacySettings) + .Include(x => x.Attachments) + .Include(x => x.Views) + .Where(x => + x.DeletedAt == null && + x.ExpiresAt > now) + .WhereVisibleTo(userId, peerAuthorIds) + .OrderByDescending(x => x.CreatedAt) + .Take(100) + .ToListAsync(cancellationToken); + + return Ok(stories + .Select(x => x.ToDto( + presenceTracker, + userId, + sharesChatWithViewer: peerAuthorIds.Contains(x.AuthorUserId))) + .ToList()); + } + + [HttpGet("{storyId:guid}")] + public async Task> GetStory(Guid storyId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var peerAuthorIds = await LoadStoryPeerAuthorIdsAsync(userId, cancellationToken); + var now = DateTimeOffset.UtcNow; + + var story = await dbContext.Stories + .Include(x => x.AuthorUser) + .ThenInclude(x => x.Sessions) + .Include(x => x.AuthorUser) + .ThenInclude(x => x.PrivacySettings) + .Include(x => x.Attachments) + .Include(x => x.Views) + .Where(x => + x.Id == storyId && + x.DeletedAt == null && + x.ExpiresAt > now) + .WhereVisibleTo(userId, peerAuthorIds) + .SingleOrDefaultAsync(cancellationToken); + if (story is null) + { + return NotFound(); + } + + if (story.AuthorUserId != userId && story.Views.All(x => x.ViewerUserId != userId)) + { + story.Views.Add(new StoryView + { + StoryId = story.Id, + ViewerUserId = userId, + ViewedAt = DateTimeOffset.UtcNow + }); + await dbContext.SaveChangesAsync(cancellationToken); + } + + return Ok(story.ToDto( + presenceTracker, + userId, + sharesChatWithViewer: peerAuthorIds.Contains(story.AuthorUserId))); + } + + [HttpPost] + public async Task> CreateStory(CreateStoryRequest request, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var text = request.Text?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(text)) + { + return BadRequest("Story text is required for MVP stories."); + } + + var now = DateTimeOffset.UtcNow; + var ttlSeconds = Math.Clamp(request.TtlSeconds, 60, 7 * 24 * 60 * 60); + var story = new Story + { + AuthorUserId = userId, + Text = text.Length <= 4096 ? text : text[..4096], + Visibility = request.Visibility, + HasProtectedContent = request.HasProtectedContent, + CreatedAt = now, + ExpiresAt = now.AddSeconds(ttlSeconds) + }; + + dbContext.Stories.Add(story); + await AddModerationLogAsync(userId, ModerationLogTargetType.Story, story.Id, "story.created", cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + + var loaded = await LoadStoryForAuthorAsync(story.Id, userId, cancellationToken) + ?? throw new InvalidOperationException("The created story could not be reloaded."); + await NotifyStoriesChangedAsync(loaded, cancellationToken); + return Ok(loaded.ToDto(presenceTracker, userId)); + } + + [HttpPost("media")] + [RequestFormLimits(MultipartBodyLengthLimit = MaxStoryUploadSizeBytes)] + [RequestSizeLimit(MaxStoryUploadSizeBytes)] + public async Task> CreateMediaStory( + [FromForm] UploadStoryRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var files = request.Files + .Where(file => file.Length > 0) + .ToList(); + var text = request.Text?.Trim() ?? string.Empty; + + if (files.Count == 0 && string.IsNullOrWhiteSpace(text)) + { + return BadRequest("Story text or media is required."); + } + + if (files.Count > MaxStoryAttachmentCount) + { + return BadRequest($"A story can contain at most {MaxStoryAttachmentCount} files."); + } + + var totalBytes = files.Sum(x => x.Length); + if (totalBytes > MaxStoryUploadSizeBytes) + { + return BadRequest($"Story upload is limited to {MaxStoryUploadSizeBytes} bytes."); + } + + var unsupportedFile = files.FirstOrDefault(file => !IsSupportedStoryMedia(file)); + if (unsupportedFile is not null) + { + return BadRequest($"Story media type is not supported: {unsupportedFile.ContentType}"); + } + + var now = DateTimeOffset.UtcNow; + var ttlSeconds = Math.Clamp(request.TtlSeconds, 60, 7 * 24 * 60 * 60); + var story = new Story + { + AuthorUserId = userId, + Text = text.Length <= 4096 ? text : text[..4096], + Visibility = request.Visibility, + HasProtectedContent = request.HasProtectedContent, + CreatedAt = now, + ExpiresAt = now.AddSeconds(ttlSeconds) + }; + + dbContext.Stories.Add(story); + var savedAttachments = new List(); + try + { + for (var index = 0; index < files.Count; index++) + { + var attachment = await attachmentStorageService.SaveStoryAsync(story.Id, files[index], cancellationToken, index); + savedAttachments.Add(attachment); + story.Attachments.Add(attachment); + } + + await AddModerationLogAsync(userId, ModerationLogTargetType.Story, story.Id, "story.media.created", cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (Exception ex) when (ex is IOException or InvalidOperationException) + { + foreach (var attachment in savedAttachments) + { + attachmentStorageService.DeleteIfExists(attachment.StoragePath); + } + + return BadRequest(ex.Message); + } + + var loaded = await LoadStoryForAuthorAsync(story.Id, userId, cancellationToken) + ?? throw new InvalidOperationException("The created story could not be reloaded."); + await NotifyStoriesChangedAsync(loaded, cancellationToken); + return Ok(loaded.ToDto(presenceTracker, userId)); + } + + [HttpDelete("{storyId:guid}")] + public async Task DeleteStory(Guid storyId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var story = await dbContext.Stories + .Include(x => x.Attachments) + .SingleOrDefaultAsync(x => x.Id == storyId && x.AuthorUserId == userId, cancellationToken); + if (story is null) + { + return NotFound(); + } + + if (story.DeletedAt is null) + { + foreach (var attachment in story.Attachments) + { + attachmentStorageService.DeleteIfExists(attachment.StoragePath); + } + + dbContext.StoryAttachments.RemoveRange(story.Attachments); + story.Attachments.Clear(); + story.Text = string.Empty; + story.DeletedAt = DateTimeOffset.UtcNow; + await AddModerationLogAsync(userId, ModerationLogTargetType.Story, story.Id, "story.deleted", cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyStoriesChangedAsync(story, cancellationToken); + } + + return NoContent(); + } + + [HttpGet("{storyId:guid}/views")] + public async Task>> GetStoryViews(Guid storyId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var peerAuthorIds = await LoadStoryPeerAuthorIdsAsync(userId, cancellationToken); + var storyExists = await dbContext.Stories + .AsNoTracking() + .AnyAsync(x => x.Id == storyId && x.AuthorUserId == userId, cancellationToken); + if (!storyExists) + { + return NotFound(); + } + + var views = await dbContext.StoryViews + .AsNoTracking() + .Include(x => x.ViewerUser) + .ThenInclude(x => x.Sessions) + .Include(x => x.ViewerUser) + .ThenInclude(x => x.PrivacySettings) + .Where(x => x.StoryId == storyId) + .OrderByDescending(x => x.ViewedAt) + .Take(250) + .ToListAsync(cancellationToken); + + return Ok(views + .Select(x => new StoryViewDto( + x.StoryId, + x.ViewerUser.ToDtoForViewer( + presenceTracker, + userId, + sharesChatWithViewer: peerAuthorIds.Contains(x.ViewerUserId)), + x.ViewedAt)) + .ToList()); + } + + [HttpGet("{storyId:guid}/attachments/{attachmentId:guid}/content")] + public async Task DownloadAttachment(Guid storyId, Guid attachmentId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var peerAuthorIds = await LoadStoryPeerAuthorIdsAsync(userId, cancellationToken); + var now = DateTimeOffset.UtcNow; + var attachment = await dbContext.StoryAttachments + .Include(x => x.Story) + .Where( + x => x.Id == attachmentId && + x.StoryId == storyId && + x.Story.DeletedAt == null && + x.Story.ExpiresAt > now) + .WhereStoryVisibleTo(userId, peerAuthorIds) + .SingleOrDefaultAsync(cancellationToken); + if (attachment is null || !System.IO.File.Exists(attachment.StoragePath)) + { + return NotFound(); + } + + var stream = System.IO.File.OpenRead(attachment.StoragePath); + return File(stream, string.IsNullOrWhiteSpace(attachment.ContentType) ? MediaTypeNames.Application.Octet : attachment.ContentType, attachment.OriginalFileName); + } + + private async Task> LoadStoryPeerAuthorIdsAsync(Guid userId, CancellationToken cancellationToken) + { + var peerIds = await dbContext.ChatMembers + .AsNoTracking() + .Where(x => x.UserId == userId) + .Join( + dbContext.ChatMembers.AsNoTracking(), + mine => mine.ChatId, + other => other.ChatId, + (_, other) => other.UserId) + .Distinct() + .ToListAsync(cancellationToken); + + return peerIds + .Where(id => id != userId) + .Distinct() + .ToList(); + } + + private async Task NotifyStoriesChangedAsync(Story story, CancellationToken cancellationToken) + { + if (story.Visibility == StoryVisibility.Everyone) + { + await hubContext.Clients.All.SendAsync("StoriesChanged", cancellationToken); + return; + } + + var userIds = new List { story.AuthorUserId }; + if (story.Visibility is StoryVisibility.Contacts or StoryVisibility.CloseFriends) + { + userIds.AddRange(await LoadStoryPeerAuthorIdsAsync(story.AuthorUserId, cancellationToken)); + } + + await NotifyStoriesChangedAsync(userIds, cancellationToken); + } + + private Task NotifyStoriesChangedAsync(IEnumerable userIds, CancellationToken cancellationToken) + { + var affectedUsers = userIds + .Distinct() + .Select(userId => userId.ToString()) + .ToList(); + + return affectedUsers.Count == 0 + ? Task.CompletedTask + : hubContext.Clients.Users(affectedUsers).SendAsync("StoriesChanged", cancellationToken); + } + + private async Task LoadStoryForAuthorAsync(Guid storyId, Guid userId, CancellationToken cancellationToken) => + await dbContext.Stories + .Include(x => x.AuthorUser) + .ThenInclude(x => x.Sessions) + .Include(x => x.AuthorUser) + .ThenInclude(x => x.PrivacySettings) + .Include(x => x.Attachments) + .Include(x => x.Views) + .SingleOrDefaultAsync(x => x.Id == storyId && x.AuthorUserId == userId, cancellationToken); + + private static bool IsSupportedStoryMedia(IFormFile file) + { + var contentType = file.ContentType?.Split(';', 2, StringSplitOptions.TrimEntries).FirstOrDefault(); + return !string.IsNullOrWhiteSpace(contentType) && + (contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) || + contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase)); + } + + private async Task AddModerationLogAsync( + Guid actorUserId, + ModerationLogTargetType targetType, + Guid targetId, + string action, + CancellationToken cancellationToken) + { + dbContext.ModerationLogEntries.Add(new ModerationLogEntry + { + ActorUserId = actorUserId, + TargetType = targetType, + TargetId = targetId, + Action = action, + CreatedAt = DateTimeOffset.UtcNow + }); + + await Task.CompletedTask; + } +} + +internal static class StoryVisibilityQueryExtensions +{ + public static IQueryable WhereVisibleTo( + this IQueryable query, + Guid userId, + IReadOnlyCollection peerAuthorIds) => + query.Where(story => + story.AuthorUserId == userId || + story.Visibility == StoryVisibility.Everyone || + ((story.Visibility == StoryVisibility.Contacts || story.Visibility == StoryVisibility.CloseFriends) && + peerAuthorIds.Contains(story.AuthorUserId))); + + public static IQueryable WhereStoryVisibleTo( + this IQueryable query, + Guid userId, + IReadOnlyCollection peerAuthorIds) => + query.Where(attachment => + attachment.Story.AuthorUserId == userId || + attachment.Story.Visibility == StoryVisibility.Everyone || + ((attachment.Story.Visibility == StoryVisibility.Contacts || + attachment.Story.Visibility == StoryVisibility.CloseFriends) && + peerAuthorIds.Contains(attachment.Story.AuthorUserId))); +} diff --git a/ikar_server/src/Ikar.Server/Controllers/UploadAttachmentRequest.cs b/ikar_server/src/Ikar.Server/Controllers/UploadAttachmentRequest.cs new file mode 100644 index 0000000..7ba54bc --- /dev/null +++ b/ikar_server/src/Ikar.Server/Controllers/UploadAttachmentRequest.cs @@ -0,0 +1,66 @@ +using Ikar.Shared; + +namespace Ikar.Server.Controllers; + +public sealed class UploadAttachmentRequest +{ + public string? Text { get; set; } + + public Guid? ReplyToMessageId { get; set; } + + public Guid? TopicId { get; set; } + + public bool? HasProtectedContent { get; set; } + + public int? TtlSeconds { get; set; } + + public bool IsVoiceNote { get; set; } + + public IFormFile? File { get; set; } +} + +public sealed class UploadAttachmentAlbumRequest +{ + public string? Text { get; set; } + + public Guid? ReplyToMessageId { get; set; } + + public Guid? TopicId { get; set; } + + public bool? HasProtectedContent { get; set; } + + public int? TtlSeconds { get; set; } + + public List Files { get; set; } = []; +} + +public sealed class BotSendAttachmentRequest +{ + public Guid ChatId { get; set; } + + public string? Text { get; set; } + + public IFormFile? File { get; set; } +} + +public sealed class BotSendMediaGroupRequest +{ + public Guid ChatId { get; set; } + + public string? Text { get; set; } + + public List Files { get; set; } = []; +} + +public sealed class UploadStoryRequest +{ + public string? Text { get; set; } + + public StoryVisibility Visibility { get; set; } = StoryVisibility.Everyone; + + public bool HasProtectedContent { get; set; } + + public int TtlSeconds { get; set; } = 86400; + + public List Files { get; set; } = []; +} diff --git a/ikar_server/src/Ikar.Server/Controllers/UsersController.cs b/ikar_server/src/Ikar.Server/Controllers/UsersController.cs new file mode 100644 index 0000000..8ef93f8 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Controllers/UsersController.cs @@ -0,0 +1,694 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Shared; + +namespace Ikar.Server.Controllers; + +[ApiController] +[Authorize] +[Route("api/[controller]")] +public sealed class UsersController( + IkarDbContext dbContext, + PresenceTracker presenceTracker, + AvatarStorageService avatarStorageService) : ControllerBase +{ + private const long MaxAvatarSizeBytes = 5 * 1024 * 1024; + private const int MaxAboutLength = 200; + private const int ResolveContactsBatchSize = 500; + private static readonly HashSet AllowedAvatarContentTypes = + [ + "image/gif", + "image/heic", + "image/heif", + "image/jpeg", + "image/png", + "image/webp" + ]; + + private static readonly HashSet AllowedAvatarExtensions = + [ + ".gif", + ".heic", + ".heif", + ".jpeg", + ".jpg", + ".png", + ".webp" + ]; + + [HttpGet("me")] + public async Task> GetMe(CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var user = await dbContext.Users + .Include(x => x.Sessions) + .AsNoTracking() + .SingleAsync(x => x.Id == userId, cancellationToken); + return Ok(user.ToDto(presenceTracker)); + } + + [HttpGet("{userId:guid}")] + public async Task> GetUser(Guid userId, CancellationToken cancellationToken) + { + var currentUserId = User.GetRequiredUserId(); + var user = await dbContext.Users + .Include(x => x.Sessions) + .Include(x => x.PrivacySettings) + .AsNoTracking() + .SingleOrDefaultAsync(x => x.Id == userId && !x.IsBlocked, cancellationToken); + + if (user is null) + { + return NotFound(); + } + + if (user.Id != currentUserId && !user.IsBot) + { + var hasSharedChat = await dbContext.ChatMembers + .AsNoTracking() + .Where(x => x.UserId == currentUserId) + .Join( + dbContext.ChatMembers.AsNoTracking().Where(x => x.UserId == userId), + left => left.ChatId, + right => right.ChatId, + (left, _) => left.ChatId) + .AnyAsync(cancellationToken); + + if (!hasSharedChat) + { + return NotFound(); + } + } + + return Ok(await ToUserDtoForViewerAsync(user, currentUserId, cancellationToken)); + } + + [HttpGet("{userId:guid}/bot-commands")] + public async Task>> GetBotCommands( + Guid userId, + CancellationToken cancellationToken) + { + var botUser = await dbContext.Users + .AsNoTracking() + .Include(x => x.BotProfile) + .ThenInclude(x => x!.Commands) + .SingleOrDefaultAsync(x => x.Id == userId && x.IsBot && !x.IsBlocked, cancellationToken); + + if (botUser?.BotProfile is null || !botUser.BotProfile.IsEnabled) + { + return NotFound(); + } + + return Ok(botUser.BotProfile.Commands + .OrderBy(x => x.SortOrder) + .Select(x => new BotCommandDto(x.Command, x.Description)) + .ToList()); + } + + [HttpPut("me")] + public async Task> UpdateMe( + UpdateMyProfileRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var displayName = request.DisplayName?.Trim() ?? string.Empty; + var about = string.IsNullOrWhiteSpace(request.About) + ? null + : request.About.Trim(); + + if (string.IsNullOrWhiteSpace(displayName)) + { + return ValidationProblem("Display name is required."); + } + + if (displayName.Length > 120) + { + return ValidationProblem("Display name must be 120 characters or fewer."); + } + + if (about?.Length > MaxAboutLength) + { + return ValidationProblem($"About must be {MaxAboutLength} characters or fewer."); + } + + var user = await dbContext.Users + .Include(x => x.Sessions) + .SingleAsync(x => x.Id == userId, cancellationToken); + + user.DisplayName = displayName; + user.About = about; + await dbContext.SaveChangesAsync(cancellationToken); + + return Ok(user.ToDto(presenceTracker)); + } + + [HttpPost("me/avatar")] + public async Task> UploadMyAvatar( + [FromForm] IFormFile? file, + CancellationToken cancellationToken) + { + if (file is null) + { + return ValidationProblem("Avatar file is required."); + } + + if (file.Length <= 0) + { + return ValidationProblem("Avatar file is empty."); + } + + if (file.Length > MaxAvatarSizeBytes) + { + return ValidationProblem("Avatar file must be 5 MB or smaller."); + } + + if (!IsSupportedAvatar(file)) + { + return ValidationProblem("Avatar must be PNG, JPG, HEIC, HEIF, WEBP or GIF."); + } + + var userId = User.GetRequiredUserId(); + var user = await dbContext.Users + .Include(x => x.Sessions) + .SingleAsync(x => x.Id == userId, cancellationToken); + + var previousAvatarPath = user.AvatarStoragePath; + var storedAvatar = await avatarStorageService.SaveAsync(userId, file, cancellationToken); + + user.AvatarStoragePath = storedAvatar.PhysicalPath; + user.AvatarContentType = storedAvatar.ContentType; + await dbContext.SaveChangesAsync(cancellationToken); + + avatarStorageService.DeleteIfExists(previousAvatarPath); + return Ok(user.ToDto(presenceTracker)); + } + + [HttpDelete("me/avatar")] + public async Task> DeleteMyAvatar(CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var user = await dbContext.Users + .Include(x => x.Sessions) + .SingleAsync(x => x.Id == userId, cancellationToken); + + var previousAvatarPath = user.AvatarStoragePath; + user.AvatarStoragePath = null; + user.AvatarContentType = null; + await dbContext.SaveChangesAsync(cancellationToken); + + avatarStorageService.DeleteIfExists(previousAvatarPath); + return Ok(user.ToDto(presenceTracker)); + } + + [HttpGet("me/privacy")] + public async Task> GetMyPrivacySettings(CancellationToken cancellationToken) + { + var settings = await GetOrCreatePrivacySettingsAsync(User.GetRequiredUserId(), cancellationToken); + return Ok(await ToPrivacyDtoAsync(settings, cancellationToken)); + } + + [HttpPut("me/privacy")] + public async Task> UpdateMyPrivacySettings( + UpdatePrivacySettingsRequest request, + CancellationToken cancellationToken) + { + var settings = await GetOrCreatePrivacySettingsAsync(User.GetRequiredUserId(), cancellationToken); + settings.PhoneNumberVisibility = NormalizePrivacyVisibility(request.PhoneNumberVisibility, fallback: "contacts"); + settings.LastSeenVisibility = NormalizePrivacyVisibility(request.LastSeenVisibility, fallback: "contacts"); + settings.ProfilePhotoVisibility = NormalizePrivacyVisibility(request.ProfilePhotoVisibility, fallback: "everyone"); + if (request.BlockedUserIds is not null) + { + settings.BlockedUserIds = SerializeUserIdList(request.BlockedUserIds, userId: User.GetRequiredUserId()); + } + + if (request.AlwaysAllowUserIds is not null) + { + settings.AlwaysAllowUserIds = SerializeUserIdList(request.AlwaysAllowUserIds, userId: User.GetRequiredUserId()); + } + + if (request.NeverAllowUserIds is not null) + { + settings.NeverAllowUserIds = SerializeUserIdList(request.NeverAllowUserIds, userId: User.GetRequiredUserId()); + } + + settings.PasscodeLockEnabled = request.PasscodeLockEnabled; + settings.ShowMessagePreview = request.ShowMessagePreview; + settings.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + return Ok(await ToPrivacyDtoAsync(settings, cancellationToken)); + } + + [HttpGet("me/sessions")] + public async Task>> GetMySessions(CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var currentSessionId = User.GetRequiredSessionId(); + var sessions = await dbContext.UserSessions + .AsNoTracking() + .Where(x => x.UserId == userId) + .OrderByDescending(x => x.LastSeenAt) + .Select(x => new UserSessionDto( + x.Id, + x.CreatedAt, + x.LastSeenAt, + x.Id == currentSessionId, + x.Id == currentSessionId ? "This device" : "Ikar session")) + .ToListAsync(cancellationToken); + return Ok(sessions); + } + + [HttpDelete("me/sessions/{sessionId:guid}")] + public async Task RevokeMySession(Guid sessionId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var session = await dbContext.UserSessions + .SingleOrDefaultAsync(x => x.Id == sessionId && x.UserId == userId, cancellationToken); + if (session is null) + { + return NotFound(); + } + + dbContext.UserSessions.Remove(session); + await dbContext.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [AllowAnonymous] + [HttpGet("{userId:guid}/avatar/{avatarFileName}")] + public async Task GetAvatar( + Guid userId, + string avatarFileName, + CancellationToken cancellationToken) + { + var avatar = await dbContext.Users + .AsNoTracking() + .Where(x => x.Id == userId) + .Select(x => new + { + x.AvatarStoragePath, + x.AvatarContentType, + ProfilePhotoVisibility = x.PrivacySettings == null ? "everyone" : x.PrivacySettings.ProfilePhotoVisibility, + AlwaysAllowUserIds = x.PrivacySettings == null ? string.Empty : x.PrivacySettings.AlwaysAllowUserIds, + NeverAllowUserIds = x.PrivacySettings == null ? string.Empty : x.PrivacySettings.NeverAllowUserIds + }) + .SingleOrDefaultAsync(cancellationToken); + + if (avatar is null || string.IsNullOrWhiteSpace(avatar.AvatarStoragePath)) + { + return NotFound(); + } + + var storedFileName = Path.GetFileName(avatar.AvatarStoragePath); + if (!string.Equals(storedFileName, avatarFileName, StringComparison.OrdinalIgnoreCase) || + !System.IO.File.Exists(avatar.AvatarStoragePath)) + { + return NotFound(); + } + + var viewerUserId = TryGetAuthenticatedUserId(); + if (!await CanViewAvatarAsync( + ownerUserId: userId, + viewerUserId, + avatar.ProfilePhotoVisibility, + avatar.AlwaysAllowUserIds, + avatar.NeverAllowUserIds, + cancellationToken)) + { + return NotFound(); + } + + return PhysicalFile( + avatar.AvatarStoragePath, + string.IsNullOrWhiteSpace(avatar.AvatarContentType) ? "application/octet-stream" : avatar.AvatarContentType); + } + + [HttpGet("search")] + public async Task>> Search([FromQuery] string? q, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var query = q?.Trim(); + var normalizedPhone = PhoneNumberNormalizer.Normalize(query); + + var usersQuery = dbContext.Users + .AsNoTracking() + .Where(x => x.Id != userId && !x.IsBlocked); + if (!string.IsNullOrWhiteSpace(query)) + { + var normalized = query.ToUpperInvariant(); + usersQuery = usersQuery.Where(x => + x.NormalizedUsername.Contains(normalized) || + EF.Functions.Like(x.DisplayName, $"%{query}%") || + (normalizedPhone != null && x.NormalizedPhoneNumber == normalizedPhone)); + } + + var users = await usersQuery + .Include(x => x.Sessions) + .Include(x => x.PrivacySettings) + .OrderBy(x => x.DisplayName) + .Take(25) + .ToListAsync(cancellationToken); + + var dtos = new List(); + foreach (var user in users) + { + if (normalizedPhone is not null && + string.Equals(user.NormalizedPhoneNumber, normalizedPhone, StringComparison.Ordinal) && + !await CanViewFieldByPrivacyAsync( + user.Id, + userId, + user.PrivacySettings?.PhoneNumberVisibility, + user.PrivacySettings?.AlwaysAllowUserIds, + user.PrivacySettings?.NeverAllowUserIds, + fallback: "contacts", + cancellationToken) && + !MatchesUserSearchWithoutPhone(user, query)) + { + continue; + } + + dtos.Add(await ToUserDtoForViewerAsync(user, userId, cancellationToken)); + } + + return Ok(dtos); + } + + [HttpPost("contacts/resolve")] + public async Task>> ResolveContacts( + ResolveContactsRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var normalizedPhones = request.PhoneNumbers + .Select(PhoneNumberNormalizer.Normalize) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (normalizedPhones.Count == 0) + { + return Ok(Array.Empty()); + } + + var phoneOrder = normalizedPhones + .Select((phone, index) => new { phone, index }) + .ToDictionary(x => x.phone!, x => x.index, StringComparer.Ordinal); + + var users = new List(normalizedPhones.Count); + foreach (var batch in normalizedPhones.Chunk(ResolveContactsBatchSize)) + { + var batchPhones = batch.ToList(); + var batchUsers = await dbContext.Users + .Include(x => x.Sessions) + .Include(x => x.PrivacySettings) + .AsNoTracking() + .Where(x => + x.Id != userId && + !x.IsBlocked && + x.NormalizedPhoneNumber != null && + batchPhones.Contains(x.NormalizedPhoneNumber)) + .ToListAsync(cancellationToken); + + users.AddRange(batchUsers); + } + + var orderedUsers = users + .Where(x => x.NormalizedPhoneNumber is not null) + .OrderBy(x => phoneOrder[x.NormalizedPhoneNumber!]) + .ToList(); + var matches = new List(orderedUsers.Count); + foreach (var matchedUser in orderedUsers) + { + matches.Add(new ContactMatchDto( + matchedUser.NormalizedPhoneNumber!, + await ToUserDtoForViewerAsync(matchedUser, userId, cancellationToken))); + } + + return Ok(matches); + } + + private async Task GetOrCreatePrivacySettingsAsync(Guid userId, CancellationToken cancellationToken) + { + var settings = await dbContext.UserPrivacySettings + .SingleOrDefaultAsync(x => x.UserId == userId, cancellationToken); + if (settings is not null) + { + return settings; + } + + settings = new UserPrivacySettings + { + UserId = userId, + PhoneNumberVisibility = "contacts", + LastSeenVisibility = "contacts", + ProfilePhotoVisibility = "everyone", + BlockedUserIds = string.Empty, + AlwaysAllowUserIds = string.Empty, + NeverAllowUserIds = string.Empty, + ShowMessagePreview = true, + UpdatedAt = DateTimeOffset.UtcNow + }; + dbContext.UserPrivacySettings.Add(settings); + await dbContext.SaveChangesAsync(cancellationToken); + return settings; + } + + private async Task ToPrivacyDtoAsync( + UserPrivacySettings settings, + CancellationToken cancellationToken) + { + var blockedUserIds = DeserializeUserIdList(settings.BlockedUserIds); + var alwaysAllowUserIds = DeserializeUserIdList(settings.AlwaysAllowUserIds); + var neverAllowUserIds = DeserializeUserIdList(settings.NeverAllowUserIds); + var usersById = await LoadPrivacyListUsersAsync( + blockedUserIds + .Concat(alwaysAllowUserIds) + .Concat(neverAllowUserIds) + .Distinct() + .ToList(), + cancellationToken); + + return new PrivacySettingsDto( + settings.PhoneNumberVisibility, + settings.LastSeenVisibility, + settings.ProfilePhotoVisibility, + blockedUserIds, + alwaysAllowUserIds, + neverAllowUserIds, + ToOrderedPrivacyUsers(blockedUserIds, usersById), + ToOrderedPrivacyUsers(alwaysAllowUserIds, usersById), + ToOrderedPrivacyUsers(neverAllowUserIds, usersById), + settings.PasscodeLockEnabled, + settings.ShowMessagePreview, + settings.UpdatedAt); + } + + private async Task> LoadPrivacyListUsersAsync( + IReadOnlyCollection userIds, + CancellationToken cancellationToken) + { + if (userIds.Count == 0) + { + return []; + } + + var users = await dbContext.Users + .AsNoTracking() + .Include(x => x.Sessions) + .Where(x => userIds.Contains(x.Id) && !x.IsBlocked) + .ToListAsync(cancellationToken); + + return users.ToDictionary(x => x.Id, x => x.ToDto(presenceTracker)); + } + + private static IReadOnlyList ToOrderedPrivacyUsers( + IReadOnlyList userIds, + IReadOnlyDictionary usersById) => + userIds + .Select(userId => usersById.GetValueOrDefault(userId)) + .OfType() + .ToList(); + + private static string NormalizePrivacyVisibility(string? value, string fallback) + { + var normalized = value?.Trim().ToLowerInvariant(); + return normalized is "everyone" or "contacts" or "nobody" ? normalized : fallback; + } + + private static string SerializeUserIdList(IEnumerable userIds, Guid userId) => + string.Join( + ',', + userIds + .Where(x => x != Guid.Empty && x != userId) + .Distinct() + .Take(500) + .Select(x => x.ToString("D"))); + + private async Task ToUserDtoForViewerAsync( + User user, + Guid viewerUserId, + CancellationToken cancellationToken) + { + var dto = user.ToDto(presenceTracker); + if (user.Id == viewerUserId) + { + return dto; + } + + var privacy = user.PrivacySettings; + var canViewPhone = await CanViewFieldByPrivacyAsync( + user.Id, + viewerUserId, + privacy?.PhoneNumberVisibility, + privacy?.AlwaysAllowUserIds, + privacy?.NeverAllowUserIds, + fallback: "contacts", + cancellationToken); + var canViewLastSeen = await CanViewFieldByPrivacyAsync( + user.Id, + viewerUserId, + privacy?.LastSeenVisibility, + privacy?.AlwaysAllowUserIds, + privacy?.NeverAllowUserIds, + fallback: "contacts", + cancellationToken); + var canViewAvatar = await CanViewFieldByPrivacyAsync( + user.Id, + viewerUserId, + privacy?.ProfilePhotoVisibility, + privacy?.AlwaysAllowUserIds, + privacy?.NeverAllowUserIds, + fallback: "everyone", + cancellationToken); + + return dto with + { + PhoneNumber = canViewPhone ? dto.PhoneNumber : null, + LastSeenAt = canViewLastSeen ? dto.LastSeenAt : null, + IsOnline = canViewLastSeen && dto.IsOnline, + AvatarPath = canViewAvatar ? dto.AvatarPath : null + }; + } + + private static bool MatchesUserSearchWithoutPhone(User user, string? query) + { + if (string.IsNullOrWhiteSpace(query)) + { + return true; + } + + var normalized = query.Trim().ToUpperInvariant(); + return user.NormalizedUsername.Contains(normalized, StringComparison.Ordinal) || + user.DisplayName.Contains(query.Trim(), StringComparison.OrdinalIgnoreCase); + } + + private Guid? TryGetAuthenticatedUserId() + { + var value = User.FindFirstValue(ClaimTypes.NameIdentifier); + return Guid.TryParse(value, out var userId) ? userId : null; + } + + private async Task CanViewAvatarAsync( + Guid ownerUserId, + Guid? viewerUserId, + string? profilePhotoVisibility, + string? alwaysAllowUserIds, + string? neverAllowUserIds, + CancellationToken cancellationToken) + { + return await CanViewFieldByPrivacyAsync( + ownerUserId, + viewerUserId, + profilePhotoVisibility, + alwaysAllowUserIds, + neverAllowUserIds, + fallback: "everyone", + cancellationToken); + } + + private async Task CanViewFieldByPrivacyAsync( + Guid ownerUserId, + Guid? viewerUserId, + string? visibilityValue, + string? alwaysAllowUserIds, + string? neverAllowUserIds, + string fallback, + CancellationToken cancellationToken) + { + if (viewerUserId == ownerUserId) + { + return true; + } + + var neverAllow = DeserializeUserIdList(neverAllowUserIds).ToHashSet(); + if (viewerUserId is not null && neverAllow.Contains(viewerUserId.Value)) + { + return false; + } + + var visibility = NormalizePrivacyVisibility(visibilityValue, fallback); + if (viewerUserId is null) + { + return visibility == "everyone" && neverAllow.Count == 0; + } + + var alwaysAllow = DeserializeUserIdList(alwaysAllowUserIds).ToHashSet(); + if (alwaysAllow.Contains(viewerUserId.Value)) + { + return true; + } + + return visibility switch + { + "everyone" => true, + "contacts" => await HasSharedChatAsync(ownerUserId, viewerUserId.Value, cancellationToken), + _ => false + }; + } + + private async Task HasSharedChatAsync( + Guid firstUserId, + Guid secondUserId, + CancellationToken cancellationToken) => + await dbContext.ChatMembers + .AsNoTracking() + .Where(x => x.UserId == firstUserId) + .Join( + dbContext.ChatMembers.AsNoTracking().Where(x => x.UserId == secondUserId), + left => left.ChatId, + right => right.ChatId, + (left, _) => left.ChatId) + .AnyAsync(cancellationToken); + + private static IReadOnlyList DeserializeUserIdList(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } + + return value + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(x => Guid.TryParse(x, out var parsed) ? parsed : Guid.Empty) + .Where(x => x != Guid.Empty) + .Distinct() + .ToList(); + } + + private static bool IsSupportedAvatar(IFormFile file) + { + var extension = Path.GetExtension(file.FileName ?? string.Empty).ToLowerInvariant(); + if (!string.IsNullOrWhiteSpace(extension) && AllowedAvatarExtensions.Contains(extension)) + { + return true; + } + + return !string.IsNullOrWhiteSpace(file.ContentType) && + AllowedAvatarContentTypes.Contains(file.ContentType.Trim().ToLowerInvariant()); + } +} diff --git a/ikar_server/src/Ikar.Server/Data/Entities/Entities.cs b/ikar_server/src/Ikar.Server/Data/Entities/Entities.cs new file mode 100644 index 0000000..f144ea0 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Data/Entities/Entities.cs @@ -0,0 +1,482 @@ +using Ikar.Shared; + +namespace Ikar.Server.Data.Entities; + +public sealed class User +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Username { get; set; } = string.Empty; + public string NormalizedUsername { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string PasswordHash { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public bool IsBlocked { get; set; } + public DateTimeOffset? BlockedAt { get; set; } + public long? ScheduledDeletionUnixMs { get; set; } + public string? PhoneNumber { get; set; } + public string? NormalizedPhoneNumber { get; set; } + public string? Email { get; set; } + public string? NormalizedEmail { get; set; } + public string? About { get; set; } + public string? AvatarStoragePath { get; set; } + public string? AvatarContentType { get; set; } + public bool IsBot { get; set; } + + public ICollection Sessions { get; set; } = new List(); + public UserPrivacySettings? PrivacySettings { get; set; } + public ICollection ChatMemberships { get; set; } = new List(); + public ICollection CreatedChats { get; set; } = new List(); + public ICollection Messages { get; set; } = new List(); + public ICollection MessageReactions { get; set; } = new List(); + public ICollection PushDevices { get; set; } = new List(); + public ICollection DeviceCredentials { get; set; } = new List(); + public ICollection OwnedBots { get; set; } = new List(); + public Bot? BotProfile { get; set; } +} + +public sealed class UserPrivacySettings +{ + public Guid UserId { get; set; } + public User User { get; set; } = null!; + public string PhoneNumberVisibility { get; set; } = "contacts"; + public string LastSeenVisibility { get; set; } = "contacts"; + public string ProfilePhotoVisibility { get; set; } = "everyone"; + public string BlockedUserIds { get; set; } = string.Empty; + public string AlwaysAllowUserIds { get; set; } = string.Empty; + public string NeverAllowUserIds { get; set; } = string.Empty; + public bool PasscodeLockEnabled { get; set; } + public bool ShowMessagePreview { get; set; } = true; + public string QlyraConfigJson { get; set; } = "{}"; + public string QlyraFavoriteStickerSetIdsJson { get; set; } = "[]"; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class UserContact +{ + public Guid OwnerUserId { get; set; } + public User OwnerUser { get; set; } = null!; + public Guid ContactUserId { get; set; } + public User ContactUser { get; set; } = null!; + public string? CustomFirstName { get; set; } + public string? CustomLastName { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class UserSession +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid UserId { get; set; } + public User User { get; set; } = null!; + public string TokenHash { get; set; } = string.Empty; + public string? ClientPlatform { get; set; } + public int? ClientVersionCode { get; set; } + public string? ClientVersionName { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class Chat +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public ChatType Type { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public string? PublicUsername { get; set; } + public string ReactionSettingsJson { get; set; } = "{}"; + public Guid? LinkedDiscussionChatId { get; set; } + public Chat? LinkedDiscussionChat { get; set; } + public bool ChannelSignaturesEnabled { get; set; } + public bool TopicsEnabled { get; set; } + public bool HasProtectedContent { get; set; } + public int? DefaultMessageTtlSeconds { get; set; } + public string? AvatarStoragePath { get; set; } + public string? AvatarContentType { get; set; } + public Guid CreatedById { get; set; } + public User CreatedBy { get; set; } = null!; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? LastActivityAt { get; set; } + + public ICollection Members { get; set; } = new List(); + public ICollection Messages { get; set; } = new List(); + public ICollection InviteLinks { get; set; } = new List(); + public ICollection JoinRequests { get; set; } = new List(); + public ICollection Topics { get; set; } = new List(); +} + +public sealed class ChatInviteLink +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ChatId { get; set; } + public Chat Chat { get; set; } = null!; + public Guid CreatedById { get; set; } + public User CreatedBy { get; set; } = null!; + public string Token { get; set; } = string.Empty; + public string? Title { get; set; } + public bool CreatesJoinRequest { get; set; } + public bool IsRevoked { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? RevokedAt { get; set; } + public DateTimeOffset? ExpiresAt { get; set; } + public int? MaxUses { get; set; } + public int UsesCount { get; set; } + public int? MemberLimit { get; set; } +} + +public sealed class ChatTopic +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ChatId { get; set; } + public Chat Chat { get; set; } = null!; + public Guid CreatedById { get; set; } + public User CreatedBy { get; set; } = null!; + public string Title { get; set; } = string.Empty; + public string Icon { get; set; } = "💬"; + public bool IsPinned { get; set; } + public bool IsClosed { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset LastActivityAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class ChatJoinRequest +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ChatId { get; set; } + public Chat Chat { get; set; } = null!; + public Guid InviteLinkId { get; set; } + public ChatInviteLink InviteLink { get; set; } = null!; + public Guid UserId { get; set; } + public User User { get; set; } = null!; + public ChatJoinRequestStatus Status { get; set; } = ChatJoinRequestStatus.Pending; + public DateTimeOffset RequestedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? ResolvedAt { get; set; } + public Guid? ResolvedByUserId { get; set; } + public User? ResolvedByUser { get; set; } +} + +public sealed class ChatMember +{ + public Guid ChatId { get; set; } + public Chat Chat { get; set; } = null!; + public Guid UserId { get; set; } + public User User { get; set; } = null!; + public bool IsOwner { get; set; } + public ChatMemberRole Role { get; set; } = ChatMemberRole.Member; + public bool CanPinMessages { get; set; } = true; + public bool CanDeleteMessages { get; set; } + public bool CanInviteUsers { get; set; } = true; + public bool CanPostMessages { get; set; } = true; + public bool IsArchived { get; set; } + public bool IsPinned { get; set; } + public DateTimeOffset? MutedUntil { get; set; } + public string? FolderKey { get; set; } + public DateTimeOffset JoinedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? LastDeliveredAt { get; set; } + public DateTimeOffset? LastReadAt { get; set; } +} + +public sealed class Message +{ + public long? ScheduledForUnixMs { get; set; } + public long? IkarClientCid { get; set; } + public string? IkarElementsJson { get; set; } + public string? IkarTranscriptText { get; set; } + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ChatId { get; set; } + public Chat Chat { get; set; } = null!; + public Guid SenderId { get; set; } + public User Sender { get; set; } = null!; + public Guid? ClientMessageId { get; set; } + public string Text { get; set; } = string.Empty; + public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? EditedAt { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + public string? ForwardedFromDisplayName { get; set; } + public Guid? ReplyToMessageId { get; set; } + public Message? ReplyToMessage { get; set; } + public DateTimeOffset? PinnedAt { get; set; } + public Guid? PinnedByUserId { get; set; } + public User? PinnedByUser { get; set; } + public Guid? MediaAlbumId { get; set; } + public Guid? TopicId { get; set; } + public ChatTopic? Topic { get; set; } + public Guid? StoryReplyStoryId { get; set; } + public Guid? StoryReplyAuthorUserId { get; set; } + public string? StoryReplyPreviewText { get; set; } + public string? StoryReplyAttachmentKind { get; set; } + public bool HasProtectedContent { get; set; } + public DateTimeOffset? ExpiresAt { get; set; } + public int? TtlSeconds { get; set; } + public bool PostedAsChannel { get; set; } + public string? AuthorSignature { get; set; } + public Guid? DiscussionMessageId { get; set; } + public int CommentCount { get; set; } + public string? QlyraAttachesJson { get; set; } + + public ICollection Replies { get; set; } = new List(); + public ICollection Attachments { get; set; } = new List(); + public ICollection Reactions { get; set; } = new List(); + public ICollection Views { get; set; } = new List(); + public ICollection BotUpdates { get; set; } = new List(); +} + +public sealed class MessageAttachment +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid MessageId { get; set; } + public Message Message { get; set; } = null!; + public string OriginalFileName { get; set; } = string.Empty; + public string ContentType { get; set; } = "application/octet-stream"; + public AttachmentKind Kind { get; set; } = AttachmentKind.File; + public long FileSizeBytes { get; set; } + public string StoragePath { get; set; } = string.Empty; + public int SortOrder { get; set; } + public DateTimeOffset UploadedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class MessageReaction +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid MessageId { get; set; } + public Message Message { get; set; } = null!; + public Guid UserId { get; set; } + public User User { get; set; } = null!; + public string Emoji { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class MessageView +{ + public Guid MessageId { get; set; } + public Message Message { get; set; } = null!; + public Guid ViewerUserId { get; set; } + public User ViewerUser { get; set; } = null!; + public DateTimeOffset ViewedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class MessageHiddenForUser +{ + public Guid MessageId { get; set; } + public Message Message { get; set; } = null!; + public Guid UserId { get; set; } + public User User { get; set; } = null!; + public DateTimeOffset HiddenAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class PushDispatchOutboxItem +{ + public long Id { get; set; } + public string DeduplicationKey { get; set; } = string.Empty; + public Guid ChatId { get; set; } + public Guid MessageId { get; set; } + public int Kind { get; set; } + public Guid? ActorUserId { get; set; } + public string? ReactionEmoji { get; set; } + public int AttemptCount { get; set; } + public DateTimeOffset NextAttemptAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public string? LastError { get; set; } +} + +public sealed class PushDevice +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid UserId { get; set; } + public User User { get; set; } = null!; + public PushPlatform Platform { get; set; } + public string InstallationId { get; set; } = string.Empty; + public string DeviceToken { get; set; } = string.Empty; + public string? DeviceName { get; set; } + public string? ClientPlatform { get; set; } + public int? ClientVersionCode { get; set; } + public string? ClientVersionName { get; set; } + public string? WebPushPublicKey { get; set; } + public string? WebPushAuthKey { get; set; } + public bool NotificationsEnabled { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class Bot +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid UserId { get; set; } + public User User { get; set; } = null!; + public Guid OwnerUserId { get; set; } + public User OwnerUser { get; set; } = null!; + public string TokenHash { get; set; } = string.Empty; + public bool IsEnabled { get; set; } = true; + public string? WebAppUrl { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + + public ICollection Commands { get; set; } = new List(); + public ICollection Updates { get; set; } = new List(); +} + +public sealed class BotCommand +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid BotId { get; set; } + public Bot Bot { get; set; } = null!; + public string Command { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public int SortOrder { get; set; } +} + +public sealed class BotUpdate +{ + public long Id { get; set; } + public BotUpdateKind Kind { get; set; } = BotUpdateKind.Message; + public Guid BotId { get; set; } + public Bot Bot { get; set; } = null!; + public Guid ChatId { get; set; } + public Chat Chat { get; set; } = null!; + public Guid MessageId { get; set; } + public Message Message { get; set; } = null!; + public Guid? CallbackUserId { get; set; } + public User? CallbackUser { get; set; } + public string? CallbackId { get; set; } + public string? CallbackPayload { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class ModerationLogEntry +{ + public long Id { get; set; } + public Guid? ChatId { get; set; } + public Chat? Chat { get; set; } + public Guid ActorUserId { get; set; } + public User ActorUser { get; set; } = null!; + public ModerationLogTargetType TargetType { get; set; } + public Guid? TargetId { get; set; } + public string Action { get; set; } = string.Empty; + public string? Reason { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class Story +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid AuthorUserId { get; set; } + public User AuthorUser { get; set; } = null!; + public string Text { get; set; } = string.Empty; + public StoryVisibility Visibility { get; set; } = StoryVisibility.Everyone; + public bool HasProtectedContent { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset ExpiresAt { get; set; } = DateTimeOffset.UtcNow.AddHours(24); + public DateTimeOffset? DeletedAt { get; set; } + + public ICollection Attachments { get; set; } = new List(); + public ICollection Views { get; set; } = new List(); +} + +public sealed class StoryAttachment +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid StoryId { get; set; } + public Story Story { get; set; } = null!; + public string OriginalFileName { get; set; } = string.Empty; + public string ContentType { get; set; } = "application/octet-stream"; + public long FileSizeBytes { get; set; } + public string StoragePath { get; set; } = string.Empty; + public int SortOrder { get; set; } + public DateTimeOffset UploadedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class StoryView +{ + public Guid StoryId { get; set; } + public Story Story { get; set; } = null!; + public Guid ViewerUserId { get; set; } + public User ViewerUser { get; set; } = null!; + public DateTimeOffset ViewedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class AndroidUpdateCompatibilityHit +{ + public long Id { get; set; } + public DateTimeOffset HitAt { get; set; } = DateTimeOffset.UtcNow; + public string Route { get; set; } = string.Empty; + public string RequestPath { get; set; } = string.Empty; + public string? RequestedFileName { get; set; } + public string? ClientPlatform { get; set; } + public int? ClientVersionCode { get; set; } + public string? ClientVersionName { get; set; } + public string? UserAgent { get; set; } + public string? RemoteIp { get; set; } +} + +public sealed class DeviceCredential +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid UserId { get; set; } + public User User { get; set; } = null!; + public string InstallationId { get; set; } = string.Empty; + public string PublicKey { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset LastUsedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class QlyraClientIdMap +{ + public int Id { get; set; } + public string EntityKind { get; set; } = string.Empty; + public Guid EntityId { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class QlyraChatFolder +{ + public Guid UserId { get; set; } + public User User { get; set; } = null!; + public string Id { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string? Emoji { get; set; } + public string IncludeJson { get; set; } = "[]"; + public string FiltersJson { get; set; } = "[]"; + public string OptionsJson { get; set; } = "[]"; + public string FavoritesJson { get; set; } = "[]"; + public int SortOrder { get; set; } + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class QlyraPoll +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid MessageId { get; set; } + public Message Message { get; set; } = null!; + public string Title { get; set; } = string.Empty; + public int Settings { get; set; } + public int Version { get; set; } = 1; + public ICollection Answers { get; set; } = new List(); + public ICollection Votes { get; set; } = new List(); +} + +public sealed class QlyraPollAnswer +{ + public Guid PollId { get; set; } + public QlyraPoll Poll { get; set; } = null!; + public int AnswerId { get; set; } + public string Text { get; set; } = string.Empty; +} + +public sealed class QlyraPollVote +{ + public Guid PollId { get; set; } + public QlyraPoll Poll { get; set; } = null!; + public int AnswerId { get; set; } + public Guid UserId { get; set; } + public User User { get; set; } = null!; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class UserTwoFactorSettings +{ + public Guid UserId { get; set; } + public User User { get; set; } = null!; + public string PasswordHash { get; set; } = string.Empty; + public string? Hint { get; set; } + public string? Email { get; set; } + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/ikar_server/src/Ikar.Server/Data/IkarDbContext.cs b/ikar_server/src/Ikar.Server/Data/IkarDbContext.cs new file mode 100644 index 0000000..7eefa63 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Data/IkarDbContext.cs @@ -0,0 +1,646 @@ +using Ikar.Server.Data.Entities; +using Ikar.Shared; +using System.Globalization; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Ikar.Server.Data; + +public sealed class IkarDbContext(DbContextOptions options) : DbContext(options) +{ + private static readonly ValueConverter DateTimeOffsetToStringConverter = new( + value => value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), + value => DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + + private static readonly ValueConverter NullableDateTimeOffsetToStringConverter = new( + value => value.HasValue ? value.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture) : null, + value => value == null ? null : DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + + public DbSet Users => Set(); + public DbSet UserSessions => Set(); + public DbSet UserPrivacySettings => Set(); + public DbSet Chats => Set(); + public DbSet ChatMembers => Set(); + public DbSet ChatInviteLinks => Set(); + public DbSet ChatJoinRequests => Set(); + public DbSet ChatTopics => Set(); + public DbSet Messages => Set(); + public DbSet MessageAttachments => Set(); + public DbSet MessageReactions => Set(); + public DbSet MessageViews => Set(); + public DbSet MessageHiddenForUsers => Set(); + public DbSet PushDevices => Set(); + public DbSet DeviceCredentials => Set(); + public DbSet PushDispatchOutbox => Set(); + public DbSet Bots => Set(); + public DbSet BotCommands => Set(); + public DbSet BotUpdates => Set(); + public DbSet ModerationLogEntries => Set(); + public DbSet Stories => Set(); + public DbSet StoryAttachments => Set(); + public DbSet StoryViews => Set(); + public DbSet AndroidUpdateCompatibilityHits => Set(); + public DbSet QlyraClientIdMaps => Set(); + public DbSet QlyraChatFolders => Set(); + public DbSet QlyraPolls => Set(); + public DbSet QlyraPollAnswers => Set(); + public DbSet QlyraPollVotes => Set(); + public DbSet UserTwoFactorSettings => Set(); + public DbSet UserContacts => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ConfigureUsers(modelBuilder); + ConfigureChats(modelBuilder); + ConfigureMessages(modelBuilder); + ConfigurePushDevices(modelBuilder); + ConfigureDeviceCredentials(modelBuilder); + ConfigurePushDispatchOutbox(modelBuilder); + ConfigureBots(modelBuilder); + ConfigureModeration(modelBuilder); + ConfigureStories(modelBuilder); + ConfigureAndroidUpdateCompatibility(modelBuilder); + ConfigureQlyraClientIdMaps(modelBuilder); + ConfigureQlyraChatFolders(modelBuilder); + ConfigureQlyraPolls(modelBuilder); + ConfigureUserTwoFactorSettings(modelBuilder); + ConfigureUserContacts(modelBuilder); + ConfigureDateTimeOffsetConverters(modelBuilder); + } + + private static void ConfigureUserContacts(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("UserContacts"); + entity.HasKey(x => new { x.OwnerUserId, x.ContactUserId }); + entity.HasIndex(x => x.ContactUserId); + entity.Property(x => x.CustomFirstName).HasMaxLength(120); + entity.Property(x => x.CustomLastName).HasMaxLength(120); + entity.HasOne(x => x.OwnerUser).WithMany().HasForeignKey(x => x.OwnerUserId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.ContactUser).WithMany().HasForeignKey(x => x.ContactUserId).OnDelete(DeleteBehavior.Cascade); + }); + } + + private static void ConfigureUserTwoFactorSettings(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("UserTwoFactorSettings"); + entity.HasKey(x => x.UserId); + entity.Property(x => x.PasswordHash).IsRequired(); + entity.Property(x => x.Hint).HasMaxLength(100); + entity.Property(x => x.Email).HasMaxLength(320); + entity.HasOne(x => x.User).WithOne().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + }); + } + + private static void ConfigureQlyraPolls(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("QlyraPolls"); entity.HasKey(x => x.Id); entity.HasIndex(x => x.MessageId).IsUnique(); entity.Property(x => x.Title).HasMaxLength(255).IsRequired(); + entity.HasOne(x => x.Message).WithOne().HasForeignKey(x => x.MessageId).OnDelete(DeleteBehavior.Cascade); + entity.HasMany(x => x.Answers).WithOne(x => x.Poll).HasForeignKey(x => x.PollId).OnDelete(DeleteBehavior.Cascade); + entity.HasMany(x => x.Votes).WithOne(x => x.Poll).HasForeignKey(x => x.PollId).OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity(entity => { entity.ToTable("QlyraPollAnswers"); entity.HasKey(x => new { x.PollId, x.AnswerId }); entity.Property(x => x.Text).HasMaxLength(255).IsRequired(); }); + modelBuilder.Entity(entity => + { + entity.ToTable("QlyraPollVotes"); entity.HasKey(x => new { x.PollId, x.AnswerId, x.UserId }); entity.HasIndex(x => x.UserId); + entity.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + }); + } + + private static void ConfigureQlyraChatFolders(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("QlyraChatFolders"); + entity.HasKey(x => new { x.UserId, x.Id }); + entity.HasIndex(x => new { x.UserId, x.SortOrder }); + entity.Property(x => x.Id).HasMaxLength(64).IsRequired(); + entity.Property(x => x.Title).HasMaxLength(20).IsRequired(); + entity.Property(x => x.Emoji).HasMaxLength(32); + entity.Property(x => x.IncludeJson).IsRequired(); + entity.Property(x => x.FiltersJson).IsRequired(); + entity.Property(x => x.OptionsJson).IsRequired(); + entity.Property(x => x.FavoritesJson).IsRequired(); + entity.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + }); + } + + private static void ConfigureQlyraClientIdMaps(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("QlyraClientIdMaps"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Id).ValueGeneratedOnAdd(); + entity.Property(x => x.EntityKind).HasMaxLength(32).IsRequired(); + entity.HasIndex(x => new { x.EntityKind, x.EntityId }).IsUnique(); + }); + } + + private static void ConfigureDateTimeOffsetConverters(ModelBuilder modelBuilder) + { + foreach (var property in modelBuilder.Model.GetEntityTypes().SelectMany(entityType => entityType.GetProperties())) + { + if (property.ClrType == typeof(DateTimeOffset)) + { + property.SetValueConverter(DateTimeOffsetToStringConverter); + } + else if (property.ClrType == typeof(DateTimeOffset?)) + { + property.SetValueConverter(NullableDateTimeOffsetToStringConverter); + } + } + } + + private static void ConfigureUsers(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Users"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.NormalizedUsername).IsUnique(); + entity.HasIndex(x => x.NormalizedPhoneNumber) + .IsUnique() + .HasFilter("\"NormalizedPhoneNumber\" IS NOT NULL"); + entity.HasIndex(x => x.NormalizedEmail) + .IsUnique() + .HasFilter("\"NormalizedEmail\" IS NOT NULL"); + + entity.Property(x => x.Username).IsRequired(); + entity.Property(x => x.NormalizedUsername).IsRequired(); + entity.Property(x => x.DisplayName).IsRequired(); + entity.Property(x => x.PasswordHash).IsRequired(); + + entity.HasMany(x => x.Sessions) + .WithOne(x => x.User) + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(x => x.PrivacySettings) + .WithOne(x => x.User) + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("UserPrivacySettings"); + entity.HasKey(x => x.UserId); + entity.Property(x => x.PhoneNumberVisibility).IsRequired(); + entity.Property(x => x.LastSeenVisibility).IsRequired(); + entity.Property(x => x.ProfilePhotoVisibility).IsRequired(); + entity.Property(x => x.BlockedUserIds).IsRequired(); + entity.Property(x => x.AlwaysAllowUserIds).IsRequired(); + entity.Property(x => x.NeverAllowUserIds).IsRequired(); + entity.Property(x => x.QlyraConfigJson).IsRequired(); + entity.Property(x => x.QlyraFavoriteStickerSetIdsJson).IsRequired(); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("UserSessions"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.TokenHash).IsUnique(); + entity.HasIndex(x => x.UserId); + entity.Property(x => x.TokenHash).IsRequired(); + entity.Property(x => x.ClientPlatform).HasMaxLength(32); + entity.Property(x => x.ClientVersionName).HasMaxLength(64); + }); + } + + private static void ConfigureChats(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Chats"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.CreatedById); + entity.HasIndex(x => x.LastActivityAt); + entity.HasIndex(x => x.LinkedDiscussionChatId); + entity.HasIndex(x => x.PublicUsername) + .IsUnique() + .HasFilter("\"PublicUsername\" IS NOT NULL"); + + entity.HasOne(x => x.CreatedBy) + .WithMany(x => x.CreatedChats) + .HasForeignKey(x => x.CreatedById) + .OnDelete(DeleteBehavior.Restrict); + + entity.HasMany(x => x.Members) + .WithOne(x => x.Chat) + .HasForeignKey(x => x.ChatId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(x => x.Messages) + .WithOne(x => x.Chat) + .HasForeignKey(x => x.ChatId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(x => x.InviteLinks) + .WithOne(x => x.Chat) + .HasForeignKey(x => x.ChatId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(x => x.JoinRequests) + .WithOne(x => x.Chat) + .HasForeignKey(x => x.ChatId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(x => x.Topics) + .WithOne(x => x.Chat) + .HasForeignKey(x => x.ChatId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(x => x.LinkedDiscussionChat) + .WithMany() + .HasForeignKey(x => x.LinkedDiscussionChatId) + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("ChatInviteLinks"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.ChatId); + entity.HasIndex(x => x.Token).IsUnique(); + entity.Property(x => x.Token).IsRequired(); + + entity.HasOne(x => x.CreatedBy) + .WithMany() + .HasForeignKey(x => x.CreatedById) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("ChatJoinRequests"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => new { x.ChatId, x.Status }); + entity.HasIndex(x => new { x.InviteLinkId, x.UserId }).IsUnique(); + entity.HasIndex(x => x.UserId); + + entity.HasOne(x => x.InviteLink) + .WithMany() + .HasForeignKey(x => x.InviteLinkId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(x => x.User) + .WithMany() + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(x => x.ResolvedByUser) + .WithMany() + .HasForeignKey(x => x.ResolvedByUserId) + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("ChatTopics"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => new { x.ChatId, x.IsPinned }); + entity.HasIndex(x => new { x.ChatId, x.LastActivityAt }); + entity.Property(x => x.Title).IsRequired(); + entity.Property(x => x.Icon).IsRequired(); + + entity.HasOne(x => x.CreatedBy) + .WithMany() + .HasForeignKey(x => x.CreatedById) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("ChatMembers"); + entity.HasKey(x => new { x.ChatId, x.UserId }); + entity.HasIndex(x => x.UserId); + entity.HasIndex(x => new { x.UserId, x.IsPinned }); + entity.HasIndex(x => new { x.UserId, x.IsArchived }); + entity.HasIndex(x => new { x.UserId, x.FolderKey }); + + entity.HasOne(x => x.User) + .WithMany(x => x.ChatMemberships) + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + } + + private static void ConfigureMessages(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Messages"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => new { x.ChatId, x.SentAt }); + entity.HasIndex(x => new { x.ChatId, x.SenderId, x.SentAt }); + entity.HasIndex(x => new { x.SenderId, x.ClientMessageId }) + .IsUnique() + .HasFilter("\"ClientMessageId\" IS NOT NULL"); + entity.HasIndex(x => x.SenderId); + entity.HasIndex(x => x.ReplyToMessageId); + entity.HasIndex(x => x.TopicId); + entity.HasIndex(x => new { x.ChatId, x.PinnedAt }); + entity.HasIndex(x => x.MediaAlbumId); + entity.HasIndex(x => x.ExpiresAt); + entity.HasIndex(x => x.StoryReplyStoryId); + entity.HasIndex(x => x.DiscussionMessageId); + + entity.Property(x => x.Text).IsRequired(); + + entity.HasOne(x => x.Sender) + .WithMany(x => x.Messages) + .HasForeignKey(x => x.SenderId) + .OnDelete(DeleteBehavior.Restrict); + + entity.HasOne(x => x.ReplyToMessage) + .WithMany(x => x.Replies) + .HasForeignKey(x => x.ReplyToMessageId) + .OnDelete(DeleteBehavior.SetNull); + + entity.HasOne(x => x.PinnedByUser) + .WithMany() + .HasForeignKey(x => x.PinnedByUserId) + .OnDelete(DeleteBehavior.SetNull); + + entity.HasOne(x => x.Topic) + .WithMany() + .HasForeignKey(x => x.TopicId) + .OnDelete(DeleteBehavior.SetNull); + + entity.HasMany(x => x.Attachments) + .WithOne(x => x.Message) + .HasForeignKey(x => x.MessageId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(x => x.Reactions) + .WithOne(x => x.Message) + .HasForeignKey(x => x.MessageId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(x => x.Views) + .WithOne(x => x.Message) + .HasForeignKey(x => x.MessageId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MessageAttachments"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.MessageId); + entity.HasIndex(x => new { x.MessageId, x.SortOrder }); + entity.Property(x => x.OriginalFileName).IsRequired(); + entity.Property(x => x.ContentType).IsRequired(); + entity.Property(x => x.Kind).HasConversion().HasDefaultValue(AttachmentKind.File); + entity.Property(x => x.StoragePath).IsRequired(); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MessageReactions"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.MessageId); + entity.HasIndex(x => new { x.MessageId, x.UserId }).IsUnique(); + entity.Property(x => x.Emoji).IsRequired(); + + entity.HasOne(x => x.User) + .WithMany(x => x.MessageReactions) + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MessageViews"); + entity.HasKey(x => new { x.MessageId, x.ViewerUserId }); + entity.HasIndex(x => x.ViewerUserId); + + entity.HasOne(x => x.ViewerUser) + .WithMany() + .HasForeignKey(x => x.ViewerUserId) + .OnDelete(DeleteBehavior.Cascade); + }); + } + + private static void ConfigureModeration(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("ModerationLogEntries"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Id).ValueGeneratedOnAdd(); + entity.Property(x => x.Action).IsRequired(); + entity.HasIndex(x => new { x.ChatId, x.CreatedAt }); + entity.HasIndex(x => new { x.TargetType, x.TargetId }); + + entity.HasOne(x => x.Chat) + .WithMany() + .HasForeignKey(x => x.ChatId) + .OnDelete(DeleteBehavior.SetNull); + + entity.HasOne(x => x.ActorUser) + .WithMany() + .HasForeignKey(x => x.ActorUserId) + .OnDelete(DeleteBehavior.Restrict); + }); + } + + private static void ConfigureStories(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Stories"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => new { x.AuthorUserId, x.CreatedAt }); + entity.HasIndex(x => x.ExpiresAt); + entity.Property(x => x.Text).IsRequired(); + + entity.HasOne(x => x.AuthorUser) + .WithMany() + .HasForeignKey(x => x.AuthorUserId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(x => x.Attachments) + .WithOne(x => x.Story) + .HasForeignKey(x => x.StoryId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(x => x.Views) + .WithOne(x => x.Story) + .HasForeignKey(x => x.StoryId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("StoryAttachments"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => new { x.StoryId, x.SortOrder }); + entity.Property(x => x.OriginalFileName).IsRequired(); + entity.Property(x => x.ContentType).IsRequired(); + entity.Property(x => x.StoragePath).IsRequired(); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("StoryViews"); + entity.HasKey(x => new { x.StoryId, x.ViewerUserId }); + entity.HasIndex(x => x.ViewerUserId); + + entity.HasOne(x => x.ViewerUser) + .WithMany() + .HasForeignKey(x => x.ViewerUserId) + .OnDelete(DeleteBehavior.Cascade); + }); + } + + private static void ConfigurePushDevices(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("PushDevices"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.InstallationId).IsUnique(); + entity.HasIndex(x => x.UserId); + entity.Property(x => x.InstallationId).IsRequired(); + entity.Property(x => x.DeviceToken).IsRequired(); + entity.Property(x => x.ClientPlatform).HasMaxLength(32); + entity.Property(x => x.ClientVersionName).HasMaxLength(64); + entity.Property(x => x.WebPushPublicKey).HasMaxLength(256); + entity.Property(x => x.WebPushAuthKey).HasMaxLength(256); + + entity.HasOne(x => x.User) + .WithMany(x => x.PushDevices) + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + } + + private static void ConfigureDeviceCredentials(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("DeviceCredentials"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.InstallationId).IsUnique(); + entity.HasIndex(x => x.UserId); + entity.Property(x => x.InstallationId).HasMaxLength(64).IsRequired(); + entity.Property(x => x.PublicKey).HasMaxLength(2048).IsRequired(); + entity.HasOne(x => x.User) + .WithMany(x => x.DeviceCredentials) + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MessageHiddenForUsers"); + entity.HasKey(x => new { x.MessageId, x.UserId }); + entity.HasIndex(x => x.UserId); + entity.HasOne(x => x.Message).WithMany().HasForeignKey(x => x.MessageId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + }); + } + + private static void ConfigurePushDispatchOutbox(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("PushDispatchOutbox"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Id).ValueGeneratedOnAdd(); + entity.Property(x => x.DeduplicationKey).IsRequired(); + entity.HasIndex(x => x.DeduplicationKey).IsUnique(); + entity.HasIndex(x => new { x.NextAttemptAt, x.Id }); + entity.HasIndex(x => new { x.MessageId, x.Kind }); + }); + } + + private static void ConfigureBots(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Bots"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.UserId).IsUnique(); + entity.HasIndex(x => x.OwnerUserId); + entity.HasIndex(x => x.TokenHash).IsUnique(); + entity.Property(x => x.TokenHash).IsRequired(); + + entity.HasOne(x => x.User) + .WithOne(x => x.BotProfile) + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(x => x.OwnerUser) + .WithMany(x => x.OwnedBots) + .HasForeignKey(x => x.OwnerUserId) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("BotCommands"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => new { x.BotId, x.Command }).IsUnique(); + entity.Property(x => x.Command).IsRequired(); + entity.Property(x => x.Description).IsRequired(); + + entity.HasOne(x => x.Bot) + .WithMany(x => x.Commands) + .HasForeignKey(x => x.BotId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("BotUpdates"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Id).ValueGeneratedOnAdd(); + entity.Property(x => x.Kind).HasConversion(); + entity.HasIndex(x => new { x.BotId, x.Id }); + entity.HasIndex(x => new { x.BotId, x.MessageId }); + + entity.HasOne(x => x.Bot) + .WithMany(x => x.Updates) + .HasForeignKey(x => x.BotId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(x => x.Chat) + .WithMany() + .HasForeignKey(x => x.ChatId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(x => x.Message) + .WithMany(x => x.BotUpdates) + .HasForeignKey(x => x.MessageId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(x => x.CallbackUser) + .WithMany() + .HasForeignKey(x => x.CallbackUserId) + .OnDelete(DeleteBehavior.SetNull); + }); + } + + private static void ConfigureAndroidUpdateCompatibility(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("AndroidUpdateCompatibilityHits"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Id).ValueGeneratedOnAdd(); + entity.HasIndex(x => x.HitAt); + entity.HasIndex(x => new { x.Route, x.HitAt }); + entity.HasIndex(x => new { x.ClientPlatform, x.ClientVersionCode, x.HitAt }); + entity.Property(x => x.Route).IsRequired(); + entity.Property(x => x.RequestPath).IsRequired(); + }); + } +} diff --git a/ikar_server/src/Ikar.Server/Ikar.Server.csproj b/ikar_server/src/Ikar.Server/Ikar.Server.csproj new file mode 100644 index 0000000..37080c4 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Ikar.Server.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/ikar_server/src/Ikar.Server/Ikar.Server.http b/ikar_server/src/Ikar.Server/Ikar.Server.http new file mode 100644 index 0000000..36fbba3 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Ikar.Server.http @@ -0,0 +1,6 @@ +@Ikar.Server_HostAddress = http://localhost:5289 + +GET {{Ikar.Server_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminAuthConstants.cs b/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminAuthConstants.cs new file mode 100644 index 0000000..5c9a4ba --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminAuthConstants.cs @@ -0,0 +1,7 @@ +namespace Ikar.Server.Infrastructure.Admin; + +public static class AdminAuthConstants +{ + public const string Scheme = "AdminCookie"; + public const string Policy = "AdminOnly"; +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminDtos.cs b/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminDtos.cs new file mode 100644 index 0000000..a7c200b --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminDtos.cs @@ -0,0 +1,247 @@ +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure.Admin; + +public sealed record AdminSessionStateDto( + bool Configured, + bool Authenticated, + string? Username); + +public sealed record AdminStatusDto( + string Environment, + string ServerVersion, + DateTimeOffset StartedAtUtc, + DateTimeOffset CurrentTimeUtc, + bool DatabaseReachable, + string? DatabaseConnection, + int TotalUsers, + int BlockedUsers, + int BotUsers, + int TotalBots, + int EnabledBots, + int TotalChats, + int DirectChats, + int GroupChats, + int ChannelChats, + int ProtectedChats, + int TotalMessages, + int ProtectedMessages, + int ExpiringMessages, + int PinnedMessages, + int TotalAttachments, + long AttachmentBytes, + int TotalStories, + int ActiveStories, + int ProtectedStories, + int ActiveSessions, + int RegisteredPushDevices, + int PendingJoinRequests, + int ActiveInviteLinks, + int Topics, + int ModerationEvents, + int ProcessId); + +public sealed record AdminUserDto( + Guid Id, + string Username, + string DisplayName, + bool IsBlocked, + bool IsBot, + DateTimeOffset CreatedAt, + DateTimeOffset? BlockedAt, + DateTimeOffset? LastSeenAt, + string? PhoneNumber, + string? About, + string? ClientPlatform, + int? ClientVersionCode, + string? ClientVersionName, + IReadOnlyList ClientVersions, + int ActiveSessions, + int PushDevices, + int ChatMemberships, + int MessagesSent, + int Reactions, + bool PasscodeLockEnabled, + bool ShowMessagePreview); + +public sealed record AdminUserClientVersionDto( + string? ClientPlatform, + int? ClientVersionCode, + string? ClientVersionName, + DateTimeOffset LastSeenAt, + int Sessions, + int PushDevices); + +public sealed record AdminChatDto( + Guid Id, + string Type, + string Title, + string CreatedBy, + DateTimeOffset CreatedAt, + DateTimeOffset? LastActivityAt, + int Members, + int Messages, + int Attachments, + int PinnedMessages, + int ProtectedMessages, + int InviteLinks, + int PendingJoinRequests, + int Topics, + string? PublicUsername, + bool TopicsEnabled, + bool HasProtectedContent, + int? DefaultMessageTtlSeconds); + +public sealed record AdminAttachmentDto( + Guid Id, + Guid ChatId, + Guid MessageId, + string ChatTitle, + string UploadedBy, + string FileName, + string ContentType, + long FileSizeBytes, + int SortOrder, + Guid? MediaAlbumId, + DateTimeOffset UploadedAt, + bool Exists, + bool IsProtected, + string DownloadPath); + +public sealed record AdminChatDetailsDto( + AdminChatDto Chat, + IReadOnlyList Members, + IReadOnlyList InviteLinks, + IReadOnlyList JoinRequests, + IReadOnlyList Topics, + IReadOnlyList RecentMessages, + IReadOnlyList ModerationLog); + +public sealed record AdminChatMemberDto( + Guid UserId, + string Username, + string DisplayName, + bool IsBot, + ChatMemberRole Role, + bool IsOwner, + bool CanPinMessages, + bool CanDeleteMessages, + bool CanInviteUsers, + bool CanPostMessages, + bool IsArchived, + bool IsPinned, + DateTimeOffset? MutedUntil, + string? FolderKey, + DateTimeOffset JoinedAt, + DateTimeOffset? LastReadAt); + +public sealed record AdminChatInviteLinkDto( + Guid Id, + string Token, + string Link, + string? Title, + bool CreatesJoinRequest, + bool IsRevoked, + DateTimeOffset CreatedAt, + DateTimeOffset? RevokedAt); + +public sealed record AdminChatJoinRequestDto( + Guid Id, + Guid InviteLinkId, + string Username, + string DisplayName, + ChatJoinRequestStatus Status, + DateTimeOffset RequestedAt, + DateTimeOffset? ResolvedAt, + string? ResolvedBy); + +public sealed record AdminChatTopicDto( + Guid Id, + string Title, + string Icon, + bool IsPinned, + bool IsClosed, + DateTimeOffset CreatedAt, + DateTimeOffset LastActivityAt, + int MessageCount); + +public sealed record AdminMessagePreviewDto( + Guid Id, + string Sender, + string Text, + DateTimeOffset SentAt, + DateTimeOffset? EditedAt, + DateTimeOffset? DeletedAt, + bool HasProtectedContent, + Guid? MediaAlbumId, + int Attachments, + int Reactions); + +public sealed record AdminModerationLogItemDto( + long Id, + Guid? ChatId, + string ActorDisplayName, + ModerationLogTargetType TargetType, + Guid? TargetId, + string Action, + string? Reason, + DateTimeOffset CreatedAt); + +public sealed record AdminChatSettingsRequest( + string? PublicUsername, + bool TopicsEnabled, + bool HasProtectedContent, + int? DefaultMessageTtlSeconds); + +public sealed record AdminChatMemberPermissionsRequest( + ChatMemberRole Role, + bool CanPinMessages, + bool CanDeleteMessages, + bool CanInviteUsers, + bool CanPostMessages); + +public sealed record AdminCreateInviteLinkRequest( + string? Title, + bool CreatesJoinRequest); + +public sealed record AdminResolveJoinRequestRequest(bool Approve); + +public sealed record AdminExpiredMessagesCleanupDto( + int DeletedMessages, + int DeletedAttachments); + +public sealed record AdminAndroidUpdateCompatibilityReportDto( + DateTimeOffset CurrentTimeUtc, + int TotalHitsLast24Hours, + int TotalHitsLast7Days, + int HitsWithoutVersionLast24Hours, + int HitsWithoutVersionLast7Days, + DateTimeOffset? LatestHitAt, + IReadOnlyList RoutesLast7Days, + IReadOnlyList VersionsLast7Days, + IReadOnlyList RecentHits); + +public sealed record AdminAndroidUpdateCompatibilityRouteDto( + string Route, + int HitsLast24Hours, + int HitsLast7Days, + DateTimeOffset? LatestHitAt); + +public sealed record AdminAndroidUpdateCompatibilityVersionDto( + string ClientPlatform, + int? ClientVersionCode, + string? ClientVersionName, + int HitsLast24Hours, + int HitsLast7Days, + DateTimeOffset? LatestHitAt); + +public sealed record AdminAndroidUpdateCompatibilityHitDto( + DateTimeOffset HitAt, + string Route, + string RequestPath, + string? RequestedFileName, + string? ClientPlatform, + int? ClientVersionCode, + string? ClientVersionName, + string? RemoteIp, + string? UserAgent); diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminOptions.cs b/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminOptions.cs new file mode 100644 index 0000000..a6a431e --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminOptions.cs @@ -0,0 +1,14 @@ +namespace Ikar.Server.Infrastructure.Admin; + +public sealed class AdminOptions +{ + public const string SectionName = "Admin"; + + public string Username { get; set; } = string.Empty; + + public string Password { get; set; } = string.Empty; + + public bool IsConfigured => + !string.IsNullOrWhiteSpace(Username) && + !string.IsNullOrWhiteSpace(Password); +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminService.cs b/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminService.cs new file mode 100644 index 0000000..2b50806 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminService.cs @@ -0,0 +1,1154 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Bots; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure.Admin; + +public sealed class AdminService( + IkarDbContext dbContext, + IWebHostEnvironment environment, + ServerRuntimeState runtimeState, + AttachmentStorageService attachmentStorageService, + BotDeletionService botDeletionService, + IHubContext hubContext) +{ + public async Task GetStatusAsync(CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var databaseReachable = await dbContext.Database.CanConnectAsync(cancellationToken); + var serverVersion = typeof(Program).Assembly.GetName().Version?.ToString() ?? "unknown"; + var attachmentBytes = await dbContext.MessageAttachments.SumAsync(x => (long?)x.FileSizeBytes, cancellationToken) ?? 0; + + return new AdminStatusDto( + environment.EnvironmentName, + serverVersion, + runtimeState.StartedAtUtc, + now, + databaseReachable, + dbContext.Database.GetConnectionString(), + await dbContext.Users.CountAsync(cancellationToken), + await dbContext.Users.CountAsync(x => x.IsBlocked, cancellationToken), + await dbContext.Users.CountAsync(x => x.IsBot, cancellationToken), + await dbContext.Bots.CountAsync(cancellationToken), + await dbContext.Bots.CountAsync(x => x.IsEnabled, cancellationToken), + await dbContext.Chats.CountAsync(cancellationToken), + await dbContext.Chats.CountAsync(x => x.Type == ChatType.Direct, cancellationToken), + await dbContext.Chats.CountAsync(x => x.Type == ChatType.Group, cancellationToken), + await dbContext.Chats.CountAsync(x => x.Type == ChatType.Channel, cancellationToken), + await dbContext.Chats.CountAsync(x => x.HasProtectedContent, cancellationToken), + await dbContext.Messages.CountAsync(cancellationToken), + await dbContext.Messages.CountAsync(x => x.HasProtectedContent, cancellationToken), + await dbContext.Messages.CountAsync(x => x.ExpiresAt != null && x.ExpiresAt > now && x.DeletedAt == null, cancellationToken), + await dbContext.Messages.CountAsync(x => x.PinnedAt != null && x.DeletedAt == null, cancellationToken), + await dbContext.MessageAttachments.CountAsync(cancellationToken), + attachmentBytes, + await dbContext.Stories.CountAsync(cancellationToken), + await dbContext.Stories.CountAsync(x => x.DeletedAt == null && x.ExpiresAt > now, cancellationToken), + await dbContext.Stories.CountAsync(x => x.HasProtectedContent && x.DeletedAt == null, cancellationToken), + await dbContext.UserSessions.CountAsync(cancellationToken), + await dbContext.PushDevices.CountAsync(cancellationToken), + await dbContext.ChatJoinRequests.CountAsync(x => x.Status == ChatJoinRequestStatus.Pending, cancellationToken), + await dbContext.ChatInviteLinks.CountAsync(x => !x.IsRevoked, cancellationToken), + await dbContext.ChatTopics.CountAsync(cancellationToken), + await dbContext.ModerationLogEntries.CountAsync(cancellationToken), + Environment.ProcessId); + } + + public async Task GetAndroidUpdateCompatibilityReportAsync(CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var since24Hours = now.AddHours(-24); + var since7Days = now.AddDays(-7); + + var allHits = await dbContext.AndroidUpdateCompatibilityHits + .AsNoTracking() + .ToListAsync(cancellationToken); + + var hitsLast7Days = allHits + .Where(x => x.HitAt >= since7Days) + .ToList(); + + var totalHitsLast24Hours = hitsLast7Days.Count(x => x.HitAt >= since24Hours); + var totalHitsLast7Days = hitsLast7Days.Count; + var hitsWithoutVersionLast24Hours = hitsLast7Days.Count(x => x.HitAt >= since24Hours && x.ClientVersionCode == null); + var hitsWithoutVersionLast7Days = hitsLast7Days.Count(x => x.ClientVersionCode == null); + var latestHitAt = hitsLast7Days + .Select(x => (DateTimeOffset?)x.HitAt) + .DefaultIfEmpty(null) + .Max(); + + var routeBuckets = hitsLast7Days + .GroupBy(x => x.Route) + .Select(group => new AdminAndroidUpdateCompatibilityRouteDto( + group.Key, + group.Count(x => x.HitAt >= since24Hours), + group.Count(), + group.Max(x => (DateTimeOffset?)x.HitAt))) + .OrderByDescending(x => x.HitsLast7Days) + .ThenByDescending(x => x.LatestHitAt) + .ToList(); + + var versionBuckets = hitsLast7Days + .GroupBy(x => new { x.ClientPlatform, x.ClientVersionCode, x.ClientVersionName }) + .Select(group => new AdminAndroidUpdateCompatibilityVersionDto( + group.Key.ClientPlatform ?? "unknown", + group.Key.ClientVersionCode, + group.Key.ClientVersionName, + group.Count(x => x.HitAt >= since24Hours), + group.Count(), + group.Max(x => (DateTimeOffset?)x.HitAt))) + .OrderByDescending(x => x.HitsLast7Days) + .ThenByDescending(x => x.LatestHitAt) + .ToList(); + + var recentHits = hitsLast7Days + .OrderByDescending(x => x.HitAt) + .Take(50) + .Select(x => new AdminAndroidUpdateCompatibilityHitDto( + x.HitAt, + x.Route, + x.RequestPath, + x.RequestedFileName, + x.ClientPlatform, + x.ClientVersionCode, + x.ClientVersionName, + x.RemoteIp, + x.UserAgent)) + .ToList(); + + return new AdminAndroidUpdateCompatibilityReportDto( + now, + totalHitsLast24Hours, + totalHitsLast7Days, + hitsWithoutVersionLast24Hours, + hitsWithoutVersionLast7Days, + latestHitAt, + routeBuckets, + versionBuckets, + recentHits); + } + + public async Task> GetUsersAsync(string? query, CancellationToken cancellationToken) + { + var usersQuery = dbContext.Users.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query)) + { + var normalized = query.Trim().ToUpperInvariant(); + var like = $"%{query.Trim()}%"; + usersQuery = usersQuery.Where(x => + x.NormalizedUsername.Contains(normalized) || + EF.Functions.Like(x.DisplayName, like) || + (x.PhoneNumber != null && EF.Functions.Like(x.PhoneNumber, like))); + } + + var users = await usersQuery + .OrderByDescending(x => x.CreatedAt) + .Take(250) + .Select(x => new + { + x.Id, + x.Username, + x.DisplayName, + x.IsBlocked, + x.IsBot, + x.CreatedAt, + x.BlockedAt, + LastSeenAt = x.Sessions + .OrderByDescending(session => session.LastSeenAt) + .Select(session => (DateTimeOffset?)session.LastSeenAt) + .FirstOrDefault(), + x.PhoneNumber, + x.About, + ActiveSessions = x.Sessions.Count, + PushDevices = x.PushDevices.Count, + ChatMemberships = x.ChatMemberships.Count, + MessagesSent = x.Messages.Count, + Reactions = x.MessageReactions.Count, + PasscodeLockEnabled = x.PrivacySettings != null && x.PrivacySettings.PasscodeLockEnabled, + ShowMessagePreview = x.PrivacySettings == null || x.PrivacySettings.ShowMessagePreview + }) + .ToListAsync(cancellationToken); + + var userIds = users.Select(x => x.Id).ToList(); + var sessionVersions = userIds.Count == 0 + ? new List() + : await dbContext.UserSessions + .AsNoTracking() + .Where(x => userIds.Contains(x.UserId)) + .Select(x => new AdminUserClientVersionProjection( + x.UserId, + x.ClientPlatform, + x.ClientVersionCode, + x.ClientVersionName, + x.LastSeenAt, + false)) + .ToListAsync(cancellationToken); + + var pushDeviceVersions = userIds.Count == 0 + ? new List() + : await dbContext.PushDevices + .AsNoTracking() + .Where(x => userIds.Contains(x.UserId)) + .Select(x => new AdminUserClientVersionProjection( + x.UserId, + x.ClientPlatform, + x.ClientVersionCode, + x.ClientVersionName, + x.UpdatedAt, + true)) + .ToListAsync(cancellationToken); + + var versionsByUser = sessionVersions + .Concat(pushDeviceVersions) + .GroupBy(x => x.UserId) + .ToDictionary(x => x.Key, x => x.ToList()); + + return users + .Select(user => + { + versionsByUser.TryGetValue(user.Id, out var versions); + versions ??= new List(); + + var latestClientVersion = versions + .Where(HasClientVersion) + .OrderByDescending(x => x.LastSeenAt) + .FirstOrDefault(); + + return new AdminUserDto( + user.Id, + user.Username, + user.DisplayName, + user.IsBlocked, + user.IsBot, + user.CreatedAt, + user.BlockedAt, + user.LastSeenAt, + user.PhoneNumber, + user.About, + latestClientVersion?.ClientPlatform, + latestClientVersion?.ClientVersionCode, + latestClientVersion?.ClientVersionName, + BuildClientVersions(versions), + user.ActiveSessions, + user.PushDevices, + user.ChatMemberships, + user.MessagesSent, + user.Reactions, + user.PasscodeLockEnabled, + user.ShowMessagePreview); + }) + .ToList(); + } + + public async Task> GetChatsAsync(string? query, CancellationToken cancellationToken) + { + var chatsQuery = BuildChatsQuery(query); + var projections = await ProjectChats(chatsQuery.OrderByDescending(x => x.LastActivityAt ?? x.CreatedAt)) + .Take(250) + .ToListAsync(cancellationToken); + + var directTitles = await LoadDirectTitlesAsync(projections, cancellationToken); + return projections + .Select(x => ToAdminChatDto(x, directTitles)) + .ToList(); + } + + public async Task GetChatDetailsAsync(Guid chatId, CancellationToken cancellationToken) + { + var projection = await ProjectChats(dbContext.Chats.AsNoTracking().Where(x => x.Id == chatId)) + .SingleOrDefaultAsync(cancellationToken); + if (projection is null) + { + return null; + } + + var directTitles = await LoadDirectTitlesAsync([projection], cancellationToken); + var chatDto = ToAdminChatDto(projection, directTitles); + + var members = await dbContext.ChatMembers + .AsNoTracking() + .Where(x => x.ChatId == chatId) + .Include(x => x.User) + .OrderByDescending(x => x.IsOwner) + .ThenByDescending(x => x.Role) + .ThenBy(x => x.User.DisplayName) + .Select(x => new AdminChatMemberDto( + x.UserId, + x.User.Username, + x.User.DisplayName, + x.User.IsBot, + x.Role, + x.IsOwner, + x.CanPinMessages, + x.CanDeleteMessages, + x.CanInviteUsers, + x.CanPostMessages, + x.IsArchived, + x.IsPinned, + x.MutedUntil, + x.FolderKey, + x.JoinedAt, + x.LastReadAt)) + .ToListAsync(cancellationToken); + + var inviteLinks = await dbContext.ChatInviteLinks + .AsNoTracking() + .Where(x => x.ChatId == chatId) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new AdminChatInviteLinkDto( + x.Id, + x.Token, + $"/join/{x.Token}", + x.Title, + x.CreatesJoinRequest, + x.IsRevoked, + x.CreatedAt, + x.RevokedAt)) + .ToListAsync(cancellationToken); + + var joinRequests = await dbContext.ChatJoinRequests + .AsNoTracking() + .Where(x => x.ChatId == chatId) + .Include(x => x.User) + .Include(x => x.ResolvedByUser) + .OrderByDescending(x => x.Status == ChatJoinRequestStatus.Pending) + .ThenByDescending(x => x.RequestedAt) + .Select(x => new AdminChatJoinRequestDto( + x.Id, + x.InviteLinkId, + x.User.Username, + x.User.DisplayName, + x.Status, + x.RequestedAt, + x.ResolvedAt, + x.ResolvedByUser == null ? null : x.ResolvedByUser.DisplayName)) + .ToListAsync(cancellationToken); + + var topics = await dbContext.ChatTopics + .AsNoTracking() + .Where(x => x.ChatId == chatId) + .OrderByDescending(x => x.IsPinned) + .ThenByDescending(x => x.LastActivityAt) + .Select(x => new AdminChatTopicDto( + x.Id, + x.Title, + x.Icon, + x.IsPinned, + x.IsClosed, + x.CreatedAt, + x.LastActivityAt, + dbContext.Messages.Count(message => message.TopicId == x.Id && message.DeletedAt == null))) + .ToListAsync(cancellationToken); + + var recentMessages = await dbContext.Messages + .AsNoTracking() + .Where(x => x.ChatId == chatId) + .Include(x => x.Sender) + .OrderByDescending(x => x.SentAt) + .Take(30) + .Select(x => new AdminMessagePreviewDto( + x.Id, + x.Sender.DisplayName, + x.DeletedAt == null ? x.Text : string.Empty, + x.SentAt, + x.EditedAt, + x.DeletedAt, + x.HasProtectedContent, + x.MediaAlbumId, + x.Attachments.Count, + x.Reactions.Count)) + .ToListAsync(cancellationToken); + + var moderationLog = await GetModerationLogItemsAsync(chatId, 80, cancellationToken); + + return new AdminChatDetailsDto( + chatDto, + members, + inviteLinks, + joinRequests, + topics, + recentMessages, + moderationLog); + } + + public async Task UpdateChatSettingsAsync(Guid chatId, AdminChatSettingsRequest request, CancellationToken cancellationToken) + { + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync(x => x.Id == chatId, cancellationToken); + if (chat is null) + { + return null; + } + + var publicUsername = NormalizePublicUsername(request.PublicUsername); + if (publicUsername is not null) + { + var duplicate = await dbContext.Chats + .AnyAsync(x => x.Id != chatId && x.PublicUsername == publicUsername, cancellationToken); + if (duplicate) + { + throw new InvalidOperationException("Публичное имя уже занято другим чатом."); + } + } + + chat.PublicUsername = publicUsername; + chat.TopicsEnabled = request.TopicsEnabled; + chat.HasProtectedContent = request.HasProtectedContent; + chat.DefaultMessageTtlSeconds = NormalizeTtlSeconds(request.DefaultMessageTtlSeconds); + chat.LastActivityAt ??= DateTimeOffset.UtcNow; + + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(chat.Members.Select(x => x.UserId), cancellationToken); + return await GetChatDetailsAsync(chatId, cancellationToken); + } + + public async Task UpdateMemberPermissionsAsync( + Guid chatId, + Guid memberUserId, + AdminChatMemberPermissionsRequest request, + CancellationToken cancellationToken) + { + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync(x => x.Id == chatId, cancellationToken); + if (chat is null) + { + return null; + } + + var member = chat.Members.FirstOrDefault(x => x.UserId == memberUserId); + if (member is null) + { + return null; + } + + member.Role = request.Role; + member.IsOwner = request.Role == ChatMemberRole.Owner; + member.CanPinMessages = request.CanPinMessages || member.IsOwner; + member.CanDeleteMessages = request.CanDeleteMessages || member.IsOwner; + member.CanInviteUsers = request.CanInviteUsers || member.IsOwner; + member.CanPostMessages = request.CanPostMessages || member.IsOwner; + + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(chat.Members.Select(x => x.UserId), cancellationToken); + return await GetChatDetailsAsync(chatId, cancellationToken); + } + + public async Task CreateInviteLinkAsync(Guid chatId, AdminCreateInviteLinkRequest request, CancellationToken cancellationToken) + { + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync(x => x.Id == chatId, cancellationToken); + if (chat is null) + { + return null; + } + + if (chat.Type == ChatType.Direct) + { + throw new InvalidOperationException("Личные чаты не поддерживают ссылки-приглашения."); + } + + var link = new ChatInviteLink + { + ChatId = chatId, + CreatedById = chat.CreatedById, + Token = await GenerateInviteTokenAsync(cancellationToken), + Title = TrimToNull(request.Title, 64), + CreatesJoinRequest = request.CreatesJoinRequest, + CreatedAt = DateTimeOffset.UtcNow + }; + + dbContext.ChatInviteLinks.Add(link); + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(chat.Members.Select(x => x.UserId), cancellationToken); + return await GetChatDetailsAsync(chatId, cancellationToken); + } + + public async Task RevokeInviteLinkAsync(Guid chatId, Guid inviteLinkId, CancellationToken cancellationToken) + { + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync(x => x.Id == chatId, cancellationToken); + if (chat is null) + { + return null; + } + + var link = await dbContext.ChatInviteLinks + .SingleOrDefaultAsync(x => x.Id == inviteLinkId && x.ChatId == chatId, cancellationToken); + if (link is null) + { + return null; + } + + link.IsRevoked = true; + link.RevokedAt = DateTimeOffset.UtcNow; + + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(chat.Members.Select(x => x.UserId), cancellationToken); + return await GetChatDetailsAsync(chatId, cancellationToken); + } + + public async Task ResolveJoinRequestAsync( + Guid chatId, + Guid joinRequestId, + bool approve, + CancellationToken cancellationToken) + { + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync(x => x.Id == chatId, cancellationToken); + if (chat is null) + { + return null; + } + + var joinRequest = await dbContext.ChatJoinRequests + .Include(x => x.User) + .SingleOrDefaultAsync(x => x.Id == joinRequestId && x.ChatId == chatId, cancellationToken); + if (joinRequest is null) + { + return null; + } + + if (joinRequest.Status == ChatJoinRequestStatus.Pending) + { + joinRequest.Status = approve ? ChatJoinRequestStatus.Approved : ChatJoinRequestStatus.Declined; + joinRequest.ResolvedAt = DateTimeOffset.UtcNow; + joinRequest.ResolvedByUserId = null; + + if (approve && chat.Members.All(x => x.UserId != joinRequest.UserId)) + { + chat.Members.Add(new ChatMember + { + ChatId = chatId, + UserId = joinRequest.UserId, + Role = ChatMemberRole.Member, + JoinedAt = DateTimeOffset.UtcNow + }); + chat.LastActivityAt = DateTimeOffset.UtcNow; + } + + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(chat.Members.Select(x => x.UserId).Append(joinRequest.UserId), cancellationToken); + } + + return await GetChatDetailsAsync(chatId, cancellationToken); + } + + public async Task> GetAttachmentsAsync(string? query, CancellationToken cancellationToken) + { + var attachmentsQuery = dbContext.MessageAttachments + .AsNoTracking() + .Include(x => x.Message) + .ThenInclude(x => x.Chat) + .Include(x => x.Message) + .ThenInclude(x => x.Sender) + .AsQueryable(); + + if (!string.IsNullOrWhiteSpace(query)) + { + var like = $"%{query.Trim()}%"; + attachmentsQuery = attachmentsQuery.Where(x => + EF.Functions.Like(x.OriginalFileName, like) || + EF.Functions.Like(x.ContentType, like) || + EF.Functions.Like(x.Message.Sender.DisplayName, like) || + (x.Message.Chat.Title != null && EF.Functions.Like(x.Message.Chat.Title, like))); + } + + var attachments = await attachmentsQuery.ToListAsync(cancellationToken); + + return attachments + .OrderByDescending(x => x.UploadedAt) + .Take(250) + .Select(x => new AdminAttachmentDto( + x.Id, + x.Message.ChatId, + x.MessageId, + string.IsNullOrWhiteSpace(x.Message.Chat.Title) ? x.Message.Chat.Type.ToString() : x.Message.Chat.Title!, + x.Message.Sender.DisplayName, + x.OriginalFileName, + x.ContentType, + x.FileSizeBytes, + x.SortOrder, + x.Message.MediaAlbumId, + x.UploadedAt, + File.Exists(x.StoragePath), + x.Message.HasProtectedContent || x.Message.Chat.HasProtectedContent, + $"/admin/api/attachments/{x.Id}/content")) + .ToList(); + } + + public async Task DeleteAttachmentAsync(Guid attachmentId, CancellationToken cancellationToken) + { + var attachment = await dbContext.MessageAttachments + .Include(x => x.Message) + .ThenInclude(x => x.Chat) + .ThenInclude(x => x.Members) + .SingleOrDefaultAsync(x => x.Id == attachmentId, cancellationToken); + if (attachment is null) + { + return false; + } + + var affectedUserIds = attachment.Message.Chat.Members.Select(x => x.UserId).ToList(); + var path = attachment.StoragePath; + dbContext.MessageAttachments.Remove(attachment); + await dbContext.SaveChangesAsync(cancellationToken); + + if (!string.IsNullOrWhiteSpace(path)) + { + attachmentStorageService.DeleteIfExists(path); + } + + await NotifyChatsChangedAsync(affectedUserIds, cancellationToken); + return true; + } + + public async Task> GetModerationLogAsync(Guid? chatId, int limit, CancellationToken cancellationToken) => + await GetModerationLogItemsAsync(chatId, Math.Clamp(limit, 1, 250), cancellationToken); + + public async Task CleanupExpiredMessagesAsync(CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var messages = await dbContext.Messages + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .Include(x => x.Attachments) + .Include(x => x.Reactions) + .Where(x => x.ExpiresAt != null && x.ExpiresAt <= now && x.DeletedAt == null) + .Take(500) + .ToListAsync(cancellationToken); + + var deletedAttachments = 0; + var affectedUserIds = messages + .SelectMany(x => x.Chat.Members.Select(member => member.UserId)) + .Distinct() + .ToList(); + + foreach (var message in messages) + { + DeleteFiles(message.Attachments.Select(x => x.StoragePath)); + deletedAttachments += message.Attachments.Count; + dbContext.MessageAttachments.RemoveRange(message.Attachments); + dbContext.MessageReactions.RemoveRange(message.Reactions); + message.Attachments.Clear(); + message.Reactions.Clear(); + message.Text = string.Empty; + message.EditedAt = null; + message.DeletedAt = now; + message.PinnedAt = null; + message.PinnedByUserId = null; + } + + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(affectedUserIds, cancellationToken); + return new AdminExpiredMessagesCleanupDto(messages.Count, deletedAttachments); + } + + public async Task SetUserBlockedAsync(Guid userId, bool isBlocked, CancellationToken cancellationToken) + { + var user = await dbContext.Users + .Include(x => x.Sessions) + .SingleOrDefaultAsync(x => x.Id == userId, cancellationToken); + + if (user is null) + { + return false; + } + + user.IsBlocked = isBlocked; + user.BlockedAt = isBlocked ? DateTimeOffset.UtcNow : null; + + if (isBlocked && user.Sessions.Count > 0) + { + dbContext.UserSessions.RemoveRange(user.Sessions); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return true; + } + + public async Task DeleteChatAsync(Guid chatId, CancellationToken cancellationToken) + { + var chat = await dbContext.Chats + .Include(x => x.Members) + .Include(x => x.Messages) + .ThenInclude(x => x.Attachments) + .SingleOrDefaultAsync(x => x.Id == chatId, cancellationToken); + + if (chat is null) + { + return false; + } + + var affectedUserIds = chat.Members.Select(x => x.UserId).Distinct().ToList(); + var attachmentPaths = chat.Messages + .SelectMany(x => x.Attachments) + .Select(x => x.StoragePath) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct() + .ToList(); + + dbContext.Chats.Remove(chat); + await dbContext.SaveChangesAsync(cancellationToken); + + DeleteFiles(attachmentPaths); + await NotifyChatsChangedAsync(affectedUserIds, cancellationToken); + + return true; + } + + public async Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken) + { + var userBotId = await dbContext.Bots + .AsNoTracking() + .Where(x => x.UserId == userId) + .Select(x => (Guid?)x.Id) + .SingleOrDefaultAsync(cancellationToken); + if (userBotId.HasValue) + { + var deletedBot = await botDeletionService.DeleteBotAsync(userBotId.Value, ownerUserId: null, cancellationToken); + return deletedBot is not null; + } + + var ownedBotIds = await dbContext.Bots + .AsNoTracking() + .Where(x => x.OwnerUserId == userId) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + foreach (var botId in ownedBotIds) + { + await botDeletionService.DeleteBotAsync(botId, ownerUserId: userId, cancellationToken); + } + + var user = await dbContext.Users + .Include(x => x.Sessions) + .Include(x => x.PushDevices) + .SingleOrDefaultAsync(x => x.Id == userId, cancellationToken); + + if (user is null) + { + return false; + } + + var chats = await dbContext.Chats + .Include(x => x.Members) + .Include(x => x.Messages) + .ThenInclude(x => x.Attachments) + .Where(x => x.CreatedById == userId || x.Members.Any(member => member.UserId == userId)) + .ToListAsync(cancellationToken); + + var affectedUserIds = new HashSet(); + var attachmentPathsToDelete = new HashSet(StringComparer.OrdinalIgnoreCase); + var chatsToDelete = new List(); + + foreach (var chat in chats) + { + foreach (var memberId in chat.Members.Select(x => x.UserId)) + { + if (memberId != userId) + { + affectedUserIds.Add(memberId); + } + } + + if (chat.Type == ChatType.Direct && chat.Members.Any(x => x.UserId == userId)) + { + CollectAttachmentPaths(chat.Messages.SelectMany(x => x.Attachments), attachmentPathsToDelete); + chatsToDelete.Add(chat); + continue; + } + + var messagesToDelete = chat.Messages + .Where(x => x.SenderId == userId) + .ToList(); + + CollectAttachmentPaths(messagesToDelete.SelectMany(x => x.Attachments), attachmentPathsToDelete); + + if (messagesToDelete.Count > 0) + { + dbContext.Messages.RemoveRange(messagesToDelete); + } + + var membership = chat.Members.FirstOrDefault(x => x.UserId == userId); + if (membership is not null) + { + dbContext.ChatMembers.Remove(membership); + } + + var remainingMembers = chat.Members + .Where(x => x.UserId != userId) + .OrderByDescending(x => x.IsOwner) + .ThenBy(x => x.JoinedAt) + .ToList(); + + if (remainingMembers.Count == 0) + { + chatsToDelete.Add(chat); + continue; + } + + if (chat.CreatedById == userId) + { + var replacementOwner = remainingMembers.First(); + replacementOwner.IsOwner = true; + replacementOwner.Role = ChatMemberRole.Owner; + chat.CreatedById = replacementOwner.UserId; + } + + if (chat.Type == ChatType.Channel && remainingMembers.All(x => !x.IsOwner)) + { + remainingMembers[0].IsOwner = true; + remainingMembers[0].Role = ChatMemberRole.Owner; + } + + var latestMessageSentAt = chat.Messages + .Where(x => x.SenderId != userId) + .OrderByDescending(x => x.SentAt) + .Select(x => (DateTimeOffset?)x.SentAt) + .FirstOrDefault(); + chat.LastActivityAt = latestMessageSentAt ?? chat.CreatedAt; + } + + foreach (var chat in chatsToDelete.DistinctBy(x => x.Id)) + { + CollectAttachmentPaths(chat.Messages.SelectMany(x => x.Attachments), attachmentPathsToDelete); + dbContext.Chats.Remove(chat); + } + + if (user.Sessions.Count > 0) + { + dbContext.UserSessions.RemoveRange(user.Sessions); + } + + if (user.PushDevices.Count > 0) + { + dbContext.PushDevices.RemoveRange(user.PushDevices); + } + + var removedMessageIds = dbContext.ChangeTracker + .Entries() + .Where(x => x.State == EntityState.Deleted) + .Select(x => x.Entity.Id) + .ToHashSet(); + var remainingMessagesByUser = await dbContext.Messages + .Include(x => x.Attachments) + .Where(x => x.SenderId == userId && !removedMessageIds.Contains(x.Id)) + .ToListAsync(cancellationToken); + if (remainingMessagesByUser.Count > 0) + { + CollectAttachmentPaths(remainingMessagesByUser.SelectMany(x => x.Attachments), attachmentPathsToDelete); + dbContext.Messages.RemoveRange(remainingMessagesByUser); + } + + var storyAttachmentPaths = await dbContext.StoryAttachments + .Where(x => x.Story.AuthorUserId == userId) + .Select(x => x.StoragePath) + .ToListAsync(cancellationToken); + foreach (var path in storyAttachmentPaths.Where(x => !string.IsNullOrWhiteSpace(x))) + { + attachmentPathsToDelete.Add(path); + } + + var moderationEntries = await dbContext.ModerationLogEntries + .Where(x => x.ActorUserId == userId) + .ToListAsync(cancellationToken); + dbContext.ModerationLogEntries.RemoveRange(moderationEntries); + + var inviteLinks = await dbContext.ChatInviteLinks + .Where(x => x.CreatedById == userId) + .ToListAsync(cancellationToken); + dbContext.ChatInviteLinks.RemoveRange(inviteLinks); + + var topics = await dbContext.ChatTopics + .Where(x => x.CreatedById == userId) + .ToListAsync(cancellationToken); + dbContext.ChatTopics.RemoveRange(topics); + + var avatarPath = user.AvatarStoragePath; + dbContext.Users.Remove(user); + await dbContext.SaveChangesAsync(cancellationToken); + + DeleteFiles(attachmentPathsToDelete); + attachmentStorageService.DeleteIfExists(avatarPath); + await NotifyChatsChangedAsync(affectedUserIds, cancellationToken); + + return true; + } + + private IQueryable BuildChatsQuery(string? query) + { + var chatsQuery = dbContext.Chats.AsNoTracking(); + if (string.IsNullOrWhiteSpace(query)) + { + return chatsQuery; + } + + var like = $"%{query.Trim()}%"; + return chatsQuery.Where(x => + (x.Title != null && EF.Functions.Like(x.Title, like)) || + (x.PublicUsername != null && EF.Functions.Like(x.PublicUsername, like)) || + EF.Functions.Like(x.CreatedBy.DisplayName, like) || + x.Members.Any(member => + EF.Functions.Like(member.User.DisplayName, like) || + EF.Functions.Like(member.User.Username, like))); + } + + private IQueryable ProjectChats(IQueryable chatsQuery) => + chatsQuery.Select(x => new AdminChatProjection( + x.Id, + x.Type, + x.Title, + x.PublicUsername, + x.TopicsEnabled, + x.HasProtectedContent, + x.DefaultMessageTtlSeconds, + x.CreatedBy.DisplayName, + x.CreatedAt, + x.LastActivityAt, + x.Members.Count, + x.Messages.Count, + x.Messages.SelectMany(m => m.Attachments).Count(), + x.Messages.Count(m => m.PinnedAt != null && m.DeletedAt == null), + x.Messages.Count(m => m.HasProtectedContent && m.DeletedAt == null), + x.InviteLinks.Count(m => !m.IsRevoked), + x.JoinRequests.Count(m => m.Status == ChatJoinRequestStatus.Pending), + x.Topics.Count)); + + private async Task> LoadDirectTitlesAsync(IEnumerable chats, CancellationToken cancellationToken) + { + var directChatIds = chats + .Where(x => x.Type == ChatType.Direct) + .Select(x => x.Id) + .ToList(); + + if (directChatIds.Count == 0) + { + return []; + } + + var rows = await dbContext.ChatMembers + .AsNoTracking() + .Where(x => directChatIds.Contains(x.ChatId)) + .Select(x => new { x.ChatId, x.User.DisplayName, x.User.Username }) + .ToListAsync(cancellationToken); + + return rows + .GroupBy(x => x.ChatId) + .ToDictionary( + x => x.Key, + x => string.Join(" / ", x.Select(member => DisplayName(member.DisplayName, member.Username)))); + } + + private static AdminChatDto ToAdminChatDto(AdminChatProjection chat, IReadOnlyDictionary directTitles) + { + var title = chat.Type == ChatType.Direct + ? directTitles.GetValueOrDefault(chat.Id, "Личный чат") + : string.IsNullOrWhiteSpace(chat.Title) ? "Без названия" : chat.Title!; + + return new AdminChatDto( + chat.Id, + chat.Type.ToString(), + title, + chat.CreatedBy, + chat.CreatedAt, + chat.LastActivityAt, + chat.Members, + chat.Messages, + chat.Attachments, + chat.PinnedMessages, + chat.ProtectedMessages, + chat.InviteLinks, + chat.PendingJoinRequests, + chat.Topics, + chat.PublicUsername, + chat.TopicsEnabled, + chat.HasProtectedContent, + chat.DefaultMessageTtlSeconds); + } + + private async Task> GetModerationLogItemsAsync(Guid? chatId, int limit, CancellationToken cancellationToken) + { + var take = Math.Clamp(limit, 1, 250); + var query = dbContext.ModerationLogEntries + .AsNoTracking() + .Include(x => x.ActorUser) + .AsQueryable(); + + if (chatId is not null) + { + query = query.Where(x => x.ChatId == chatId.Value); + } + + return await query + .OrderByDescending(x => x.CreatedAt) + .Take(take) + .Select(x => new AdminModerationLogItemDto( + x.Id, + x.ChatId, + string.IsNullOrWhiteSpace(x.ActorUser.DisplayName) ? x.ActorUser.Username : x.ActorUser.DisplayName, + x.TargetType, + x.TargetId, + x.Action, + x.Reason, + x.CreatedAt)) + .ToListAsync(cancellationToken); + } + + private async Task GenerateInviteTokenAsync(CancellationToken cancellationToken) + { + while (true) + { + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(18)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + var exists = await dbContext.ChatInviteLinks + .AnyAsync(x => x.Token == token, cancellationToken); + if (!exists) + { + return token; + } + } + } + + private static string? NormalizePublicUsername(string? value) + { + var trimmed = value?.Trim().TrimStart('@'); + if (string.IsNullOrWhiteSpace(trimmed)) + { + return null; + } + + var normalized = new string(trimmed + .Where(ch => ch is >= 'a' and <= 'z' || ch is >= 'A' and <= 'Z' || char.IsDigit(ch) || ch == '_') + .Take(32) + .ToArray()) + .ToLowerInvariant(); + + if (normalized.Length < 5) + { + throw new InvalidOperationException("Публичное имя должно содержать минимум 5 символов."); + } + + return normalized; + } + + private static int? NormalizeTtlSeconds(int? ttlSeconds) + { + if (ttlSeconds is null || ttlSeconds <= 0) + { + return null; + } + + return Math.Clamp(ttlSeconds.Value, 5, 31 * 24 * 60 * 60); + } + + private static string? TrimToNull(string? value, int maxLength) + { + var trimmed = value?.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) + { + return null; + } + + return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength]; + } + + private static string DisplayName(string? displayName, string username) => + string.IsNullOrWhiteSpace(displayName) ? $"@{username}" : displayName!; + + private static IReadOnlyList BuildClientVersions( + IEnumerable versions) + { + return versions + .Where(HasClientVersion) + .GroupBy(x => new + { + x.ClientPlatform, + x.ClientVersionCode, + x.ClientVersionName + }) + .Select(x => new AdminUserClientVersionDto( + x.Key.ClientPlatform, + x.Key.ClientVersionCode, + x.Key.ClientVersionName, + x.Max(version => version.LastSeenAt), + x.Count(version => !version.IsPushDevice), + x.Count(version => version.IsPushDevice))) + .OrderByDescending(x => x.LastSeenAt) + .ToList(); + } + + private static bool HasClientVersion(AdminUserClientVersionProjection version) => + !string.IsNullOrWhiteSpace(version.ClientPlatform) || + version.ClientVersionCode is not null || + !string.IsNullOrWhiteSpace(version.ClientVersionName); + + private static void CollectAttachmentPaths(IEnumerable attachments, ISet target) + { + foreach (var path in attachments + .Select(x => x.StoragePath) + .Where(x => !string.IsNullOrWhiteSpace(x))) + { + target.Add(path); + } + } + + private void DeleteFiles(IEnumerable attachmentPaths) + { + foreach (var path in attachmentPaths) + { + attachmentStorageService.DeleteIfExists(path); + } + } + + private Task NotifyChatsChangedAsync(IEnumerable userIds, CancellationToken cancellationToken) + { + var affectedUsers = userIds + .Distinct() + .Select(x => x.ToString()) + .ToList(); + + return affectedUsers.Count == 0 + ? Task.CompletedTask + : hubContext.Clients.Users(affectedUsers).SendAsync("ChatsChanged", cancellationToken); + } + + private sealed record AdminChatProjection( + Guid Id, + ChatType Type, + string? Title, + string? PublicUsername, + bool TopicsEnabled, + bool HasProtectedContent, + int? DefaultMessageTtlSeconds, + string CreatedBy, + DateTimeOffset CreatedAt, + DateTimeOffset? LastActivityAt, + int Members, + int Messages, + int Attachments, + int PinnedMessages, + int ProtectedMessages, + int InviteLinks, + int PendingJoinRequests, + int Topics); + + private sealed record AdminUserClientVersionProjection( + Guid UserId, + string? ClientPlatform, + int? ClientVersionCode, + string? ClientVersionName, + DateTimeOffset LastSeenAt, + bool IsPushDevice); +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Admin/ServerRuntimeState.cs b/ikar_server/src/Ikar.Server/Infrastructure/Admin/ServerRuntimeState.cs new file mode 100644 index 0000000..2e8ad1f --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Admin/ServerRuntimeState.cs @@ -0,0 +1,3 @@ +namespace Ikar.Server.Infrastructure.Admin; + +public sealed record ServerRuntimeState(DateTimeOffset StartedAtUtc); diff --git a/ikar_server/src/Ikar.Server/Infrastructure/ApiRateLimitMiddleware.cs b/ikar_server/src/Ikar.Server/Infrastructure/ApiRateLimitMiddleware.cs new file mode 100644 index 0000000..a8ca8ef --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/ApiRateLimitMiddleware.cs @@ -0,0 +1,103 @@ +using System.Collections.Concurrent; +using System.Security.Claims; + +namespace Ikar.Server.Infrastructure; + +public sealed class ApiRateLimitMiddleware(RequestDelegate next, ILogger logger) +{ + private static readonly TimeSpan Window = TimeSpan.FromMinutes(1); + private static readonly ConcurrentDictionary Buckets = new(); + private static long _lastCleanupTicks; + + public async Task InvokeAsync(HttpContext context) + { + if (!ShouldRateLimit(context.Request)) + { + await next(context); + return; + } + + var now = DateTimeOffset.UtcNow; + var key = BuildKey(context); + var limit = context.User.Identity?.IsAuthenticated == true ? 1800 : 240; + var bucket = Buckets.GetOrAdd(key, _ => new RateLimitBucket(now, 0)); + int count; + DateTimeOffset resetAt; + + lock (bucket) + { + if (now - bucket.WindowStartedAt >= Window) + { + bucket.WindowStartedAt = now; + bucket.Count = 0; + } + + bucket.Count++; + count = bucket.Count; + resetAt = bucket.WindowStartedAt.Add(Window); + } + + MaybeCleanup(now); + + if (count <= limit) + { + context.Response.Headers["X-RateLimit-Limit"] = limit.ToString(); + context.Response.Headers["X-RateLimit-Remaining"] = Math.Max(0, limit - count).ToString(); + context.Response.Headers["X-RateLimit-Reset"] = resetAt.ToUnixTimeSeconds().ToString(); + await next(context); + return; + } + + logger.LogWarning("Rate limit exceeded for {RateLimitKey} on {Path}.", key, context.Request.Path); + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + context.Response.Headers["Retry-After"] = Math.Max(1, (int)(resetAt - now).TotalSeconds).ToString(); + await context.Response.WriteAsJsonAsync(new { error = "Too many requests. Try again later." }, context.RequestAborted); + } + + private static bool ShouldRateLimit(HttpRequest request) => + request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase) || + request.Path.StartsWithSegments("/hubs/messenger", StringComparison.OrdinalIgnoreCase); + + private static string BuildKey(HttpContext context) + { + var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!string.IsNullOrWhiteSpace(userId)) + { + return $"user:{userId}"; + } + + var forwardedFor = context.Request.Headers["X-Forwarded-For"].ToString(); + var ip = forwardedFor.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault() + ?? context.Connection.RemoteIpAddress?.ToString() + ?? "unknown"; + return $"ip:{ip}"; + } + + private static void MaybeCleanup(DateTimeOffset now) + { + var lastCleanup = new DateTimeOffset(Interlocked.Read(ref _lastCleanupTicks), TimeSpan.Zero); + if (now - lastCleanup < Window) + { + return; + } + + if (Interlocked.Exchange(ref _lastCleanupTicks, now.UtcTicks) == now.UtcTicks) + { + return; + } + + foreach (var (key, bucket) in Buckets) + { + if (now - bucket.WindowStartedAt > Window.Add(Window)) + { + Buckets.TryRemove(key, out _); + } + } + } + + private sealed class RateLimitBucket(DateTimeOffset windowStartedAt, int count) + { + public DateTimeOffset WindowStartedAt { get; set; } = windowStartedAt; + public int Count { get; set; } = count; + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Auth/BotTokenService.cs b/ikar_server/src/Ikar.Server/Infrastructure/Auth/BotTokenService.cs new file mode 100644 index 0000000..1d9ae14 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Auth/BotTokenService.cs @@ -0,0 +1,23 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Ikar.Server.Infrastructure.Auth; + +public sealed class BotTokenService +{ + public string GenerateToken(Guid botId) + { + var secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + return $"ikarbot_{botId:N}_{secret}"; + } + + public string HashToken(string token) + { + var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + return Convert.ToHexString(hashBytes); + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Auth/ClientVersionRequestReader.cs b/ikar_server/src/Ikar.Server/Infrastructure/Auth/ClientVersionRequestReader.cs new file mode 100644 index 0000000..1d8111d --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Auth/ClientVersionRequestReader.cs @@ -0,0 +1,133 @@ +using Microsoft.AspNetCore.Http; +using Ikar.Server.Data.Entities; +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure.Auth; + +public sealed record ClientVersionSnapshot( + string? ClientPlatform, + int? ClientVersionCode, + string? ClientVersionName); + +public static class ClientVersionRequestReader +{ + private const int MaxClientPlatformLength = 32; + private const int MaxClientVersionNameLength = 64; + + public static ClientVersionSnapshot Read(HttpRequest request) + { + var clientPlatform = TrimToNull( + ReadString(request, ClientVersionMetadata.PlatformHeaderName, ClientVersionMetadata.PlatformQueryName), + MaxClientPlatformLength); + + var clientVersionName = TrimToNull( + ReadString(request, ClientVersionMetadata.VersionNameHeaderName, ClientVersionMetadata.VersionNameQueryName), + MaxClientVersionNameLength); + + var clientVersionCode = TryGetClientVersionCode(request, out var parsedVersionCode) + ? parsedVersionCode + : (int?)null; + + return new ClientVersionSnapshot(clientPlatform, clientVersionCode, clientVersionName); + } + + public static bool Apply(UserSession session, ClientVersionSnapshot snapshot) + { + return Apply( + snapshot, + session.ClientPlatform, + session.ClientVersionCode, + session.ClientVersionName, + platform => session.ClientPlatform = platform, + versionCode => session.ClientVersionCode = versionCode, + versionName => session.ClientVersionName = versionName); + } + + public static bool Apply(PushDevice device, ClientVersionSnapshot snapshot) + { + return Apply( + snapshot, + device.ClientPlatform, + device.ClientVersionCode, + device.ClientVersionName, + platform => device.ClientPlatform = platform, + versionCode => device.ClientVersionCode = versionCode, + versionName => device.ClientVersionName = versionName); + } + + private static bool Apply( + ClientVersionSnapshot snapshot, + string? currentPlatform, + int? currentVersionCode, + string? currentVersionName, + Action setPlatform, + Action setVersionCode, + Action setVersionName) + { + var changed = false; + + if (snapshot.ClientPlatform is not null && + !string.Equals(currentPlatform, snapshot.ClientPlatform, StringComparison.Ordinal)) + { + setPlatform(snapshot.ClientPlatform); + changed = true; + } + + if (snapshot.ClientVersionCode is not null && currentVersionCode != snapshot.ClientVersionCode) + { + setVersionCode(snapshot.ClientVersionCode); + changed = true; + } + + if (snapshot.ClientVersionName is not null && + !string.Equals(currentVersionName, snapshot.ClientVersionName, StringComparison.Ordinal)) + { + setVersionName(snapshot.ClientVersionName); + changed = true; + } + + return changed; + } + + public static bool TryGetClientPlatform(HttpRequest request, out string? clientPlatform) + { + clientPlatform = TrimToNull( + ReadString(request, ClientVersionMetadata.PlatformHeaderName, ClientVersionMetadata.PlatformQueryName), + MaxClientPlatformLength); + return clientPlatform is not null; + } + + public static bool TryGetClientVersionCode(HttpRequest request, out int clientVersionCode) + { + clientVersionCode = 0; + var rawVersionCode = ReadString( + request, + ClientVersionMetadata.VersionCodeHeaderName, + ClientVersionMetadata.VersionCodeQueryName); + + return int.TryParse(rawVersionCode, out clientVersionCode) && clientVersionCode > 0; + } + + private static string? ReadString(HttpRequest request, string headerName, string queryName) + { + var value = request.Headers[headerName].ToString(); + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + + value = request.Query[queryName].ToString(); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static string? TrimToNull(string? value, int maxLength) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength]; + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Auth/DeviceAuthChallengeStore.cs b/ikar_server/src/Ikar.Server/Infrastructure/Auth/DeviceAuthChallengeStore.cs new file mode 100644 index 0000000..2321735 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Auth/DeviceAuthChallengeStore.cs @@ -0,0 +1,74 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; + +namespace Ikar.Server.Infrastructure.Auth; + +public sealed class DeviceAuthChallengeStore +{ + private static readonly TimeSpan ChallengeLifetime = TimeSpan.FromMinutes(5); + private readonly ConcurrentDictionary _challenges = new(StringComparer.Ordinal); + + public DeviceAuthChallenge Create( + string normalizedPhoneNumber, + string installationId, + string publicKey, + bool isRegistered, + bool bindCredential) + { + CleanupExpired(); + + var id = Guid.NewGuid().ToString("N"); + var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + var signaturePayload = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"IKAR_DEVICE_AUTH_V1\n{id}\n{nonce}")); + var challenge = new DeviceAuthChallenge( + id, + normalizedPhoneNumber, + installationId, + publicKey, + signaturePayload, + DateTimeOffset.UtcNow.Add(ChallengeLifetime), + isRegistered, + bindCredential); + + _challenges[challenge.Id] = challenge; + return challenge; + } + + public DeviceAuthChallenge? Get(string challengeId) + { + CleanupExpired(); + return _challenges.TryGetValue(challengeId, out var challenge) && + challenge.ExpiresAt > DateTimeOffset.UtcNow + ? challenge + : null; + } + + public bool Consume(string challengeId, DeviceAuthChallenge expected) => + _challenges.TryRemove(new KeyValuePair(challengeId, expected)); + + public void Remove(string challengeId) => _challenges.TryRemove(challengeId, out _); + + private void CleanupExpired() + { + var now = DateTimeOffset.UtcNow; + foreach (var pair in _challenges) + { + if (pair.Value.ExpiresAt <= now) + { + _challenges.TryRemove(pair.Key, out _); + } + } + } + + public sealed record DeviceAuthChallenge( + string Id, + string NormalizedPhoneNumber, + string InstallationId, + string PublicKey, + string SignaturePayload, + DateTimeOffset ExpiresAt, + bool IsRegistered, + bool BindCredential); +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Auth/DeviceKeyVerifier.cs b/ikar_server/src/Ikar.Server/Infrastructure/Auth/DeviceKeyVerifier.cs new file mode 100644 index 0000000..51c95a8 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Auth/DeviceKeyVerifier.cs @@ -0,0 +1,52 @@ +using System.Security.Cryptography; + +namespace Ikar.Server.Infrastructure.Auth; + +public static class DeviceKeyVerifier +{ + public static string DeriveInstallationId(string publicKey) + { + var publicKeyBytes = Convert.FromBase64String(publicKey); + return Convert.ToHexString(SHA256.HashData(publicKeyBytes).AsSpan(0, 16)) + .ToLowerInvariant(); + } + + public static bool IsValidPublicKey(string publicKey) + { + try + { + using var ecdsa = ECDsa.Create(); + var bytes = Convert.FromBase64String(publicKey); + ecdsa.ImportSubjectPublicKeyInfo(bytes, out var bytesRead); + return bytesRead == bytes.Length && ecdsa.KeySize == 256; + } + catch (Exception exception) when (exception is FormatException or CryptographicException) + { + return false; + } + } + + public static bool Verify(string publicKey, string payload, string signature) + { + try + { + using var ecdsa = ECDsa.Create(); + var publicKeyBytes = Convert.FromBase64String(publicKey); + ecdsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out var bytesRead); + if (bytesRead != publicKeyBytes.Length || ecdsa.KeySize != 256) + { + return false; + } + + return ecdsa.VerifyData( + Convert.FromBase64String(payload), + Convert.FromBase64String(signature), + HashAlgorithmName.SHA256, + DSASignatureFormat.Rfc3279DerSequence); + } + catch (Exception exception) when (exception is FormatException or CryptographicException) + { + return false; + } + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Auth/EmailAddressNormalizer.cs b/ikar_server/src/Ikar.Server/Infrastructure/Auth/EmailAddressNormalizer.cs new file mode 100644 index 0000000..6a22569 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Auth/EmailAddressNormalizer.cs @@ -0,0 +1,48 @@ +using System.Net.Mail; + +namespace Ikar.Server.Infrastructure.Auth; + +public static class EmailAddressNormalizer +{ + private static readonly HashSet AllowedRegistrationDomains = new(StringComparer.OrdinalIgnoreCase) + { + "YANDEX.RU", + "MAIL.RU" + }; + + public const string UnsupportedRegistrationDomainMessage = + "Регистрация доступна только через почту yandex.ru или mail.ru."; + + public static string? Normalize(string? email) + { + var trimmed = email?.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) + { + return null; + } + + try + { + var address = new MailAddress(trimmed); + return string.Equals(address.Address, trimmed, StringComparison.OrdinalIgnoreCase) + ? address.Address.ToUpperInvariant() + : null; + } + catch (FormatException) + { + return null; + } + } + + public static bool IsAllowedRegistrationDomain(string normalizedEmail) + { + var atIndex = normalizedEmail.LastIndexOf('@'); + if (atIndex < 0 || atIndex == normalizedEmail.Length - 1) + { + return false; + } + + var domain = normalizedEmail[(atIndex + 1)..]; + return AllowedRegistrationDomains.Contains(domain); + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Auth/EmailAuthChallengeStore.cs b/ikar_server/src/Ikar.Server/Infrastructure/Auth/EmailAuthChallengeStore.cs new file mode 100644 index 0000000..99337bf --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Auth/EmailAuthChallengeStore.cs @@ -0,0 +1,152 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; + +namespace Ikar.Server.Infrastructure.Auth; + +public sealed class EmailAuthChallengeStore +{ + private static readonly TimeSpan ChallengeLifetime = TimeSpan.FromMinutes(10); + private static readonly TimeSpan MinimumRequestInterval = TimeSpan.FromSeconds(60); + private static readonly TimeSpan RequestWindow = TimeSpan.FromMinutes(15); + private const int MaxRequestsPerWindow = 5; + private const int MaxVerificationAttempts = 5; + private readonly ConcurrentDictionary _challenges = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _requestBuckets = new(StringComparer.Ordinal); + + public EmailAuthChallenge Create(string normalizedEmail, string normalizedPhoneNumber, bool isRegistered) + { + CleanupExpired(); + EnforceRequestLimit($"{normalizedEmail}|{normalizedPhoneNumber}"); + + var challenge = new EmailAuthChallenge( + Guid.NewGuid().ToString("N"), + normalizedEmail, + normalizedPhoneNumber, + RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6"), + DateTimeOffset.UtcNow.Add(ChallengeLifetime), + isRegistered); + + _challenges[challenge.Id] = challenge; + return challenge; + } + + public EmailAuthChallenge? Consume( + string challengeId, + string normalizedEmail, + string normalizedPhoneNumber, + string code) + { + CleanupExpired(); + + if (!_challenges.TryGetValue(challengeId, out var challenge)) + { + return null; + } + + if (!string.Equals(challenge.NormalizedEmail, normalizedEmail, StringComparison.Ordinal) || + !string.Equals(challenge.NormalizedPhoneNumber, normalizedPhoneNumber, StringComparison.Ordinal) || + challenge.ExpiresAt <= DateTimeOffset.UtcNow) + { + return null; + } + + if (!string.Equals(challenge.Code, code, StringComparison.Ordinal)) + { + if (challenge.RegisterFailedAttempt() >= MaxVerificationAttempts) + { + _challenges.TryRemove(challenge.Id, out _); + } + + return null; + } + + _challenges.TryRemove(challenge.Id, out _); + return challenge; + } + + public void Remove(string challengeId) + { + _challenges.TryRemove(challengeId, out _); + } + + private void CleanupExpired() + { + var now = DateTimeOffset.UtcNow; + foreach (var pair in _challenges) + { + if (pair.Value.ExpiresAt <= now) + { + _challenges.TryRemove(pair.Key, out _); + } + } + + foreach (var pair in _requestBuckets) + { + if (now - pair.Value.WindowStartedAt >= RequestWindow) + { + _requestBuckets.TryRemove(pair.Key, out _); + } + } + } + + private void EnforceRequestLimit(string key) + { + var now = DateTimeOffset.UtcNow; + var bucket = _requestBuckets.GetOrAdd(key, _ => new RequestBucket(now)); + lock (bucket) + { + if (now - bucket.WindowStartedAt >= RequestWindow) + { + bucket.WindowStartedAt = now; + bucket.LastRequestedAt = DateTimeOffset.MinValue; + bucket.Count = 0; + } + + var sinceLastRequest = now - bucket.LastRequestedAt; + if (bucket.LastRequestedAt != DateTimeOffset.MinValue && sinceLastRequest < MinimumRequestInterval) + { + throw new AuthChallengeRateLimitException(MinimumRequestInterval - sinceLastRequest); + } + + if (bucket.Count >= MaxRequestsPerWindow) + { + throw new AuthChallengeRateLimitException(RequestWindow - (now - bucket.WindowStartedAt)); + } + + bucket.LastRequestedAt = now; + bucket.Count++; + } + } + + public sealed class EmailAuthChallenge( + string id, + string normalizedEmail, + string normalizedPhoneNumber, + string code, + DateTimeOffset expiresAt, + bool isRegistered) + { + private int _failedAttempts; + + public string Id { get; } = id; + public string NormalizedEmail { get; } = normalizedEmail; + public string NormalizedPhoneNumber { get; } = normalizedPhoneNumber; + public string Code { get; } = code; + public DateTimeOffset ExpiresAt { get; } = expiresAt; + public bool IsRegistered { get; } = isRegistered; + + public int RegisterFailedAttempt() => Interlocked.Increment(ref _failedAttempts); + } + + private sealed class RequestBucket(DateTimeOffset windowStartedAt) + { + public DateTimeOffset WindowStartedAt { get; set; } = windowStartedAt; + public DateTimeOffset LastRequestedAt { get; set; } = DateTimeOffset.MinValue; + public int Count { get; set; } + } +} + +public sealed class AuthChallengeRateLimitException(TimeSpan retryAfter) : Exception("Authentication challenge rate limit exceeded.") +{ + public TimeSpan RetryAfter { get; } = retryAfter; +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Auth/EmailAuthOptions.cs b/ikar_server/src/Ikar.Server/Infrastructure/Auth/EmailAuthOptions.cs new file mode 100644 index 0000000..5f2c9e7 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Auth/EmailAuthOptions.cs @@ -0,0 +1,18 @@ +namespace Ikar.Server.Infrastructure.Auth; + +public sealed class EmailAuthOptions +{ + public const string SectionName = "EmailAuth"; + + public string SmtpHost { get; init; } = string.Empty; + public int SmtpPort { get; init; } = 587; + public bool EnableSsl { get; init; } = true; + public string Username { get; init; } = string.Empty; + public string Password { get; init; } = string.Empty; + public string FromAddress { get; init; } = string.Empty; + public string FromName { get; init; } = "Ikar"; + + public bool IsConfigured => + !string.IsNullOrWhiteSpace(SmtpHost) && + !string.IsNullOrWhiteSpace(FromAddress); +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Auth/IEmailCodeSender.cs b/ikar_server/src/Ikar.Server/Infrastructure/Auth/IEmailCodeSender.cs new file mode 100644 index 0000000..a481c86 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Auth/IEmailCodeSender.cs @@ -0,0 +1,10 @@ +namespace Ikar.Server.Infrastructure.Auth; + +public interface IEmailCodeSender +{ + Task SendLoginCodeAsync( + string email, + string code, + DateTimeOffset expiresAt, + CancellationToken cancellationToken = default); +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Auth/JwtOptions.cs b/ikar_server/src/Ikar.Server/Infrastructure/Auth/JwtOptions.cs new file mode 100644 index 0000000..9f3e230 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Auth/JwtOptions.cs @@ -0,0 +1,16 @@ +namespace Ikar.Server.Infrastructure.Auth; + +public sealed class JwtOptions +{ + public const string SectionName = "Jwt"; + + public string Issuer { get; init; } = "Ikar.Server"; + + public string Audience { get; init; } = "Ikar.Client"; + + public string SigningKey { get; init; } = "Ikar-Replace-This-Development-Key-With-A-Long-Random-Secret-CHANGE_ME_USE_SECRET_STORE_VALUE_32_CHARS_MINIMUM"; + + public int AccessTokenLifetimeMinutes { get; init; } = 15; + + public int RefreshTokenLifetimeDays { get; init; } = 30; +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Auth/PhoneAuthChallengeStore.cs b/ikar_server/src/Ikar.Server/Infrastructure/Auth/PhoneAuthChallengeStore.cs new file mode 100644 index 0000000..2935070 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Auth/PhoneAuthChallengeStore.cs @@ -0,0 +1,89 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; + +namespace Ikar.Server.Infrastructure.Auth; + +public sealed class PhoneAuthChallengeStore +{ + private static readonly TimeSpan ChallengeLifetime = TimeSpan.FromMinutes(10); + private readonly ConcurrentDictionary _challenges = new(StringComparer.Ordinal); + + public PhoneAuthChallenge Create( + string normalizedPhoneNumber, + bool isRegistered, + string? registrationInstallationId = null, + string? registrationPushToken = null) + { + CleanupExpired(); + + var challenge = new PhoneAuthChallenge( + Guid.NewGuid().ToString("N"), + normalizedPhoneNumber, + RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6"), + DateTimeOffset.UtcNow.Add(ChallengeLifetime), + isRegistered, + registrationInstallationId, + registrationPushToken); + + _challenges[challenge.Id] = challenge; + return challenge; + } + + public PhoneAuthChallenge? Consume(string challengeId, string normalizedPhoneNumber, string code) + { + CleanupExpired(); + + if (!_challenges.TryGetValue(challengeId, out var challenge)) + { + return null; + } + + if (!string.Equals(challenge.NormalizedPhoneNumber, normalizedPhoneNumber, StringComparison.Ordinal) || + !string.Equals(challenge.Code, code, StringComparison.Ordinal) || + challenge.ExpiresAt <= DateTimeOffset.UtcNow) + { + return null; + } + + _challenges.TryRemove(challenge.Id, out _); + return challenge; + } + + public PhoneAuthChallenge? Consume(string challengeId, string code) + { + CleanupExpired(); + + if (!_challenges.TryGetValue(challengeId, out var challenge) || + !string.Equals(challenge.Code, code, StringComparison.Ordinal) || + challenge.ExpiresAt <= DateTimeOffset.UtcNow) + { + return null; + } + + _challenges.TryRemove(challenge.Id, out _); + return challenge; + } + + public void Remove(string challengeId) => _challenges.TryRemove(challengeId, out _); + + private void CleanupExpired() + { + var now = DateTimeOffset.UtcNow; + foreach (var pair in _challenges) + { + if (pair.Value.ExpiresAt <= now) + { + _challenges.TryRemove(pair.Key, out _); + } + } + } + + public sealed record PhoneAuthChallenge( + string Id, + string NormalizedPhoneNumber, + string Code, + DateTimeOffset ExpiresAt, + bool IsRegistered, + string? RegistrationInstallationId, + string? RegistrationPushToken); +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Auth/SmtpEmailCodeSender.cs b/ikar_server/src/Ikar.Server/Infrastructure/Auth/SmtpEmailCodeSender.cs new file mode 100644 index 0000000..70e47fa --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Auth/SmtpEmailCodeSender.cs @@ -0,0 +1,46 @@ +using System.Net; +using System.Net.Mail; +using System.Text; +using Microsoft.Extensions.Options; + +namespace Ikar.Server.Infrastructure.Auth; + +public sealed class SmtpEmailCodeSender(IOptions options) : IEmailCodeSender +{ + private readonly EmailAuthOptions _options = options.Value; + + public async Task SendLoginCodeAsync( + string email, + string code, + DateTimeOffset expiresAt, + CancellationToken cancellationToken = default) + { + if (!_options.IsConfigured) + { + throw new InvalidOperationException("Email login is not configured on the server."); + } + + using var message = new MailMessage + { + From = new MailAddress(_options.FromAddress, _options.FromName), + Subject = "\u041a\u043e\u0434 \u0432\u0445\u043e\u0434\u0430 \u0432 \u0418\u043a\u0430\u0440", + SubjectEncoding = Encoding.UTF8, + Body = $"\u041a\u043e\u0434 \u0432\u0445\u043e\u0434\u0430 \u0432 \u0418\u043a\u0430\u0440: {code}{Environment.NewLine}\u041a\u043e\u0434 \u0434\u0435\u0439\u0441\u0442\u0432\u0443\u0435\u0442 \u0434\u043e {expiresAt:dd.MM.yyyy HH:mm:ss zzz}.", + BodyEncoding = Encoding.UTF8, + IsBodyHtml = false + }; + message.To.Add(new MailAddress(email)); + + using var client = new SmtpClient(_options.SmtpHost, _options.SmtpPort) + { + EnableSsl = _options.EnableSsl + }; + + if (!string.IsNullOrWhiteSpace(_options.Username)) + { + client.Credentials = new NetworkCredential(_options.Username, _options.Password); + } + + await client.SendMailAsync(message, cancellationToken); + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Auth/TokenService.cs b/ikar_server/src/Ikar.Server/Infrastructure/Auth/TokenService.cs new file mode 100644 index 0000000..8e3be58 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Auth/TokenService.cs @@ -0,0 +1,175 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure.Auth; + +public sealed class TokenService( + IkarDbContext dbContext, + IOptions jwtOptions) +{ + private readonly JwtOptions _jwtOptions = jwtOptions.Value; + + public Task CreateSessionAsync(User user, CancellationToken cancellationToken = default) => + CreateSessionAsync(user, null, cancellationToken); + + public async Task CreateSessionAsync( + User user, + ClientVersionSnapshot? clientVersion, + CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + var session = new UserSession + { + UserId = user.Id, + LastSeenAt = now + }; + if (clientVersion is not null) + { + ClientVersionRequestReader.Apply(session, clientVersion); + } + + var refreshToken = CreateOpaqueToken(); + session.TokenHash = HashToken(refreshToken); + dbContext.UserSessions.Add(session); + + await dbContext.SaveChangesAsync(cancellationToken); + return CreateAuthSession(user, session.Id, refreshToken, now); + } + + public Task RefreshSessionAsync(string rawRefreshToken, CancellationToken cancellationToken = default) => + RefreshSessionAsync(rawRefreshToken, null, cancellationToken); + + public async Task RefreshSessionAsync( + string rawRefreshToken, + ClientVersionSnapshot? clientVersion, + CancellationToken cancellationToken = default) + { + var hash = HashToken(rawRefreshToken); + var session = await dbContext.UserSessions.SingleOrDefaultAsync(x => x.TokenHash == hash, cancellationToken); + if (session is null) + { + return null; + } + + if (session.CreatedAt.AddDays(_jwtOptions.RefreshTokenLifetimeDays) <= DateTimeOffset.UtcNow) + { + dbContext.UserSessions.Remove(session); + await dbContext.SaveChangesAsync(cancellationToken); + return null; + } + + var user = await dbContext.Users.SingleAsync(x => x.Id == session.UserId, cancellationToken); + if (user.IsBlocked) + { + dbContext.UserSessions.Remove(session); + await dbContext.SaveChangesAsync(cancellationToken); + return null; + } + + var now = DateTimeOffset.UtcNow; + var refreshToken = CreateOpaqueToken(); + session.TokenHash = HashToken(refreshToken); + session.CreatedAt = now; + session.LastSeenAt = now; + if (clientVersion is not null) + { + ClientVersionRequestReader.Apply(session, clientVersion); + } + + await dbContext.SaveChangesAsync(cancellationToken); + + return CreateAuthSession(user, session.Id, refreshToken, now); + } + + public async Task RevokeSessionAsync(Guid sessionId, CancellationToken cancellationToken = default) + { + var session = await dbContext.UserSessions.SingleOrDefaultAsync(x => x.Id == sessionId, cancellationToken); + if (session is null) + { + return false; + } + + dbContext.UserSessions.Remove(session); + await dbContext.SaveChangesAsync(cancellationToken); + return true; + } + + public static string HashToken(string token) + { + var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + return Convert.ToHexString(hashBytes); + } + + private AuthSessionDto CreateAuthSession(User user, Guid sessionId, string refreshToken, DateTimeOffset now) + { + var accessTokenExpiresAt = now.AddMinutes(_jwtOptions.AccessTokenLifetimeMinutes); + var credentials = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.SigningKey)), + SecurityAlgorithms.HmacSha256); + + var claims = new List + { + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(ClaimTypes.Name, user.DisplayName), + new("username", user.Username), + new("sid", sessionId.ToString()), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")) + }; + + var jwt = new JwtSecurityToken( + issuer: _jwtOptions.Issuer, + audience: _jwtOptions.Audience, + claims: claims, + notBefore: now.UtcDateTime, + expires: accessTokenExpiresAt.UtcDateTime, + signingCredentials: credentials); + + var accessToken = new JwtSecurityTokenHandler().WriteToken(jwt); + + return new AuthSessionDto( + accessToken, + refreshToken, + accessTokenExpiresAt, + new( + user.Id, + user.Username, + user.DisplayName, + false, + user.PhoneNumber, + now, + user.About, + string.IsNullOrWhiteSpace(user.AvatarStoragePath) + ? null + : $"/api/users/{user.Id}/avatar/{Uri.EscapeDataString(Path.GetFileName(user.AvatarStoragePath))}", + user.IsBot)); + } + + public string CreateMediaToken(Guid userId, Guid sessionId, string resourcePath) + { + var jwt = new JwtSecurityToken( + issuer: _jwtOptions.Issuer, + audience: _jwtOptions.Audience, + claims: [new(ClaimTypes.NameIdentifier, userId.ToString()), new("sid", sessionId.ToString()), new("media_path", resourcePath)], + notBefore: DateTime.UtcNow, + expires: DateTime.UtcNow.AddDays(1), + signingCredentials: new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.SigningKey)), SecurityAlgorithms.HmacSha256)); + return new JwtSecurityTokenHandler().WriteToken(jwt); + } + + private static string CreateOpaqueToken() + { + var tokenBytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(tokenBytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Bots/BotCreatorService.cs b/ikar_server/src/Ikar.Server/Infrastructure/Bots/BotCreatorService.cs new file mode 100644 index 0000000..dbd48b7 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Bots/BotCreatorService.cs @@ -0,0 +1,491 @@ +using System.Text.RegularExpressions; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Push; +using Ikar.Shared; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Infrastructure.Bots; + +public sealed class BotCreatorService( + IkarDbContext dbContext, + BotTokenService botTokenService, + BotDeletionService botDeletionService, + PresenceTracker presenceTracker, + IHubContext hubContext, + PushDispatchQueue pushDispatchQueue) +{ + public const string CreatorUsername = "botfatherbot"; + private const string CreatorDisplayName = "Создатель ботов"; + private static readonly Regex BotUsernameRegex = new("^[A-Za-z0-9_]{5,64}$", RegexOptions.Compiled); + private readonly PasswordHasher _passwordHasher = new(); + + public async Task EnsureCreatorBotAsync(CancellationToken cancellationToken = default) + { + var normalizedUsername = CreatorUsername.ToUpperInvariant(); + var user = await dbContext.Users + .Include(x => x.BotProfile) + .ThenInclude(x => x!.Commands) + .SingleOrDefaultAsync(x => x.NormalizedUsername == normalizedUsername, cancellationToken); + + if (user is null) + { + user = new User + { + Username = CreatorUsername, + NormalizedUsername = normalizedUsername, + DisplayName = CreatorDisplayName, + About = "Создаёт и настраивает ботов Икара через команды в чате.", + IsBot = true + }; + user.PasswordHash = _passwordHasher.HashPassword(user, Guid.NewGuid().ToString("N")); + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(cancellationToken); + } + + if (!user.IsBot) + { + user.IsBot = true; + } + + user.DisplayName = CreatorDisplayName; + user.About = "Создаёт и настраивает ботов Икара через команды в чате."; + user.IsBlocked = false; + user.BlockedAt = null; + + var bot = user.BotProfile; + if (bot is null) + { + bot = new Bot + { + UserId = user.Id, + OwnerUserId = user.Id + }; + var rawToken = botTokenService.GenerateToken(bot.Id); + bot.TokenHash = botTokenService.HashToken(rawToken); + dbContext.Bots.Add(bot); + await dbContext.SaveChangesAsync(cancellationToken); + } + + bot.IsEnabled = true; + bot.UpdatedAt = DateTimeOffset.UtcNow; + await ReplaceCommandsAsync(bot.Id, CreatorCommands, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task TryHandleIncomingMessageAsync(Guid messageId, CancellationToken cancellationToken = default) + { + var message = await dbContext.Messages + .Include(x => x.Sender) + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.BotProfile) + .SingleOrDefaultAsync(x => x.Id == messageId, cancellationToken); + + if (message is null || + message.Sender.IsBot || + message.Chat.Type != ChatType.Direct || + string.IsNullOrWhiteSpace(message.Text)) + { + return false; + } + + var creator = await LoadCreatorBotAsync(cancellationToken); + if (creator is null || message.Chat.Members.All(member => member.UserId != creator.UserId)) + { + return false; + } + + var replyText = await BuildReplyAsync(message.SenderId, message.Text.Trim(), cancellationToken); + await SendCreatorReplyAsync(message.Chat, creator.User, replyText, cancellationToken); + return true; + } + + private async Task BuildReplyAsync(Guid ownerUserId, string text, CancellationToken cancellationToken) + { + var parts = text.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var command = parts.FirstOrDefault()?.TrimStart('/').ToLowerInvariant(); + var payload = parts.Length > 1 ? parts[1].Trim() : string.Empty; + + return command switch + { + "start" or "help" or "помощь" => HelpText, + "newbot" or "создать_бота" => await CreateBotAsync(ownerUserId, payload, cancellationToken), + "mybots" or "мои_боты" => await ListBotsAsync(ownerUserId, cancellationToken), + "token" or "токен" => await RegenerateTokenAsync(ownerUserId, payload, cancellationToken), + "setcommands" or "команды" => await SetCommandsAsync(ownerUserId, payload, cancellationToken), + "disablebot" or "выключить_бота" => await SetBotEnabledAsync(ownerUserId, payload, isEnabled: false, cancellationToken), + "enablebot" or "включить_бота" => await SetBotEnabledAsync(ownerUserId, payload, isEnabled: true, cancellationToken), + "deletebot" or "удалить_бота" => await DeleteBotAsync(ownerUserId, payload, cancellationToken), + _ => "Я понимаю команды /создать_бота, /мои_боты, /токен, /команды, /выключить_бота, /включить_бота и /удалить_бота. Напишите /помощь для формата команд." + }; + } + + private async Task CreateBotAsync(Guid ownerUserId, string payload, CancellationToken cancellationToken) + { + var fields = SplitPipePayload(payload); + if (fields.Count < 2) + { + return "Формат: /создать_бота Имя бота | usernamebot | описание"; + } + + var displayName = fields[0]; + var username = NormalizeBotUsername(fields[1]); + var about = fields.Count >= 3 ? fields[2] : null; + + var validationError = await ValidateBotDefinitionAsync(username, displayName, about, excludingBotId: null, cancellationToken); + if (validationError is not null) + { + return validationError; + } + + var botUser = new User + { + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = displayName, + About = string.IsNullOrWhiteSpace(about) ? null : about.Trim(), + IsBot = true + }; + botUser.PasswordHash = _passwordHasher.HashPassword(botUser, Guid.NewGuid().ToString("N")); + + var bot = new Bot + { + User = botUser, + OwnerUserId = ownerUserId + }; + + var rawToken = botTokenService.GenerateToken(bot.Id); + bot.TokenHash = botTokenService.HashToken(rawToken); + dbContext.Bots.Add(bot); + await dbContext.SaveChangesAsync(cancellationToken); + + return $"Бот создан: {displayName} (@{username})\nТокен:\n{rawToken}\n\nСохраните токен сейчас. Позже можно выпустить новый командой /токен {username}."; + } + + private async Task ListBotsAsync(Guid ownerUserId, CancellationToken cancellationToken) + { + var bots = await dbContext.Bots + .AsNoTracking() + .Include(x => x.User) + .Where(x => x.OwnerUserId == ownerUserId && x.User.NormalizedUsername != CreatorUsername.ToUpperInvariant()) + .OrderBy(x => x.User.DisplayName) + .ToListAsync(cancellationToken); + + if (bots.Count == 0) + { + return "У вас пока нет ботов. Создайте первого командой /создать_бота Имя | usernamebot | описание"; + } + + return "Ваши боты:\n" + string.Join( + "\n", + bots.Select(bot => $"{bot.User.DisplayName} (@{bot.User.Username}) — {(bot.IsEnabled ? "включён" : "выключен")}")); + } + + private async Task RegenerateTokenAsync(Guid ownerUserId, string payload, CancellationToken cancellationToken) + { + var bot = await LoadOwnedBotByUsernameAsync(ownerUserId, payload, cancellationToken); + if (bot is null) + { + return "Бот не найден. Формат: /токен usernamebot"; + } + + var rawToken = botTokenService.GenerateToken(bot.Id); + bot.TokenHash = botTokenService.HashToken(rawToken); + bot.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + return $"Новый токен для @{bot.User.Username}:\n{rawToken}"; + } + + private async Task SetCommandsAsync(Guid ownerUserId, string payload, CancellationToken cancellationToken) + { + var fields = SplitPipePayload(payload); + if (fields.Count < 2) + { + return "Формат: /команды usernamebot | старт - описание; помощь - описание"; + } + + var bot = await LoadOwnedBotByUsernameAsync(ownerUserId, fields[0], cancellationToken); + if (bot is null) + { + return "Бот не найден."; + } + + var commands = ParseCommandDefinitions(fields[1]); + if (commands.Count == 0) + { + return "Не нашёл корректных команд. Пример: /команды usernamebot | старт - старт; помощь - помощь"; + } + + await ReplaceCommandsAsync(bot.Id, commands, cancellationToken); + bot.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + return $"Команды @{bot.User.Username} обновлены."; + } + + private async Task SetBotEnabledAsync( + Guid ownerUserId, + string payload, + bool isEnabled, + CancellationToken cancellationToken) + { + var bot = await LoadOwnedBotByUsernameAsync(ownerUserId, payload, cancellationToken); + if (bot is null) + { + return $"Бот не найден. Формат: /{(isEnabled ? "включить_бота" : "выключить_бота")} usernamebot"; + } + + bot.IsEnabled = isEnabled; + bot.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + return isEnabled ? $"@{bot.User.Username} включён." : $"@{bot.User.Username} выключен."; + } + + private async Task DeleteBotAsync(Guid ownerUserId, string payload, CancellationToken cancellationToken) + { + var bot = await LoadOwnedBotByUsernameAsync(ownerUserId, payload, cancellationToken, includeOwnedData: true); + if (bot is null) + { + return "Бот не найден. Формат: /удалить_бота usernamebot"; + } + + var username = bot.User.Username; + var deleted = await botDeletionService.DeleteBotAsync(bot.Id, ownerUserId, cancellationToken); + return deleted is null + ? "Бот не найден. Формат: /удалить_бота usernamebot" + : $"@{username} удалён."; + } + + private async Task LoadCreatorBotAsync(CancellationToken cancellationToken) => + await dbContext.Bots + .Include(x => x.User) + .SingleOrDefaultAsync(x => x.User.NormalizedUsername == CreatorUsername.ToUpperInvariant(), cancellationToken); + + private async Task LoadOwnedBotByUsernameAsync( + Guid ownerUserId, + string rawUsername, + CancellationToken cancellationToken, + bool includeOwnedData = false) + { + var username = NormalizeBotUsername(rawUsername); + if (string.IsNullOrWhiteSpace(username) || string.Equals(username, CreatorUsername, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var query = dbContext.Bots + .Include(x => x.User) + .Where(x => x.OwnerUserId == ownerUserId && x.User.NormalizedUsername == username.ToUpperInvariant()); + + if (includeOwnedData) + { + query = query + .Include(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.User) + .ThenInclude(x => x.PushDevices); + } + + return await query.SingleOrDefaultAsync(cancellationToken); + } + + private async Task ValidateBotDefinitionAsync( + string? username, + string? displayName, + string? about, + Guid? excludingBotId, + CancellationToken cancellationToken) + { + var trimmedUsername = username?.Trim() ?? string.Empty; + var trimmedDisplayName = displayName?.Trim() ?? string.Empty; + var normalizedUsername = trimmedUsername.ToUpperInvariant(); + + if (string.IsNullOrWhiteSpace(trimmedDisplayName)) + { + return "Укажите имя бота."; + } + + if (trimmedDisplayName.Length > 120) + { + return "Имя бота должно быть не длиннее 120 символов."; + } + + if (!string.IsNullOrWhiteSpace(about) && about.Trim().Length > 70) + { + return "Описание должно быть не длиннее 70 символов."; + } + + if (!BotUsernameRegex.IsMatch(trimmedUsername) || + !trimmedUsername.EndsWith("bot", StringComparison.OrdinalIgnoreCase)) + { + return "Username бота должен содержать латинские буквы, цифры или подчёркивание и заканчиваться на bot."; + } + + if (string.Equals(trimmedUsername, CreatorUsername, StringComparison.OrdinalIgnoreCase)) + { + return "Этот username зарезервирован системным ботом."; + } + + var usernameTaken = await dbContext.Users + .AsNoTracking() + .Include(x => x.BotProfile) + .AnyAsync( + x => x.NormalizedUsername == normalizedUsername && + (!excludingBotId.HasValue || x.BotProfile == null || x.BotProfile.Id != excludingBotId.Value), + cancellationToken); + return usernameTaken ? "Username уже занят." : null; + } + + private async Task ReplaceCommandsAsync(Guid botId, IReadOnlyList commands, CancellationToken cancellationToken) + { + var existingCommands = await dbContext.BotCommands + .Where(x => x.BotId == botId) + .ToListAsync(cancellationToken); + dbContext.BotCommands.RemoveRange(existingCommands); + + var normalizedCommands = NormalizeCommands(commands); + for (var index = 0; index < normalizedCommands.Count; index++) + { + dbContext.BotCommands.Add(new BotCommand + { + BotId = botId, + Command = normalizedCommands[index].Command, + Description = normalizedCommands[index].Description, + SortOrder = index + }); + } + } + + private async Task SendCreatorReplyAsync(Chat chat, User creatorUser, string text, CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var reply = new Message + { + ChatId = chat.Id, + Chat = chat, + SenderId = creatorUser.Id, + Sender = creatorUser, + Text = text, + SentAt = now + }; + + chat.LastActivityAt = now; + dbContext.Messages.Add(reply); + pushDispatchQueue.StageMessages(dbContext, reply); + await dbContext.SaveChangesAsync(cancellationToken); + + var loadedReply = await dbContext.Messages + .Include(x => x.Sender) + .ThenInclude(x => x.Sessions) + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.Attachments) + .Include(x => x.Reactions) + .SingleAsync(x => x.Id == reply.Id, cancellationToken); + + var tasks = loadedReply.Chat.Members.Select(async member => + { + var dto = loadedReply.ToDto(presenceTracker, member.UserId); + await hubContext.Clients.User(member.UserId.ToString()) + .SendAsync("MessageCreated", dto, cancellationToken); + }); + await Task.WhenAll(tasks); + await pushDispatchQueue.EnqueueAsync(loadedReply.ChatId, loadedReply.Id); + } + + private static string NormalizeBotUsername(string value) => value.Trim().TrimStart('@'); + + private static List SplitPipePayload(string payload) => + payload + .Split('|', StringSplitOptions.TrimEntries) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .ToList(); + + private static List ParseCommandDefinitions(string value) => + value + .Split([';', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(ParseCommandDefinition) + .Where(x => x is not null) + .Cast() + .ToList(); + + private static BotCommandDto? ParseCommandDefinition(string value) + { + var parts = value.Split(" - ", 2, StringSplitOptions.TrimEntries); + if (parts.Length < 2) + { + parts = value.Split(':', 2, StringSplitOptions.TrimEntries); + } + + if (parts.Length < 2) + { + return null; + } + + return new BotCommandDto(parts[0], parts[1]); + } + + private static List NormalizeCommands(IReadOnlyList commands) + { + var normalized = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var command in commands) + { + var normalizedCommand = (command.Command ?? string.Empty).Trim().TrimStart('/'); + var normalizedDescription = (command.Description ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(normalizedCommand) || string.IsNullOrWhiteSpace(normalizedDescription)) + { + continue; + } + + if (!Regex.IsMatch(normalizedCommand, @"^[\p{L}0-9_]{1,32}$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) + { + continue; + } + + if (!seen.Add(normalizedCommand)) + { + continue; + } + + normalized.Add(new BotCommandDto(normalizedCommand.ToLowerInvariant(), normalizedDescription)); + } + + return normalized; + } + + private static readonly IReadOnlyList CreatorCommands = + [ + new("помощь", "показать команды"), + new("создать_бота", "создать бота"), + new("мои_боты", "показать моих ботов"), + new("токен", "выпустить новый токен"), + new("команды", "настроить команды бота"), + new("выключить_бота", "выключить бота"), + new("включить_бота", "включить бота"), + new("удалить_бота", "удалить бота") + ]; + + private const string HelpText = + """ + Я создаю и настраиваю ботов Икара. + + /создать_бота Имя бота | usernamebot | описание + /мои_боты + /токен usernamebot + /команды usernamebot | старт - описание; помощь - описание + /выключить_бота usernamebot + /включить_бота usernamebot + /удалить_бота usernamebot + + Username должен быть латиницей и заканчиваться на bot. + """; +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Bots/BotDeletionService.cs b/ikar_server/src/Ikar.Server/Infrastructure/Bots/BotDeletionService.cs new file mode 100644 index 0000000..a006b0e --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Bots/BotDeletionService.cs @@ -0,0 +1,131 @@ +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Shared; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Infrastructure.Bots; + +public sealed class BotDeletionService( + IkarDbContext dbContext, + AttachmentStorageService attachmentStorageService) +{ + public async Task DeleteBotAsync( + Guid botId, + Guid? ownerUserId, + CancellationToken cancellationToken) + { + var bot = await dbContext.Bots + .Include(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.User) + .ThenInclude(x => x.PushDevices) + .SingleOrDefaultAsync( + x => x.Id == botId && (!ownerUserId.HasValue || x.OwnerUserId == ownerUserId.Value), + cancellationToken); + if (bot is null) + { + return null; + } + + var result = new BotDeletionResult(bot.Id, bot.UserId, bot.User.Username); + var botUserId = bot.UserId; + var attachmentPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + + var ownedBotIds = await dbContext.Bots + .AsNoTracking() + .Where(x => x.OwnerUserId == botUserId && x.Id != bot.Id) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + foreach (var ownedBotId in ownedBotIds) + { + await DeleteBotAsync(ownedBotId, ownerUserId: null, cancellationToken); + } + + var directChats = await dbContext.Chats + .Include(x => x.Members) + .Include(x => x.Messages) + .ThenInclude(x => x.Attachments) + .Where(x => x.Type == ChatType.Direct && x.Members.Any(member => member.UserId == botUserId)) + .ToListAsync(cancellationToken); + var directChatIds = directChats.Select(x => x.Id).ToHashSet(); + foreach (var chat in directChats) + { + CollectAttachmentPaths(chat.Messages.SelectMany(x => x.Attachments), attachmentPaths); + dbContext.Chats.Remove(chat); + } + + var botMessages = await dbContext.Messages + .Include(x => x.Attachments) + .Where(x => x.SenderId == botUserId && !directChatIds.Contains(x.ChatId)) + .ToListAsync(cancellationToken); + CollectAttachmentPaths(botMessages.SelectMany(x => x.Attachments), attachmentPaths); + dbContext.Messages.RemoveRange(botMessages); + + var memberships = await dbContext.ChatMembers + .Where(x => x.UserId == botUserId && !directChatIds.Contains(x.ChatId)) + .ToListAsync(cancellationToken); + dbContext.ChatMembers.RemoveRange(memberships); + + var storyAttachmentPaths = await dbContext.StoryAttachments + .Where(x => x.Story.AuthorUserId == botUserId) + .Select(x => x.StoragePath) + .ToListAsync(cancellationToken); + foreach (var path in storyAttachmentPaths) + { + if (!string.IsNullOrWhiteSpace(path)) + { + attachmentPaths.Add(path); + } + } + + var moderationEntries = await dbContext.ModerationLogEntries + .Where(x => x.ActorUserId == botUserId) + .ToListAsync(cancellationToken); + dbContext.ModerationLogEntries.RemoveRange(moderationEntries); + + var inviteLinks = await dbContext.ChatInviteLinks + .Where(x => x.CreatedById == botUserId) + .ToListAsync(cancellationToken); + dbContext.ChatInviteLinks.RemoveRange(inviteLinks); + + var topics = await dbContext.ChatTopics + .Where(x => x.CreatedById == botUserId) + .ToListAsync(cancellationToken); + dbContext.ChatTopics.RemoveRange(topics); + + var botCommands = await dbContext.BotCommands + .Where(x => x.BotId == bot.Id) + .ToListAsync(cancellationToken); + var botUpdates = await dbContext.BotUpdates + .Where(x => x.BotId == bot.Id) + .ToListAsync(cancellationToken); + dbContext.BotUpdates.RemoveRange(botUpdates); + dbContext.BotCommands.RemoveRange(botCommands); + dbContext.UserSessions.RemoveRange(bot.User.Sessions); + dbContext.PushDevices.RemoveRange(bot.User.PushDevices); + dbContext.Bots.Remove(bot); + dbContext.Users.Remove(bot.User); + + await dbContext.SaveChangesAsync(cancellationToken); + foreach (var path in attachmentPaths) + { + attachmentStorageService.DeleteIfExists(path); + } + + attachmentStorageService.DeleteIfExists(bot.User.AvatarStoragePath); + return result; + } + + private static void CollectAttachmentPaths(IEnumerable attachments, ISet target) + { + foreach (var path in attachments + .Select(x => x.StoragePath) + .Where(x => !string.IsNullOrWhiteSpace(x))) + { + target.Add(path); + } + } +} + +public sealed record BotDeletionResult(Guid BotId, Guid BotUserId, string Username); diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Bots/BotUpdateService.cs b/ikar_server/src/Ikar.Server/Infrastructure/Bots/BotUpdateService.cs new file mode 100644 index 0000000..9d12772 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Bots/BotUpdateService.cs @@ -0,0 +1,143 @@ +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Shared; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Infrastructure.Bots; + +public sealed class BotUpdateService(IkarDbContext dbContext) +{ + public async Task EnqueueIncomingMessageAsync(Guid messageId, CancellationToken cancellationToken = default) + { + var message = await dbContext.Messages + .Include(x => x.Sender) + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.BotProfile) + .SingleOrDefaultAsync(x => x.Id == messageId, cancellationToken); + + if (message is null || + message.Sender.IsBot) + { + return; + } + + var eligibleBotMembers = message.Chat.Members + .Where(member => + member.UserId != message.SenderId && + member.User.IsBot && + member.User.BotProfile is not null && + member.User.BotProfile.IsEnabled && + CanReceiveUpdates(message.Chat.Type, member)) + .ToList(); + if (eligibleBotMembers.Count == 0) + { + return; + } + + var botIds = eligibleBotMembers + .Select(member => member.User.BotProfile!.Id) + .ToList(); + var existingBotIds = await dbContext.BotUpdates + .AsNoTracking() + .Where( + x => botIds.Contains(x.BotId) && + x.MessageId == message.Id) + .Select(x => x.BotId) + .ToListAsync(cancellationToken); + var existingBotIdSet = existingBotIds.ToHashSet(); + + foreach (var botMember in eligibleBotMembers) + { + var bot = botMember.User.BotProfile!; + if (!existingBotIdSet.Add(bot.Id)) + { + continue; + } + + dbContext.BotUpdates.Add(new BotUpdate + { + Kind = BotUpdateKind.Message, + BotId = bot.Id, + ChatId = message.ChatId, + MessageId = message.Id, + CreatedAt = message.SentAt + }); + } + + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task EnqueueCallbackAsync( + Bot bot, + Message message, + User user, + string callbackId, + string? payload, + CancellationToken cancellationToken = default) + { + dbContext.BotUpdates.Add(new BotUpdate + { + Kind = BotUpdateKind.CallbackQuery, + BotId = bot.Id, + ChatId = message.ChatId, + MessageId = message.Id, + CallbackUserId = user.Id, + CallbackId = callbackId, + CallbackPayload = payload, + CreatedAt = DateTimeOffset.UtcNow + }); + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static bool CanReceiveUpdates(ChatType chatType, ChatMember member) + { + if (chatType is ChatType.Direct or ChatType.Group) + { + return true; + } + + return DtoMapper.BuildPermissions(member, chatType) is { CanManageMembers: true } or { CanPostMessages: true }; + } + + public async Task MarkMessagesReadByBotAsync( + Guid botUserId, + IReadOnlyCollection messages, + CancellationToken cancellationToken = default) + { + var maxByChat = messages + .GroupBy(message => message.ChatId) + .Select(group => new + { + ChatId = group.Key, + MaxSentAt = group.Max(message => message.SentAt) + }) + .ToList(); + + foreach (var item in maxByChat) + { + var membership = await dbContext.ChatMembers + .SingleOrDefaultAsync( + x => x.ChatId == item.ChatId && x.UserId == botUserId, + cancellationToken); + if (membership is null) + { + continue; + } + + if (membership.LastDeliveredAt is null || membership.LastDeliveredAt < item.MaxSentAt) + { + membership.LastDeliveredAt = item.MaxSentAt; + } + + if (membership.LastReadAt is null || membership.LastReadAt < item.MaxSentAt) + { + membership.LastReadAt = item.MaxSentAt; + } + } + + await dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Calls/CallSessionCleanupService.cs b/ikar_server/src/Ikar.Server/Infrastructure/Calls/CallSessionCleanupService.cs new file mode 100644 index 0000000..6d3aa95 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Calls/CallSessionCleanupService.cs @@ -0,0 +1,40 @@ +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Shared; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Options; + +namespace Ikar.Server.Infrastructure.Calls; + +public sealed class CallSessionCleanupService( + CallSessionStore store, + IHubContext hubContext, + IOptions options, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5)); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + var expired = store.DrainExpired( + DateTimeOffset.UtcNow, + TimeSpan.FromSeconds(Math.Clamp(options.Value.RingingTimeoutSeconds, 15, 300)), + TimeSpan.FromHours(Math.Clamp(options.Value.MaximumCallDurationHours, 1, 24))); + foreach (var session in expired) + { + var payload = new CallEndedDto( + session.CallId, + session.ChatId, + session.EndReason, + session.MediaType); + await Task.WhenAll( + hubContext.Clients.User(session.CallerId.ToString()).SendAsync("CallEnded", payload, stoppingToken), + hubContext.Clients.User(session.CalleeId.ToString()).SendAsync("CallEnded", payload, stoppingToken)); + logger.LogInformation( + "Expired call session {CallId} with reason {Reason}.", + session.CallId, + session.EndReason); + } + } + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Calls/CallSessionStore.cs b/ikar_server/src/Ikar.Server/Infrastructure/Calls/CallSessionStore.cs new file mode 100644 index 0000000..74aab43 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Calls/CallSessionStore.cs @@ -0,0 +1,236 @@ +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure.Calls; + +public sealed class CallSessionStore +{ + private readonly object _sync = new(); + private readonly Dictionary _sessions = []; + private readonly Dictionary _participantCalls = []; + + public bool TryCreate(Guid chatId, Guid callerId, Guid calleeId, CallMediaType mediaType, out ActiveCallSession? session, out string error) + { + lock (_sync) + { + if (_participantCalls.ContainsKey(callerId)) + { + error = "У вас уже есть активный звонок."; + session = default; + return false; + } + + if (_participantCalls.ContainsKey(calleeId)) + { + error = "Собеседник уже занят другим звонком."; + session = default; + return false; + } + + var stored = new StoredCallSession(chatId, callerId, calleeId, mediaType); + _sessions[stored.CallId] = stored; + _participantCalls[callerId] = stored.CallId; + _participantCalls[calleeId] = stored.CallId; + session = stored.ToSnapshot(); + error = string.Empty; + return true; + } + } + + public bool TryAccept(Guid callId, Guid userId, out ActiveCallSession? session, out string error) + { + lock (_sync) + { + if (!_sessions.TryGetValue(callId, out var stored)) + { + error = "Звонок уже завершён."; + session = default; + return false; + } + + if (stored.State != CallSessionState.Ringing) + { + error = "Звонок уже недоступен."; + session = default; + return false; + } + + if (stored.CalleeId != userId) + { + error = "Принять звонок может только вызываемый пользователь."; + session = default; + return false; + } + + stored.State = CallSessionState.Connected; + stored.ConnectedAt = DateTimeOffset.UtcNow; + session = stored.ToSnapshot(); + error = string.Empty; + return true; + } + } + + public bool TryDecline(Guid callId, Guid userId, out ActiveCallSession? session, out string error) => + TryTerminate(callId, userId, "declined", requireParticipant: true, out session, out error); + + public bool TryEnd(Guid callId, Guid userId, string reason, out ActiveCallSession? session, out string error) => + TryTerminate(callId, userId, reason, requireParticipant: true, out session, out error); + + public bool TryEndByParticipant(Guid userId, string reason, out ActiveCallSession? session) + { + lock (_sync) + { + if (!_participantCalls.TryGetValue(userId, out var callId) || + !_sessions.TryGetValue(callId, out var stored)) + { + session = default; + return false; + } + + RemoveSession(stored, reason); + session = stored.ToSnapshot(); + return true; + } + } + + public bool TryResolveRecipient(Guid callId, Guid senderUserId, out Guid recipientUserId, out ActiveCallSession? session, out string error) + { + lock (_sync) + { + if (!_sessions.TryGetValue(callId, out var stored)) + { + recipientUserId = Guid.Empty; + session = default; + error = "Звонок уже завершён."; + return false; + } + + if (stored.State != CallSessionState.Connected) + { + recipientUserId = Guid.Empty; + session = default; + error = "Соединение ещё не установлено."; + return false; + } + + if (stored.CallerId == senderUserId) + { + recipientUserId = stored.CalleeId; + session = stored.ToSnapshot(); + error = string.Empty; + return true; + } + + if (stored.CalleeId == senderUserId) + { + recipientUserId = stored.CallerId; + session = stored.ToSnapshot(); + error = string.Empty; + return true; + } + + recipientUserId = Guid.Empty; + session = default; + error = "Нет доступа к этому звонку."; + return false; + } + } + + public IReadOnlyList DrainExpired( + DateTimeOffset now, + TimeSpan ringingTimeout, + TimeSpan maximumConnectedDuration) + { + lock (_sync) + { + var expired = _sessions.Values + .Where(stored => + stored.State == CallSessionState.Ringing && stored.CreatedAt.Add(ringingTimeout) <= now || + stored.State == CallSessionState.Connected && + stored.ConnectedAt is not null && + stored.ConnectedAt.Value.Add(maximumConnectedDuration) <= now) + .ToList(); + foreach (var stored in expired) + { + RemoveSession(stored, stored.State == CallSessionState.Ringing ? "no_answer" : "timeout"); + } + + return expired.Select(stored => stored.ToSnapshot()).ToList(); + } + } + + private bool TryTerminate(Guid callId, Guid userId, string reason, bool requireParticipant, out ActiveCallSession? session, out string error) + { + lock (_sync) + { + if (!_sessions.TryGetValue(callId, out var stored)) + { + error = "Звонок уже завершён."; + session = default; + return false; + } + + if (requireParticipant && stored.CallerId != userId && stored.CalleeId != userId) + { + error = "Нет доступа к этому звонку."; + session = default; + return false; + } + + RemoveSession(stored, reason); + session = stored.ToSnapshot(); + error = string.Empty; + return true; + } + } + + private void RemoveSession(StoredCallSession stored, string reason) + { + stored.State = CallSessionState.Ended; + stored.EndReason = reason; + _sessions.Remove(stored.CallId); + _participantCalls.Remove(stored.CallerId); + _participantCalls.Remove(stored.CalleeId); + } + + private sealed class StoredCallSession(Guid chatId, Guid callerId, Guid calleeId, CallMediaType mediaType) + { + public Guid CallId { get; } = Guid.NewGuid(); + + public Guid ChatId { get; } = chatId; + + public Guid CallerId { get; } = callerId; + + public Guid CalleeId { get; } = calleeId; + + public CallMediaType MediaType { get; } = mediaType; + + public DateTimeOffset CreatedAt { get; } = DateTimeOffset.UtcNow; + + public DateTimeOffset? ConnectedAt { get; set; } + + public string EndReason { get; set; } = string.Empty; + + public CallSessionState State { get; set; } = CallSessionState.Ringing; + + public ActiveCallSession ToSnapshot() => + new(CallId, ChatId, CallerId, CalleeId, MediaType, CreatedAt, ConnectedAt, EndReason, State); + } +} + +public sealed record ActiveCallSession( + Guid CallId, + Guid ChatId, + Guid CallerId, + Guid CalleeId, + CallMediaType MediaType, + DateTimeOffset CreatedAt, + DateTimeOffset? ConnectedAt, + string EndReason, + CallSessionState State); + +public enum CallSessionState +{ + Ringing = 0, + Connected = 1, + Ended = 2 +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Calls/QlyraCallCompatibilityStore.cs b/ikar_server/src/Ikar.Server/Infrastructure/Calls/QlyraCallCompatibilityStore.cs new file mode 100644 index 0000000..81f23d5 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Calls/QlyraCallCompatibilityStore.cs @@ -0,0 +1,205 @@ +using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; + +namespace Ikar.Server.Infrastructure.Calls; + +public sealed class QlyraCallCompatibilityStore(IOptions webRtcOptions) +{ + private readonly ConcurrentDictionary _conversations = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _tokens = new(StringComparer.Ordinal); + public IReadOnlyList IceServers => webRtcOptions.Value.ToDto(); + + public QlyraCallConversation Create(string conversationId, bool isVideo, params (Guid UserId, int ClientId)[] users) + { + var conversation = new QlyraCallConversation(conversationId, isVideo); + foreach (var user in users) + { + var participant = new QlyraCallParticipant(user.UserId, user.ClientId); + conversation.Participants[user.UserId] = participant; + _tokens[participant.Token] = (conversation, participant); + } + _conversations[conversationId] = conversation; + return conversation; + } + + public QlyraCallConversation? Find(string conversationId) => _conversations.TryGetValue(conversationId, out var value) ? value : null; + public QlyraCallConversation? FindByJoinToken(string token) => _conversations.Values.FirstOrDefault(item => item.JoinToken == token); + + public QlyraCallParticipant AddParticipant(QlyraCallConversation conversation, Guid userId, int clientId) + { + var participant = conversation.Participants.GetOrAdd(userId, _ => new QlyraCallParticipant(userId, clientId)); + _tokens[participant.Token] = (conversation, participant); + return participant; + } + + public async Task HandleWebSocketAsync(HttpContext context) + { + var token = context.Request.Query["token"].FirstOrDefault(); + if (!context.WebSockets.IsWebSocketRequest || token is null || !_tokens.TryGetValue(token, out var resolved)) + { + context.Response.StatusCode = token is null ? 400 : 404; + return; + } + using var socket = await context.WebSockets.AcceptWebSocketAsync(); + resolved.Participant.IsReady = false; + resolved.Participant.Socket = socket; + await SendAsync(resolved.Participant, ConnectionNotification(resolved.Conversation), context.RequestAborted); + await FlushPendingAsync(resolved.Participant, context.RequestAborted); + resolved.Participant.IsReady = true; + await FlushPendingAsync(resolved.Participant, context.RequestAborted); + await BroadcastAsync(resolved.Conversation, resolved.Participant, new { type = "notification", notification = "participant-joined", participant = ParticipantPayload(resolved.Participant) }, context.RequestAborted); + var buffer = new byte[1024 * 1024]; + try + { + while (socket.State == WebSocketState.Open && !context.RequestAborted.IsCancellationRequested) + { + var result = await socket.ReceiveAsync(buffer, context.RequestAborted); + if (result.MessageType == WebSocketMessageType.Close) break; + if (result.MessageType != WebSocketMessageType.Text) continue; + var text = Encoding.UTF8.GetString(buffer, 0, result.Count); + if (text == "ping") { await SendTextAsync(socket, "pong", context.RequestAborted); continue; } + using var document = JsonDocument.Parse(text); + var root = document.RootElement; + var command = root.TryGetProperty("command", out var commandValue) ? commandValue.GetString() : null; + var sequence = root.TryGetProperty("sequence", out var sequenceValue) && sequenceValue.TryGetInt64(out var parsed) ? parsed : 0; + if (command is null) continue; + await SendAsync(resolved.Participant, new { type = "response", response = command, sequence }, context.RequestAborted); + if (command == "transmit-data") + { + var data = root.TryGetProperty("data", out var dataValue) ? dataValue.Clone() : default; + await BroadcastAsync(resolved.Conversation, resolved.Participant, new { type = "notification", notification = "transmitted-data", participantId = resolved.Participant.ClientId, data }, context.RequestAborted); + } + else if (command == "accept-call") + { + await SendAsync(resolved.Participant, new { type = "notification", notification = "accepted-call", participantId = resolved.Participant.ClientId }, context.RequestAborted); + await BroadcastAsync(resolved.Conversation, resolved.Participant, new { type = "notification", notification = "accepted-call", participantId = resolved.Participant.ClientId }, context.RequestAborted); + } + else if (command == "change-media-settings") + { + var media = root.TryGetProperty("mediaSettings", out var mediaValue) ? mediaValue.Clone() : default; + await BroadcastAsync(resolved.Conversation, resolved.Participant, new { type = "notification", notification = "media-settings-changed", participantId = resolved.Participant.ClientId, mediaSettings = media }, context.RequestAborted); + } + else if (command == "hangup") + { + await BroadcastAsync(resolved.Conversation, resolved.Participant, new { type = "notification", notification = "hungup", participantId = resolved.Participant.ClientId }, context.RequestAborted); + await FinishDirectAsync(resolved.Conversation, resolved.Participant); + break; + } + } + } + finally + { + resolved.Participant.IsReady = false; + resolved.Participant.Socket = null; + await BroadcastAsync(resolved.Conversation, resolved.Participant, new { type = "notification", notification = "participant-left", participantId = resolved.Participant.ClientId }, CancellationToken.None); + } + } + + private async Task FinishDirectAsync(QlyraCallConversation conversation, QlyraCallParticipant sender) + { + if (conversation.Participants.Count > 2 || !_conversations.TryRemove(conversation.Id, out _)) return; + foreach (var participant in conversation.Participants.Values) _tokens.TryRemove(participant.Token, out _); + await BroadcastAsync(conversation, sender, new { type = "notification", notification = "closed-conversation" }, CancellationToken.None); + } + + private object ConnectionNotification(QlyraCallConversation conversation) + { + var servers = webRtcOptions.Value.ToDto(); + var stun = servers.FirstOrDefault(server => server.Urls.Any(url => url.StartsWith("stun:", StringComparison.OrdinalIgnoreCase))); + var turn = servers.FirstOrDefault(server => server.Urls.Any(url => url.StartsWith("turn:", StringComparison.OrdinalIgnoreCase))); + return new + { + type = "notification", + notification = "connection", + conversation = new { id = conversation.Id, topology = "P2P", participants = conversation.Participants.Values.Select(ParticipantPayload).ToArray() }, + conversationParams = new + { + stun = stun is null ? null : new { urls = stun.Urls }, + turn = turn is null ? null : new { urls = turn.Urls, username = turn.Username, credential = turn.Credential } + } + }; + } + + private static object ParticipantPayload(QlyraCallParticipant participant) => new { id = participant.ClientId, externalId = new { id = participant.ClientId }, participantType = "USER", state = "CONNECTED", responderTypes = new[] { "USER" }, responderDeviceIdxs = new[] { 0 } }; + private static Task BroadcastAsync(QlyraCallConversation conversation, QlyraCallParticipant sender, object payload, CancellationToken token) + { + var text = JsonSerializer.Serialize(payload); + return Task.WhenAll(conversation.Participants.Values.Where(item => item != sender).Select(item => SendOrQueueAsync(item, text, token))); + } + + private static async Task SendOrQueueAsync(QlyraCallParticipant participant, string text, CancellationToken token) + { + await participant.SendLock.WaitAsync(token); + try + { + var socket = participant.Socket; + if (!participant.IsReady || socket?.State != WebSocketState.Open) + { + participant.PendingMessages.Enqueue(text); + return; + } + try { await SendTextAsync(socket, text, token); } + catch (WebSocketException) { participant.PendingMessages.Enqueue(text); } + } + finally { participant.SendLock.Release(); } + } + + private static async Task FlushPendingAsync(QlyraCallParticipant participant, CancellationToken token) + { + await participant.SendLock.WaitAsync(token); + try + { + while (participant.PendingMessages.TryDequeue(out var text)) + { + var socket = participant.Socket; + if (socket?.State != WebSocketState.Open) + { + participant.PendingMessages.Enqueue(text); + return; + } + try { await SendTextAsync(socket, text, token); } + catch (WebSocketException) + { + participant.PendingMessages.Enqueue(text); + return; + } + } + } + finally { participant.SendLock.Release(); } + } + + private static async Task SendAsync(QlyraCallParticipant participant, object payload, CancellationToken token) + => await SendTextToParticipantAsync(participant, JsonSerializer.Serialize(payload), token); + + private static async Task SendTextToParticipantAsync(QlyraCallParticipant participant, string text, CancellationToken token) + { + var socket = participant.Socket; + if (socket?.State != WebSocketState.Open) return; + await participant.SendLock.WaitAsync(token); + try { await SendTextAsync(socket, text, token); } + finally { participant.SendLock.Release(); } + } + private static Task SendTextAsync(WebSocket socket, string text, CancellationToken token) => socket.SendAsync(Encoding.UTF8.GetBytes(text), WebSocketMessageType.Text, true, token); +} + +public sealed class QlyraCallConversation(string id, bool isVideo) +{ + public string Id { get; } = id; + public bool IsVideo { get; } = isVideo; + public string JoinToken { get; } = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(18)).ToLowerInvariant(); + public ConcurrentDictionary Participants { get; } = new(); +} + +public sealed class QlyraCallParticipant(Guid userId, int clientId) +{ + public Guid UserId { get; } = userId; + public int ClientId { get; } = clientId; + public string Token { get; } = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); + public WebSocket? Socket { get; set; } + public bool IsReady { get; set; } + public SemaphoreSlim SendLock { get; } = new(1, 1); + public ConcurrentQueue PendingMessages { get; } = new(); +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Calls/WebRtcOptions.cs b/ikar_server/src/Ikar.Server/Infrastructure/Calls/WebRtcOptions.cs new file mode 100644 index 0000000..e1419f3 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Calls/WebRtcOptions.cs @@ -0,0 +1,38 @@ +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure.Calls; + +public sealed class WebRtcOptions +{ + public const string SectionName = "WebRtc"; + + public List IceServers { get; set; } = + [ + new() + { + Urls = ["stun:stun.l.google.com:19302"] + } + ]; + + public int RingingTimeoutSeconds { get; set; } = 60; + + public int MaximumCallDurationHours { get; set; } = 8; + + public IReadOnlyList ToDto() => + IceServers + .Where(server => server.Urls.Count > 0) + .Select(server => new CallIceServerDto( + Urls: server.Urls, + Username: string.IsNullOrWhiteSpace(server.Username) ? null : server.Username, + Credential: string.IsNullOrWhiteSpace(server.Credential) ? null : server.Credential)) + .ToList(); +} + +public sealed class WebRtcIceServerOptions +{ + public List Urls { get; set; } = []; + + public string? Username { get; set; } + + public string? Credential { get; set; } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Channels/ChannelDiscussionMessageFactory.cs b/ikar_server/src/Ikar.Server/Infrastructure/Channels/ChannelDiscussionMessageFactory.cs new file mode 100644 index 0000000..c1341f2 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Channels/ChannelDiscussionMessageFactory.cs @@ -0,0 +1,94 @@ +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Shared; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Infrastructure.Channels; + +internal static class ChannelDiscussionMessageFactory +{ + public static async Task CreateLinkedDiscussionRootAsync( + IkarDbContext dbContext, + AttachmentStorageService? attachmentStorageService, + Chat channel, + Message channelMessage, + User sender, + DateTimeOffset sentAt, + CancellationToken cancellationToken) + { + if (channel.Type != ChatType.Channel || + !channelMessage.PostedAsChannel || + channel.LinkedDiscussionChatId is null) + { + return null; + } + + var discussionChat = await dbContext.Chats + .Include(x => x.Members) + .FirstOrDefaultAsync( + x => x.Id == channel.LinkedDiscussionChatId.Value && + x.Type == ChatType.Group, + cancellationToken); + if (discussionChat is null) + { + return null; + } + + var rootMessage = new Message + { + ChatId = discussionChat.Id, + Chat = discussionChat, + SenderId = sender.Id, + Sender = sender, + Text = BuildDiscussionRootText(channelMessage), + SentAt = sentAt, + ForwardedFromDisplayName = string.IsNullOrWhiteSpace(channel.Title) ? "Channel" : channel.Title, + MediaAlbumId = channelMessage.MediaAlbumId, + HasProtectedContent = channelMessage.HasProtectedContent, + TtlSeconds = channelMessage.TtlSeconds, + ExpiresAt = channelMessage.ExpiresAt + }; + + var clonedAttachments = new List(); + try + { + foreach (var sourceAttachment in channelMessage.Attachments + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.OriginalFileName)) + { + if (attachmentStorageService is null) + { + throw new InvalidOperationException("Attachment storage is required to mirror channel attachments to a discussion group."); + } + + var clonedAttachment = await attachmentStorageService.CloneAsync( + rootMessage.Id, + sourceAttachment, + cancellationToken, + sourceAttachment.SortOrder); + clonedAttachments.Add(clonedAttachment); + rootMessage.Attachments.Add(clonedAttachment); + } + } + catch + { + foreach (var clonedAttachment in clonedAttachments) + { + attachmentStorageService?.DeleteIfExists(clonedAttachment.StoragePath); + } + + throw; + } + + discussionChat.LastActivityAt = sentAt; + dbContext.Messages.Add(rootMessage); + return rootMessage; + } + + public static string BuildDiscussionRootText(Message channelMessage) + { + var text = channelMessage.Text.Trim(); + return string.IsNullOrWhiteSpace(text) ? "Publication" : text; + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/ClaimsPrincipalExtensions.cs b/ikar_server/src/Ikar.Server/Infrastructure/ClaimsPrincipalExtensions.cs new file mode 100644 index 0000000..d9ecef8 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/ClaimsPrincipalExtensions.cs @@ -0,0 +1,22 @@ +using System.Security.Claims; + +namespace Ikar.Server.Infrastructure; + +public static class ClaimsPrincipalExtensions +{ + public static Guid GetRequiredUserId(this ClaimsPrincipal principal) + { + var value = principal.FindFirstValue(ClaimTypes.NameIdentifier); + return Guid.TryParse(value, out var userId) + ? userId + : throw new InvalidOperationException("Authenticated user id claim is missing."); + } + + public static Guid GetRequiredSessionId(this ClaimsPrincipal principal) + { + var value = principal.FindFirstValue("sid"); + return Guid.TryParse(value, out var sessionId) + ? sessionId + : throw new InvalidOperationException("Authenticated session id claim is missing."); + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/DatabaseSeeder.cs b/ikar_server/src/Ikar.Server/Infrastructure/DatabaseSeeder.cs new file mode 100644 index 0000000..91a32c7 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/DatabaseSeeder.cs @@ -0,0 +1,119 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure; + +public sealed class DatabaseSeeder(IkarDbContext dbContext) +{ + private readonly PasswordHasher _passwordHasher = new(); + + public async Task SeedAsync() + { + if (await dbContext.Users.AnyAsync()) + { + return; + } + + var alice = CreateUser("alice", "Alice Carter", "demo123", "+10000000001"); + var bob = CreateUser("bob", "Bob Miller", "demo123", "+10000000002"); + var carol = CreateUser("carol", "Carol Stone", "demo123", "+10000000003"); + + var directChat = new Chat + { + Type = ChatType.Direct, + CreatedBy = alice, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-30), + LastActivityAt = DateTimeOffset.UtcNow.AddMinutes(-5), + Members = + [ + new ChatMember { User = alice, IsOwner = true, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-30) }, + new ChatMember { User = bob, IsOwner = false, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-30) } + ] + }; + + var groupChat = new Chat + { + Type = ChatType.Group, + Title = "Ikar Crew", + CreatedBy = alice, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-20), + LastActivityAt = DateTimeOffset.UtcNow.AddMinutes(-2), + Members = + [ + new ChatMember { User = alice, IsOwner = true, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-20) }, + new ChatMember { User = bob, IsOwner = false, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-20) }, + new ChatMember { User = carol, IsOwner = false, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-20) } + ] + }; + + directChat.Messages.Add(new Message + { + Chat = directChat, + Sender = alice, + Text = "Welcome to Ikar. This is a seeded private dialog.", + SentAt = DateTimeOffset.UtcNow.AddMinutes(-10) + }); + + directChat.Messages.Add(new Message + { + Chat = directChat, + Sender = bob, + Text = "Messages are stored in SQLite and delivered through SignalR.", + SentAt = DateTimeOffset.UtcNow.AddMinutes(-5) + }); + + groupChat.Messages.Add(new Message + { + Chat = groupChat, + Sender = carol, + Text = "Use this group to validate shared conversations.", + SentAt = DateTimeOffset.UtcNow.AddMinutes(-2) + }); + + var channel = new Chat + { + Type = ChatType.Channel, + Title = "Ikar Updates", + CreatedBy = alice, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-15), + LastActivityAt = DateTimeOffset.UtcNow.AddMinutes(-1), + Members = + [ + new ChatMember { User = alice, IsOwner = true, Role = ChatMemberRole.Owner, CanPinMessages = true, CanDeleteMessages = true, CanInviteUsers = true, CanPostMessages = true, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-15) }, + new ChatMember { User = bob, IsOwner = false, CanPostMessages = false, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-15) }, + new ChatMember { User = carol, IsOwner = false, CanPostMessages = false, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-15) } + ] + }; + + channel.Messages.Add(new Message + { + Chat = channel, + Sender = alice, + Text = "This channel is broadcast-only. Owners can post, subscribers can read.", + PostedAsChannel = true, + SentAt = DateTimeOffset.UtcNow.AddMinutes(-1) + }); + + dbContext.Users.AddRange(alice, bob, carol); + dbContext.Chats.AddRange(directChat, groupChat, channel); + await dbContext.SaveChangesAsync(); + } + + private User CreateUser(string username, string displayName, string password, string phoneNumber) + { + var user = new User + { + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = displayName, + PhoneNumber = phoneNumber, + NormalizedPhoneNumber = PhoneNumberNormalizer.Normalize(phoneNumber) + }; + + user.PasswordHash = _passwordHasher.HashPassword(user, password); + return user; + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/DtoMapper.cs b/ikar_server/src/Ikar.Server/Infrastructure/DtoMapper.cs new file mode 100644 index 0000000..d0038e5 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/DtoMapper.cs @@ -0,0 +1,808 @@ +using Ikar.Server.Data.Entities; +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure; + +public static class DtoMapper +{ + public static UserSummaryDto ToDto(this User user, PresenceTracker presenceTracker) => + new( + user.Id, + user.Username, + user.DisplayName, + presenceTracker.IsOnline(user.Id), + user.PhoneNumber, + user.Sessions + .OrderByDescending(x => x.LastSeenAt) + .Select(x => (DateTimeOffset?)x.LastSeenAt) + .FirstOrDefault(), + user.About, + ResolveAvatarPath(user), + user.IsBot); + + public static UserSummaryDto ToDtoForViewer( + this User user, + PresenceTracker presenceTracker, + Guid currentUserId, + bool sharesChatWithViewer) + { + var dto = user.ToDto(presenceTracker); + if (user.Id == currentUserId) + { + return dto; + } + + var privacy = user.PrivacySettings; + var canViewPhone = CanViewFieldByPrivacy( + currentUserId, + privacy?.PhoneNumberVisibility, + privacy?.AlwaysAllowUserIds, + privacy?.NeverAllowUserIds, + fallback: "contacts", + sharesChatWithViewer); + var canViewLastSeen = CanViewFieldByPrivacy( + currentUserId, + privacy?.LastSeenVisibility, + privacy?.AlwaysAllowUserIds, + privacy?.NeverAllowUserIds, + fallback: "contacts", + sharesChatWithViewer); + var canViewAvatar = CanViewFieldByPrivacy( + currentUserId, + privacy?.ProfilePhotoVisibility, + privacy?.AlwaysAllowUserIds, + privacy?.NeverAllowUserIds, + fallback: "everyone", + sharesChatWithViewer); + + return dto with + { + PhoneNumber = canViewPhone ? dto.PhoneNumber : null, + LastSeenAt = canViewLastSeen ? dto.LastSeenAt : null, + IsOnline = canViewLastSeen && dto.IsOnline, + AvatarPath = canViewAvatar ? dto.AvatarPath : null + }; + } + + public static BotSummaryDto ToDto(this Bot bot) => + new( + bot.Id, + bot.UserId, + bot.User.Username, + bot.User.DisplayName, + bot.User.About, + bot.IsEnabled, + bot.CreatedAt, + bot.Commands + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Command, StringComparer.OrdinalIgnoreCase) + .Select(x => new BotCommandDto(x.Command, x.Description)) + .ToList()); + + public static PushDeviceDto ToDto(this PushDevice device) => + new( + device.Id, + device.Platform, + device.InstallationId, + device.DeviceName, + device.NotificationsEnabled, + device.UpdatedAt); + + public static MessageDto ToDto(this Message message, PresenceTracker presenceTracker, Guid currentUserId) + { + var sender = ResolveMessageSenderDto(message, presenceTracker, currentUserId); + var displayAuthorName = ResolveDisplayAuthorName(message); + var isChannelPost = message.Chat?.Type == ChatType.Channel && message.PostedAsChannel; + var viewCount = isChannelPost ? message.Views.Count : (int?)null; + var commentCount = isChannelPost && message.DiscussionMessageId is not null + ? message.CommentCount + : (int?)null; + + return new MessageDto( + message.Id, + message.ChatId, + message.Chat?.Type ?? ChatType.Direct, + sender, + message.Text, + message.Attachments + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.OriginalFileName) + .Select(x => x.ToDto(message.ChatId)) + .ToList(), + message.SentAt, + message.EditedAt, + message.DeletedAt, + ResolveDeliveryState(message, currentUserId), + message.ForwardedFromDisplayName, + BuildReplyDto(message.ReplyToMessage, message.Chat), + message.PinnedAt, + message.PinnedByUser?.ToDtoForViewer( + presenceTracker, + currentUserId, + SharesChatWithCurrentUser(message.Chat, currentUserId)), + message.MediaAlbumId, + BuildReactions(message, currentUserId), + message.HasProtectedContent || message.Chat?.HasProtectedContent == true, + message.ExpiresAt, + message.TtlSeconds, + message.PostedAsChannel, + displayAuthorName, + viewCount, + BuildStoryReplyDto(message), + message.DiscussionMessageId, + commentCount, + message.ClientMessageId); + } + + public static AttachmentDto ToDto(this MessageAttachment attachment, Guid chatId) => + new( + attachment.Id, + attachment.OriginalFileName, + attachment.ContentType, + attachment.FileSizeBytes, + $"/api/chats/{chatId}/attachments/{attachment.Id}/content", + ResolveAttachmentKind(attachment), + attachment.SortOrder); + + public static ChatSummaryDto ToSummaryDto( + this Chat chat, + Guid currentUserId, + PresenceTracker presenceTracker, + Message? latestMessage = null, + int? unreadCountOverride = null) + { + var currentMembership = chat.Members.FirstOrDefault(x => x.UserId == currentUserId); + var permissions = BuildPermissions(currentMembership, chat.Type); + var canSendMessages = permissions.CanPostMessages; + var channelStats = BuildChannelStats(chat); + var visibleMembers = BuildVisibleMembers(chat, currentMembership); + var orderedMembers = visibleMembers + .Select(x => x.User) + .OrderBy(x => x.DisplayName) + .ToList(); + + var sharesChatWithCurrentUser = currentMembership is not null; + var participants = orderedMembers + .Select(user => user.ToDtoForViewer(presenceTracker, currentUserId, sharesChatWithCurrentUser)) + .ToList(); + + var counterpart = chat.Type == ChatType.Direct + ? orderedMembers.FirstOrDefault(x => x.Id != currentUserId) + : null; + var title = chat.Type == ChatType.Direct + ? counterpart?.DisplayName ?? (string.IsNullOrWhiteSpace(chat.Title) ? "Dialog" : chat.Title) + : string.IsNullOrWhiteSpace(chat.Title) ? "New Group" : chat.Title; + + var secondary = chat.Type switch + { + ChatType.Direct => counterpart is null + ? null + : counterpart.IsBot + ? $"бот · @{counterpart.Username}" + : $"@{counterpart.Username}", + ChatType.Channel => $"{FormatSubscribers(channelStats.SubscriberCount)}{(canSendMessages ? " · публикация разрешена" : string.Empty)}", + _ => $"{participants.Count} members" + }; + + var lastMessage = latestMessage ?? chat.Messages.OrderByDescending(x => x.SentAt).FirstOrDefault(); + if (lastMessage is not null) + { + lastMessage.Chat ??= chat; + } + + var lastPreview = lastMessage is null ? null : BuildMessagePreview(lastMessage); + var lastMessageAttachment = lastMessage is null || + lastMessage.DeletedAt is not null || + !string.IsNullOrWhiteSpace(lastMessage.Text) + ? null + : lastMessage.Attachments + .OrderBy(attachment => attachment.SortOrder) + .ThenBy(attachment => attachment.Id) + .FirstOrDefault() + ?.ToDto(chat.Id); + var unreadCount = unreadCountOverride ?? chat.Messages.Count(message => + message.SenderId != currentUserId && + message.DeletedAt is null && + (currentMembership?.LastReadAt is null || message.SentAt > currentMembership.LastReadAt.Value)); + + return new ChatSummaryDto( + chat.Id, + title, + chat.Type, + secondary, + chat.LastActivityAt ?? chat.CreatedAt, + lastPreview, + participants, + canSendMessages, + unreadCount, + currentMembership?.IsPinned == true, + currentMembership?.IsArchived == true, + currentMembership?.MutedUntil is not null && currentMembership.MutedUntil > DateTimeOffset.UtcNow, + currentMembership?.FolderKey, + chat.HasProtectedContent, + chat.DefaultMessageTtlSeconds, + lastMessageAttachment, + channelStats.SubscriberCount, + channelStats.AdminCount, + currentMembership is not null, + chat.Type == ChatType.Channel && currentMembership is null && !string.IsNullOrWhiteSpace(chat.PublicUsername), + permissions.CanManageMembers, + chat.Type == ChatType.Channel && canSendMessages, + chat.PublicUsername, + chat.LinkedDiscussionChatId, + chat.ChannelSignaturesEnabled, + chat.Description, + CanDeleteForEveryone(chat, currentMembership)); + } + + public static ChatDetailsDto ToDetailsDto( + this Chat chat, + Guid currentUserId, + PresenceTracker presenceTracker, + Guid? firstUnreadMessageId = null, + IReadOnlyList? messagesOverride = null, + bool hasMoreMessages = false, + IReadOnlyList? pinnedMessagesOverride = null) + { + var currentMembership = chat.Members.FirstOrDefault(x => x.UserId == currentUserId); + var permissions = BuildPermissions(currentMembership, chat.Type); + var canSendMessages = permissions.CanPostMessages; + var channelStats = BuildChannelStats(chat); + var visibleMembers = BuildVisibleMembers(chat, currentMembership); + var participants = visibleMembers + .Select(x => x.User) + .OrderBy(x => x.DisplayName) + .Select(x => x.ToDtoForViewer(presenceTracker, currentUserId, currentMembership is not null)) + .ToList(); + var members = visibleMembers + .OrderByDescending(x => x.IsOwner || x.Role == ChatMemberRole.Owner) + .ThenByDescending(x => x.Role == ChatMemberRole.Admin) + .ThenBy(x => x.User.DisplayName) + .Select(x => new ChatMemberDto( + x.User.ToDtoForViewer(presenceTracker, currentUserId, currentMembership is not null), + x.Role, + x.IsOwner, + BuildPermissions(x, chat.Type))) + .ToList(); + + var counterpart = chat.Type == ChatType.Direct + ? chat.Members.Select(x => x.User).FirstOrDefault(x => x.Id != currentUserId) + : null; + + var title = chat.Type == ChatType.Direct + ? counterpart?.DisplayName ?? (string.IsNullOrWhiteSpace(chat.Title) ? "Dialog" : chat.Title) + : string.IsNullOrWhiteSpace(chat.Title) ? "New Group" : chat.Title; + + var sourceMessages = messagesOverride ?? chat.Messages.OrderBy(x => x.SentAt).ToList(); + foreach (var message in sourceMessages) + { + message.Chat = chat; + } + + var messages = sourceMessages + .OrderBy(x => x.SentAt) + .Select(x => x.ToDto(presenceTracker, currentUserId)) + .ToList(); + + var pinnedMessages = (pinnedMessagesOverride ?? chat.Messages + .Where(x => x.PinnedAt is not null && x.DeletedAt is null) + .OrderByDescending(x => x.PinnedAt) + .Take(5) + .ToList()) + .Select(message => + { + message.Chat = chat; + return message.ToDto(presenceTracker, currentUserId); + }) + .ToList(); + + return new ChatDetailsDto( + chat.Id, + title, + chat.Type, + participants, + canSendMessages, + messages, + firstUnreadMessageId, + hasMoreMessages, + pinnedMessages, + permissions, + members, + chat.HasProtectedContent, + chat.DefaultMessageTtlSeconds, + channelStats.SubscriberCount, + channelStats.AdminCount, + currentMembership is not null, + chat.Type == ChatType.Channel && currentMembership is null && !string.IsNullOrWhiteSpace(chat.PublicUsername), + permissions.CanManageMembers, + chat.Type == ChatType.Channel && canSendMessages, + chat.PublicUsername, + chat.LinkedDiscussionChatId, + chat.ChannelSignaturesEnabled, + chat.Description, + CanDeleteForEveryone(chat, currentMembership)); + } + + public static ModerationLogDto ToDto(this ModerationLogEntry entry, PresenceTracker presenceTracker) => + new( + entry.Id, + entry.ChatId, + entry.ActorUserId, + string.IsNullOrWhiteSpace(entry.ActorUser.DisplayName) + ? entry.ActorUser.Username + : entry.ActorUser.DisplayName, + entry.TargetType, + entry.TargetId, + entry.Action, + entry.Reason, + entry.CreatedAt); + + public static StoryDto ToDto( + this Story story, + PresenceTracker presenceTracker, + Guid currentUserId, + bool sharesChatWithViewer = false) => + new( + story.Id, + story.AuthorUser.ToDtoForViewer( + presenceTracker, + currentUserId, + story.AuthorUserId == currentUserId || sharesChatWithViewer), + story.DeletedAt is null ? story.Text : string.Empty, + story.DeletedAt is null + ? story.Attachments + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.OriginalFileName) + .Select(x => x.ToDto(story.Id)) + .ToList() + : [], + story.Visibility, + story.HasProtectedContent, + story.CreatedAt, + story.ExpiresAt, + story.DeletedAt, + story.Views.Count, + story.Views.Any(x => x.ViewerUserId == currentUserId)); + + public static AttachmentDto ToDto(this StoryAttachment attachment, Guid storyId) => + new( + attachment.Id, + attachment.OriginalFileName, + attachment.ContentType, + attachment.FileSizeBytes, + $"/api/stories/{storyId}/attachments/{attachment.Id}/content", + AttachmentKind.File, + attachment.SortOrder); + + public static ChatPermissionsDto BuildPermissions(ChatMember? membership, ChatType chatType) + { + if (membership is null) + { + return new ChatPermissionsDto(false, false, false, false, false); + } + + var isManager = membership.IsOwner || membership.Role is ChatMemberRole.Owner or ChatMemberRole.Admin; + var canPost = chatType == ChatType.Direct || isManager || membership.CanPostMessages; + return new ChatPermissionsDto( + CanPostMessages: canPost, + CanPinMessages: isManager || membership.CanPinMessages, + CanDeleteMessages: isManager || membership.CanDeleteMessages, + CanInviteUsers: isManager || membership.CanInviteUsers, + CanManageMembers: isManager); + } + + private static IReadOnlyList BuildVisibleMembers(Chat chat, ChatMember? currentMembership) + { + if (chat.Type != ChatType.Channel) + { + return chat.Members.ToList(); + } + + if (currentMembership is not null && BuildPermissions(currentMembership, chat.Type).CanManageMembers) + { + return chat.Members.ToList(); + } + + if (currentMembership is null) + { + return chat.Members + .Where(IsChannelManager) + .ToList(); + } + + return chat.Members + .Where(member => member.UserId == currentMembership.UserId || IsChannelManager(member)) + .ToList(); + } + + private static ChannelStats BuildChannelStats(Chat chat) + { + if (chat.Type != ChatType.Channel) + { + var memberCount = chat.Members.Count; + return new ChannelStats(memberCount, chat.Members.Count(IsChannelManager)); + } + + return new ChannelStats( + chat.Members.Count, + chat.Members.Count(IsChannelManager)); + } + + private static bool IsChannelManager(ChatMember member) => + member.IsOwner || member.Role is ChatMemberRole.Owner or ChatMemberRole.Admin; + + private static bool CanDeleteForEveryone(Chat chat, ChatMember? membership) => + membership is not null && + (chat.Type == ChatType.Direct || + membership.IsOwner || + membership.Role == ChatMemberRole.Owner); + + private static string FormatSubscribers(int count) => + $"{count} {FormatRussianPlural(count, "подписчик", "подписчика", "подписчиков")}"; + + private static string FormatRussianPlural(int count, string one, string few, string many) + { + var value = Math.Abs(count) % 100; + var lastDigit = value % 10; + if (value is >= 11 and <= 14) + { + return many; + } + + return lastDigit switch + { + 1 => one, + >= 2 and <= 4 => few, + _ => many + }; + } + + private static UserSummaryDto ResolveMessageSenderDto(Message message, PresenceTracker presenceTracker, Guid currentUserId) + { + if (message.Chat is { Type: ChatType.Channel } chat && + message.PostedAsChannel && + !CanInspectChannelSender(chat, currentUserId)) + { + return BuildChannelIdentityDto(chat); + } + + return message.Sender.ToDtoForViewer( + presenceTracker, + currentUserId, + SharesChatWithCurrentUser(message.Chat, currentUserId)); + } + + private static bool SharesChatWithCurrentUser(Chat? chat, Guid currentUserId) => + chat?.Members.Any(member => member.UserId == currentUserId) == true; + + private static bool CanViewFieldByPrivacy( + Guid viewerUserId, + string? visibilityValue, + string? alwaysAllowUserIds, + string? neverAllowUserIds, + string fallback, + bool sharesChatWithViewer) + { + var neverAllow = DeserializeUserIdList(neverAllowUserIds).ToHashSet(); + if (neverAllow.Contains(viewerUserId)) + { + return false; + } + + var alwaysAllow = DeserializeUserIdList(alwaysAllowUserIds).ToHashSet(); + if (alwaysAllow.Contains(viewerUserId)) + { + return true; + } + + return NormalizePrivacyVisibility(visibilityValue, fallback) switch + { + "everyone" => true, + "contacts" => sharesChatWithViewer, + _ => false + }; + } + + private static string NormalizePrivacyVisibility(string? value, string fallback) + { + var normalized = value?.Trim().ToLowerInvariant(); + return normalized is "everyone" or "contacts" or "nobody" ? normalized : fallback; + } + + private static IReadOnlyList DeserializeUserIdList(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } + + return value + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(x => Guid.TryParse(x, out var parsed) ? parsed : Guid.Empty) + .Where(x => x != Guid.Empty) + .Distinct() + .ToList(); + } + + private static bool CanInspectChannelSender(Chat chat, Guid currentUserId) + { + var membership = chat.Members.FirstOrDefault(member => member.UserId == currentUserId); + return membership is not null && BuildPermissions(membership, chat.Type).CanManageMembers; + } + + private static UserSummaryDto BuildChannelIdentityDto(Chat chat) => + new( + chat.Id, + chat.PublicUsername ?? $"channel-{chat.Id:N}", + string.IsNullOrWhiteSpace(chat.Title) ? "Channel" : chat.Title!, + false, + null, + null, + null, + null); + + private static string ResolveDisplayAuthorName(Message message) + { + if (message.Chat?.Type == ChatType.Channel && message.PostedAsChannel) + { + if (!string.IsNullOrWhiteSpace(message.AuthorSignature)) + { + return message.AuthorSignature!; + } + + return string.IsNullOrWhiteSpace(message.Chat.Title) ? "Channel" : message.Chat.Title!; + } + + return string.IsNullOrWhiteSpace(message.Sender.DisplayName) + ? message.Sender.Username + : message.Sender.DisplayName; + } + + private static string ResolveReplyAuthorName(Message replyToMessage, Chat? currentChat) + { + if (currentChat?.Type == ChatType.Channel && replyToMessage.PostedAsChannel) + { + if (!string.IsNullOrWhiteSpace(replyToMessage.AuthorSignature)) + { + return replyToMessage.AuthorSignature!; + } + + return string.IsNullOrWhiteSpace(currentChat.Title) ? "Channel" : currentChat.Title!; + } + + return string.IsNullOrWhiteSpace(replyToMessage.Sender.DisplayName) + ? replyToMessage.Sender.Username + : replyToMessage.Sender.DisplayName; + } + + public static string BuildPushTitle(this Chat chat, Message message) => + chat.Type switch + { + ChatType.Direct => message.Sender.DisplayName, + ChatType.Channel when message.PostedAsChannel => string.IsNullOrWhiteSpace(chat.Title) ? "Channel" : chat.Title!, + _ => string.IsNullOrWhiteSpace(chat.Title) ? message.Sender.DisplayName : chat.Title! + }; + + public static string BuildPushBody(this Message message) + { + if (message.DeletedAt is not null) + { + return "Message removed"; + } + + if (!string.IsNullOrWhiteSpace(message.Text)) + { + return message.Attachments.Count == 0 + ? message.Text + : $"{message.Text} ({BuildAttachmentPreview(message.Attachments)})"; + } + + return BuildAttachmentPreview(message.Attachments); + } + + private static string BuildMessagePreview(Message lastMessage) + { + var isChannelPost = lastMessage.Chat?.Type == ChatType.Channel && lastMessage.PostedAsChannel; + + if (lastMessage.DeletedAt is not null) + { + return isChannelPost + ? "Публикация удалена" + : $"{ResolveDisplayAuthorName(lastMessage)}: message removed"; + } + + if (string.IsNullOrWhiteSpace(lastMessage.Text)) + { + var attachmentPreview = BuildAttachmentPreview(lastMessage.Attachments); + return isChannelPost + ? attachmentPreview + : $"{ResolveDisplayAuthorName(lastMessage)}: {attachmentPreview}"; + } + + var textPreview = lastMessage.Attachments.Count == 0 + ? lastMessage.Text + : $"{lastMessage.Text} ({BuildAttachmentPreview(lastMessage.Attachments)})"; + if (lastMessage.StoryReplyStoryId is not null) + { + textPreview = $"Ответ на сторис: {textPreview}"; + } + + return isChannelPost + ? textPreview + : $"{ResolveDisplayAuthorName(lastMessage)}: {textPreview}"; + } + + private static MessageReplyDto? BuildReplyDto(Message? replyToMessage, Chat? currentChat) + { + if (replyToMessage is null) + { + return null; + } + + return new MessageReplyDto( + replyToMessage.Id, + replyToMessage.SenderId, + ResolveReplyAuthorName(replyToMessage, currentChat), + BuildReplyPreviewText(replyToMessage)); + } + + private static StoryReplyDto? BuildStoryReplyDto(Message message) + { + if (message.StoryReplyStoryId is null || message.StoryReplyAuthorUserId is null) + { + return null; + } + + return new StoryReplyDto( + message.StoryReplyStoryId.Value, + message.StoryReplyAuthorUserId.Value, + string.IsNullOrWhiteSpace(message.StoryReplyPreviewText) ? "Story" : message.StoryReplyPreviewText!, + message.StoryReplyAttachmentKind); + } + + private static IReadOnlyList BuildReactions(Message message, Guid currentUserId) => + message.Reactions + .Where(reaction => !string.IsNullOrWhiteSpace(reaction.Emoji)) + .GroupBy(reaction => reaction.Emoji) + .Select(group => new MessageReactionDto( + group.Key, + group.Count(), + group.Any(reaction => reaction.UserId == currentUserId))) + .OrderByDescending(reaction => reaction.Count) + .ThenBy(reaction => reaction.Emoji, StringComparer.Ordinal) + .ToList(); + + private static string BuildAttachmentPreview(ICollection attachments) + { + if (attachments.Count == 0) + { + return string.Empty; + } + + if (attachments.Count == 1) + { + var attachment = attachments.First(); + if (ResolveAttachmentKind(attachment) == AttachmentKind.VoiceNote) + { + return "голосовое сообщение"; + } + + if (IsImageAttachment(attachment.ContentType, attachment.OriginalFileName)) + { + return "фотография"; + } + + return $"файл: {attachment.OriginalFileName}"; + } + + if (attachments.All(attachment => IsImageAttachment(attachment.ContentType, attachment.OriginalFileName))) + { + return $"{attachments.Count} {FormatRussianPlural(attachments.Count, "фотография", "фотографии", "фотографий")}"; + } + + return $"{attachments.Count} {FormatRussianPlural(attachments.Count, "файл", "файла", "файлов")}"; + } + + private static string BuildReplyPreviewText(Message message) + { + if (message.DeletedAt is not null) + { + return "Сообщение удалено"; + } + + if (!string.IsNullOrWhiteSpace(message.Text)) + { + return message.Attachments.Count == 0 + ? message.Text + : $"{message.Text} ({BuildAttachmentPreview(message.Attachments)})"; + } + + return BuildAttachmentPreview(message.Attachments); + } + + private static AttachmentKind ResolveAttachmentKind(MessageAttachment attachment) => + attachment.Kind == AttachmentKind.VoiceNote || IsVoiceLikeAttachment(attachment) + ? AttachmentKind.VoiceNote + : AttachmentKind.File; + + private static bool IsVoiceLikeAttachment(MessageAttachment attachment) + { + var contentType = attachment.ContentType + .Split(';', 2, StringSplitOptions.TrimEntries) + .FirstOrDefault() + ?.Trim() + .ToLowerInvariant(); + if (contentType is "audio/ogg" or "audio/opus" or "application/ogg" or "application/opus" or "application/x-ogg" or "video/ogg") + { + return true; + } + + var extension = Path.GetExtension(attachment.OriginalFileName).ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(extension)) + { + var lastToken = attachment.OriginalFileName + .Split([' ', '.', '_', '-', '(', ')', '[', ']'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .LastOrDefault() + ?.ToLowerInvariant(); + extension = string.IsNullOrWhiteSpace(lastToken) ? string.Empty : $".{lastToken}"; + } + + return extension is ".ogg" or ".oga" or ".opus"; + } + + private static MessageDeliveryState ResolveDeliveryState(Message message, Guid currentUserId) + { + if (message.SenderId != currentUserId) + { + return MessageDeliveryState.Sent; + } + + var recipients = (message.Chat?.Members ?? []) + .Where(member => member.UserId != currentUserId) + .ToList(); + + if (recipients.Count == 0) + { + return MessageDeliveryState.Sent; + } + + if (recipients.All(member => member.LastReadAt is not null && member.LastReadAt.Value >= message.SentAt)) + { + return MessageDeliveryState.Read; + } + + if (recipients.All(member => member.LastDeliveredAt is not null && member.LastDeliveredAt.Value >= message.SentAt)) + { + return MessageDeliveryState.Delivered; + } + + return MessageDeliveryState.Sent; + } + + private static bool IsImageAttachment(string? contentType, string? fileName) + { + if (!string.IsNullOrWhiteSpace(contentType) && + contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var extension = Path.GetExtension(fileName ?? string.Empty).ToLowerInvariant(); + return extension is ".jpg" or ".jpeg" or ".png" or ".webp" or ".gif"; + } + + private sealed record ChannelStats(int SubscriberCount, int AdminCount); + + private static string? ResolveAvatarPath(User user) + { + if (string.IsNullOrWhiteSpace(user.AvatarStoragePath)) + { + return null; + } + + var fileName = Path.GetFileName(user.AvatarStoragePath); + return string.IsNullOrWhiteSpace(fileName) + ? null + : $"/api/users/{user.Id}/avatar/{Uri.EscapeDataString(fileName)}"; + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Hubs/MessengerHub.cs b/ikar_server/src/Ikar.Server/Infrastructure/Hubs/MessengerHub.cs new file mode 100644 index 0000000..f7082ed --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Hubs/MessengerHub.cs @@ -0,0 +1,384 @@ +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Calls; +using Ikar.Server.Infrastructure.Push; +using Ikar.Server.Infrastructure; +using Ikar.Shared; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using System.Text.Json; + +namespace Ikar.Server.Infrastructure.Hubs; + +[Authorize] +public sealed class MessengerHub( + PresenceTracker presenceTracker, + IkarDbContext dbContext, + CallSessionStore callSessionStore, + IPushNotificationSender pushNotificationSender, + IOptions webRtcOptions, + QlyraClientIdMapper qlyraClientIdMapper) : Hub +{ + public override async Task OnConnectedAsync() + { + var userId = Context.User!.GetRequiredUserId(); + await Groups.AddToGroupAsync(Context.ConnectionId, GetUserGroup(userId)); + presenceTracker.MarkOnline(userId); + await CaptureClientVersionAsync(); + await base.OnConnectedAsync(); + } + + public override async Task OnDisconnectedAsync(Exception? exception) + { + if (Context.User?.Identity?.IsAuthenticated == true) + { + var userId = Context.User.GetRequiredUserId(); + presenceTracker.MarkOffline(userId); + if (callSessionStore.TryEndByParticipant(userId, "disconnected", out var endedSession)) + { + await NotifyCallEndedAsync(endedSession!); + } + } + + await base.OnDisconnectedAsync(exception); + } + + public Task StartCall(Guid chatId) => + StartMediaCall(chatId, CallMediaType.Audio); + + public async Task SendTyping(Guid chatId, bool isTyping) + { + var userId = Context.User!.GetRequiredUserId(); + var chat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .AsNoTracking() + .SingleOrDefaultAsync( + x => x.Id == chatId && x.Members.Any(member => member.UserId == userId), + Context.ConnectionAborted); + + if (chat is null) + { + throw new HubException("Chat not found."); + } + + if (chat.Type == ChatType.Channel && + !DtoMapper.BuildPermissions(chat.Members.FirstOrDefault(member => member.UserId == userId), chat.Type).CanPostMessages) + { + throw new HubException("Read-only channel."); + } + + var sender = chat.Members.Single(member => member.UserId == userId).User; + var payload = new TypingIndicatorDto(chatId, sender.ToDto(presenceTracker), isTyping, DateTimeOffset.UtcNow); + var tasks = chat.Members + .Where(member => member.UserId != userId) + .Select(member => Clients.User(member.UserId.ToString()) + .SendAsync("TypingChanged", payload, Context.ConnectionAborted)); + + await Task.WhenAll(tasks); + } + + public async Task StartMediaCall(Guid chatId, CallMediaType mediaType) + { + var callerId = Context.User!.GetRequiredUserId(); + var chat = await LoadDirectChatAsync(chatId, callerId); + var caller = chat.Members.Single(member => member.UserId == callerId).User; + var callee = chat.Members.Single(member => member.UserId != callerId).User; + var calleeDevices = await LoadPushDevicesAsync(callee.Id); + var hasRealtimePresence = presenceTracker.IsOnline(callee.Id); + var hasPushDevices = calleeDevices.Count > 0; + + if (!hasRealtimePresence && !hasPushDevices) + { + throw new HubException("Собеседник сейчас недоступен для звонка."); + } + + if (!callSessionStore.TryCreate(chatId, callerId, callee.Id, mediaType, out var session, out var error)) + { + throw new HubException(error); + } + + var callerDto = caller.ToDto(presenceTracker); + var calleeDto = callee.ToDto(presenceTracker); + var resolvedSession = session!; + var incomingCall = new IncomingCallDto(resolvedSession.CallId, chatId, callerDto, mediaType); + + if (hasRealtimePresence) + { + await Clients.Group(GetUserGroup(callee.Id)) + .SendAsync("IncomingCall", incomingCall); + } + + if (hasPushDevices) + { + await pushNotificationSender.SendIncomingCallAsync(incomingCall, calleeDevices, Context.ConnectionAborted); + } + + return new OutgoingCallDto(resolvedSession.CallId, chatId, calleeDto, mediaType); + } + + public async Task AcceptCall(Guid callId) + { + var userId = Context.User!.GetRequiredUserId(); + if (!callSessionStore.TryAccept(callId, userId, out var session, out var error)) + { + throw new HubException(error); + } + + var resolvedSession = session!; + var connectedAt = resolvedSession.ConnectedAt ?? DateTimeOffset.UtcNow; + await NotifyCallConnectedAsync(resolvedSession with { ConnectedAt = connectedAt }); + } + + public async Task DeclineCall(Guid callId) + { + var userId = Context.User!.GetRequiredUserId(); + if (!callSessionStore.TryDecline(callId, userId, out var session, out var error)) + { + throw new HubException(error); + } + + await NotifyCallEndedAsync(session!); + } + + public async Task EndCall(Guid callId) + { + var userId = Context.User!.GetRequiredUserId(); + if (!callSessionStore.TryEnd(callId, userId, "ended", out var session, out var error)) + { + throw new HubException(error); + } + + await NotifyCallEndedAsync(session!); + } + + public async Task SendCallOffer(CallOfferDto payload) + { + if (string.IsNullOrWhiteSpace(payload.Sdp) || string.IsNullOrWhiteSpace(payload.Type)) + { + throw new HubException("Пустое WebRTC offer."); + } + + var recipientUserId = ResolveRecipientForConnectedCall(payload.CallId); + await Clients.Group(GetUserGroup(recipientUserId)) + .SendAsync("CallOffer", payload); + } + + public async Task SendCallAnswer(CallAnswerDto payload) + { + if (string.IsNullOrWhiteSpace(payload.Sdp) || string.IsNullOrWhiteSpace(payload.Type)) + { + throw new HubException("Пустой WebRTC answer."); + } + + var recipientUserId = ResolveRecipientForConnectedCall(payload.CallId); + await Clients.Group(GetUserGroup(recipientUserId)) + .SendAsync("CallAnswer", payload); + } + + public async Task SendCallIceCandidate(CallIceCandidateDto payload) + { + if (string.IsNullOrWhiteSpace(payload.Candidate)) + { + throw new HubException("Пустой ICE candidate."); + } + + var recipientUserId = ResolveRecipientForConnectedCall(payload.CallId); + await Clients.Group(GetUserGroup(recipientUserId)) + .SendAsync("CallIceCandidate", payload); + } + + private Guid ResolveRecipientForConnectedCall(Guid callId) + { + var userId = Context.User!.GetRequiredUserId(); + if (!callSessionStore.TryResolveRecipient(callId, userId, out var recipientUserId, out _, out var error)) + { + throw new HubException(error); + } + + return recipientUserId; + } + + private async Task LoadDirectChatAsync(Guid chatId, Guid userId) + { + var chat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .SingleOrDefaultAsync(x => x.Id == chatId); + + if (chat is null) + { + throw new HubException("Чат не найден."); + } + + if (chat.Type != ChatType.Direct) + { + throw new HubException("Звонки доступны только в личных чатах."); + } + + if (!chat.Members.Any(member => member.UserId == userId)) + { + throw new HubException("Нет доступа к этому чату."); + } + + if (chat.Members.Count != 2) + { + throw new HubException("Для звонка требуется один собеседник."); + } + + if (chat.Members.Any(member => member.User.IsBot)) + { + throw new HubException("Звонки недоступны в чатах с ботами."); + } + + return chat; + } + + private Task NotifyCallConnectedAsync(ActiveCallSession session) + { + var iceServers = webRtcOptions.Value.ToDto(); + var callerPayload = new CallConnectedDto( + session.CallId, + session.ChatId, + session.ConnectedAt ?? DateTimeOffset.UtcNow, + session.MediaType, + iceServers, + ShouldCreateOffer: true); + var calleePayload = new CallConnectedDto( + session.CallId, + session.ChatId, + session.ConnectedAt ?? DateTimeOffset.UtcNow, + session.MediaType, + iceServers, + ShouldCreateOffer: false); + + return Task.WhenAll( + Clients.Group(GetUserGroup(session.CallerId)).SendAsync("CallConnected", callerPayload), + Clients.Group(GetUserGroup(session.CalleeId)).SendAsync("CallConnected", calleePayload)); + } + + private async Task NotifyCallEndedAsync(ActiveCallSession session) + { + var payload = new CallEndedDto(session.CallId, session.ChatId, session.EndReason, session.MediaType); + await Task.WhenAll( + Clients.Group(GetUserGroup(session.CallerId)).SendAsync("CallEnded", payload), + Clients.Group(GetUserGroup(session.CalleeId)).SendAsync("CallEnded", payload)); + + await PersistCallHistoryMessageAsync(session); + + var calleeDevices = await LoadPushDevicesAsync(session.CalleeId); + if (calleeDevices.Count > 0) + { + await pushNotificationSender.SendCallEndedAsync(payload, calleeDevices, Context.ConnectionAborted); + } + } + + private async Task PersistCallHistoryMessageAsync(ActiveCallSession session) + { + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync(x => x.Id == session.ChatId, Context.ConnectionAborted); + + if (chat is null || chat.Members.Count == 0) + { + return; + } + + var sender = await dbContext.Users + .SingleOrDefaultAsync(x => x.Id == session.CallerId, Context.ConnectionAborted); + + if (sender is null) + { + return; + } + + var sentAt = DateTimeOffset.UtcNow; + var calleeClientId = await qlyraClientIdMapper.GetOrCreateAsync("user", session.CalleeId, Context.ConnectionAborted); + var duration = session.ConnectedAt is null ? 0L : Math.Max(0L, (sentAt - session.ConnectedAt.Value).Ticks / TimeSpan.TicksPerMillisecond); + var message = new Message + { + ChatId = chat.Id, + Chat = chat, + SenderId = sender.Id, + Sender = sender, + Text = BuildCallHistoryText(session), + SentAt = sentAt, + QlyraAttachesJson = JsonSerializer.Serialize(new[] + { + new + { + _type = "CALL", + callType = session.MediaType == CallMediaType.Video ? "VIDEO" : "AUDIO", + duration, + hangupType = session.ConnectedAt is null ? session.EndReason == "declined" ? "REJECTED" : "MISSED" : "HUNGUP", + conversationId = session.CallId.ToString("D"), + contactIds = new[] { calleeClientId } + } + }) + }; + + chat.LastActivityAt = sentAt; + dbContext.Messages.Add(message); + await dbContext.SaveChangesAsync(Context.ConnectionAborted); + await NotifyMessageCreatedAsync(chat, message); + } + + private Task NotifyMessageCreatedAsync(Chat chat, Message message) + { + var tasks = chat.Members.Select(async member => + { + var dto = message.ToDto(presenceTracker, member.UserId); + await Clients.User(member.UserId.ToString()) + .SendAsync("MessageCreated", dto, Context.ConnectionAborted); + }); + + return Task.WhenAll(tasks); + } + + private static string BuildCallHistoryText(ActiveCallSession session) + { + var callType = session.MediaType == CallMediaType.Video ? "Видеозвонок" : "Аудиозвонок"; + if (session.ConnectedAt is not null) + { + return $"{callType} завершён"; + } + + return session.EndReason switch + { + "declined" => "Вызов отклонён", + _ => $"{callType} без ответа" + }; + } + + private Task> LoadPushDevicesAsync(Guid userId) => + dbContext.PushDevices + .Where(device => device.UserId == userId && device.NotificationsEnabled) + .ToListAsync(Context.ConnectionAborted); + + private async Task CaptureClientVersionAsync() + { + var httpContext = Context.GetHttpContext(); + if (httpContext is null) + { + return; + } + + var sessionId = Context.User!.GetRequiredSessionId(); + var session = await dbContext.UserSessions + .SingleOrDefaultAsync(x => x.Id == sessionId, Context.ConnectionAborted); + if (session is null) + { + return; + } + + if (ClientVersionRequestReader.Apply(session, ClientVersionRequestReader.Read(httpContext.Request))) + { + await dbContext.SaveChangesAsync(Context.ConnectionAborted); + } + } + + private static string GetUserGroup(Guid userId) => $"user:{userId:D}"; +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/PresenceTracker.cs b/ikar_server/src/Ikar.Server/Infrastructure/PresenceTracker.cs new file mode 100644 index 0000000..ffc2881 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/PresenceTracker.cs @@ -0,0 +1,41 @@ +using System.Collections.Concurrent; + +namespace Ikar.Server.Infrastructure; + +public sealed class PresenceTracker +{ + private readonly ConcurrentDictionary _connections = new(); + + public void MarkOnline(Guid userId) + { + _connections.AddOrUpdate(userId, 1, (_, current) => current + 1); + } + + public void MarkOffline(Guid userId) + { + while (true) + { + if (!_connections.TryGetValue(userId, out var current)) + { + return; + } + + if (current <= 1) + { + if (_connections.TryRemove(userId, out _)) + { + return; + } + + continue; + } + + if (_connections.TryUpdate(userId, current - 1, current)) + { + return; + } + } + } + + public bool IsOnline(Guid userId) => _connections.ContainsKey(userId); +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Push/FirebasePushNotificationSender.cs b/ikar_server/src/Ikar.Server/Infrastructure/Push/FirebasePushNotificationSender.cs new file mode 100644 index 0000000..7624f62 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Push/FirebasePushNotificationSender.cs @@ -0,0 +1,597 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure.Push; + +public sealed class FirebasePushNotificationSender( + HttpClient httpClient, + IOptions options, + IHostEnvironment hostEnvironment, + IkarDbContext dbContext, + ILogger logger) : IPushNotificationSender +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private const string PersistentNotificationTtl = "604800s"; + private const string EphemeralCallTtl = "45s"; + private const string AppUpdateNotificationTtl = "3600s"; + private readonly PushOptions _options = options.Value; + private readonly SemaphoreSlim _tokenLock = new(1, 1); + + private OAuthTokenCache? _cachedToken; + private ServiceAccountCredentials? _cachedCredentials; + + public async Task SendMessageAsync( + Chat chat, + Message message, + IReadOnlyCollection devices, + CancellationToken cancellationToken = default) + { + var androidDevices = GetAndroidDevices(devices); + + if (androidDevices.Count == 0) + { + return; + } + + var credentials = LoadCredentials(); + if (credentials is null) + { + logger.LogDebug("Firebase push is not configured. Skipping {DeviceCount} Android device(s).", androidDevices.Count); + return; + } + + var projectId = string.IsNullOrWhiteSpace(_options.FirebaseProjectId) + ? credentials.ProjectId + : _options.FirebaseProjectId.Trim(); + + if (string.IsNullOrWhiteSpace(projectId)) + { + logger.LogWarning("Push:FirebaseProjectId is empty and the service account JSON does not contain project_id."); + return; + } + + var accessToken = await GetAccessTokenAsync(credentials, cancellationToken); + var title = chat.BuildPushTitle(message); + var body = message.BuildPushBody(); + var mapper = new QlyraClientIdMapper(dbContext); + var senderClientId = await mapper.GetOrCreateAsync("user", message.SenderId, cancellationToken); + var messageClientId = await mapper.GetOrCreateAsync("message", message.Id, cancellationToken); + var memberIds = await dbContext.ChatMembers.Where(member => member.ChatId == chat.Id).Select(member => member.UserId).ToListAsync(cancellationToken); + var memberClientIds = new List(); + foreach (var memberId in memberIds) memberClientIds.Add(await mapper.GetOrCreateAsync("user", memberId, cancellationToken)); + var chatClientId = chat.Type == ChatType.Direct && memberClientIds.Count == 2 ? memberClientIds[0] ^ memberClientIds[1] : await mapper.GetOrCreateAsync("chat", chat.Id, cancellationToken); + + foreach (var device in androidDevices) + { + await SendToAndroidAsync( + projectId, + accessToken, + device, + new Dictionary + { + [PushDataKeys.Kind] = PushDataKinds.ChatMessage, + [PushDataKeys.ChatId] = message.ChatId.ToString(), + [PushDataKeys.ChatType] = chat.Type.ToString(), + [PushDataKeys.MessageId] = message.Id.ToString(), + [PushDataKeys.Title] = title, + [PushDataKeys.Body] = body, + [PushDataKeys.SenderId] = message.SenderId.ToString(), + [PushDataKeys.SenderName] = message.Sender.DisplayName, + [PushDataKeys.SenderPhoneNumber] = message.Sender.PhoneNumber ?? string.Empty, + ["type"] = "Message", + ["mc"] = chatClientId.ToString(), + ["c"] = (await mapper.GetOrCreateAsync("user", device.UserId, cancellationToken)).ToString(), + ["suid"] = senderClientId.ToString(), + ["userName"] = message.Sender.DisplayName, + ["msgid"] = messageClientId.ToString(), + ["msg"] = body, + ["ctime"] = message.SentAt.ToUnixTimeMilliseconds().ToString() + }, + PersistentNotificationTtl, + cancellationToken); + } + } + + public async Task SendMessageReactionAsync( + Chat chat, + Message message, + User reactor, + string emoji, + IReadOnlyCollection devices, + CancellationToken cancellationToken = default) + { + var androidDevices = GetAndroidDevices(devices); + if (androidDevices.Count == 0) + { + return; + } + + var credentials = LoadCredentials(); + if (credentials is null) + { + logger.LogDebug("Firebase push is not configured. Skipping reaction push for {DeviceCount} Android device(s).", androidDevices.Count); + return; + } + + var projectId = ResolveProjectId(credentials); + if (string.IsNullOrWhiteSpace(projectId)) + { + logger.LogWarning("Push:FirebaseProjectId is empty and the service account JSON does not contain project_id."); + return; + } + + var accessToken = await GetAccessTokenAsync(credentials, cancellationToken); + var title = BuildReactionPushTitle(chat, reactor); + var body = BuildReactionPushBody(emoji); + + foreach (var device in androidDevices) + { + await SendToAndroidAsync( + projectId, + accessToken, + device, + new Dictionary + { + [PushDataKeys.Kind] = PushDataKinds.MessageReaction, + [PushDataKeys.ChatId] = message.ChatId.ToString(), + [PushDataKeys.ChatType] = chat.Type.ToString(), + [PushDataKeys.MessageId] = message.Id.ToString(), + [PushDataKeys.Title] = title, + [PushDataKeys.Body] = body, + [PushDataKeys.SenderId] = reactor.Id.ToString(), + [PushDataKeys.SenderName] = ResolveDisplayName(reactor), + [PushDataKeys.SenderPhoneNumber] = reactor.PhoneNumber ?? string.Empty, + [PushDataKeys.ReactionEmoji] = emoji + }, + PersistentNotificationTtl, + cancellationToken); + } + } + + public async Task SendIncomingCallAsync( + IncomingCallDto call, + IReadOnlyCollection devices, + CancellationToken cancellationToken = default) + { + var androidDevices = GetAndroidDevices(devices); + if (androidDevices.Count == 0) + { + return; + } + + var credentials = LoadCredentials(); + if (credentials is null) + { + logger.LogDebug("Firebase push is not configured. Skipping incoming call push for {CallId}.", call.CallId); + return; + } + + var projectId = ResolveProjectId(credentials); + if (string.IsNullOrWhiteSpace(projectId)) + { + logger.LogWarning("Push:FirebaseProjectId is empty and the service account JSON does not contain project_id."); + return; + } + + var accessToken = await GetAccessTokenAsync(credentials, cancellationToken); + var isVideoCall = call.MediaType == CallMediaType.Video; + var data = new Dictionary + { + [PushDataKeys.Kind] = isVideoCall ? PushDataKinds.VideoCallInvite : PushDataKinds.AudioCallInvite, + [PushDataKeys.CallId] = call.CallId.ToString(), + [PushDataKeys.ChatId] = call.ChatId.ToString(), + [PushDataKeys.SenderId] = call.Caller.Id.ToString(), + [PushDataKeys.SenderName] = call.Caller.DisplayName, + [PushDataKeys.SenderPhoneNumber] = call.Caller.PhoneNumber ?? string.Empty, + [PushDataKeys.CallMediaType] = call.MediaType.ToString(), + [PushDataKeys.Title] = call.Caller.DisplayName, + [PushDataKeys.Body] = isVideoCall ? "Входящий видеозвонок" : "Входящий аудиозвонок" + }; + + foreach (var device in androidDevices) + { + await SendToAndroidAsync(projectId, accessToken, device, data, EphemeralCallTtl, cancellationToken); + } + } + + public async Task SendCallEndedAsync( + CallEndedDto call, + IReadOnlyCollection devices, + CancellationToken cancellationToken = default) + { + var androidDevices = GetAndroidDevices(devices); + if (androidDevices.Count == 0) + { + return; + } + + var credentials = LoadCredentials(); + if (credentials is null) + { + logger.LogDebug("Firebase push is not configured. Skipping call ended push for {CallId}.", call.CallId); + return; + } + + var projectId = ResolveProjectId(credentials); + if (string.IsNullOrWhiteSpace(projectId)) + { + logger.LogWarning("Push:FirebaseProjectId is empty and the service account JSON does not contain project_id."); + return; + } + + var accessToken = await GetAccessTokenAsync(credentials, cancellationToken); + var isVideoCall = call.MediaType == CallMediaType.Video; + var data = new Dictionary + { + [PushDataKeys.Kind] = isVideoCall ? PushDataKinds.VideoCallEnded : PushDataKinds.AudioCallEnded, + [PushDataKeys.CallId] = call.CallId.ToString(), + [PushDataKeys.ChatId] = call.ChatId.ToString(), + [PushDataKeys.CallMediaType] = call.MediaType.ToString(), + [PushDataKeys.Body] = call.Reason + }; + + foreach (var device in androidDevices) + { + await SendToAndroidAsync(projectId, accessToken, device, data, EphemeralCallTtl, cancellationToken); + } + } + + public async Task SendAppUpdateAvailableAsync( + AndroidAppReleaseDto release, + IReadOnlyCollection devices, + CancellationToken cancellationToken = default) + { + var androidDevices = GetAndroidDevices(devices); + if (androidDevices.Count == 0) + { + return; + } + + var credentials = LoadCredentials(); + if (credentials is null) + { + logger.LogDebug("Firebase push is not configured. Skipping app update push for version {VersionCode}.", release.VersionCode); + return; + } + + var projectId = ResolveProjectId(credentials); + if (string.IsNullOrWhiteSpace(projectId)) + { + logger.LogWarning("Push:FirebaseProjectId is empty and the service account JSON does not contain project_id."); + return; + } + + var accessToken = await GetAccessTokenAsync(credentials, cancellationToken); + var data = new Dictionary + { + [PushDataKeys.Kind] = PushDataKinds.AppUpdateAvailable, + [PushDataKeys.Title] = "Доступно обновление Ikar", + [PushDataKeys.Body] = $"Доступна версия {release.VersionName}", + [PushDataKeys.VersionCode] = release.VersionCode.ToString(), + [PushDataKeys.VersionName] = release.VersionName + }; + + foreach (var device in androidDevices) + { + await SendToAndroidAsync(projectId, accessToken, device, data, AppUpdateNotificationTtl, cancellationToken); + } + } + + public async Task SendQlyraIncomingCallAsync( + QlyraIncomingCallPushDto call, + IReadOnlyCollection devices, + CancellationToken cancellationToken = default) + { + var androidDevices = GetAndroidDevices(devices); + if (androidDevices.Count == 0) return; + var credentials = LoadCredentials(); + if (credentials is null) + { + logger.LogDebug("Firebase push is not configured. Skipping Qlyra incoming call push for {ConversationId}.", call.ConversationId); + return; + } + var projectId = ResolveProjectId(credentials); + if (string.IsNullOrWhiteSpace(projectId)) + { + logger.LogWarning("Push:FirebaseProjectId is empty for Qlyra incoming call delivery."); + return; + } + var accessToken = await GetAccessTokenAsync(credentials, cancellationToken); + var data = new Dictionary + { + [PushDataKeys.Type] = "InboundCall", + [PushDataKeys.Vcp] = call.Vcp, + [PushDataKeys.ConversationId] = call.ConversationId, + [PushDataKeys.SenderId] = call.CallerClientId.ToString(), + [PushDataKeys.QlyraSenderId] = call.CallerClientId.ToString(), + [PushDataKeys.SenderName] = call.CallerName, + [PushDataKeys.Title] = call.CallerName, + [PushDataKeys.AccountId] = call.CalleeClientId.ToString(), + [PushDataKeys.IsVideo] = call.IsVideo ? "true" : "false" + }; + foreach (var device in androidDevices) + { + await SendToAndroidAsync(projectId, accessToken, device, data, EphemeralCallTtl, cancellationToken); + } + } + + public async Task SendLoginCodeAsync( + string code, + string challengeId, + DateTimeOffset expiresAt, + IReadOnlyCollection devices, + CancellationToken cancellationToken = default) + { + var androidDevices = GetAndroidDevices(devices); + if (androidDevices.Count == 0) return false; + var credentials = LoadCredentials(); + if (credentials is null) + { + logger.LogWarning("Firebase push is not configured for login-code delivery."); + return false; + } + var projectId = ResolveProjectId(credentials); + if (string.IsNullOrWhiteSpace(projectId)) + { + logger.LogWarning("Push:FirebaseProjectId is empty for login-code delivery."); + return false; + } + var accessToken = await GetAccessTokenAsync(credentials, cancellationToken); + var ttl = Math.Max(1, (int)Math.Ceiling((expiresAt - DateTimeOffset.UtcNow).TotalSeconds)); + var data = new Dictionary + { + [PushDataKeys.Kind] = PushDataKinds.LoginCode, + [PushDataKeys.LoginChallengeId] = challengeId, + [PushDataKeys.Title] = "IKAR", + [PushDataKeys.Body] = $"Код входа: {code}" + }; + var delivered = false; + foreach (var device in androidDevices) + { + delivered |= await SendToAndroidAsync(projectId, accessToken, device, data, $"{ttl}s", cancellationToken, showNotification: true); + } + return delivered; + } + + private ServiceAccountCredentials? LoadCredentials() + { + if (_cachedCredentials is not null) + { + return _cachedCredentials; + } + + var json = _options.ServiceAccountJson; + if (string.IsNullOrWhiteSpace(json) && !string.IsNullOrWhiteSpace(_options.ServiceAccountJsonPath)) + { + var path = _options.ServiceAccountJsonPath; + if (!Path.IsPathRooted(path)) + { + path = Path.Combine(hostEnvironment.ContentRootPath, path); + } + + if (!File.Exists(path)) + { + logger.LogWarning("Push service account file was not found: {Path}", path); + return null; + } + + json = File.ReadAllText(path); + } + + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + try + { + var credentials = JsonSerializer.Deserialize(json, JsonOptions); + if (credentials is null || + string.IsNullOrWhiteSpace(credentials.ClientEmail) || + string.IsNullOrWhiteSpace(credentials.PrivateKey)) + { + logger.LogWarning("Push service account JSON is missing required fields."); + return null; + } + + _cachedCredentials = credentials; + return credentials; + } + catch (JsonException ex) + { + logger.LogWarning(ex, "Push service account JSON could not be parsed."); + return null; + } + } + + private async Task GetAccessTokenAsync(ServiceAccountCredentials credentials, CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + if (_cachedToken is not null && _cachedToken.ExpiresAt > now.AddMinutes(5)) + { + return _cachedToken.AccessToken; + } + + await _tokenLock.WaitAsync(cancellationToken); + try + { + now = DateTimeOffset.UtcNow; + if (_cachedToken is not null && _cachedToken.ExpiresAt > now.AddMinutes(5)) + { + return _cachedToken.AccessToken; + } + + var assertion = CreateJwtAssertion(credentials, now); + using var request = new HttpRequestMessage(HttpMethod.Post, credentials.TokenUri ?? "https://oauth2.googleapis.com/token") + { + Content = new FormUrlEncodedContent( + [ + new KeyValuePair("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), + new KeyValuePair("assertion", assertion) + ]) + }; + + using var response = await httpClient.SendAsync(request, cancellationToken); + var payload = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException( + string.IsNullOrWhiteSpace(payload) + ? $"Failed to acquire Firebase access token: {(int)response.StatusCode}." + : $"Failed to acquire Firebase access token: {payload}"); + } + + var tokenResponse = JsonSerializer.Deserialize(payload, JsonOptions) + ?? throw new InvalidOperationException("Firebase access token response was empty."); + + _cachedToken = new OAuthTokenCache( + tokenResponse.AccessToken, + now.AddSeconds(tokenResponse.ExpiresIn)); + + return _cachedToken.AccessToken; + } + finally + { + _tokenLock.Release(); + } + } + + private async Task SendToAndroidAsync( + string projectId, + string accessToken, + PushDevice device, + IReadOnlyDictionary data, + string ttl, + CancellationToken cancellationToken, + bool showNotification = false) + { + var payload = new + { + message = new + { + token = device.DeviceToken, + data, + notification = showNotification ? new { title = data[PushDataKeys.Title], body = data[PushDataKeys.Body] } : null, + android = new + { + priority = "high", + ttl + } + } + }; + + using var request = new HttpRequestMessage( + HttpMethod.Post, + $"https://fcm.googleapis.com/v1/projects/{projectId}/messages:send"); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); + request.Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json"); + + using var response = await httpClient.SendAsync(request, cancellationToken); + if (response.IsSuccessStatusCode) + { + return true; + } + + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + logger.LogWarning( + "Push send failed for installation {InstallationId} with status {StatusCode}. Response: {ResponseBody}", + device.InstallationId, + (int)response.StatusCode, + responseBody); + + if (!ShouldDeleteDevice(responseBody)) + { + return false; + } + + if (dbContext.Entry(device).State != Microsoft.EntityFrameworkCore.EntityState.Detached) + { + dbContext.PushDevices.Remove(device); + await dbContext.SaveChangesAsync(cancellationToken); + } + return false; + } + + private static bool ShouldDeleteDevice(string? responseBody) => + !string.IsNullOrWhiteSpace(responseBody) && + (responseBody.Contains("UNREGISTERED", StringComparison.OrdinalIgnoreCase) || + responseBody.Contains("registration-token-not-registered", StringComparison.OrdinalIgnoreCase)); + + private static List GetAndroidDevices(IReadOnlyCollection devices) => + devices + .Where(x => x.Platform == PushPlatform.Android && !string.IsNullOrWhiteSpace(x.DeviceToken)) + .ToList(); + + private string ResolveProjectId(ServiceAccountCredentials credentials) => + string.IsNullOrWhiteSpace(_options.FirebaseProjectId) + ? credentials.ProjectId + : _options.FirebaseProjectId.Trim(); + + private static string BuildReactionPushTitle(Chat chat, User reactor) + { + var reactorName = ResolveDisplayName(reactor); + return chat.Type switch + { + ChatType.Direct => reactorName, + _ => string.IsNullOrWhiteSpace(chat.Title) ? reactorName : chat.Title! + }; + } + + private static string BuildReactionPushBody(string emoji) => + string.IsNullOrWhiteSpace(emoji) + ? "\u041d\u043e\u0432\u0430\u044f \u0440\u0435\u0430\u043a\u0446\u0438\u044f \u043d\u0430 \u0432\u0430\u0448\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435" + : $"\u0420\u0435\u0430\u043a\u0446\u0438\u044f {emoji} \u043d\u0430 \u0432\u0430\u0448\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435"; + + private static string ResolveDisplayName(User user) => + string.IsNullOrWhiteSpace(user.DisplayName) + ? user.Username + : user.DisplayName; + + private static string CreateJwtAssertion(ServiceAccountCredentials credentials, DateTimeOffset now) + { + var header = Base64UrlEncode("""{"alg":"RS256","typ":"JWT"}"""); + var payload = Base64UrlEncode(JsonSerializer.Serialize(new Dictionary + { + ["iss"] = credentials.ClientEmail, + ["scope"] = "https://www.googleapis.com/auth/firebase.messaging", + ["aud"] = credentials.TokenUri ?? "https://oauth2.googleapis.com/token", + ["iat"] = now.ToUnixTimeSeconds(), + ["exp"] = now.AddHours(1).ToUnixTimeSeconds() + })); + + var unsignedToken = $"{header}.{payload}"; + using var rsa = RSA.Create(); + rsa.ImportFromPem(credentials.PrivateKey); + var signatureBytes = rsa.SignData(Encoding.UTF8.GetBytes(unsignedToken), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return $"{unsignedToken}.{Base64UrlEncode(signatureBytes)}"; + } + + private static string Base64UrlEncode(string value) => + Base64UrlEncode(Encoding.UTF8.GetBytes(value)); + + private static string Base64UrlEncode(byte[] value) => + Convert.ToBase64String(value) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + private sealed record OAuthTokenCache(string AccessToken, DateTimeOffset ExpiresAt); + + private sealed record OAuthTokenResponse( + [property: JsonPropertyName("access_token")] string AccessToken, + [property: JsonPropertyName("expires_in")] int ExpiresIn); + + private sealed record ServiceAccountCredentials( + [property: JsonPropertyName("project_id")] string ProjectId, + [property: JsonPropertyName("client_email")] string ClientEmail, + [property: JsonPropertyName("private_key")] string PrivateKey, + [property: JsonPropertyName("token_uri")] string? TokenUri); +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Push/IPushNotificationSender.cs b/ikar_server/src/Ikar.Server/Infrastructure/Push/IPushNotificationSender.cs new file mode 100644 index 0000000..d7690f3 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Push/IPushNotificationSender.cs @@ -0,0 +1,48 @@ +using Ikar.Server.Data.Entities; +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure.Push; + +public interface IPushNotificationSender +{ + Task SendMessageAsync( + Chat chat, + Message message, + IReadOnlyCollection devices, + CancellationToken cancellationToken = default); + + Task SendMessageReactionAsync( + Chat chat, + Message message, + User reactor, + string emoji, + IReadOnlyCollection devices, + CancellationToken cancellationToken = default); + + Task SendIncomingCallAsync( + IncomingCallDto call, + IReadOnlyCollection devices, + CancellationToken cancellationToken = default); + + Task SendQlyraIncomingCallAsync( + QlyraIncomingCallPushDto call, + IReadOnlyCollection devices, + CancellationToken cancellationToken = default) => Task.CompletedTask; + + Task SendCallEndedAsync( + CallEndedDto call, + IReadOnlyCollection devices, + CancellationToken cancellationToken = default); + + Task SendAppUpdateAvailableAsync( + AndroidAppReleaseDto release, + IReadOnlyCollection devices, + CancellationToken cancellationToken = default); + + Task SendLoginCodeAsync( + string code, + string challengeId, + DateTimeOffset expiresAt, + IReadOnlyCollection devices, + CancellationToken cancellationToken = default); +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Push/IkarScheduledMessageService.cs b/ikar_server/src/Ikar.Server/Infrastructure/Push/IkarScheduledMessageService.cs new file mode 100644 index 0000000..6da9cf0 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Push/IkarScheduledMessageService.cs @@ -0,0 +1,28 @@ +using System.Security.Claims; +using Ikar.Server.Controllers; +using Ikar.Server.Data; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Infrastructure.Push; + +public sealed class IkarScheduledMessageService(IServiceScopeFactory scopes, ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2)); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + await using var scope = scopes.CreateAsyncScope(); + var controller = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; + await controller.DeliverScheduledMessagesAsync(stoppingToken); + await controller.DeliverScheduledDeletionsAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } + catch (Exception ex) { logger.LogError(ex, "Scheduled message delivery failed; durable queue will retry."); } + } + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Push/IkarTranscriptionService.cs b/ikar_server/src/Ikar.Server/Infrastructure/Push/IkarTranscriptionService.cs new file mode 100644 index 0000000..823e99d --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Push/IkarTranscriptionService.cs @@ -0,0 +1,53 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; +using Ikar.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.SignalR; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure.Push; + +public sealed class IkarTranscriptionService(IServiceScopeFactory scopes, IHttpClientFactory clients, ILogger logger) : BackgroundService +{ + private readonly ConcurrentDictionary pending = new(); + private readonly Channel<(Guid Id, string Path)> queue = Channel.CreateBounded<(Guid,string)>(new BoundedChannelOptions(100) { SingleReader=true, FullMode=BoundedChannelFullMode.Wait }); + public bool Enqueue(Guid id, string path) + { + if (!pending.TryAdd(id,true)) return true; + if (queue.Writer.TryWrite((id,path))) return true; + pending.TryRemove(id,out _); return false; + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await foreach (var item in queue.Reader.ReadAllAsync(stoppingToken)) + { + try + { + using var client=clients.CreateClient(); client.Timeout=TimeSpan.FromMinutes(5); + using var content=new StringContent(System.Text.Json.JsonSerializer.Serialize(new {path=item.Path}),System.Text.Encoding.UTF8,"application/json"); + using var response=await client.PostAsync("http://asr:8766/transcribe",content,stoppingToken); + response.EnsureSuccessStatusCode(); + var result=await response.Content.ReadFromJsonAsync(stoppingToken); + if (result is null) continue; + await using var scope=scopes.CreateAsyncScope(); + var db=scope.ServiceProvider.GetRequiredService(); + var message=await db.Messages.Include(x=>x.Chat).ThenInclude(x=>x.Members).SingleOrDefaultAsync(x=>x.Id==item.Id && x.DeletedAt==null,stoppingToken); + if (message is not null) { + message.IkarTranscriptText=result.Text.Length>100_000?result.Text[..100_000]:result.Text; + await db.SaveChangesAsync(stoppingToken); + var mapper=scope.ServiceProvider.GetRequiredService(); + var chatId=message.Chat.Type==ChatType.Direct ? 0 : await mapper.GetOrCreateAsync("chat",message.ChatId,stoppingToken); + if (message.Chat.Type==ChatType.Direct) foreach (var member in message.Chat.Members) chatId ^= await mapper.GetOrCreateAsync("user",member.UserId,stoppingToken); + var messageId=await mapper.GetOrCreateAsync("message",message.Id,stoppingToken); + var hub=scope.ServiceProvider.GetRequiredService>(); + await hub.Clients.Users(message.Chat.Members.Select(x=>x.UserId.ToString()).ToArray()).SendAsync("QlyraPacket",new {opcode=293,payload=new {chatId,messageId,transcriptionStatus=1,transcription=message.IkarTranscriptText}},stoppingToken); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } + catch (Exception ex) { logger.LogWarning(ex,"IKAR transcription failed for {MessageId}.",item.Id); } + finally { pending.TryRemove(item.Id,out _); } + } + } + private sealed record Transcript(string Text); +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Push/PushDispatchBackgroundService.cs b/ikar_server/src/Ikar.Server/Infrastructure/Push/PushDispatchBackgroundService.cs new file mode 100644 index 0000000..b4b9905 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Push/PushDispatchBackgroundService.cs @@ -0,0 +1,111 @@ +using Microsoft.EntityFrameworkCore; +using Ikar.Server.Data; + +namespace Ikar.Server.Infrastructure.Push; + +public sealed class PushDispatchBackgroundService( + IServiceScopeFactory serviceScopeFactory, + PushDispatchQueue queue, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!queue.IsDurable) + { + await ProcessFallbackQueueAsync(stoppingToken); + return; + } + + while (!stoppingToken.IsCancellationRequested) + { + var batch = await queue.LoadDueBatchAsync(stoppingToken); + if (batch.Count == 0) + { + await queue.WaitForWorkAsync(stoppingToken); + continue; + } + + foreach (var workItem in batch) + { + try + { + await ProcessAsync(workItem, stoppingToken); + await queue.CompleteAsync(workItem.OutboxId!.Value, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Background push dispatch failed for chat {ChatId}, message {MessageId}, kind {Kind}.", + workItem.ChatId, + workItem.MessageId, + workItem.Kind); + await queue.RescheduleAsync(workItem.OutboxId!.Value, ex, stoppingToken); + } + } + } + } + + private async Task ProcessFallbackQueueAsync(CancellationToken stoppingToken) + { + await foreach (var workItem in queue.ReadAllAsync(stoppingToken)) + { + await ProcessAsync(workItem, stoppingToken); + } + } + + private async Task ProcessAsync(PushDispatchWorkItem workItem, CancellationToken cancellationToken) + { + await using var scope = serviceScopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var dispatcher = scope.ServiceProvider.GetRequiredService(); + + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync(x => x.Id == workItem.ChatId, cancellationToken); + if (chat is null) + { + return; + } + + var message = await dbContext.Messages + .Include(x => x.Sender) + .Include(x => x.Attachments) + .SingleOrDefaultAsync( + x => x.Id == workItem.MessageId && x.ChatId == workItem.ChatId, + cancellationToken); + if (message is null) + { + return; + } + + if (workItem.Kind == PushDispatchKind.MessageReaction) + { + if (workItem.ActorUserId is null || string.IsNullOrWhiteSpace(workItem.ReactionEmoji)) + { + return; + } + + var reactor = await dbContext.Users + .SingleOrDefaultAsync(x => x.Id == workItem.ActorUserId.Value, cancellationToken); + if (reactor is null) + { + return; + } + + await dispatcher.DispatchMessageReactionAsync( + chat, + message, + reactor, + workItem.ReactionEmoji, + cancellationToken); + return; + } + + await dispatcher.DispatchMessageAsync(chat, message, cancellationToken); + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Push/PushDispatchQueue.cs b/ikar_server/src/Ikar.Server/Infrastructure/Push/PushDispatchQueue.cs new file mode 100644 index 0000000..6399afd --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Push/PushDispatchQueue.cs @@ -0,0 +1,254 @@ +using System.Threading.Channels; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Infrastructure.Push; + +public enum PushDispatchKind +{ + ChatMessage = 0, + MessageReaction = 1 +} + +public sealed record PushDispatchWorkItem( + Guid ChatId, + Guid MessageId, + PushDispatchKind Kind = PushDispatchKind.ChatMessage, + Guid? ActorUserId = null, + string? ReactionEmoji = null, + long? OutboxId = null); + +public sealed class PushDispatchQueue(IServiceScopeFactory? serviceScopeFactory = null) +{ + private const int BatchSize = 50; + private readonly Channel _fallbackChannel = + Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false + }); + private readonly Channel _wakeSignals = Channel.CreateBounded( + new BoundedChannelOptions(1) + { + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.DropOldest + }); + + public bool IsDurable => serviceScopeFactory is not null; + + public void StageMessages(IkarDbContext dbContext, params Message?[] messages) + { + if (!IsDurable) + { + return; + } + + foreach (var message in messages.Where(message => message is not null).Cast()) + { + StageWorkItem( + dbContext, + new PushDispatchWorkItem(message.ChatId, message.Id), + $"message:{message.Id:D}"); + } + } + + public void StageReaction( + IkarDbContext dbContext, + Guid chatId, + Guid messageId, + Guid reactorUserId, + string emoji) + { + if (!IsDurable) + { + return; + } + + StageWorkItem( + dbContext, + new PushDispatchWorkItem( + chatId, + messageId, + PushDispatchKind.MessageReaction, + reactorUserId, + emoji), + $"reaction:{messageId:D}:{reactorUserId:D}:{emoji}"); + } + + public ValueTask EnqueueAsync(Guid chatId, Guid messageId, CancellationToken cancellationToken = default) => + EnqueueWorkItemAsync( + new PushDispatchWorkItem(chatId, messageId), + $"message:{messageId:D}", + cancellationToken); + + public ValueTask EnqueueReactionAsync( + Guid chatId, + Guid messageId, + Guid reactorUserId, + string emoji, + CancellationToken cancellationToken = default) => + EnqueueWorkItemAsync( + new PushDispatchWorkItem( + chatId, + messageId, + PushDispatchKind.MessageReaction, + reactorUserId, + emoji), + $"reaction:{messageId:D}:{reactorUserId:D}:{emoji}", + cancellationToken); + + public IAsyncEnumerable ReadAllAsync(CancellationToken cancellationToken) => + _fallbackChannel.Reader.ReadAllAsync(cancellationToken); + + public async Task> LoadDueBatchAsync(CancellationToken cancellationToken) + { + if (serviceScopeFactory is null) + { + return []; + } + + await using var scope = serviceScopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var now = DateTimeOffset.UtcNow; + return await dbContext.PushDispatchOutbox + .AsNoTracking() + .Where(item => item.NextAttemptAt <= now) + .OrderBy(item => item.Id) + .Take(BatchSize) + .Select(item => new PushDispatchWorkItem( + item.ChatId, + item.MessageId, + (PushDispatchKind)item.Kind, + item.ActorUserId, + item.ReactionEmoji, + item.Id)) + .ToListAsync(cancellationToken); + } + + public async Task CompleteAsync(long outboxId, CancellationToken cancellationToken) + { + if (serviceScopeFactory is null) + { + return; + } + + await using var scope = serviceScopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.PushDispatchOutbox + .Where(item => item.Id == outboxId) + .ExecuteDeleteAsync(cancellationToken); + } + + public async Task RescheduleAsync(long outboxId, Exception error, CancellationToken cancellationToken) + { + if (serviceScopeFactory is null) + { + return; + } + + await using var scope = serviceScopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var item = await dbContext.PushDispatchOutbox.SingleOrDefaultAsync(x => x.Id == outboxId, cancellationToken); + if (item is null) + { + return; + } + + item.AttemptCount++; + var delaySeconds = Math.Min(900, 5 * (1 << Math.Min(item.AttemptCount, 8))); + item.NextAttemptAt = DateTimeOffset.UtcNow.AddSeconds(delaySeconds); + item.LastError = error.Message[..Math.Min(error.Message.Length, 1000)]; + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task CountPendingAsync(CancellationToken cancellationToken = default) + { + if (serviceScopeFactory is null) + { + return 0; + } + + await using var scope = serviceScopeFactory.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService() + .PushDispatchOutbox.CountAsync(cancellationToken); + } + + public async Task WaitForWorkAsync(CancellationToken cancellationToken) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(5)); + try + { + await _wakeSignals.Reader.ReadAsync(timeout.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + } + } + + private async ValueTask EnqueueWorkItemAsync( + PushDispatchWorkItem workItem, + string deduplicationKey, + CancellationToken cancellationToken) + { + if (serviceScopeFactory is null) + { + await _fallbackChannel.Writer.WriteAsync(workItem, cancellationToken); + return; + } + + await using var scope = serviceScopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.PushDispatchOutbox.Add(new PushDispatchOutboxItem + { + DeduplicationKey = deduplicationKey, + ChatId = workItem.ChatId, + MessageId = workItem.MessageId, + Kind = (int)workItem.Kind, + ActorUserId = workItem.ActorUserId, + ReactionEmoji = workItem.ReactionEmoji + }); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + dbContext.ChangeTracker.Clear(); + if (await dbContext.PushDispatchOutbox + .AsNoTracking() + .AnyAsync(item => item.DeduplicationKey == deduplicationKey, cancellationToken)) + { + _wakeSignals.Writer.TryWrite(true); + return; + } + + throw; + } + + _wakeSignals.Writer.TryWrite(true); + } + + private static void StageWorkItem( + IkarDbContext dbContext, + PushDispatchWorkItem workItem, + string deduplicationKey) + { + if (dbContext.PushDispatchOutbox.Local.Any(item => item.DeduplicationKey == deduplicationKey)) + { + return; + } + + dbContext.PushDispatchOutbox.Add(new PushDispatchOutboxItem + { + DeduplicationKey = deduplicationKey, + ChatId = workItem.ChatId, + MessageId = workItem.MessageId, + Kind = (int)workItem.Kind, + ActorUserId = workItem.ActorUserId, + ReactionEmoji = workItem.ReactionEmoji + }); + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Push/PushNotificationDispatcher.cs b/ikar_server/src/Ikar.Server/Infrastructure/Push/PushNotificationDispatcher.cs new file mode 100644 index 0000000..b5437fe --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Push/PushNotificationDispatcher.cs @@ -0,0 +1,159 @@ +using Microsoft.EntityFrameworkCore; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; + +namespace Ikar.Server.Infrastructure.Push; + +public sealed class PushNotificationDispatcher( + IkarDbContext dbContext, + IPushNotificationSender pushNotificationSender, + ILogger logger) +{ + public async Task DispatchMessageAsync(Chat chat, Message message, CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + var recipientIds = chat.Members + .Where(x => x.UserId != message.SenderId && !IsMuted(x, now)) + .Select(x => x.UserId) + .Distinct() + .ToList(); + + if (recipientIds.Count == 0) + { + logger.LogDebug( + "Skipping push dispatch for message {MessageId} because chat {ChatId} has no recipients.", + message.Id, + chat.Id); + return; + } + + var devices = await dbContext.PushDevices + .Include(x => x.User) + .ThenInclude(x => x.PrivacySettings) + .Where(x => recipientIds.Contains(x.UserId) && x.NotificationsEnabled) + .ToListAsync(cancellationToken); + + if (devices.Count == 0) + { + logger.LogDebug( + "Skipping push dispatch for message {MessageId} because {RecipientCount} recipient(s) have no enabled push devices.", + message.Id, + recipientIds.Count); + return; + } + + try + { + var previewDevices = devices + .Where(device => device.User.PrivacySettings?.ShowMessagePreview != false) + .ToList(); + if (previewDevices.Count > 0) + { + await pushNotificationSender.SendMessageAsync(chat, message, previewDevices, cancellationToken); + } + + var hiddenPreviewDevices = devices + .Where(device => device.User.PrivacySettings?.ShowMessagePreview == false) + .ToList(); + if (hiddenPreviewDevices.Count > 0) + { + await pushNotificationSender.SendMessageAsync( + chat, + BuildHiddenPreviewMessage(message), + hiddenPreviewDevices, + cancellationToken); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Push dispatch failed for message {MessageId}.", message.Id); + throw; + } + } + + public async Task DispatchMessageReactionAsync( + Chat chat, + Message message, + User reactor, + string emoji, + CancellationToken cancellationToken = default) + { + if (message.SenderId == reactor.Id || string.IsNullOrWhiteSpace(emoji)) + { + logger.LogDebug( + "Skipping reaction push for message {MessageId}: self reaction or empty emoji.", + message.Id); + return; + } + + var authorMembership = chat.Members.FirstOrDefault(x => x.UserId == message.SenderId); + if (authorMembership is null || IsMuted(authorMembership, DateTimeOffset.UtcNow)) + { + logger.LogDebug( + "Skipping reaction push for message {MessageId} because recipient muted chat {ChatId} or is no longer a member.", + message.Id, + chat.Id); + return; + } + + var devices = await dbContext.PushDevices + .Where(x => x.UserId == message.SenderId && x.NotificationsEnabled) + .ToListAsync(cancellationToken); + + if (devices.Count == 0) + { + logger.LogDebug( + "Skipping reaction push for message {MessageId} because recipient has no enabled push devices.", + message.Id); + return; + } + + try + { + await pushNotificationSender.SendMessageReactionAsync( + chat, + message, + reactor, + emoji, + devices, + cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Push dispatch failed for reaction to message {MessageId}.", + message.Id); + throw; + } + } + + private static bool IsMuted(ChatMember member, DateTimeOffset now) => + member.MutedUntil is not null && member.MutedUntil > now; + + private static Message BuildHiddenPreviewMessage(Message source) + { + var sender = new User + { + Id = source.Sender.Id, + Username = source.Sender.Username, + DisplayName = source.Sender.DisplayName, + PhoneNumber = null, + IsBot = source.Sender.IsBot + }; + + return new Message + { + Id = source.Id, + ChatId = source.ChatId, + Chat = source.Chat, + SenderId = source.SenderId, + Sender = sender, + Text = "Новое сообщение", + SentAt = source.SentAt, + PostedAsChannel = source.PostedAsChannel, + AuthorSignature = source.AuthorSignature, + DeletedAt = source.DeletedAt + }; + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Push/PushOptions.cs b/ikar_server/src/Ikar.Server/Infrastructure/Push/PushOptions.cs new file mode 100644 index 0000000..7eb2556 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Push/PushOptions.cs @@ -0,0 +1,12 @@ +namespace Ikar.Server.Infrastructure.Push; + +public sealed class PushOptions +{ + public const string SectionName = "Push"; + + public string FirebaseProjectId { get; set; } = string.Empty; + + public string ServiceAccountJsonPath { get; set; } = string.Empty; + + public string ServiceAccountJson { get; set; } = string.Empty; +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Push/QlyraWebPushSessionStore.cs b/ikar_server/src/Ikar.Server/Infrastructure/Push/QlyraWebPushSessionStore.cs new file mode 100644 index 0000000..aa7d15b --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Push/QlyraWebPushSessionStore.cs @@ -0,0 +1,188 @@ +using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Ikar.Server.Infrastructure.Push; + +public sealed class QlyraWebPushSessionStore(IServiceScopeFactory scopeFactory, IOptions jwtOptions, IOptions publicEndpointOptions) +{ + private readonly ConcurrentDictionary _tracks = new(StringComparer.Ordinal); + private readonly TimeSpan _sessionLifetime = TimeSpan.FromDays(jwtOptions.Value.RefreshTokenLifetimeDays); + + public bool Approve(string qrLink, Guid userId) + { + RemoveExpiredTracks(); + var trackId = ExtractTrackId(qrLink); + if (trackId is null || !_tracks.TryGetValue(trackId, out var track) || track.ExpiresAt <= DateTimeOffset.UtcNow) return false; + track.ApprovedUserId = userId; + return true; + } + + public async Task HandleWebSocketAsync(HttpContext context) + { + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + using var socket = await context.WebSockets.AcceptWebSocketAsync(); + Guid? authenticatedUserId = null; + Guid? authenticatedSessionId = null; + while (socket.State == WebSocketState.Open && !context.RequestAborted.IsCancellationRequested) + { + var text = await ReceiveTextAsync(socket, context.RequestAborted); + if (text is null) break; + int sequence = 0; + int opcode = 0; + try + { + using var document = JsonDocument.Parse(text); + var root = document.RootElement; + sequence = root.GetProperty("seq").GetInt32(); + opcode = root.GetProperty("opcode").GetInt32(); + var payload = root.TryGetProperty("payload", out var payloadElement) ? payloadElement.Clone() : default; + var result = await DispatchAsync(context, opcode, payload, authenticatedUserId, authenticatedSessionId); + authenticatedUserId = result.UserId ?? authenticatedUserId; + authenticatedSessionId = result.SessionId ?? authenticatedSessionId; + await SendAsync(socket, new { cmd = 1, seq = sequence, opcode, payload = result.Payload }, context.RequestAborted); + } + catch (QlyraWebPushException exception) + { + await SendAsync(socket, new { cmd = 3, seq = sequence, opcode, payload = new { error = exception.Code, message = exception.Message, localizedMessage = exception.Message } }, context.RequestAborted); + } + catch (Exception exception) when (exception is JsonException or KeyNotFoundException or InvalidOperationException) + { + await SendAsync(socket, new { cmd = 3, seq = sequence, opcode, payload = new { error = "BAD_REQUEST", message = "Некорректный запрос веб-сессии." } }, context.RequestAborted); + } + } + } + + private async Task DispatchAsync(HttpContext context, int opcode, JsonElement payload, Guid? currentUserId, Guid? currentSessionId) + { + RemoveExpiredTracks(); + if (opcode is 1 or 6) return new(new { }); + if (opcode == 288) + { + var track = new QlyraWebPushQrTrack(); + _tracks[track.Id] = track; + var baseUrl = publicEndpointOptions.Value.BaseUrl.TrimEnd('/'); + return new(new { trackId = track.Id, qrLink = $"{baseUrl}/authqr/{track.Id}", pollingInterval = 1000, ttl = 120000 }); + } + var trackId = ReadString(payload, "trackId"); + if (opcode == 289) + { + var approved = trackId is not null && _tracks.TryGetValue(trackId, out var track) && track.ExpiresAt > DateTimeOffset.UtcNow && track.ApprovedUserId is not null; + return new(new { status = new { loginAvailable = approved } }); + } + if (opcode == 291) + { + if (trackId is null || !_tracks.TryRemove(trackId, out var track) || track.ExpiresAt <= DateTimeOffset.UtcNow || track.ApprovedUserId is null) + throw new QlyraWebPushException("QR_NOT_APPROVED", "QR-сессия ещё не подтверждена или уже истекла."); + var token = CreateOpaqueToken(); + await using var scope = scopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var user = await dbContext.Users.SingleOrDefaultAsync(item => item.Id == track.ApprovedUserId && !item.IsBlocked, context.RequestAborted) + ?? throw new QlyraWebPushException("ACCOUNT_UNAVAILABLE", "Аккаунт недоступен."); + var session = new UserSession { UserId = user.Id, TokenHash = TokenService.HashToken(token), ClientPlatform = "WEBPUSH" }; + dbContext.UserSessions.Add(session); + await dbContext.SaveChangesAsync(context.RequestAborted); + return new(new { tokenAttrs = new { LOGIN = new { token } } }); + } + if (opcode == 19) + { + var token = ReadString(payload, "token") ?? throw new QlyraWebPushException("FAIL_LOGIN_TOKEN", "Токен веб-сессии не указан."); + await using var scope = scopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var hash = TokenService.HashToken(token); + var session = await dbContext.UserSessions.Include(item => item.User).SingleOrDefaultAsync(item => item.TokenHash == hash, context.RequestAborted); + if (session is null || session.User.IsBlocked || session.CreatedAt + _sessionLifetime <= DateTimeOffset.UtcNow) + throw new QlyraWebPushException("FAIL_LOGIN_TOKEN", "Веб-сессия недействительна или истекла."); + session.LastSeenAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(context.RequestAborted); + return new(new { accountId = session.UserId }, session.UserId, session.Id); + } + if (opcode == 22) + { + if (currentUserId is null) throw new QlyraWebPushException("FAIL_LOGIN_TOKEN", "Сначала войдите в веб-сессию."); + var endpoint = ReadString(payload, "pushToken") ?? throw new QlyraWebPushException("PUSH_ENDPOINT_REQUIRED", "Адрес Web Push не указан."); + var subscribe = ReadBool(payload, "subscribe") != false; + await using var scope = scopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var installationId = "web-" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(endpoint))).ToLowerInvariant(); + var device = await dbContext.PushDevices.SingleOrDefaultAsync(item => item.InstallationId == installationId, context.RequestAborted); + if (!subscribe) + { + if (device is not null && device.UserId == currentUserId) dbContext.PushDevices.Remove(device); + } + else + { + device ??= new PushDevice { InstallationId = installationId }; + if (device.Id == Guid.Empty) device.Id = Guid.NewGuid(); + if (dbContext.Entry(device).State == EntityState.Detached) dbContext.PushDevices.Add(device); + device.UserId = currentUserId.Value; + device.Platform = PushPlatform.Web; + device.DeviceToken = endpoint; + device.WebPushPublicKey = ReadString(payload, "publicKey"); + device.WebPushAuthKey = ReadString(payload, "secretKey"); + device.DeviceName = "Web Push"; + device.NotificationsEnabled = true; + device.UpdatedAt = DateTimeOffset.UtcNow; + } + await dbContext.SaveChangesAsync(context.RequestAborted); + return new(new { success = true }, currentUserId, currentSessionId); + } + if (opcode == 20) + { + if (currentSessionId is not null) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var session = await dbContext.UserSessions.FindAsync([currentSessionId.Value], context.RequestAborted); + if (session is not null) { dbContext.UserSessions.Remove(session); await dbContext.SaveChangesAsync(context.RequestAborted); } + } + return new(new { }); + } + throw new QlyraWebPushException("OPCODE_NOT_SUPPORTED", $"Веб-сессия не поддерживает opcode {opcode}."); + } + + private static async Task ReceiveTextAsync(WebSocket socket, CancellationToken cancellationToken) + { + using var buffer = new MemoryStream(); + var chunk = new byte[16 * 1024]; + while (true) + { + var result = await socket.ReceiveAsync(chunk, cancellationToken); + if (result.MessageType == WebSocketMessageType.Close) return null; + if (result.MessageType != WebSocketMessageType.Text) throw new QlyraWebPushException("TEXT_REQUIRED", "Ожидается текстовый WebSocket-кадр."); + buffer.Write(chunk, 0, result.Count); + if (buffer.Length > 1024 * 1024) throw new QlyraWebPushException("FRAME_TOO_LARGE", "WebSocket-кадр слишком большой."); + if (result.EndOfMessage) return Encoding.UTF8.GetString(buffer.ToArray()); + } + } + + private static Task SendAsync(WebSocket socket, object value, CancellationToken cancellationToken) => + socket.SendAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value)), WebSocketMessageType.Text, true, cancellationToken); + private static string? ReadString(JsonElement payload, string name) => payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString()?.Trim() : null; + private static bool? ReadBool(JsonElement payload, string name) => payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty(name, out var value) && value.ValueKind is JsonValueKind.True or JsonValueKind.False ? value.GetBoolean() : null; + private static string? ExtractTrackId(string link) => link.Trim().TrimEnd('/').Split('/').LastOrDefault(); + private static string CreateOpaqueToken() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + private void RemoveExpiredTracks() { var now = DateTimeOffset.UtcNow; foreach (var item in _tracks) if (item.Value.ExpiresAt <= now) _tracks.TryRemove(item.Key, out _); } +} + +public sealed class QlyraWebPushQrTrack +{ + public string Id { get; } = Convert.ToHexString(RandomNumberGenerator.GetBytes(24)).ToLowerInvariant(); + public DateTimeOffset ExpiresAt { get; } = DateTimeOffset.UtcNow.AddMinutes(2); + public Guid? ApprovedUserId { get; set; } +} + +internal sealed record QlyraWebPushResult(object Payload, Guid? UserId = null, Guid? SessionId = null); +internal sealed class QlyraWebPushException(string code, string message) : Exception(message) { public string Code { get; } = code; } diff --git a/ikar_server/src/Ikar.Server/Infrastructure/QlyraClientIdMapper.cs b/ikar_server/src/Ikar.Server/Infrastructure/QlyraClientIdMapper.cs new file mode 100644 index 0000000..5bdb655 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/QlyraClientIdMapper.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; + +namespace Ikar.Server.Infrastructure; + +public sealed class QlyraClientIdMapper(IkarDbContext dbContext) +{ + public async Task GetOrCreateAsync(string entityKind, Guid entityId, CancellationToken cancellationToken) + { + var normalizedKind = entityKind.Trim().ToLowerInvariant(); + var existing = await dbContext.QlyraClientIdMaps.SingleOrDefaultAsync( + x => x.EntityKind == normalizedKind && x.EntityId == entityId, + cancellationToken); + if (existing is not null) + { + return existing.Id; + } + + var map = new QlyraClientIdMap + { + EntityKind = normalizedKind, + EntityId = entityId + }; + dbContext.QlyraClientIdMaps.Add(map); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + return map.Id; + } + catch (DbUpdateException) + { + dbContext.Entry(map).State = EntityState.Detached; + var concurrent = await dbContext.QlyraClientIdMaps.SingleAsync( + x => x.EntityKind == normalizedKind && x.EntityId == entityId, + cancellationToken); + return concurrent.Id; + } + } + + public Task FindEntityIdAsync(string entityKind, int clientId, CancellationToken cancellationToken) => + dbContext.QlyraClientIdMaps + .Where(x => x.EntityKind == entityKind.Trim().ToLowerInvariant() && x.Id == clientId) + .Select(x => (Guid?)x.EntityId) + .SingleOrDefaultAsync(cancellationToken); +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/QlyraPublicEndpointOptions.cs b/ikar_server/src/Ikar.Server/Infrastructure/QlyraPublicEndpointOptions.cs new file mode 100644 index 0000000..80d30dd --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/QlyraPublicEndpointOptions.cs @@ -0,0 +1,7 @@ +namespace Ikar.Server.Infrastructure; + +public sealed class QlyraPublicEndpointOptions +{ + public const string SectionName = "QlyraPublicEndpoint"; + public string BaseUrl { get; set; } = "https://ikar.kusoft.xyz"; +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Storage/AttachmentContentInspector.cs b/ikar_server/src/Ikar.Server/Infrastructure/Storage/AttachmentContentInspector.cs new file mode 100644 index 0000000..7ffa835 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Storage/AttachmentContentInspector.cs @@ -0,0 +1,30 @@ +namespace Ikar.Server.Infrastructure.Storage; + +public sealed record AttachmentContentInfo(string ContentType, bool IsImage, bool IsVideo); + +public static class AttachmentContentInspector +{ + public static AttachmentContentInfo Inspect(IFormFile file) + { + Span header = stackalloc byte[16]; + using var stream = file.OpenReadStream(); + var read = stream.Read(header); + var bytes = header[..read]; + + if (StartsWith(bytes, [0xFF, 0xD8, 0xFF])) return new("image/jpeg", true, false); + if (StartsWith(bytes, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])) return new("image/png", true, false); + if (StartsWith(bytes, "GIF87a"u8) || StartsWith(bytes, "GIF89a"u8)) return new("image/gif", true, false); + if (StartsWith(bytes, "BM"u8)) return new("image/bmp", true, false); + if (read >= 12 && StartsWith(bytes, "RIFF"u8) && bytes[8..12].SequenceEqual("WEBP"u8)) return new("image/webp", true, false); + if (read >= 8 && bytes[4..8].SequenceEqual("ftyp"u8)) return new("video/mp4", false, true); + if (StartsWith(bytes, [0x1A, 0x45, 0xDF, 0xA3])) return new("video/webm", false, true); + if (StartsWith(bytes, "OggS"u8)) return new("audio/ogg", false, false); + if (StartsWith(bytes, "%PDF-"u8)) return new("application/pdf", false, false); + if (StartsWith(bytes, [0x50, 0x4B, 0x03, 0x04])) return new("application/zip", false, false); + + return new("application/octet-stream", false, false); + } + + private static bool StartsWith(ReadOnlySpan bytes, ReadOnlySpan signature) => + bytes.Length >= signature.Length && bytes[..signature.Length].SequenceEqual(signature); +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Storage/AttachmentStorageService.cs b/ikar_server/src/Ikar.Server/Infrastructure/Storage/AttachmentStorageService.cs new file mode 100644 index 0000000..d425ea0 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Storage/AttachmentStorageService.cs @@ -0,0 +1,162 @@ +using Ikar.Server.Data.Entities; + +namespace Ikar.Server.Infrastructure.Storage; + +public sealed class AttachmentStorageService(IWebHostEnvironment environment) +{ + private readonly string _rootPath = Path.Combine(environment.ContentRootPath, "Data", "Attachments"); + + public async Task SaveAsync( + Guid messageId, + IFormFile file, + CancellationToken cancellationToken, + int sortOrder = 0) + { + if (file.Length <= 0) + { + throw new InvalidOperationException("Attachment is empty."); + } + + var originalFileName = NormalizeOriginalFileName(file.FileName, "attachment"); + var contentInfo = AttachmentContentInspector.Inspect(file); + var (attachmentId, physicalPath) = CreateTargetPath(originalFileName); + + await using (var source = file.OpenReadStream()) + await using (var target = File.Create(physicalPath)) + { + await source.CopyToAsync(target, cancellationToken); + } + + return new MessageAttachment + { + Id = attachmentId, + MessageId = messageId, + OriginalFileName = originalFileName, + ContentType = contentInfo.ContentType, + FileSizeBytes = file.Length, + StoragePath = physicalPath, + SortOrder = sortOrder, + UploadedAt = DateTimeOffset.UtcNow + }; + } + + public async Task SaveStoryAsync( + Guid storyId, + IFormFile file, + CancellationToken cancellationToken, + int sortOrder = 0) + { + if (file.Length <= 0) + { + throw new InvalidOperationException("Attachment is empty."); + } + + var originalFileName = NormalizeOriginalFileName(file.FileName, "story-attachment"); + var contentInfo = AttachmentContentInspector.Inspect(file); + var (attachmentId, physicalPath) = CreateTargetPath(originalFileName); + + await using (var source = file.OpenReadStream()) + await using (var target = File.Create(physicalPath)) + { + await source.CopyToAsync(target, cancellationToken); + } + + return new StoryAttachment + { + Id = attachmentId, + StoryId = storyId, + OriginalFileName = originalFileName, + ContentType = contentInfo.ContentType, + FileSizeBytes = file.Length, + StoragePath = physicalPath, + SortOrder = sortOrder, + UploadedAt = DateTimeOffset.UtcNow + }; + } + + public async Task CloneAsync( + Guid messageId, + MessageAttachment sourceAttachment, + CancellationToken cancellationToken, + int? sortOrder = null) + { + if (string.IsNullOrWhiteSpace(sourceAttachment.StoragePath) || !File.Exists(sourceAttachment.StoragePath)) + { + throw new FileNotFoundException("Source attachment file was not found.", sourceAttachment.StoragePath); + } + + var (attachmentId, physicalPath) = CreateTargetPath(sourceAttachment.OriginalFileName); + + await using (var source = File.OpenRead(sourceAttachment.StoragePath)) + await using (var target = File.Create(physicalPath)) + { + await source.CopyToAsync(target, cancellationToken); + } + + return new MessageAttachment + { + Id = attachmentId, + MessageId = messageId, + OriginalFileName = sourceAttachment.OriginalFileName, + ContentType = sourceAttachment.ContentType, + Kind = sourceAttachment.Kind, + FileSizeBytes = sourceAttachment.FileSizeBytes, + StoragePath = physicalPath, + SortOrder = sortOrder ?? sourceAttachment.SortOrder, + UploadedAt = DateTimeOffset.UtcNow + }; + } + + public void DeleteIfExists(string? physicalPath) + { + if (!string.IsNullOrWhiteSpace(physicalPath) && File.Exists(physicalPath)) + { + File.Delete(physicalPath); + } + } + + private (Guid AttachmentId, string PhysicalPath) CreateTargetPath(string? originalFileName) + { + Directory.CreateDirectory(_rootPath); + + var attachmentId = Guid.NewGuid(); + var extension = Path.GetExtension(originalFileName); + var year = DateTime.UtcNow.ToString("yyyy"); + var month = DateTime.UtcNow.ToString("MM"); + var targetDirectory = Path.Combine(_rootPath, year, month); + Directory.CreateDirectory(targetDirectory); + + var storedFileName = $"{attachmentId:N}{extension}"; + var physicalPath = Path.Combine(targetDirectory, storedFileName); + return (attachmentId, physicalPath); + } + + private static string NormalizeOriginalFileName(string? fileName, string fallback) + { + var safeFileName = string.IsNullOrWhiteSpace(fileName) + ? fallback + : Path.GetFileName(fileName); + if (string.IsNullOrWhiteSpace(safeFileName)) + { + return fallback; + } + + var decodedFileName = safeFileName.Contains('%') + ? TryDecodeFileName(safeFileName) + : safeFileName; + var normalizedFileName = Path.GetFileName(decodedFileName); + return string.IsNullOrWhiteSpace(normalizedFileName) ? fallback : normalizedFileName; + } + + private static string TryDecodeFileName(string fileName) + { + try + { + return Uri.UnescapeDataString(fileName); + } + catch (UriFormatException) + { + return fileName; + } + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Storage/AvatarStorageService.cs b/ikar_server/src/Ikar.Server/Infrastructure/Storage/AvatarStorageService.cs new file mode 100644 index 0000000..70f2855 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Storage/AvatarStorageService.cs @@ -0,0 +1,59 @@ +namespace Ikar.Server.Infrastructure.Storage; + +public sealed record AvatarStorageResult(string PhysicalPath, string ContentType); + +public sealed class AvatarStorageService(IWebHostEnvironment environment) +{ + private readonly string _rootPath = Path.Combine(environment.ContentRootPath, "Data", "Avatars"); + + public async Task SaveAsync(Guid userId, IFormFile file, CancellationToken cancellationToken) + { + if (file.Length <= 0) + { + throw new InvalidOperationException("Avatar file is empty."); + } + + Directory.CreateDirectory(_rootPath); + + var extension = Path.GetExtension(file.FileName); + var safeExtension = string.IsNullOrWhiteSpace(extension) ? ".bin" : extension; + var targetDirectory = Path.Combine(_rootPath, userId.ToString("N")); + Directory.CreateDirectory(targetDirectory); + + var storedFileName = $"{Guid.NewGuid():N}{safeExtension}"; + var physicalPath = Path.Combine(targetDirectory, storedFileName); + + await using (var source = file.OpenReadStream()) + await using (var target = File.Create(physicalPath)) + { + await source.CopyToAsync(target, cancellationToken); + } + + var contentType = ResolveContentType(file.ContentType, safeExtension); + + return new AvatarStorageResult(physicalPath, contentType); + } + + public void DeleteIfExists(string? physicalPath) + { + if (!string.IsNullOrWhiteSpace(physicalPath) && File.Exists(physicalPath)) + { + File.Delete(physicalPath); + } + } + + private static string ResolveContentType(string? uploadedContentType, string extension) => + string.IsNullOrWhiteSpace(uploadedContentType) + ? extension.ToLowerInvariant() switch + { + ".gif" => "image/gif", + ".heic" => "image/heic", + ".heif" => "image/heif", + ".jpeg" => "image/jpeg", + ".jpg" => "image/jpeg", + ".png" => "image/png", + ".webp" => "image/webp", + _ => "application/octet-stream" + } + : uploadedContentType; +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs b/ikar_server/src/Ikar.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs new file mode 100644 index 0000000..c83bc8a --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs @@ -0,0 +1,988 @@ +using System.Data; +using Microsoft.EntityFrameworkCore; +using Ikar.Server.Data; + +namespace Ikar.Server.Infrastructure.Storage; + +public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext) +{ + public async Task EnsureLatestAsync(CancellationToken cancellationToken = default) + { + await EnableWriteAheadLoggingAsync(cancellationToken); + await EnsureColumnAsync("Users", "ScheduledDeletionUnixMs", "INTEGER NULL", cancellationToken); + await EnsureMessageAttachmentsTableAsync(cancellationToken); + await EnsurePushDevicesTableAsync(cancellationToken); + await EnsureDeviceCredentialsTableAsync(cancellationToken); + await EnsurePushDispatchOutboxTableAsync(cancellationToken); + await EnsureUserPrivacySettingsTableAsync(cancellationToken); + await EnsureChatInviteLinksTableAsync(cancellationToken); + await EnsureChatJoinRequestsTableAsync(cancellationToken); + await EnsureChatTopicsTableAsync(cancellationToken); + await EnsureBotsTableAsync(cancellationToken); + await EnsureColumnAsync("Bots", "WebAppUrl", "TEXT NULL", cancellationToken); + await EnsureBotCommandsTableAsync(cancellationToken); + await EnsureBotUpdatesTableAsync(cancellationToken); + await EnsureModerationLogEntriesTableAsync(cancellationToken); + await EnsureStoriesTablesAsync(cancellationToken); + await EnsureMessageReactionsTableAsync(cancellationToken); + await EnsureMessageViewsTableAsync(cancellationToken); + await EnsureMessageHiddenForUsersTableAsync(cancellationToken); + await EnsureAndroidUpdateCompatibilityHitsTableAsync(cancellationToken); + await EnsureQlyraClientIdMapsTableAsync(cancellationToken); + await EnsureQlyraChatFoldersTableAsync(cancellationToken); + await EnsureQlyraPollTablesAsync(cancellationToken); + await EnsureUserTwoFactorSettingsTableAsync(cancellationToken); + await EnsureUserContactsTableAsync(cancellationToken); + await EnsureChatMembersIndexesAsync(cancellationToken); + await EnsureColumnAsync("ChatMembers", "LastDeliveredAt", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Chats", "ReactionSettingsJson", "TEXT NOT NULL DEFAULT '{}'", cancellationToken); + await EnsureColumnAsync("ChatMembers", "LastReadAt", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("ChatMembers", "Role", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("ChatMembers", "CanPinMessages", "INTEGER NOT NULL DEFAULT 1", cancellationToken); + await EnsureColumnAsync("ChatMembers", "CanDeleteMessages", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("ChatMembers", "CanInviteUsers", "INTEGER NOT NULL DEFAULT 1", cancellationToken); + await EnsureColumnAsync("ChatMembers", "CanPostMessages", "INTEGER NOT NULL DEFAULT 1", cancellationToken); + await EnsureColumnAsync("ChatMembers", "IsArchived", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("ChatMembers", "IsPinned", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("ChatMembers", "MutedUntil", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("ChatMembers", "FolderKey", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("UserSessions", "ClientPlatform", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("UserSessions", "ClientVersionCode", "INTEGER NULL", cancellationToken); + await EnsureColumnAsync("UserSessions", "ClientVersionName", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("PushDevices", "ClientPlatform", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("PushDevices", "ClientVersionCode", "INTEGER NULL", cancellationToken); + await EnsureColumnAsync("PushDevices", "ClientVersionName", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("PushDevices", "WebPushPublicKey", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("PushDevices", "WebPushAuthKey", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Chats", "Description", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Chats", "PublicUsername", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Chats", "LinkedDiscussionChatId", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Chats", "ChannelSignaturesEnabled", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("Chats", "TopicsEnabled", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("Chats", "HasProtectedContent", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("Chats", "DefaultMessageTtlSeconds", "INTEGER NULL", cancellationToken); + await EnsureColumnAsync("Chats", "AvatarStoragePath", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Chats", "AvatarContentType", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "ScheduledForUnixMs", "INTEGER NULL", cancellationToken); + await EnsureColumnAsync("Messages", "IkarClientCid", "INTEGER NULL", cancellationToken); + await EnsureColumnAsync("Messages", "IkarElementsJson", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "IkarTranscriptText", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "EditedAt", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "DeletedAt", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "ForwardedFromDisplayName", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "ReplyToMessageId", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "PinnedAt", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "PinnedByUserId", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "MediaAlbumId", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "TopicId", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "HasProtectedContent", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("Messages", "ExpiresAt", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "TtlSeconds", "INTEGER NULL", cancellationToken); + await EnsureColumnAsync("Messages", "PostedAsChannel", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("Messages", "AuthorSignature", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "StoryReplyStoryId", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "StoryReplyAuthorUserId", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "StoryReplyPreviewText", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "StoryReplyAttachmentKind", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "DiscussionMessageId", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "CommentCount", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("Messages", "ClientMessageId", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "QlyraAttachesJson", "TEXT NULL", cancellationToken); + await EnsureMessagesClientMessageIdIndexAsync(cancellationToken); + await EnsureLegacyChannelPostsMarkedAsync(cancellationToken); + await EnsureColumnAsync("MessageAttachments", "SortOrder", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("MessageAttachments", "Kind", "INTEGER NOT NULL DEFAULT 1", cancellationToken); + await EnsureLegacyAudioAttachmentsMarkedAsVoiceNotesAsync(cancellationToken); + await EnsureMessagesPinnedIndexAsync(cancellationToken); + await EnsureMessagesChatSenderSentAtIndexAsync(cancellationToken); + await EnsureMessagesAlbumIndexAsync(cancellationToken); + await EnsureMessagesTopicIndexAsync(cancellationToken); + await EnsureMessagesDiscussionIndexAsync(cancellationToken); + await EnsureChatsPublicUsernameIndexAsync(cancellationToken); + await EnsureChatsLinkedDiscussionIndexAsync(cancellationToken); + await EnsureMessageAttachmentsSortIndexAsync(cancellationToken); + await EnsureChatMemberStateIndexesAsync(cancellationToken); + await EnsureColumnAsync("Users", "IsBlocked", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("Users", "BlockedAt", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Users", "PhoneNumber", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Users", "NormalizedPhoneNumber", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Users", "Email", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Users", "NormalizedEmail", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Users", "About", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Users", "AvatarStoragePath", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Users", "AvatarContentType", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Users", "IsBot", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("UserPrivacySettings", "BlockedUserIds", "TEXT NOT NULL DEFAULT ''", cancellationToken); + await EnsureColumnAsync("UserPrivacySettings", "AlwaysAllowUserIds", "TEXT NOT NULL DEFAULT ''", cancellationToken); + await EnsureColumnAsync("UserPrivacySettings", "NeverAllowUserIds", "TEXT NOT NULL DEFAULT ''", cancellationToken); + await EnsureColumnAsync("UserPrivacySettings", "QlyraConfigJson", "TEXT NOT NULL DEFAULT '{}'", cancellationToken); + await EnsureColumnAsync("UserPrivacySettings", "QlyraFavoriteStickerSetIdsJson", "TEXT NOT NULL DEFAULT '[]'", cancellationToken); + await EnsureMessagesExpiresAtIndexAsync(cancellationToken); + await EnsureMessagesStoryReplyIndexAsync(cancellationToken); + await EnsureUsersPhoneIndexAsync(cancellationToken); + await EnsureUsersEmailIndexAsync(cancellationToken); + } + + private async Task EnsureUserContactsTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "UserContacts" ( + "OwnerUserId" TEXT NOT NULL, + "ContactUserId" TEXT NOT NULL, + "CustomFirstName" TEXT NULL, + "CustomLastName" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "PK_UserContacts" PRIMARY KEY ("OwnerUserId", "ContactUserId"), + CONSTRAINT "FK_UserContacts_Users_OwnerUserId" FOREIGN KEY ("OwnerUserId") REFERENCES "Users" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_UserContacts_Users_ContactUserId" FOREIGN KEY ("ContactUserId") REFERENCES "Users" ("Id") ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS "IX_UserContacts_ContactUserId" ON "UserContacts" ("ContactUserId"); + """, + cancellationToken); + } + + private async Task EnsureUserTwoFactorSettingsTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "UserTwoFactorSettings" ( + "UserId" TEXT NOT NULL CONSTRAINT "PK_UserTwoFactorSettings" PRIMARY KEY, + "PasswordHash" TEXT NOT NULL, + "Hint" TEXT NULL, + "Email" TEXT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_UserTwoFactorSettings_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE + ); + """, + cancellationToken); + } + + private async Task EnsureQlyraPollTablesAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "QlyraPolls" ("Id" TEXT NOT NULL CONSTRAINT "PK_QlyraPolls" PRIMARY KEY, "MessageId" TEXT NOT NULL, "Title" TEXT NOT NULL, "Settings" INTEGER NOT NULL, "Version" INTEGER NOT NULL DEFAULT 1, CONSTRAINT "FK_QlyraPolls_Messages_MessageId" FOREIGN KEY ("MessageId") REFERENCES "Messages" ("Id") ON DELETE CASCADE); + CREATE UNIQUE INDEX IF NOT EXISTS "IX_QlyraPolls_MessageId" ON "QlyraPolls" ("MessageId"); + CREATE TABLE IF NOT EXISTS "QlyraPollAnswers" ("PollId" TEXT NOT NULL, "AnswerId" INTEGER NOT NULL, "Text" TEXT NOT NULL, CONSTRAINT "PK_QlyraPollAnswers" PRIMARY KEY ("PollId", "AnswerId"), CONSTRAINT "FK_QlyraPollAnswers_QlyraPolls_PollId" FOREIGN KEY ("PollId") REFERENCES "QlyraPolls" ("Id") ON DELETE CASCADE); + CREATE TABLE IF NOT EXISTS "QlyraPollVotes" ("PollId" TEXT NOT NULL, "AnswerId" INTEGER NOT NULL, "UserId" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, CONSTRAINT "PK_QlyraPollVotes" PRIMARY KEY ("PollId", "AnswerId", "UserId"), CONSTRAINT "FK_QlyraPollVotes_QlyraPolls_PollId" FOREIGN KEY ("PollId") REFERENCES "QlyraPolls" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_QlyraPollVotes_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE); + CREATE INDEX IF NOT EXISTS "IX_QlyraPollVotes_UserId" ON "QlyraPollVotes" ("UserId"); + """, + cancellationToken); + } + + private async Task EnsureMessageHiddenForUsersTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "MessageHiddenForUsers" ( + "MessageId" TEXT NOT NULL, + "UserId" TEXT NOT NULL, + "HiddenAt" TEXT NOT NULL, + CONSTRAINT "PK_MessageHiddenForUsers" PRIMARY KEY ("MessageId", "UserId"), + CONSTRAINT "FK_MessageHiddenForUsers_Messages_MessageId" FOREIGN KEY ("MessageId") REFERENCES "Messages" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_MessageHiddenForUsers_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS "IX_MessageHiddenForUsers_UserId" ON "MessageHiddenForUsers" ("UserId"); + """, + cancellationToken); + } + + private async Task EnsureQlyraChatFoldersTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "QlyraChatFolders" ( + "UserId" TEXT NOT NULL, + "Id" TEXT NOT NULL, + "Title" TEXT NOT NULL, + "Emoji" TEXT NULL, + "IncludeJson" TEXT NOT NULL DEFAULT '[]', + "FiltersJson" TEXT NOT NULL DEFAULT '[]', + "OptionsJson" TEXT NOT NULL DEFAULT '[]', + "FavoritesJson" TEXT NOT NULL DEFAULT '[]', + "SortOrder" INTEGER NOT NULL DEFAULT 0, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "PK_QlyraChatFolders" PRIMARY KEY ("UserId", "Id"), + CONSTRAINT "FK_QlyraChatFolders_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS "IX_QlyraChatFolders_UserId_SortOrder" + ON "QlyraChatFolders" ("UserId", "SortOrder"); + """, + cancellationToken); + } + + private async Task EnsureQlyraClientIdMapsTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "QlyraClientIdMaps" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_QlyraClientIdMaps" PRIMARY KEY AUTOINCREMENT, + "EntityKind" TEXT NOT NULL, + "EntityId" TEXT NOT NULL, + "CreatedAt" TEXT NOT NULL + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_QlyraClientIdMaps_EntityKind_EntityId" + ON "QlyraClientIdMaps" ("EntityKind", "EntityId"); + """, + cancellationToken); + } + + private async Task EnsureDeviceCredentialsTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "DeviceCredentials" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_DeviceCredentials" PRIMARY KEY, + "UserId" TEXT NOT NULL, + "InstallationId" TEXT NOT NULL, + "PublicKey" TEXT NOT NULL, + "CreatedAt" TEXT NOT NULL, + "LastUsedAt" TEXT NOT NULL, + CONSTRAINT "FK_DeviceCredentials_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE + ); + CREATE UNIQUE INDEX IF NOT EXISTS "IX_DeviceCredentials_InstallationId" ON "DeviceCredentials" ("InstallationId"); + CREATE INDEX IF NOT EXISTS "IX_DeviceCredentials_UserId" ON "DeviceCredentials" ("UserId"); + """, + cancellationToken); + } + + private async Task EnableWriteAheadLoggingAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync("PRAGMA journal_mode = WAL;", cancellationToken); + } + + private async Task EnsureMessageAttachmentsTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "MessageAttachments" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_MessageAttachments" PRIMARY KEY, + "MessageId" TEXT NOT NULL, + "OriginalFileName" TEXT NOT NULL, + "ContentType" TEXT NOT NULL, + "Kind" INTEGER NOT NULL DEFAULT 1, + "FileSizeBytes" INTEGER NOT NULL, + "StoragePath" TEXT NOT NULL, + "UploadedAt" TEXT NOT NULL, + CONSTRAINT "FK_MessageAttachments_Messages_MessageId" FOREIGN KEY ("MessageId") REFERENCES "Messages" ("Id") ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_MessageAttachments_MessageId" ON "MessageAttachments" ("MessageId"); + """, + cancellationToken); + } + + private async Task EnsurePushDevicesTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "PushDevices" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_PushDevices" PRIMARY KEY, + "UserId" TEXT NOT NULL, + "Platform" INTEGER NOT NULL, + "InstallationId" TEXT NOT NULL, + "DeviceToken" TEXT NOT NULL, + "DeviceName" TEXT NULL, + "NotificationsEnabled" INTEGER NOT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_PushDevices_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_PushDevices_InstallationId" ON "PushDevices" ("InstallationId"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_PushDevices_UserId" ON "PushDevices" ("UserId"); + """, + cancellationToken); + } + + private async Task EnsureBotsTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "Bots" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_Bots" PRIMARY KEY, + "UserId" TEXT NOT NULL, + "OwnerUserId" TEXT NOT NULL, + "TokenHash" TEXT NOT NULL, + "IsEnabled" INTEGER NOT NULL DEFAULT 1, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_Bots_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_Bots_Users_OwnerUserId" FOREIGN KEY ("OwnerUserId") REFERENCES "Users" ("Id") ON DELETE RESTRICT + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_Bots_UserId" ON "Bots" ("UserId"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_Bots_TokenHash" ON "Bots" ("TokenHash"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_Bots_OwnerUserId" ON "Bots" ("OwnerUserId"); + """, + cancellationToken); + } + + private async Task EnsureBotCommandsTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "BotCommands" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_BotCommands" PRIMARY KEY, + "BotId" TEXT NOT NULL, + "Command" TEXT NOT NULL, + "Description" TEXT NOT NULL, + "SortOrder" INTEGER NOT NULL, + CONSTRAINT "FK_BotCommands_Bots_BotId" FOREIGN KEY ("BotId") REFERENCES "Bots" ("Id") ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_BotCommands_BotId_Command" ON "BotCommands" ("BotId", "Command"); + """, + cancellationToken); + } + + private async Task EnsureBotUpdatesTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "BotUpdates" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_BotUpdates" PRIMARY KEY AUTOINCREMENT, + "BotId" TEXT NOT NULL, + "ChatId" TEXT NOT NULL, + "MessageId" TEXT NOT NULL, + "Kind" INTEGER NOT NULL DEFAULT 0, + "CallbackUserId" TEXT NULL, + "CallbackId" TEXT NULL, + "CallbackPayload" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + CONSTRAINT "FK_BotUpdates_Bots_BotId" FOREIGN KEY ("BotId") REFERENCES "Bots" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_BotUpdates_Messages_MessageId" FOREIGN KEY ("MessageId") REFERENCES "Messages" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_BotUpdates_Users_CallbackUserId" FOREIGN KEY ("CallbackUserId") REFERENCES "Users" ("Id") ON DELETE SET NULL + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_BotUpdates_BotId_Id" ON "BotUpdates" ("BotId", "Id"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + DROP INDEX IF EXISTS "IX_BotUpdates_BotId_MessageId"; + CREATE INDEX IF NOT EXISTS "IX_BotUpdates_BotId_MessageId" ON "BotUpdates" ("BotId", "MessageId"); + """, + cancellationToken); + + await EnsureColumnAsync("BotUpdates", "Kind", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("BotUpdates", "CallbackUserId", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("BotUpdates", "CallbackId", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("BotUpdates", "CallbackPayload", "TEXT NULL", cancellationToken); + } + + private async Task EnsureModerationLogEntriesTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "ModerationLogEntries" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_ModerationLogEntries" PRIMARY KEY AUTOINCREMENT, + "ChatId" TEXT NULL, + "ActorUserId" TEXT NOT NULL, + "TargetType" INTEGER NOT NULL, + "TargetId" TEXT NULL, + "Action" TEXT NOT NULL, + "Reason" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + CONSTRAINT "FK_ModerationLogEntries_Chats_ChatId" FOREIGN KEY ("ChatId") REFERENCES "Chats" ("Id") ON DELETE SET NULL, + CONSTRAINT "FK_ModerationLogEntries_Users_ActorUserId" FOREIGN KEY ("ActorUserId") REFERENCES "Users" ("Id") ON DELETE RESTRICT + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_ModerationLogEntries_ChatId_CreatedAt" ON "ModerationLogEntries" ("ChatId", "CreatedAt"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_ModerationLogEntries_TargetType_TargetId" ON "ModerationLogEntries" ("TargetType", "TargetId"); + """, + cancellationToken); + } + + private async Task EnsureStoriesTablesAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "Stories" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_Stories" PRIMARY KEY, + "AuthorUserId" TEXT NOT NULL, + "Text" TEXT NOT NULL, + "Visibility" INTEGER NOT NULL DEFAULT 0, + "HasProtectedContent" INTEGER NOT NULL DEFAULT 0, + "CreatedAt" TEXT NOT NULL, + "ExpiresAt" TEXT NOT NULL, + "DeletedAt" TEXT NULL, + CONSTRAINT "FK_Stories_Users_AuthorUserId" FOREIGN KEY ("AuthorUserId") REFERENCES "Users" ("Id") ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "StoryAttachments" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_StoryAttachments" PRIMARY KEY, + "StoryId" TEXT NOT NULL, + "OriginalFileName" TEXT NOT NULL, + "ContentType" TEXT NOT NULL, + "FileSizeBytes" INTEGER NOT NULL, + "StoragePath" TEXT NOT NULL, + "SortOrder" INTEGER NOT NULL DEFAULT 0, + "UploadedAt" TEXT NOT NULL, + CONSTRAINT "FK_StoryAttachments_Stories_StoryId" FOREIGN KEY ("StoryId") REFERENCES "Stories" ("Id") ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "StoryViews" ( + "StoryId" TEXT NOT NULL, + "ViewerUserId" TEXT NOT NULL, + "ViewedAt" TEXT NOT NULL, + CONSTRAINT "PK_StoryViews" PRIMARY KEY ("StoryId", "ViewerUserId"), + CONSTRAINT "FK_StoryViews_Stories_StoryId" FOREIGN KEY ("StoryId") REFERENCES "Stories" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_StoryViews_Users_ViewerUserId" FOREIGN KEY ("ViewerUserId") REFERENCES "Users" ("Id") ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync("""CREATE INDEX IF NOT EXISTS "IX_Stories_AuthorUserId_CreatedAt" ON "Stories" ("AuthorUserId", "CreatedAt");""", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("""CREATE INDEX IF NOT EXISTS "IX_Stories_ExpiresAt" ON "Stories" ("ExpiresAt");""", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("""CREATE INDEX IF NOT EXISTS "IX_StoryAttachments_StoryId_SortOrder" ON "StoryAttachments" ("StoryId", "SortOrder");""", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("""CREATE INDEX IF NOT EXISTS "IX_StoryViews_ViewerUserId" ON "StoryViews" ("ViewerUserId");""", cancellationToken); + } + + private async Task EnsureAndroidUpdateCompatibilityHitsTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "AndroidUpdateCompatibilityHits" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_AndroidUpdateCompatibilityHits" PRIMARY KEY AUTOINCREMENT, + "HitAt" TEXT NOT NULL, + "Route" TEXT NOT NULL, + "RequestPath" TEXT NOT NULL, + "RequestedFileName" TEXT NULL, + "ClientPlatform" TEXT NULL, + "ClientVersionCode" INTEGER NULL, + "ClientVersionName" TEXT NULL, + "UserAgent" TEXT NULL, + "RemoteIp" TEXT NULL + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_AndroidUpdateCompatibilityHits_HitAt" ON "AndroidUpdateCompatibilityHits" ("HitAt"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_AndroidUpdateCompatibilityHits_Route_HitAt" ON "AndroidUpdateCompatibilityHits" ("Route", "HitAt"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_AndroidUpdateCompatibilityHits_ClientPlatform_ClientVersionCode_HitAt" + ON "AndroidUpdateCompatibilityHits" ("ClientPlatform", "ClientVersionCode", "HitAt"); + """, + cancellationToken); + } + + private async Task EnsureMessageReactionsTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "MessageReactions" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_MessageReactions" PRIMARY KEY, + "MessageId" TEXT NOT NULL, + "UserId" TEXT NOT NULL, + "Emoji" TEXT NOT NULL, + "CreatedAt" TEXT NOT NULL, + CONSTRAINT "FK_MessageReactions_Messages_MessageId" FOREIGN KEY ("MessageId") REFERENCES "Messages" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_MessageReactions_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_MessageReactions_MessageId" ON "MessageReactions" ("MessageId"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_MessageReactions_MessageId_UserId" ON "MessageReactions" ("MessageId", "UserId"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_Messages_ReplyToMessageId" ON "Messages" ("ReplyToMessageId"); + """, + cancellationToken); + } + + private async Task EnsureMessageViewsTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "MessageViews" ( + "MessageId" TEXT NOT NULL, + "ViewerUserId" TEXT NOT NULL, + "ViewedAt" TEXT NOT NULL, + CONSTRAINT "PK_MessageViews" PRIMARY KEY ("MessageId", "ViewerUserId"), + CONSTRAINT "FK_MessageViews_Messages_MessageId" FOREIGN KEY ("MessageId") REFERENCES "Messages" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_MessageViews_Users_ViewerUserId" FOREIGN KEY ("ViewerUserId") REFERENCES "Users" ("Id") ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_MessageViews_ViewerUserId" ON "MessageViews" ("ViewerUserId"); + """, + cancellationToken); + } + + private async Task EnsureChatMembersIndexesAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_ChatMembers_UserId" ON "ChatMembers" ("UserId"); + """, + cancellationToken); + } + + private async Task EnsureUserPrivacySettingsTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "UserPrivacySettings" ( + "UserId" TEXT NOT NULL CONSTRAINT "PK_UserPrivacySettings" PRIMARY KEY, + "PhoneNumberVisibility" TEXT NOT NULL DEFAULT 'contacts', + "LastSeenVisibility" TEXT NOT NULL DEFAULT 'contacts', + "ProfilePhotoVisibility" TEXT NOT NULL DEFAULT 'everyone', + "BlockedUserIds" TEXT NOT NULL DEFAULT '', + "AlwaysAllowUserIds" TEXT NOT NULL DEFAULT '', + "NeverAllowUserIds" TEXT NOT NULL DEFAULT '', + "PasscodeLockEnabled" INTEGER NOT NULL DEFAULT 0, + "ShowMessagePreview" INTEGER NOT NULL DEFAULT 1, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_UserPrivacySettings_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE + ); + """, + cancellationToken); + } + + private async Task EnsureChatInviteLinksTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "ChatInviteLinks" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_ChatInviteLinks" PRIMARY KEY, + "ChatId" TEXT NOT NULL, + "CreatedById" TEXT NOT NULL, + "Token" TEXT NOT NULL, + "Title" TEXT NULL, + "CreatesJoinRequest" INTEGER NOT NULL DEFAULT 0, + "IsRevoked" INTEGER NOT NULL DEFAULT 0, + "CreatedAt" TEXT NOT NULL, + "RevokedAt" TEXT NULL, + "ExpiresAt" TEXT NULL, + "MaxUses" INTEGER NULL, + "UsesCount" INTEGER NOT NULL DEFAULT 0, + "MemberLimit" INTEGER NULL, + CONSTRAINT "FK_ChatInviteLinks_Chats_ChatId" FOREIGN KEY ("ChatId") REFERENCES "Chats" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_ChatInviteLinks_Users_CreatedById" FOREIGN KEY ("CreatedById") REFERENCES "Users" ("Id") ON DELETE RESTRICT + ); + """, + cancellationToken); + + await EnsureColumnAsync("ChatInviteLinks", "ExpiresAt", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("ChatInviteLinks", "MaxUses", "INTEGER NULL", cancellationToken); + await EnsureColumnAsync("ChatInviteLinks", "UsesCount", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("ChatInviteLinks", "MemberLimit", "INTEGER NULL", cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_ChatInviteLinks_Token" ON "ChatInviteLinks" ("Token"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_ChatInviteLinks_ChatId" ON "ChatInviteLinks" ("ChatId"); + """, + cancellationToken); + } + + private async Task EnsureChatJoinRequestsTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "ChatJoinRequests" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_ChatJoinRequests" PRIMARY KEY, + "ChatId" TEXT NOT NULL, + "InviteLinkId" TEXT NOT NULL, + "UserId" TEXT NOT NULL, + "Status" INTEGER NOT NULL DEFAULT 0, + "RequestedAt" TEXT NOT NULL, + "ResolvedAt" TEXT NULL, + "ResolvedByUserId" TEXT NULL, + CONSTRAINT "FK_ChatJoinRequests_Chats_ChatId" FOREIGN KEY ("ChatId") REFERENCES "Chats" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_ChatJoinRequests_ChatInviteLinks_InviteLinkId" FOREIGN KEY ("InviteLinkId") REFERENCES "ChatInviteLinks" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_ChatJoinRequests_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_ChatJoinRequests_Users_ResolvedByUserId" FOREIGN KEY ("ResolvedByUserId") REFERENCES "Users" ("Id") ON DELETE SET NULL + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_ChatJoinRequests_ChatId_Status" ON "ChatJoinRequests" ("ChatId", "Status"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_ChatJoinRequests_InviteLinkId_UserId" ON "ChatJoinRequests" ("InviteLinkId", "UserId"); + """, + cancellationToken); + } + + private async Task EnsureChatTopicsTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "ChatTopics" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_ChatTopics" PRIMARY KEY, + "ChatId" TEXT NOT NULL, + "CreatedById" TEXT NOT NULL, + "Title" TEXT NOT NULL, + "Icon" TEXT NOT NULL DEFAULT '💬', + "IsPinned" INTEGER NOT NULL DEFAULT 0, + "IsClosed" INTEGER NOT NULL DEFAULT 0, + "CreatedAt" TEXT NOT NULL, + "LastActivityAt" TEXT NOT NULL, + CONSTRAINT "FK_ChatTopics_Chats_ChatId" FOREIGN KEY ("ChatId") REFERENCES "Chats" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_ChatTopics_Users_CreatedById" FOREIGN KEY ("CreatedById") REFERENCES "Users" ("Id") ON DELETE RESTRICT + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_ChatTopics_ChatId_IsPinned" ON "ChatTopics" ("ChatId", "IsPinned"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_ChatTopics_ChatId_LastActivityAt" ON "ChatTopics" ("ChatId", "LastActivityAt"); + """, + cancellationToken); + } + + private async Task EnsureMessagesPinnedIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_Messages_ChatId_PinnedAt" ON "Messages" ("ChatId", "PinnedAt"); + """, + cancellationToken); + } + + private async Task EnsureMessagesChatSenderSentAtIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_Messages_ChatId_SenderId_SentAt" ON "Messages" ("ChatId", "SenderId", "SentAt"); + """, + cancellationToken); + } + + private async Task EnsureMessagesAlbumIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_Messages_MediaAlbumId" ON "Messages" ("MediaAlbumId"); + """, + cancellationToken); + } + + private async Task EnsureMessagesTopicIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_Messages_TopicId" ON "Messages" ("TopicId"); + """, + cancellationToken); + } + + private async Task EnsureMessagesDiscussionIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_Messages_DiscussionMessageId" ON "Messages" ("DiscussionMessageId"); + """, + cancellationToken); + } + + private async Task EnsureMessagesExpiresAtIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_Messages_ExpiresAt" ON "Messages" ("ExpiresAt"); + """, + cancellationToken); + } + + private async Task EnsureChatsPublicUsernameIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_Chats_PublicUsername" ON "Chats" ("PublicUsername") WHERE "PublicUsername" IS NOT NULL; + """, + cancellationToken); + } + + private async Task EnsureMessagesStoryReplyIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_Messages_StoryReplyStoryId" ON "Messages" ("StoryReplyStoryId"); + """, + cancellationToken); + } + + private async Task EnsureChatsLinkedDiscussionIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_Chats_LinkedDiscussionChatId" ON "Chats" ("LinkedDiscussionChatId"); + """, + cancellationToken); + } + + private async Task EnsureLegacyChannelPostsMarkedAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + UPDATE "Messages" + SET "PostedAsChannel" = 1 + WHERE "PostedAsChannel" = 0 + AND "ChatId" IN (SELECT "Id" FROM "Chats" WHERE "Type" = 3); + """, + cancellationToken); + } + + private async Task EnsureLegacyAudioAttachmentsMarkedAsVoiceNotesAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + UPDATE "MessageAttachments" + SET "Kind" = 2 + WHERE "Kind" = 1 + AND ( + lower("ContentType") LIKE 'audio/%' + OR lower("ContentType") IN ('application/ogg', 'application/opus', 'application/x-ogg', 'video/ogg') + OR lower("OriginalFileName") GLOB '*.ogg' + OR lower("OriginalFileName") GLOB '*.oga' + OR lower("OriginalFileName") GLOB '*.opus' + OR lower("OriginalFileName") GLOB '*.m4a' + ); + """, + cancellationToken); + } + + private async Task EnsureMessageAttachmentsSortIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_MessageAttachments_MessageId_SortOrder" ON "MessageAttachments" ("MessageId", "SortOrder"); + """, + cancellationToken); + } + + private async Task EnsureChatMemberStateIndexesAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_ChatMembers_UserId_IsPinned" ON "ChatMembers" ("UserId", "IsPinned"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_ChatMembers_UserId_IsArchived" ON "ChatMembers" ("UserId", "IsArchived"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_ChatMembers_UserId_FolderKey" ON "ChatMembers" ("UserId", "FolderKey"); + """, + cancellationToken); + } + + private async Task EnsureUsersPhoneIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_Users_NormalizedPhoneNumber" ON "Users" ("NormalizedPhoneNumber") WHERE "NormalizedPhoneNumber" IS NOT NULL; + """, + cancellationToken); + } + + private async Task EnsureUsersEmailIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_Users_NormalizedEmail" ON "Users" ("NormalizedEmail") WHERE "NormalizedEmail" IS NOT NULL; + """, + cancellationToken); + } + + private async Task EnsurePushDispatchOutboxTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "PushDispatchOutbox" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_PushDispatchOutbox" PRIMARY KEY AUTOINCREMENT, + "DeduplicationKey" TEXT NOT NULL, + "ChatId" TEXT NOT NULL, + "MessageId" TEXT NOT NULL, + "Kind" INTEGER NOT NULL, + "ActorUserId" TEXT NULL, + "ReactionEmoji" TEXT NULL, + "AttemptCount" INTEGER NOT NULL DEFAULT 0, + "NextAttemptAt" TEXT NOT NULL, + "CreatedAt" TEXT NOT NULL, + "LastError" TEXT NULL + ); + """, + cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_PushDispatchOutbox_DeduplicationKey" + ON "PushDispatchOutbox" ("DeduplicationKey"); + """, + cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_PushDispatchOutbox_NextAttemptAt_Id" + ON "PushDispatchOutbox" ("NextAttemptAt", "Id"); + """, + cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_PushDispatchOutbox_MessageId_Kind" + ON "PushDispatchOutbox" ("MessageId", "Kind"); + """, + cancellationToken); + } + + private async Task EnsureMessagesClientMessageIdIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_Messages_SenderId_ClientMessageId" + ON "Messages" ("SenderId", "ClientMessageId") + WHERE "ClientMessageId" IS NOT NULL; + """, + cancellationToken); + } + + private async Task EnsureColumnAsync( + string tableName, + string columnName, + string definition, + CancellationToken cancellationToken) + { + var connection = dbContext.Database.GetDbConnection(); + var shouldClose = connection.State != ConnectionState.Open; + if (shouldClose) + { + await connection.OpenAsync(cancellationToken); + } + + try + { + await using var command = connection.CreateCommand(); + command.CommandText = $"PRAGMA table_info(\"{tableName}\");"; + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase)) + { + return; + } + } + + await reader.DisposeAsync(); + + await using var alterCommand = connection.CreateCommand(); + alterCommand.CommandText = $"ALTER TABLE \"{tableName}\" ADD COLUMN \"{columnName}\" {definition};"; + await alterCommand.ExecuteNonQueryAsync(cancellationToken); + } + finally + { + if (shouldClose) + { + await connection.CloseAsync(); + } + } + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Storage/QlyraUploadStore.cs b/ikar_server/src/Ikar.Server/Infrastructure/Storage/QlyraUploadStore.cs new file mode 100644 index 0000000..222b200 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Storage/QlyraUploadStore.cs @@ -0,0 +1,333 @@ +using System.Collections.Concurrent; +using Ikar.Server.Data.Entities; +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure.Storage; + +public sealed class QlyraUploadStore(IWebHostEnvironment environment) +{ + private static readonly TimeSpan Lifetime = TimeSpan.FromHours(2); + private const long MaxUploadSizeBytes = 100L * 1024L * 1024L; + private readonly ConcurrentDictionary _reservations = new(StringComparer.Ordinal); + private readonly string _temporaryRoot = Path.Combine(environment.ContentRootPath, "Data", "QlyraUploads"); + private readonly string _attachmentRoot = Path.Combine(environment.ContentRootPath, "Data", "Attachments"); + + public UploadReservation Reserve(Guid userId, string mediaKind) + { + RemoveExpired(); + Directory.CreateDirectory(_temporaryRoot); + var id = Guid.NewGuid(); + var token = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); + var reservation = new UploadReservation( + id, + token, + userId, + mediaKind, + Path.Combine(_temporaryRoot, $"{id:N}.upload"), + DateTimeOffset.UtcNow.Add(Lifetime)); + if (!_reservations.TryAdd(token, reservation)) + { + throw new InvalidOperationException("Could not reserve an upload token."); + } + return reservation; + } + + public async Task SaveAsync( + string token, + Stream source, + string? fileName, + string? contentType, + long? declaredLength, + CancellationToken cancellationToken) + { + if (!_reservations.TryGetValue(token, out var reservation) || reservation.ExpiresAt <= DateTimeOffset.UtcNow) + { + return null; + } + if (declaredLength is > MaxUploadSizeBytes) + { + throw new InvalidOperationException($"Upload is limited to {MaxUploadSizeBytes} bytes."); + } + + Directory.CreateDirectory(_temporaryRoot); + var temporaryPath = reservation.TemporaryPath + ".part"; + long total = 0; + await using (var target = new FileStream(temporaryPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous)) + { + var buffer = new byte[81920]; + while (true) + { + var read = await source.ReadAsync(buffer, cancellationToken); + if (read == 0) break; + total += read; + if (total > MaxUploadSizeBytes) + { + target.Close(); + File.Delete(temporaryPath); + throw new InvalidOperationException($"Upload is limited to {MaxUploadSizeBytes} bytes."); + } + await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + } + if (total == 0) + { + File.Delete(temporaryPath); + throw new InvalidOperationException("Upload is empty."); + } + + File.Move(temporaryPath, reservation.TemporaryPath, true); + reservation.OriginalFileName = NormalizeFileName(fileName, reservation.MediaKind); + reservation.ContentType = ResolveContentType(contentType, reservation.MediaKind); + reservation.FileSizeBytes = total; + reservation.UploadedAt = DateTimeOffset.UtcNow; + return reservation; + } + + public long? GetChunkOffset(string token) + { + if (!_reservations.TryGetValue(token, out var reservation) || reservation.ExpiresAt <= DateTimeOffset.UtcNow) return null; + if (reservation.UploadedAt is not null && File.Exists(reservation.TemporaryPath)) return reservation.FileSizeBytes; + var partialPath = reservation.TemporaryPath + ".part"; + return File.Exists(partialPath) ? new FileInfo(partialPath).Length : 0; + } + + public async Task SaveChunkAsync( + string token, + Stream source, + long start, + long end, + long totalLength, + string? fileName, + string? contentType, + CancellationToken cancellationToken) + { + if (!_reservations.TryGetValue(token, out var reservation) || reservation.ExpiresAt <= DateTimeOffset.UtcNow) return null; + if (start < 0 || end < start || totalLength <= end || totalLength > MaxUploadSizeBytes) + { + throw new InvalidOperationException("Invalid upload byte range."); + } + await reservation.Gate.WaitAsync(cancellationToken); + try + { + Directory.CreateDirectory(_temporaryRoot); + var partialPath = reservation.TemporaryPath + ".part"; + await using var target = new FileStream(partialPath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous); + if (target.Length != start) throw new InvalidOperationException($"Upload must resume at byte {target.Length}."); + target.Position = start; + var expected = end - start + 1; + var copied = 0L; + var buffer = new byte[81920]; + while (copied < expected) + { + var read = await source.ReadAsync(buffer.AsMemory(0, (int)Math.Min(buffer.Length, expected - copied)), cancellationToken); + if (read == 0) break; + await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + copied += read; + } + if (copied != expected) throw new InvalidOperationException("Upload chunk length does not match Content-Range."); + await target.FlushAsync(cancellationToken); + if (end + 1 == totalLength) + { + target.Close(); + File.Move(partialPath, reservation.TemporaryPath, true); + reservation.OriginalFileName = NormalizeFileName(fileName, reservation.MediaKind); + reservation.ContentType = ResolveContentType(contentType, reservation.MediaKind); + reservation.FileSizeBytes = totalLength; + reservation.UploadedAt = DateTimeOffset.UtcNow; + } + return reservation; + } + finally + { + reservation.Gate.Release(); + } + } + + public async Task MaterializeMessageAttachmentAsync( + string token, + Guid userId, + Guid messageId, + AttachmentKind kind, + int sortOrder, + CancellationToken cancellationToken) + { + if (!TryTakeUploaded(token, userId, out var reservation)) return null; + var targetPath = CreateTargetPath(reservation!.OriginalFileName); + try + { + await MoveFileAsync(reservation.TemporaryPath, targetPath, cancellationToken); + return new MessageAttachment + { + MessageId = messageId, + OriginalFileName = reservation.OriginalFileName, + ContentType = reservation.ContentType, + Kind = kind, + FileSizeBytes = reservation.FileSizeBytes, + StoragePath = targetPath, + SortOrder = sortOrder, + UploadedAt = reservation.UploadedAt ?? DateTimeOffset.UtcNow + }; + } + catch + { + _reservations.TryAdd(token, reservation); + throw; + } + } + + public async Task MaterializeStoryAttachmentAsync( + string token, + Guid userId, + Guid storyId, + int sortOrder, + CancellationToken cancellationToken) + { + if (!TryTakeUploaded(token, userId, out var reservation)) return null; + var targetPath = CreateTargetPath(reservation!.OriginalFileName); + try + { + await MoveFileAsync(reservation.TemporaryPath, targetPath, cancellationToken); + return new StoryAttachment + { + StoryId = storyId, + OriginalFileName = reservation.OriginalFileName, + ContentType = reservation.ContentType, + FileSizeBytes = reservation.FileSizeBytes, + StoragePath = targetPath, + SortOrder = sortOrder, + UploadedAt = reservation.UploadedAt ?? DateTimeOffset.UtcNow + }; + } + catch + { + _reservations.TryAdd(token, reservation); + throw; + } + } + + public async Task MaterializeStandaloneAsync( + string token, + Guid userId, + CancellationToken cancellationToken) + { + if (!TryTakeUploaded(token, userId, out var reservation)) return null; + var targetPath = CreateTargetPath(reservation!.OriginalFileName); + try + { + await MoveFileAsync(reservation.TemporaryPath, targetPath, cancellationToken); + return new MaterializedQlyraUpload( + targetPath, + reservation.OriginalFileName, + reservation.ContentType, + reservation.FileSizeBytes); + } + catch + { + _reservations.TryAdd(token, reservation); + throw; + } + } + + private bool TryTakeUploaded(string token, Guid userId, out UploadReservation? reservation) + { + reservation = null; + if (!_reservations.TryGetValue(token, out var found) || + found.UserId != userId || + found.ExpiresAt <= DateTimeOffset.UtcNow || + found.UploadedAt is null || + !File.Exists(found.TemporaryPath)) + { + return false; + } + if (!_reservations.TryRemove(token, out var removed) || !ReferenceEquals(removed, found)) return false; + reservation = found; + return true; + } + + private string CreateTargetPath(string originalFileName) + { + var now = DateTime.UtcNow; + var directory = Path.Combine(_attachmentRoot, now.ToString("yyyy"), now.ToString("MM")); + Directory.CreateDirectory(directory); + var extension = Path.GetExtension(originalFileName); + return Path.Combine(directory, $"{Guid.NewGuid():N}{extension}"); + } + + private static async Task MoveFileAsync(string sourcePath, string targetPath, CancellationToken cancellationToken) + { + await using (var source = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous)) + await using (var target = new FileStream(targetPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous)) + { + await source.CopyToAsync(target, cancellationToken); + } + File.Delete(sourcePath); + } + + private void RemoveExpired() + { + var now = DateTimeOffset.UtcNow; + foreach (var item in _reservations) + { + if (item.Value.ExpiresAt > now) continue; + if (_reservations.TryRemove(item.Key, out var removed) && ReferenceEquals(removed, item.Value)) + { + try { File.Delete(item.Value.TemporaryPath); } catch (IOException) { } + } + } + } + + private static string NormalizeFileName(string? fileName, string fallback) + { + var normalized = Path.GetFileName(fileName); + return string.IsNullOrWhiteSpace(normalized) ? fallback : normalized; + } + + private static string NormalizeContentType(string? contentType) + { + if (string.IsNullOrWhiteSpace(contentType)) return "application/octet-stream"; + var semicolon = contentType.IndexOf(';'); + return (semicolon < 0 ? contentType : contentType[..semicolon]).Trim().ToLowerInvariant(); + } + + private static string ResolveContentType(string? contentType, string mediaKind) + { + var normalized = NormalizeContentType(contentType); + if (!string.Equals(normalized, "application/octet-stream", StringComparison.OrdinalIgnoreCase) && + !string.Equals(normalized, "application/x-binary", StringComparison.OrdinalIgnoreCase)) return normalized; + return Path.GetExtension(mediaKind).ToLowerInvariant() switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".mp4" => "video/mp4", + ".ogg" => "audio/ogg", + _ => normalized + }; + } +} + +public sealed record MaterializedQlyraUpload( + string StoragePath, + string OriginalFileName, + string ContentType, + long FileSizeBytes); + +public sealed class UploadReservation( + Guid id, + string token, + Guid userId, + string mediaKind, + string temporaryPath, + DateTimeOffset expiresAt) +{ + public Guid Id { get; } = id; + public string Token { get; } = token; + public Guid UserId { get; } = userId; + public string MediaKind { get; } = mediaKind; + public string TemporaryPath { get; } = temporaryPath; + public DateTimeOffset ExpiresAt { get; } = expiresAt; + public string OriginalFileName { get; set; } = mediaKind; + public string ContentType { get; set; } = "application/octet-stream"; + public long FileSizeBytes { get; set; } + public DateTimeOffset? UploadedAt { get; set; } + internal SemaphoreSlim Gate { get; } = new(1, 1); +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Storage/VideoPreviewService.cs b/ikar_server/src/Ikar.Server/Infrastructure/Storage/VideoPreviewService.cs new file mode 100644 index 0000000..c4fa6dc --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Storage/VideoPreviewService.cs @@ -0,0 +1,99 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text.Json; + +namespace Ikar.Server.Infrastructure.Storage; + +public sealed record VideoPreviewMetadata(int Width, int Height, int Duration); + +/// Creates a persistent preview once, including for videos uploaded before this release. +public sealed class VideoPreviewService(ILogger logger) +{ + private readonly SemaphoreSlim _worker = new(1, 1); + public static string ThumbnailPath(string videoPath) => videoPath + ".preview.jpg"; + public static string MetadataPath(string videoPath) => videoPath + ".video.json"; + + public async Task GetAsync(string videoPath, CancellationToken cancellationToken) + { + if (!File.Exists(videoPath)) return null; + await _worker.WaitAsync(cancellationToken); + string? temporary = null; + try + { + var thumbnail = ThumbnailPath(videoPath); + var metadataPath = MetadataPath(videoPath); + if (File.Exists(thumbnail) && new FileInfo(thumbnail).Length > 0 && File.Exists(metadataPath)) + { + try + { + var cached = JsonSerializer.Deserialize(await File.ReadAllTextAsync(metadataPath, cancellationToken)); + if (cached is { Width: > 0, Height: > 0, Duration: >= 0 }) return cached; + } + catch (JsonException) { } + } + + var probe = await RunAsync("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", + "stream=width,height:stream_tags=rotate:stream_side_data=rotation:format=duration", "-of", "json", videoPath], cancellationToken); + using var document = JsonDocument.Parse(probe); + var stream = document.RootElement.GetProperty("streams")[0]; + var width = stream.GetProperty("width").GetInt32(); + var height = stream.GetProperty("height").GetInt32(); + if (width <= 0 || height <= 0) return null; + var rotation = 0; + if (stream.TryGetProperty("side_data_list", out var sideData)) + foreach (var entry in sideData.EnumerateArray()) + if (entry.TryGetProperty("rotation", out var angle)) rotation = (int)angle.GetDouble(); + if (rotation == 0 && stream.TryGetProperty("tags", out var tags) && tags.TryGetProperty("rotate", out var rotate)) + int.TryParse(rotate.GetString(), out rotation); + if (Math.Abs(rotation % 180) == 90) (width, height) = (height, width); + var duration = 0; + if (document.RootElement.TryGetProperty("format", out var format) && format.TryGetProperty("duration", out var durationValue) && + double.TryParse(durationValue.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds) && double.IsFinite(seconds)) + duration = (int)Math.Clamp(Math.Round(seconds * 1000), 0, int.MaxValue); + + temporary = thumbnail + "." + Guid.NewGuid().ToString("N") + ".jpg"; + await RunAsync("ffmpeg", ["-nostdin", "-hide_banner", "-loglevel", "error", "-threads", "1", "-i", videoPath, + "-map", "0:v:0", "-frames:v", "1", "-vf", "scale=480:480:force_original_aspect_ratio=decrease", + "-filter_threads", "1", "-q:v", "4", "-y", temporary], cancellationToken); + if (!File.Exists(temporary) || new FileInfo(temporary).Length == 0) return null; + var metadata = new VideoPreviewMetadata(width, height, duration); + File.Move(temporary, thumbnail, true); + await File.WriteAllTextAsync(metadataPath, JsonSerializer.Serialize(metadata), cancellationToken); + return metadata; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception exception) + { + logger.LogWarning(exception, "Could not create video preview for {FileName}", Path.GetFileName(videoPath)); + return null; + } + finally + { + if (temporary is not null && File.Exists(temporary)) File.Delete(temporary); + _worker.Release(); + } + } + + private static async Task RunAsync(string executable, string[] arguments, CancellationToken cancellationToken) + { + var start = new ProcessStartInfo(executable) { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; + foreach (var argument in arguments) start.ArgumentList.Add(argument); + using var process = Process.Start(start) ?? throw new IOException($"Could not start {executable}."); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(25)); + var output = process.StandardOutput.ReadToEndAsync(timeout.Token); + var error = process.StandardError.ReadToEndAsync(timeout.Token); + try + { + await process.WaitForExitAsync(timeout.Token); + var result = await output; + var diagnostic = await error; + if (process.ExitCode != 0) throw new IOException($"{executable} failed: {diagnostic}"); + return result; + } + finally + { + if (!process.HasExited) process.Kill(entireProcessTree: true); + } + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Updates/AndroidAppUpdateMonitorService.cs b/ikar_server/src/Ikar.Server/Infrastructure/Updates/AndroidAppUpdateMonitorService.cs new file mode 100644 index 0000000..1744d58 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Updates/AndroidAppUpdateMonitorService.cs @@ -0,0 +1,102 @@ +using Ikar.Server.Data; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Push; +using Ikar.Shared; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Infrastructure.Updates; + +public sealed class AndroidAppUpdateMonitorService( + IServiceScopeFactory serviceScopeFactory, + AndroidAppUpdateService androidAppUpdateService, + IHubContext hubContext, + ILogger logger) : BackgroundService +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(15); + private AndroidAppReleaseDto? _lastObservedRelease; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _lastObservedRelease = await androidAppUpdateService.GetLatestReleaseAsync(stoppingToken); + + using var timer = new PeriodicTimer(PollInterval); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + var currentRelease = await androidAppUpdateService.GetLatestReleaseAsync(stoppingToken); + if (currentRelease is null) + { + continue; + } + + if (!HasRelevantUpdateChange(_lastObservedRelease, currentRelease)) + { + continue; + } + + var previousRelease = _lastObservedRelease; + _lastObservedRelease = currentRelease; + await NotifyClientsAsync(currentRelease, stoppingToken); + await NotifyPushDevicesAsync(currentRelease, stoppingToken); + + logger.LogInformation( + "Detected Android app update change from version {PreviousVersion} to {CurrentVersion}.", + previousRelease?.VersionName ?? "none", + currentRelease.VersionName); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Android app update monitor failed during polling."); + } + } + } + + private static bool HasRelevantUpdateChange(AndroidAppReleaseDto? previousRelease, AndroidAppReleaseDto currentRelease) + { + if (previousRelease is null) + { + return true; + } + + return previousRelease.VersionCode != currentRelease.VersionCode || + !string.Equals(previousRelease.VersionName, currentRelease.VersionName, StringComparison.Ordinal) || + !string.Equals(previousRelease.DownloadPath, currentRelease.DownloadPath, StringComparison.Ordinal); + } + + private Task NotifyClientsAsync(AndroidAppReleaseDto release, CancellationToken cancellationToken) => + hubContext.Clients.All.SendAsync("AppUpdateAvailable", release, cancellationToken); + + private async Task NotifyPushDevicesAsync(AndroidAppReleaseDto release, CancellationToken cancellationToken) + { + await using var scope = serviceScopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var pushNotificationSender = scope.ServiceProvider.GetRequiredService(); + + var devices = await dbContext.PushDevices + .Where(device => + device.Platform == PushPlatform.Android && + device.NotificationsEnabled && + (device.ClientVersionCode == null || device.ClientVersionCode < release.VersionCode)) + .ToListAsync(cancellationToken); + + if (devices.Count == 0) + { + return; + } + + try + { + await pushNotificationSender.SendAppUpdateAvailableAsync(release, devices, cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Android app update push dispatch failed for version {VersionCode}.", release.VersionCode); + } + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Updates/AndroidAppUpdateService.cs b/ikar_server/src/Ikar.Server/Infrastructure/Updates/AndroidAppUpdateService.cs new file mode 100644 index 0000000..e40df0a --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Updates/AndroidAppUpdateService.cs @@ -0,0 +1,357 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.RegularExpressions; +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure.Updates; + +public sealed class AndroidAppUpdateService( + IHostEnvironment hostEnvironment, + IHttpClientFactory httpClientFactory, + ILogger logger, + IConfiguration? configuration = null) +{ + private const string ArgusBaseUrl = "https://argus.kusoft.xyz"; + private const string ArgusSlug = "ikar-independent"; + private const string ArgusChannel = "stable"; + private const string ArgusPlatform = "android"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private static readonly Regex VersionCodeFromFileNameRegex = new( + @"ikar-android-(\d+)\.apk$", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + private string UpdatesRootPath => Path.Combine(hostEnvironment.ContentRootPath, "Data", "AppUpdates", "Android"); + + private string MetadataPath => Path.Combine(UpdatesRootPath, "latest.json"); + + public void EnsureStorage() + { + Directory.CreateDirectory(UpdatesRootPath); + } + + public async Task GetLatestReleaseAsync(CancellationToken cancellationToken = default) + { + var argusManifest = await TryGetArgusManifestAsync(cancellationToken); + if (argusManifest is not null) + { + return ApplyMandatoryLatestPolicy(argusManifest.Release); + } + + var localRelease = GetLatestLocalRelease(); + return localRelease is null ? null : ApplyMandatoryLatestPolicy(localRelease); + } + + public async Task ResolvePackageAsync(string fileName, CancellationToken cancellationToken = default) + { + var argusManifest = await TryGetArgusManifestAsync(cancellationToken); + if (argusManifest is not null && MatchesArgusFileName(argusManifest, fileName)) + { + return AndroidAppDownloadResolution.ForRedirect( + argusManifest.Release.DownloadPath, + argusManifest.OriginalFileName); + } + + var latest = GetLocalMetadata(); + if (latest is null) + { + return null; + } + + if (!string.Equals(latest.PackageFileName, fileName, StringComparison.Ordinal)) + { + return null; + } + + var physicalPath = Path.Combine(UpdatesRootPath, latest.PackageFileName); + if (!File.Exists(physicalPath)) + { + return null; + } + + return AndroidAppDownloadResolution.ForPhysical(physicalPath, latest.PackageFileName); + } + + public async Task ResolveLatestPackageAsync(CancellationToken cancellationToken = default) + { + var argusManifest = await TryGetArgusManifestAsync(cancellationToken); + if (argusManifest is not null) + { + return AndroidAppDownloadResolution.ForRedirect( + argusManifest.Release.DownloadPath, + argusManifest.OriginalFileName); + } + + var latest = GetLocalMetadata(); + if (latest is null) + { + return null; + } + + var physicalPath = Path.Combine(UpdatesRootPath, latest.PackageFileName); + if (!File.Exists(physicalPath)) + { + return null; + } + + return AndroidAppDownloadResolution.ForPhysical(physicalPath, latest.PackageFileName); + } + + private async Task TryGetArgusManifestAsync(CancellationToken cancellationToken) + { + if (configuration?.GetValue("IkarUpdates:LocalOnly") == true) return null; + try + { + using var client = httpClientFactory.CreateClient(); + using var request = new HttpRequestMessage( + HttpMethod.Get, + $"{ArgusBaseUrl.TrimEnd('/')}/api/apps/{ArgusSlug}/manifest?platform={ArgusPlatform}&channel={ArgusChannel}"); + request.Headers.CacheControl = new CacheControlHeaderValue + { + NoCache = true, + NoStore = true + }; + + using var response = await client.SendAsync(request, cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + + if (!response.IsSuccessStatusCode) + { + logger.LogWarning( + "Argus manifest request failed with status code {StatusCode}.", + (int)response.StatusCode); + return null; + } + + var manifest = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + if (manifest?.Release is null) + { + logger.LogWarning("Argus manifest response was empty or missing release metadata."); + return null; + } + + if (!TryMapArgusRelease(manifest.Release, out var release, out var originalFileName)) + { + logger.LogWarning( + "Argus manifest could not be mapped to AndroidAppReleaseDto. Version={Version}, OriginalFileName={OriginalFileName}.", + manifest.Release.Version, + manifest.Release.OriginalFileName); + return null; + } + + return new ArgusAndroidRelease(release, originalFileName); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to load Android app release metadata from Argus."); + return null; + } + } + + private static bool TryMapArgusRelease( + ArgusReleaseDto argusRelease, + out AndroidAppReleaseDto release, + out string originalFileName) + { + release = null!; + originalFileName = argusRelease.OriginalFileName; + + if (!TryParseVersion( + argusRelease.Version, + argusRelease.AndroidVersionCode, + argusRelease.OriginalFileName, + out var versionCode, + out var versionName)) + { + return false; + } + + var downloadPath = BuildAbsoluteArgusUrl(argusRelease.DownloadPath); + if (downloadPath is null) + { + return false; + } + + release = new AndroidAppReleaseDto( + versionCode, + versionName, + downloadPath, + argusRelease.PublishedAt, + argusRelease.Notes); + + return true; + } + + private static bool TryParseVersion( + string rawVersion, + int? androidVersionCode, + string? originalFileName, + out int versionCode, + out string versionName) + { + versionCode = androidVersionCode is > 0 ? androidVersionCode.Value : 0; + versionName = rawVersion.Trim(); + + var buildSeparatorIndex = versionName.IndexOf('+'); + if (buildSeparatorIndex >= 0) + { + var parsedVersionName = versionName[..buildSeparatorIndex].Trim(); + var buildMetadata = versionName[(buildSeparatorIndex + 1)..].Trim(); + + if (!string.IsNullOrWhiteSpace(parsedVersionName)) + { + versionName = parsedVersionName; + } + + if (versionCode <= 0 && + int.TryParse(buildMetadata, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedVersionCode) && + parsedVersionCode > 0) + { + versionCode = parsedVersionCode; + } + } + + if (versionCode <= 0 && + !string.IsNullOrWhiteSpace(originalFileName) && + TryParseVersionCodeFromFileName(originalFileName, out var parsedFromFileName)) + { + versionCode = parsedFromFileName; + } + + return versionCode > 0 && !string.IsNullOrWhiteSpace(versionName); + } + + private static bool TryParseVersionCodeFromFileName(string fileName, out int versionCode) + { + versionCode = 0; + var match = VersionCodeFromFileNameRegex.Match(fileName); + if (!match.Success) + { + return false; + } + + return int.TryParse(match.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out versionCode) && + versionCode > 0; + } + + private static string? BuildAbsoluteArgusUrl(string? rawPath) + { + if (string.IsNullOrWhiteSpace(rawPath)) + { + return null; + } + + if (Uri.TryCreate(rawPath, UriKind.Absolute, out var absoluteUri) && + (absoluteUri.Scheme == Uri.UriSchemeHttp || absoluteUri.Scheme == Uri.UriSchemeHttps)) + { + return absoluteUri.ToString(); + } + + return new Uri(new Uri($"{ArgusBaseUrl.TrimEnd('/')}/"), rawPath.TrimStart('/')).ToString(); + } + + private static bool MatchesArgusFileName(ArgusAndroidRelease argusManifest, string requestedFileName) + { + var normalizedRequested = requestedFileName.Trim(); + if (string.Equals(normalizedRequested, argusManifest.OriginalFileName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var generatedFileName = BuildExpectedFileName(argusManifest.Release.VersionCode); + return string.Equals(normalizedRequested, generatedFileName, StringComparison.OrdinalIgnoreCase); + } + + private AndroidAppReleaseDto? GetLatestLocalRelease() + { + var metadata = GetLocalMetadata(); + if (metadata is null) + { + return null; + } + + var packagePath = Path.Combine(UpdatesRootPath, metadata.PackageFileName); + if (!File.Exists(packagePath)) + { + return null; + } + + return new AndroidAppReleaseDto( + metadata.VersionCode, + metadata.VersionName, + $"/api/app-updates/android/download/{Uri.EscapeDataString(metadata.PackageFileName)}", + metadata.PublishedAt, + metadata.Notes); + } + + private static AndroidAppReleaseDto ApplyMandatoryLatestPolicy(AndroidAppReleaseDto release) + { + return release with + { + IsRequired = true, + MinimumSupportedVersionCode = release.VersionCode + }; + } + + private AndroidAppReleaseMetadata? GetLocalMetadata() + { + if (!File.Exists(MetadataPath)) + { + return null; + } + + return JsonSerializer.Deserialize(File.ReadAllText(MetadataPath), JsonOptions); + } + + private static string BuildExpectedFileName(int versionCode) => $"ikar-android-{versionCode}.apk"; + + private sealed record ArgusManifestDto( + string Slug, + string Name, + string Summary, + string Description, + string? RepositoryUrl, + string? HomepageUrl, + ArgusReleaseDto Release); + + private sealed record ArgusReleaseDto( + Guid Id, + string Version, + string Channel, + string Platform, + string PackageKind, + int? AndroidVersionCode, + string DownloadPath, + string OriginalFileName, + string ContentType, + long PackageSizeBytes, + string Sha256, + DateTimeOffset PublishedAt, + string? Notes); + + private sealed record ArgusAndroidRelease(AndroidAppReleaseDto Release, string OriginalFileName); + + public sealed record AndroidAppDownloadResolution( + string FileName, + string? PhysicalPath, + string? RedirectUrl) + { + public static AndroidAppDownloadResolution ForPhysical(string physicalPath, string fileName) => + new(fileName, physicalPath, null); + + public static AndroidAppDownloadResolution ForRedirect(string redirectUrl, string fileName) => + new(fileName, null, redirectUrl); + } + + public sealed record AndroidAppReleaseMetadata( + int VersionCode, + string VersionName, + string PackageFileName, + DateTimeOffset PublishedAt, + string? Notes); +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Updates/AndroidUpdateCompatibilityTrackingService.cs b/ikar_server/src/Ikar.Server/Infrastructure/Updates/AndroidUpdateCompatibilityTrackingService.cs new file mode 100644 index 0000000..2346bb7 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Updates/AndroidUpdateCompatibilityTrackingService.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure.Updates; + +public sealed class AndroidUpdateCompatibilityTrackingService(IkarDbContext dbContext) +{ + private const int MaxUserAgentLength = 512; + + public async Task TrackHitAsync( + HttpRequest request, + string route, + string? requestedFileName, + CancellationToken cancellationToken) + { + var hit = new AndroidUpdateCompatibilityHit + { + HitAt = DateTimeOffset.UtcNow, + Route = TrimToLength(route, 64) ?? "unknown", + RequestPath = TrimToLength(request.Path.Value, 256) ?? string.Empty, + RequestedFileName = TrimToLength(requestedFileName, 260), + ClientPlatform = TrimToLength(ReadClientPlatform(request), 32), + ClientVersionCode = ReadClientVersionCode(request), + ClientVersionName = TrimToLength(ReadClientVersionName(request), 64), + UserAgent = TrimToLength(request.Headers.UserAgent.ToString(), MaxUserAgentLength), + RemoteIp = TrimToLength(request.HttpContext.Connection.RemoteIpAddress?.ToString(), 64) + }; + + dbContext.AndroidUpdateCompatibilityHits.Add(hit); + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static string? ReadClientPlatform(HttpRequest request) + { + var platform = request.Headers[ClientVersionMetadata.PlatformHeaderName].ToString(); + if (!string.IsNullOrWhiteSpace(platform)) + { + return platform; + } + + platform = request.Query[ClientVersionMetadata.PlatformQueryName].ToString(); + return string.IsNullOrWhiteSpace(platform) ? null : platform; + } + + private static int? ReadClientVersionCode(HttpRequest request) + { + var rawVersionCode = request.Headers[ClientVersionMetadata.VersionCodeHeaderName].ToString(); + if (string.IsNullOrWhiteSpace(rawVersionCode)) + { + rawVersionCode = request.Query[ClientVersionMetadata.VersionCodeQueryName].ToString(); + } + + return int.TryParse(rawVersionCode, out var parsedVersionCode) && parsedVersionCode > 0 + ? parsedVersionCode + : null; + } + + private static string? ReadClientVersionName(HttpRequest request) + { + var versionName = request.Headers[ClientVersionMetadata.VersionNameHeaderName].ToString(); + if (!string.IsNullOrWhiteSpace(versionName)) + { + return versionName; + } + + versionName = request.Query[ClientVersionMetadata.VersionNameQueryName].ToString(); + return string.IsNullOrWhiteSpace(versionName) ? null : versionName; + } + + private static string? TrimToLength(string? value, int maxLength) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength]; + } +} diff --git a/ikar_server/src/Ikar.Server/Program.cs b/ikar_server/src/Ikar.Server/Program.cs new file mode 100644 index 0000000..509e5fa --- /dev/null +++ b/ikar_server/src/Ikar.Server/Program.cs @@ -0,0 +1,388 @@ +using System.Net; +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using Ikar.Server.Data; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Admin; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Bots; +using Ikar.Server.Infrastructure.Calls; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Push; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Server.Infrastructure.Updates; +using Ikar.Shared; +using System.Text; + +var builder = WebApplication.CreateBuilder(args); +var sessionActivityWriteInterval = TimeSpan.FromMinutes(1); +var seedDemoData = builder.Configuration.GetValue("SeedDemoData") ?? builder.Environment.IsDevelopment(); +var configuredUrls = + builder.Configuration["ASPNETCORE_URLS"] ?? + builder.Configuration["URLS"] ?? + "http://0.0.0.0:5099"; + +builder.WebHost.UseUrls(configuredUrls); + +builder.Services.AddControllers(); +builder.Services.AddScoped(); +builder.Services.AddOpenApi(); +builder.Services.AddSignalR(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddHttpClient(); + +builder.Services.AddDbContext(options => +{ + var configuredConnectionString = builder.Configuration.GetConnectionString("IkarDb") ?? + "Data Source=Data/ikar.db"; + var connectionString = new SqliteConnectionStringBuilder(configuredConnectionString) + { + DefaultTimeout = 30 + }.ToString(); + options.UseSqlite(connectionString, sqliteOptions => sqliteOptions.CommandTimeout(30)); +}); + +builder.Services.Configure(options => +{ + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost; + options.KnownProxies.Add(IPAddress.Loopback); + options.KnownProxies.Add(IPAddress.IPv6Loopback); +}); + +builder.Services.Configure(builder.Configuration.GetSection(JwtOptions.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection(PushOptions.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection(AdminOptions.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection(WebRtcOptions.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection(QlyraPublicEndpointOptions.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection(EmailAuthOptions.SectionName)); + +var jwtOptions = builder.Configuration + .GetSection(JwtOptions.SectionName) + .Get() ?? new JwtOptions(); +var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)); + +builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateIssuerSigningKey = true, + ValidateLifetime = true, + ValidIssuer = jwtOptions.Issuer, + ValidAudience = jwtOptions.Audience, + IssuerSigningKey = signingKey, + ClockSkew = TimeSpan.FromSeconds(30) + }; + + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"]; + var path = context.HttpContext.Request.Path; + if (!string.IsNullOrWhiteSpace(accessToken) && + (path.StartsWithSegments("/hubs/messenger") || + (path.StartsWithSegments("/api/chats") || path.StartsWithSegments("/api/stories")) && path.Value!.Contains("/attachments/"))) + { + context.Token = accessToken; + } + + return Task.CompletedTask; + }, + OnTokenValidated = async context => + { + var principal = context.Principal; + if (principal is null) + { + context.Fail("Token principal is missing."); + return; + } + + var mediaPath = principal.FindFirstValue("media_path"); + if (mediaPath is not null && !string.Equals(mediaPath, context.HttpContext.Request.Path.Value, StringComparison.Ordinal)) + { + context.Fail("Media token is restricted to its resource."); + return; + } + + var sessionId = principal.GetRequiredSessionId(); + var dbContext = context.HttpContext.RequestServices.GetRequiredService(); + var session = await dbContext.UserSessions + .Include(x => x.User) + .SingleOrDefaultAsync(x => x.Id == sessionId, context.HttpContext.RequestAborted); + if (session is null) + { + context.Fail("Session is no longer active."); + return; + } + + if (session.User.IsBlocked) + { + context.Fail("User is blocked."); + return; + } + + var now = DateTimeOffset.UtcNow; + var changed = ClientVersionRequestReader.Apply( + session, + ClientVersionRequestReader.Read(context.HttpContext.Request)); + if (session.LastSeenAt <= now.Subtract(sessionActivityWriteInterval)) + { + session.LastSeenAt = now; + changed = true; + } + + if (changed) + { + await dbContext.SaveChangesAsync(context.HttpContext.RequestAborted); + } + } + }; + }) + .AddCookie(AdminAuthConstants.Scheme, options => + { + options.Cookie.Name = "Ikar.Admin"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Strict; + options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() + ? CookieSecurePolicy.SameAsRequest + : CookieSecurePolicy.Always; + options.ExpireTimeSpan = TimeSpan.FromHours(12); + options.SlidingExpiration = true; + options.Events = new CookieAuthenticationEvents + { + OnRedirectToLogin = context => + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + }, + OnRedirectToAccessDenied = context => + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return Task.CompletedTask; + } + }; + }); + +builder.Services.AddAuthorization(options => +{ + options.AddPolicy(AdminAuthConstants.Policy, policy => + { + policy.AuthenticationSchemes.Add(AdminAuthConstants.Scheme); + policy.RequireAuthenticatedUser(); + policy.RequireClaim(ClaimTypes.Role, "Admin"); + }); +}); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Services.AddSingleton(new ServerRuntimeState(DateTimeOffset.UtcNow)); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(provider => provider.GetRequiredService()); +builder.Services.AddHttpClient(); + +var app = builder.Build(); + +Directory.CreateDirectory(Path.Combine(app.Environment.ContentRootPath, "Data")); +app.Services.GetRequiredService().EnsureStorage(); + +await using (var scope = app.Services.CreateAsyncScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + await scope.ServiceProvider.GetRequiredService().EnsureLatestAsync(); + if (seedDemoData) + { + await scope.ServiceProvider.GetRequiredService().SeedAsync(); + } + await scope.ServiceProvider.GetRequiredService().EnsureCreatorBotAsync(); +} + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.Use(async (context, next) => +{ + if (context.Request.Path == "/" || context.Request.Path == "/index.html") + { + context.Response.Redirect("https://argus.kusoft.xyz/#catalog", permanent: false); + return; + } + + if (context.Request.Path == "/admin" || context.Request.Path == "/admin/") + { + context.Response.Redirect("/admin/index.html"); + return; + } + + await next(); +}); +app.UseForwardedHeaders(); +app.UseStaticFiles(); +app.Use(async (context, next) => +{ + context.Response.Headers["X-Content-Type-Options"] = "nosniff"; + context.Response.Headers["Referrer-Policy"] = "no-referrer"; + context.Response.Headers["X-Frame-Options"] = "DENY"; + await next(); +}); +app.Use(async (context, next) => +{ + if (!ShouldEnforceAndroidClientVersion(context.Request)) + { + await next(); + return; + } + + var release = await context.RequestServices + .GetRequiredService() + .GetLatestReleaseAsync(context.RequestAborted); + + var minimumSupportedVersionCode = release?.MinimumSupportedVersionCode ?? 0; + if (release is null || minimumSupportedVersionCode <= 0) + { + await next(); + return; + } + + if (!TryGetClientPlatform(context.Request, out var clientPlatform) || + !string.Equals(clientPlatform, ClientPlatformNames.Android, StringComparison.OrdinalIgnoreCase)) + { + await next(); + return; + } + + if (!TryGetClientVersionCode(context.Request, out var clientVersionCode) || + clientVersionCode >= minimumSupportedVersionCode) + { + await next(); + return; + } + + context.Response.StatusCode = StatusCodes.Status426UpgradeRequired; + context.Response.ContentType = "application/json; charset=utf-8"; + await context.Response.WriteAsJsonAsync(release, cancellationToken: context.RequestAborted); +}); +app.UseWebSockets(); +app.UseAuthentication(); +app.UseMiddleware(); +app.UseAuthorization(); + +app.MapControllers(); +app.Map("/api/ikar/calls/ws2", async context => + await context.RequestServices.GetRequiredService().HandleWebSocketAsync(context)); +app.Map("/api/ikar/webpush/ws", async context => + await context.RequestServices.GetRequiredService().HandleWebSocketAsync(context)); +app.MapGet("/health", async ( + IkarDbContext dbContext, + IWebHostEnvironment environment, + IConfiguration configuration, + PushDispatchQueue pushQueue, + CancellationToken cancellationToken) => +{ + var canConnect = await dbContext.Database.CanConnectAsync(cancellationToken); + var dataPath = Path.Combine(environment.ContentRootPath, "Data"); + var freeDiskBytes = ResolveAvailableDiskSpace(dataPath); + var diskHealthy = freeDiskBytes >= 512L * 1024 * 1024; + var pendingPushCount = await pushQueue.CountPendingAsync(cancellationToken); + + var backupDirectory = configuration["Backup:Directory"]; + DateTimeOffset? latestBackupAt = null; + if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory)) + { + latestBackupAt = Directory.EnumerateFiles(backupDirectory, "ikar-*.tar.gz") + .Select(path => new DateTimeOffset(File.GetLastWriteTimeUtc(path), TimeSpan.Zero)) + .OrderByDescending(value => value) + .FirstOrDefault(); + } + + var backupRequired = configuration.GetValue("Backup:RequireFreshBackup", false); + var backupHealthy = !backupRequired || + latestBackupAt is not null && latestBackupAt >= DateTimeOffset.UtcNow.AddHours(-36); + var healthy = canConnect && diskHealthy && backupHealthy; + var payload = new + { + status = healthy ? "ok" : "degraded", + database = canConnect ? "ok" : "unavailable", + disk = diskHealthy ? "ok" : "low", + freeDiskBytes, + backup = backupHealthy ? "ok" : "stale", + latestBackupAt, + pendingPushCount + }; + return Results.Json( + payload, + statusCode: healthy ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable); +}); +app.MapHub("/hubs/messenger"); + +app.Run(); + +static long ResolveAvailableDiskSpace(string path) +{ + var fullPath = Path.GetFullPath(path); + var drive = DriveInfo.GetDrives() + .Where(candidate => fullPath.StartsWith(candidate.Name, StringComparison.Ordinal)) + .OrderByDescending(candidate => candidate.Name.Length) + .FirstOrDefault(); + + return drive?.AvailableFreeSpace ?? new DriveInfo(Path.GetPathRoot(fullPath)!).AvailableFreeSpace; +} + +static bool ShouldEnforceAndroidClientVersion(HttpRequest request) +{ + if (request.Path.StartsWithSegments("/health", StringComparison.OrdinalIgnoreCase) || + request.Path.StartsWithSegments("/api/app-updates", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase) || + request.Path.StartsWithSegments("/hubs/messenger", StringComparison.OrdinalIgnoreCase); +} + +static bool TryGetClientPlatform(HttpRequest request, out string? clientPlatform) +{ + return ClientVersionRequestReader.TryGetClientPlatform(request, out clientPlatform); +} + +static bool TryGetClientVersionCode(HttpRequest request, out int clientVersionCode) +{ + return ClientVersionRequestReader.TryGetClientVersionCode(request, out clientVersionCode); +} diff --git a/ikar_server/src/Ikar.Server/Properties/launchSettings.json b/ikar_server/src/Ikar.Server/Properties/launchSettings.json new file mode 100644 index 0000000..402e6ed --- /dev/null +++ b/ikar_server/src/Ikar.Server/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5099", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Ikar.Server": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5099", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ikar_server/src/Ikar.Server/appsettings.Development.json b/ikar_server/src/Ikar.Server/appsettings.Development.json new file mode 100644 index 0000000..70147db --- /dev/null +++ b/ikar_server/src/Ikar.Server/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "SeedDemoData": true, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/ikar_server/src/Ikar.Server/appsettings.json b/ikar_server/src/Ikar.Server/appsettings.json new file mode 100644 index 0000000..53d32ab --- /dev/null +++ b/ikar_server/src/Ikar.Server/appsettings.json @@ -0,0 +1,43 @@ +{ + "ConnectionStrings": { + "IkarDb": "Data Source=Data/ikar.db" + }, + "Jwt": { + "Issuer": "Ikar.Server", + "Audience": "Ikar.Client", + "SigningKey": "Ikar-Replace-This-Development-Key-With-A-Long-Random-Secret-CHANGE_ME_USE_SECRET_STORE_VALUE_32_CHARS_MINIMUM", + "AccessTokenLifetimeMinutes": 15, + "RefreshTokenLifetimeDays": 30 + }, + "Push": { + "FirebaseProjectId": "", + "ServiceAccountJsonPath": "", + "ServiceAccountJson": "" + }, + "WebRtc": { + "RingingTimeoutSeconds": 60, + "MaximumCallDurationHours": 8, + "IceServers": [ + { + "Urls": [ + "stun:stun.l.google.com:19302" + ] + } + ] + }, + "QlyraPublicEndpoint": { + "BaseUrl": "https://ikar.kusoft.xyz" + }, + "Admin": { + "Username": "", + "Password": "" + }, + "SeedDemoData": false, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/ikar_server/src/Ikar.Server/wwwroot/admin/admin.css b/ikar_server/src/Ikar.Server/wwwroot/admin/admin.css new file mode 100644 index 0000000..184bc1f --- /dev/null +++ b/ikar_server/src/Ikar.Server/wwwroot/admin/admin.css @@ -0,0 +1,704 @@ +:root { + --paper: #f4f1ea; + --paper-2: #ebe6dc; + --ink: #14171b; + --ink-2: #2a3038; + --muted: #68707b; + --line: #c9c2b5; + --line-strong: #9e9688; + --panel: #fffdf8; + --rail: #171b20; + --rail-2: #20262e; + --accent: #0f5f9f; + --accent-2: #0b7c86; + --warning: #a56300; + --danger: #b3261e; + --ok: #256b3b; + --shadow: 0 24px 80px rgba(34, 29, 22, 0.12); + --font: "Aptos", "Segoe UI Variable", "Segoe UI", sans-serif; + --mono: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + background: + linear-gradient(90deg, rgba(20, 23, 27, 0.035) 1px, transparent 1px), + linear-gradient(0deg, rgba(20, 23, 27, 0.025) 1px, transparent 1px), + linear-gradient(135deg, #f9f6ef 0%, var(--paper) 48%, #e7edf0 100%); + background-size: 40px 40px, 40px 40px, auto; + color: var(--ink); + font-family: var(--font); +} + +button, +input, +select { + font: inherit; +} + +button { + border-radius: 0; +} + +.hidden { + display: none !important; +} + +.login-view { + min-height: 100vh; + display: grid; + place-items: center; + padding: 32px; +} + +.login-card { + width: min(480px, 100%); + border: 1px solid var(--line-strong); + background: var(--panel); + padding: 34px; + box-shadow: var(--shadow); +} + +.login-card h1, +.topbar h1, +.command-panel h2, +.empty-state h2 { + margin: 0; + letter-spacing: -0.035em; +} + +.login-card h1 { + font-size: clamp(32px, 5vw, 52px); + line-height: 0.95; +} + +.app-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 292px minmax(0, 1fr); +} + +.rail { + position: sticky; + top: 0; + height: 100vh; + display: flex; + flex-direction: column; + gap: 22px; + padding: 22px; + border-right: 1px solid #0b0d10; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 30%), + var(--rail); + color: #f5efe4; +} + +.brand { + display: grid; + grid-template-columns: 46px minmax(0, 1fr); + gap: 12px; + align-items: center; + padding-bottom: 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.14); +} + +.brand-mark { + display: grid; + place-items: center; + width: 46px; + height: 46px; + border: 1px solid rgba(255, 255, 255, 0.35); + background: #f5efe4; + color: var(--rail); + font-weight: 850; + letter-spacing: -0.08em; +} + +.brand-title { + font-size: 21px; + font-weight: 820; + letter-spacing: -0.04em; +} + +.brand-subtitle, +.rail-label, +.rail-search span, +.eyebrow, +.muted, +.cell-secondary, +th, +.topbar-meta, +.counter { + color: var(--muted); +} + +.rail .muted, +.rail-label, +.rail-search span, +.brand-subtitle { + color: rgba(245, 239, 228, 0.62); +} + +.rail-section { + border: 1px solid rgba(255, 255, 255, 0.14); + padding: 13px; + background: rgba(255, 255, 255, 0.035); +} + +.rail-value { + margin-top: 6px; + overflow: hidden; + color: #fffdf8; + font-weight: 760; + text-overflow: ellipsis; +} + +.rail-search { + display: grid; + gap: 8px; +} + +.rail-search input, +.field input, +.toolbar select, +.toolbar input, +.inspector input, +.inspector select { + width: 100%; + min-height: 40px; + border: 1px solid var(--line-strong); + border-radius: 0; + background: #fffdf8; + color: var(--ink); + padding: 9px 10px; +} + +.rail-search input { + border-color: rgba(255, 255, 255, 0.2); + background: #101318; + color: #fffdf8; +} + +input:focus, +select:focus, +button:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 40%, transparent); + outline-offset: 2px; +} + +.nav-list { + display: grid; + gap: 6px; +} + +.nav-item { + width: 100%; + border: 1px solid transparent; + background: transparent; + color: rgba(245, 239, 228, 0.72); + cursor: pointer; + padding: 11px 12px; + text-align: left; +} + +.nav-item:hover, +.nav-item.active { + border-color: rgba(255, 255, 255, 0.18); + background: var(--rail-2); + color: #fffdf8; +} + +.rail-footer { + display: grid; + gap: 9px; + margin-top: auto; +} + +.workspace { + min-width: 0; + padding: 28px; +} + +.topbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + margin-bottom: 20px; +} + +.topbar h1 { + font-size: clamp(28px, 4vw, 46px); + line-height: 0.98; +} + +.topbar-meta { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + justify-content: flex-end; + font-size: 13px; +} + +.section { + display: none; +} + +.section.active { + display: grid; + gap: 18px; +} + +.hero-grid { + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(320px, 0.85fr); + gap: 18px; +} + +.split-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} + +.registry-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(360px, 460px); + gap: 18px; + align-items: start; +} + +.command-panel, +.inspector { + border: 1px solid var(--line); + background: color-mix(in srgb, var(--panel) 92%, white); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.7) inset; +} + +.command-panel { + padding: 18px; +} + +.inspector { + position: sticky; + top: 18px; + max-height: calc(100vh - 56px); + overflow: auto; + padding: 0; +} + +.panel-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} + +.eyebrow { + margin: 0 0 7px; + font-size: 11px; + font-weight: 850; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.muted { + line-height: 1.55; +} + +.mono { + font-family: var(--mono); +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.metric-grid.compact { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.metric { + min-height: 104px; + border: 1px solid var(--line); + background: #fbf8f1; + padding: 13px; +} + +.metric-label { + color: var(--muted); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.metric-value { + margin-top: 12px; + font-size: clamp(24px, 3vw, 34px); + font-weight: 860; + letter-spacing: -0.045em; +} + +.metric-note { + margin-top: 8px; + color: var(--muted); + font-size: 13px; +} + +.focus-list, +.feature-matrix, +.action-stack, +.stack { + display: grid; + gap: 10px; +} + +.focus-item, +.feature-row, +.operation-result { + border-left: 4px solid var(--line-strong); + background: #fbf8f1; + padding: 12px; +} + +.focus-item.ok { + border-left-color: var(--ok); +} + +.focus-item.warn { + border-left-color: var(--warning); +} + +.focus-item.danger { + border-left-color: var(--danger); +} + +.feature-row { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 12px; +} + +.toolbar label { + display: grid; + min-width: 180px; + gap: 6px; + color: var(--muted); + font-size: 13px; +} + +.table-wrap { + overflow: auto; + border: 1px solid var(--line); + background: #fffdf8; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + padding: 11px 12px; + border-bottom: 1px solid #e2dbcf; + text-align: left; + vertical-align: top; +} + +th { + position: sticky; + top: 0; + z-index: 1; + background: #ede7dc; + font-size: 11px; + font-weight: 850; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +td { + font-size: 14px; +} + +tr[data-chat-id], +tr[data-row-click] { + cursor: pointer; +} + +tr[data-chat-id]:hover, +tr[data-row-click]:hover { + background: #f7f1e7; +} + +tr.selected { + background: #eef5f4; + box-shadow: inset 4px 0 0 var(--accent-2); +} + +.cell-primary { + font-weight: 780; +} + +.cell-secondary { + margin-top: 4px; + font-size: 12px; + line-height: 1.45; +} + +.status, +.counter, +.rect-badge { + display: inline-flex; + align-items: center; + min-height: 28px; + border: 1px solid var(--line-strong); + background: #fffdf8; + padding: 5px 8px; + font-size: 12px; + font-weight: 780; + white-space: nowrap; +} + +.status.ok, +.rect-badge.ok { + border-color: color-mix(in srgb, var(--ok) 60%, var(--line)); + color: var(--ok); +} + +.status.warn, +.rect-badge.warn { + border-color: color-mix(in srgb, var(--warning) 60%, var(--line)); + color: var(--warning); +} + +.status.danger, +.rect-badge.danger { + border-color: color-mix(in srgb, var(--danger) 60%, var(--line)); + color: var(--danger); +} + +.button { + border: 1px solid var(--line-strong); + background: #fffdf8; + color: var(--ink); + cursor: pointer; + min-height: 38px; + padding: 9px 12px; + font-weight: 780; +} + +.button:hover { + transform: translateY(-1px); +} + +.button.primary { + border-color: var(--accent); + background: var(--accent); + color: #fff; +} + +.button.secondary { + border-color: var(--ink-2); + background: var(--ink-2); + color: #fffdf8; +} + +.button.ghost { + background: transparent; + color: inherit; +} + +.button.danger { + border-color: var(--danger); + background: #fff7f4; + color: var(--danger); +} + +.button.small { + min-height: 30px; + padding: 6px 8px; + font-size: 12px; +} + +.action-row { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.table-link { + color: var(--accent); + font-weight: 800; + text-decoration: none; +} + +.table-link:hover { + text-decoration: underline; +} + +.notice, +.toast { + border: 1px solid var(--line-strong); + background: #fff9e9; + color: #6b3f00; + padding: 12px 14px; +} + +.notice { + margin-bottom: 16px; +} + +.toast { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 20; + max-width: min(460px, calc(100vw - 44px)); + background: var(--ink); + color: #fffdf8; +} + +.field { + display: grid; + gap: 7px; +} + +.field span { + color: var(--muted); + font-size: 13px; +} + +.inspector-block { + padding: 16px; + border-bottom: 1px solid var(--line); +} + +.inspector-block h3 { + margin: 0 0 10px; + font-size: 16px; +} + +.inspector-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.check-line { + display: flex; + align-items: center; + gap: 8px; + min-height: 36px; +} + +.check-line input { + width: auto; +} + +.empty-state { + padding: 22px; +} + +.mini-list { + display: grid; + gap: 8px; +} + +.mini-row { + border: 1px solid #e2dbcf; + background: #fbf8f1; + padding: 10px; +} + +.permissions-grid { + display: grid; + grid-template-columns: 1fr; + gap: 8px; +} + +.permission-row { + border: 1px solid #e2dbcf; + padding: 10px; + background: #fffdf8; +} + +.permission-controls { + display: grid; + grid-template-columns: minmax(120px, 0.9fr) repeat(4, minmax(70px, 1fr)) auto; + gap: 8px; + align-items: center; + margin-top: 8px; +} + +.coverage-list { + margin: 0; + padding-left: 18px; + color: var(--ink-2); + line-height: 1.65; +} + +@media (max-width: 1280px) { + .metric-grid, + .metric-grid.compact { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .hero-grid, + .split-grid, + .registry-layout { + grid-template-columns: 1fr; + } + + .inspector { + position: static; + max-height: none; + } +} + +@media (max-width: 860px) { + .app-shell { + grid-template-columns: 1fr; + } + + .rail { + position: static; + height: auto; + } + + .workspace { + padding: 18px; + } + + .topbar { + display: grid; + } + + .metric-grid, + .metric-grid.compact, + .inspector-grid, + .permission-controls { + grid-template-columns: 1fr; + } +} diff --git a/ikar_server/src/Ikar.Server/wwwroot/admin/admin.js b/ikar_server/src/Ikar.Server/wwwroot/admin/admin.js new file mode 100644 index 0000000..de50b74 --- /dev/null +++ b/ikar_server/src/Ikar.Server/wwwroot/admin/admin.js @@ -0,0 +1,1027 @@ +const state = { + session: null, + activeSection: "overview", + query: "", + status: null, + compatibility: null, + users: [], + chats: [], + attachments: [], + moderation: [], + selectedChatId: null, + selectedChatDetails: null, + refreshTimer: null, +}; + +const sectionMeta = { + overview: ["Обзор", "Операционная картина"], + users: ["Пользователи", "Пользователи и боты"], + chats: ["Чаты и каналы", "Управление чатами, группами и каналами"], + media: ["Медиа", "Хранилище вложений"], + moderation: ["Модерация", "Журнал действий"], + updates: ["Обновления", "Android-релизы и совместимость"], + operations: ["Операции", "Maintenance и контроль данных"], +}; + +const elements = { + loginView: document.getElementById("loginView"), + appView: document.getElementById("appView"), + loginForm: document.getElementById("loginForm"), + loginUsername: document.getElementById("loginUsername"), + loginPassword: document.getElementById("loginPassword"), + loginNotice: document.getElementById("loginNotice"), + adminIdentity: document.getElementById("adminIdentity"), + globalSearch: document.getElementById("globalSearch"), + refreshButton: document.getElementById("refreshButton"), + logoutButton: document.getElementById("logoutButton"), + sectionKicker: document.getElementById("sectionKicker"), + sectionTitle: document.getElementById("sectionTitle"), + lastUpdated: document.getElementById("lastUpdated"), + healthBadge: document.getElementById("healthBadge"), + noticeBar: document.getElementById("noticeBar"), + metricGrid: document.getElementById("metricGrid"), + uptimeValue: document.getElementById("uptimeValue"), + focusList: document.getElementById("focusList"), + featureMatrix: document.getElementById("featureMatrix"), + usersCount: document.getElementById("usersCount"), + usersTable: document.getElementById("usersTable"), + chatsCount: document.getElementById("chatsCount"), + chatsTable: document.getElementById("chatsTable"), + chatTypeFilter: document.getElementById("chatTypeFilter"), + chatAttentionFilter: document.getElementById("chatAttentionFilter"), + chatInspector: document.getElementById("chatInspector"), + attachmentsCount: document.getElementById("attachmentsCount"), + attachmentsTable: document.getElementById("attachmentsTable"), + moderationCount: document.getElementById("moderationCount"), + moderationTable: document.getElementById("moderationTable"), + compatibilityCount: document.getElementById("compatibilityCount"), + compatibilitySummary: document.getElementById("compatibilitySummary"), + updateRiskList: document.getElementById("updateRiskList"), + compatibilityRoutesTable: document.getElementById("compatibilityRoutesTable"), + compatibilityVersionsTable: document.getElementById("compatibilityVersionsTable"), + compatibilityHitsTable: document.getElementById("compatibilityHitsTable"), + operationResult: document.getElementById("operationResult"), + toast: document.getElementById("toast"), +}; + +document.addEventListener("DOMContentLoaded", () => { + wireEvents(); + initialize().catch(handleError); +}); + +function wireEvents() { + elements.loginForm.addEventListener("submit", async (event) => { + event.preventDefault(); + await login(); + }); + + elements.refreshButton.addEventListener("click", () => refreshAll().catch(handleError)); + elements.logoutButton.addEventListener("click", () => logout().catch(handleError)); + elements.chatTypeFilter.addEventListener("change", renderChats); + elements.chatAttentionFilter.addEventListener("change", renderChats); + + elements.globalSearch.addEventListener("input", () => { + window.clearTimeout(state.refreshTimer); + state.refreshTimer = window.setTimeout(() => { + state.query = elements.globalSearch.value.trim(); + refreshAll().catch(handleError); + }, 360); + }); + + document.addEventListener("click", handleDocumentClick); +} + +async function initialize() { + state.session = await fetchJson("/admin/api/session", { method: "GET" }, false); + renderSession(); + + if (!state.session.configured) { + showLogin("Административные учетные данные на сервере не настроены."); + return; + } + + if (!state.session.authenticated) { + showLogin(); + return; + } + + showApp(); + await refreshAll(); +} + +async function login() { + elements.loginNotice.classList.add("hidden"); + await fetchJson("/admin/api/session/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: elements.loginUsername.value.trim(), + password: elements.loginPassword.value, + }), + }, false); + + state.session = await fetchJson("/admin/api/session", { method: "GET" }, false); + elements.loginPassword.value = ""; + renderSession(); + showApp(); + await refreshAll(); +} + +async function logout() { + await fetchJson("/admin/api/session/logout", { method: "POST" }); + state.session = await fetchJson("/admin/api/session", { method: "GET" }, false); + state.selectedChatId = null; + state.selectedChatDetails = null; + renderSession(); + showLogin(); +} + +async function refreshAll() { + state.query = elements.globalSearch.value.trim(); + const query = state.query ? `?q=${encodeURIComponent(state.query)}` : ""; + + const [status, compatibility, users, chats, attachments, moderation] = await Promise.all([ + fetchJson("/admin/api/status"), + fetchJson("/admin/api/app-updates/compatibility-report"), + fetchJson(`/admin/api/users${query}`), + fetchJson(`/admin/api/chats${query}`), + fetchJson(`/admin/api/attachments${query}`), + fetchJson("/admin/api/moderation-log?limit=150"), + ]); + + state.status = status; + state.compatibility = compatibility; + state.users = users || []; + state.chats = chats || []; + state.attachments = attachments || []; + state.moderation = moderation || []; + + if (state.selectedChatId) { + await loadChatDetails(state.selectedChatId, false); + } + + elements.lastUpdated.textContent = `обновлено ${new Date().toLocaleTimeString("ru-RU")}`; + renderAll(); +} + +async function loadChatDetails(chatId, rerender = true) { + state.selectedChatId = chatId; + state.selectedChatDetails = await fetchJson(`/admin/api/chats/${chatId}/details`); + if (rerender) { + renderChats(); + renderChatInspector(); + } +} + +function showLogin(message) { + elements.appView.classList.add("hidden"); + elements.loginView.classList.remove("hidden"); + if (message) { + elements.loginNotice.textContent = message; + elements.loginNotice.classList.remove("hidden"); + } else { + elements.loginNotice.classList.add("hidden"); + } +} + +function showApp() { + elements.loginView.classList.add("hidden"); + elements.appView.classList.remove("hidden"); + setSection(state.activeSection); +} + +function setSection(section) { + state.activeSection = section; + document.querySelectorAll(".nav-item").forEach((item) => { + item.classList.toggle("active", item.dataset.section === section); + }); + document.querySelectorAll(".section").forEach((panel) => { + panel.classList.toggle("active", panel.dataset.panel === section); + }); + + const [kicker, title] = sectionMeta[section] || sectionMeta.overview; + elements.sectionKicker.textContent = kicker; + elements.sectionTitle.textContent = title; +} + +function renderAll() { + renderStatus(); + renderUsers(); + renderChats(); + renderAttachments(); + renderModeration(); + renderCompatibility(); + renderChatInspector(); +} + +function renderSession() { + elements.adminIdentity.textContent = state.session?.authenticated ? state.session.username : "Гость"; +} + +function renderStatus() { + const status = state.status; + if (!status) { + return; + } + + const healthy = Boolean(status.databaseReachable); + elements.healthBadge.textContent = healthy ? "База доступна" : "База недоступна"; + elements.healthBadge.className = `status ${healthy ? "ok" : "danger"}`; + elements.uptimeValue.textContent = formatDuration(status.startedAtUtc, status.currentTimeUtc); + + elements.metricGrid.innerHTML = [ + metric("Пользователи", status.totalUsers, `${status.blockedUsers} заблокировано, ${status.botUsers} ботов`), + metric("Чаты", status.totalChats, `${status.groupChats} групп, ${status.channelChats} каналов`), + metric("Сообщения", status.totalMessages, `${status.pinnedMessages} закреплено, ${status.expiringMessages} с TTL`), + metric("Медиа", status.totalAttachments, formatBytes(status.attachmentBytes)), + metric("Protected", status.protectedChats, `${status.protectedMessages} защищенных сообщений`), + metric("Stories", status.totalStories, `${status.activeStories} активных, ${status.protectedStories} protected`), + metric("Join requests", status.pendingJoinRequests, `${status.activeInviteLinks} активных invite links`), + metric("Сессии", status.activeSessions, `${status.registeredPushDevices} push-устройств`), + ].join(""); + + elements.focusList.innerHTML = [ + focus(healthy ? "ok" : "danger", "База данных", healthy ? "Соединение активно." : "Нужно проверить подключение к БД."), + focus(status.pendingJoinRequests > 0 ? "warn" : "ok", "Заявки на вход", `${status.pendingJoinRequests} ожидают решения.`), + focus(status.expiringMessages > 0 ? "warn" : "ok", "TTL-сообщения", `${status.expiringMessages} активных сообщений с автоудалением.`), + focus(status.blockedUsers > 0 ? "warn" : "ok", "Ограниченные аккаунты", `${status.blockedUsers} пользователей заблокировано.`), + focus(status.moderationEvents > 0 ? "ok" : "warn", "Модерация", `${status.moderationEvents} событий в журнале.`), + ].join(""); + + elements.featureMatrix.innerHTML = [ + feature("Пользователи и privacy", `${status.totalUsers} аккаунтов`, status.totalUsers > 0), + feature("Группы и каналы", `${status.groupChats + status.channelChats} публичных контуров`, status.groupChats + status.channelChats > 0), + feature("Protected content", `${status.protectedChats} чатов`, status.protectedChats > 0), + feature("Топики и роли", `${status.topics} топиков`, status.topics > 0), + feature("Истории", `${status.activeStories} активных`, status.totalStories > 0), + feature("Боты", `${status.enabledBots}/${status.totalBots} включено`, status.totalBots > 0), + ].join(""); +} + +function renderUsers() { + elements.usersCount.textContent = `${state.users.length} строк`; + elements.usersTable.innerHTML = state.users.length === 0 + ? emptyRow("Пользователи по текущему фильтру не найдены.", 9) + : state.users.map((user) => ` + + +
${escapeHtml(displayName(user.displayName, user.username))}
+
@${escapeHtml(user.username)}${user.phoneNumber ? ` · ${escapeHtml(user.phoneNumber)}` : ""}
+ ${user.about ? `
${escapeHtml(user.about)}
` : ""} + + ${badge(user.isBot ? "Бот" : "Человек", user.isBot ? "warn" : "ok")} + ${formatDate(user.createdAt)} + ${user.lastSeenAt ? formatDate(user.lastSeenAt) : "—"} + ${renderUserClientVersion(user)} + ${user.activeSessions} / ${user.pushDevices} + ${user.chatMemberships} / ${user.messagesSent} + + ${badge(user.passcodeLockEnabled ? "Passcode" : "Без lock", user.passcodeLockEnabled ? "ok" : "warn")} +
${user.showMessagePreview ? "Preview включены" : "Preview скрыты"}
+ + +
+ + +
+ + + `).join(""); +} + +function renderUserClientVersion(user) { + const versions = Array.isArray(user.clientVersions) ? user.clientVersions : []; + if (!versions.length && user.clientVersionCode == null && !user.clientVersionName && !user.clientPlatform) { + return "—"; + } + + const platform = user.clientPlatform ? escapeHtml(user.clientPlatform) : "unknown"; + const details = versions.slice(0, 3).map((version) => { + const sessions = Number(version.sessions || 0); + const pushDevices = Number(version.pushDevices || 0); + const countParts = []; + if (sessions > 0) { + countParts.push(`${sessions} сесс.`); + } + if (pushDevices > 0) { + countParts.push(`${pushDevices} push`); + } + const countLabel = countParts.length ? ` · ${countParts.join(" / ")}` : ""; + const seenLabel = version.lastSeenAt ? ` · ${formatDate(version.lastSeenAt)}` : ""; + return `
${escapeHtml(version.clientPlatform || "unknown")} · ${formatVersion(version.clientVersionCode, version.clientVersionName)}${countLabel}${seenLabel}
`; + }).join(""); + + return ` +
${formatVersion(user.clientVersionCode, user.clientVersionName)}
+
${platform}
+ ${details} + `; +} + +function renderChats() { + const typeFilter = elements.chatTypeFilter.value; + const attentionFilter = elements.chatAttentionFilter.value; + const rows = state.chats.filter((chat) => { + if (typeFilter !== "all" && chat.type !== typeFilter) { + return false; + } + if (attentionFilter === "pending") { + return chat.pendingJoinRequests > 0; + } + if (attentionFilter === "protected") { + return chat.hasProtectedContent || chat.protectedMessages > 0; + } + if (attentionFilter === "topics") { + return chat.topicsEnabled || chat.topics > 0; + } + return true; + }); + + elements.chatsCount.textContent = `${rows.length} строк`; + elements.chatsTable.innerHTML = rows.length === 0 + ? emptyRow("Чаты по текущему фильтру не найдены.", 8) + : rows.map((chat) => ` + + +
${escapeHtml(chat.title)}
+
${chat.publicUsername ? `@${escapeHtml(chat.publicUsername)}` : "без public username"}
+ + ${formatChatType(chat.type)} + ${escapeHtml(chat.createdBy || "—")} + ${chat.lastActivityAt ? formatDate(chat.lastActivityAt) : formatDate(chat.createdAt)} + ${chat.members} + +
${chat.messages}
+
${chat.pinnedMessages} pinned · ${chat.protectedMessages} protected
+ + +
+ ${chat.hasProtectedContent ? badge("Protected", "ok") : badge("Обычный", "warn")} + ${chat.pendingJoinRequests > 0 ? badge(`${chat.pendingJoinRequests} заявок`, "warn") : ""} + ${chat.topicsEnabled ? badge(`${chat.topics} топиков`, "ok") : ""} +
+ + +
+ + +
+ + + `).join(""); +} + +function renderChatInspector() { + const details = state.selectedChatDetails; + if (!details) { + elements.chatInspector.innerHTML = ` +
+

Инспектор

+

Выберите чат

+

Здесь появятся настройки protected mode, TTL, public username, участники, права, invite links и заявки на вход.

+
+ `; + return; + } + + const chat = details.chat; + elements.chatInspector.innerHTML = ` +
+

${formatChatType(chat.type)}

+

${escapeHtml(chat.title)}

+

${chat.members} участников · ${chat.messages} сообщений · ${chat.attachments} вложений

+
+ +
+

Настройки чата

+
+ + + + +
+
+ +
+
+ +
+

Участники и права

+
+ ${details.members.length === 0 ? `
Участников нет.
` : details.members.map(renderMemberPermissions).join("")} +
+
+ +
+

Invite links

+
+ + +
+
+ +
+
+ ${details.inviteLinks.length === 0 ? `
Ссылок пока нет.
` : details.inviteLinks.map((link) => ` +
+
${escapeHtml(link.title || "Без названия")}
+
${escapeHtml(link.link)}
+
+ ${badge(link.createsJoinRequest ? "approval" : "direct join", link.createsJoinRequest ? "warn" : "ok")} + ${badge(link.isRevoked ? "отозвана" : "активна", link.isRevoked ? "danger" : "ok")} + ${link.isRevoked ? "" : ``} +
+
+ `).join("")} +
+
+ +
+

Заявки на вход

+
+ ${details.joinRequests.length === 0 ? `
Заявок нет.
` : details.joinRequests.map((request) => ` +
+
${escapeHtml(displayName(request.displayName, request.username))}
+
${formatDate(request.requestedAt)} · ${formatJoinStatus(request.status)}
+ ${isPendingJoinRequest(request.status) ? ` +
+ + +
+ ` : ""} +
+ `).join("")} +
+
+ +
+

Топики

+
+ ${details.topics.length === 0 ? `
Топиков нет.
` : details.topics.map((topic) => ` +
+
${escapeHtml(topic.icon || "")} ${escapeHtml(topic.title)}
+
${topic.messageCount} сообщений · ${topic.isPinned ? "закреплен" : "обычный"} · ${topic.isClosed ? "закрыт" : "открыт"}
+
+ `).join("")} +
+
+ +
+

Последние сообщения

+
+ ${details.recentMessages.length === 0 ? `
Сообщений нет.
` : details.recentMessages.map((message) => ` +
+
${escapeHtml(message.sender)} · ${formatDate(message.sentAt)}
+
${message.deletedAt ? "Сообщение удалено" : escapeHtml(shorten(message.text || "медиа-сообщение", 160))}
+
${message.attachments} вложений · ${message.reactions} реакций ${message.hasProtectedContent ? "· protected" : ""}
+
+ `).join("")} +
+
+ +
+

Модерация чата

+
+ ${details.moderationLog.length === 0 ? `
Событий нет.
` : details.moderationLog.map((event) => ` +
+
${escapeHtml(event.action)}
+
${formatDate(event.createdAt)} · ${escapeHtml(event.actorDisplayName || "—")}
+
+ `).join("")} +
+
+ `; +} + +function renderMemberPermissions(member) { + const role = normalizeRole(member.role); + return ` +
+
${escapeHtml(displayName(member.displayName, member.username))}
+
@${escapeHtml(member.username)}${member.isBot ? " · бот" : ""}
+
+ + ${permissionCheckbox("pin", "Pin", member.canPinMessages)} + ${permissionCheckbox("delete", "Delete", member.canDeleteMessages)} + ${permissionCheckbox("invite", "Invite", member.canInviteUsers)} + ${permissionCheckbox("post", "Post", member.canPostMessages)} + +
+
+ `; +} + +function renderAttachments() { + elements.attachmentsCount.textContent = `${state.attachments.length} строк`; + elements.attachmentsTable.innerHTML = state.attachments.length === 0 + ? emptyRow("Вложения по текущему фильтру не найдены.", 7) + : state.attachments.map((attachment) => ` + + +
${escapeHtml(attachment.fileName)}
+
${escapeHtml(attachment.contentType || "application/octet-stream")} · sort ${attachment.sortOrder}
+ ${attachment.mediaAlbumId ? `
album ${escapeHtml(attachment.mediaAlbumId)}
` : ""} + + ${escapeHtml(attachment.chatTitle)} + ${escapeHtml(attachment.uploadedBy)} + ${formatDate(attachment.uploadedAt)} + ${formatBytes(attachment.fileSizeBytes)} + +
+ ${badge(attachment.exists ? "файл есть" : "нет файла", attachment.exists ? "ok" : "danger")} + ${attachment.isProtected ? badge("protected", "ok") : ""} +
+ + +
+ ${attachment.exists ? `Скачать` : ""} + +
+ + + `).join(""); +} + +function renderModeration() { + elements.moderationCount.textContent = `${state.moderation.length} событий`; + elements.moderationTable.innerHTML = state.moderation.length === 0 + ? emptyRow("Журнал модерации пуст.", 6) + : state.moderation.map((event) => ` + + ${formatDate(event.createdAt)} + ${escapeHtml(event.actorDisplayName || "—")} + +
${formatModerationTarget(event.targetType)}
+
${escapeHtml(event.targetId || "—")}
+ + ${escapeHtml(event.action)} + ${escapeHtml(event.reason || "—")} + ${escapeHtml(event.chatId || "—")} + + `).join(""); +} + +function renderCompatibility() { + const report = state.compatibility; + if (!report) { + return; + } + + elements.compatibilityCount.textContent = `${report.recentHits.length} последних записей`; + elements.compatibilitySummary.innerHTML = [ + metric("Хиты за 24 часа", report.totalHitsLast24Hours, `${report.hitsWithoutVersionLast24Hours} без версии`), + metric("Хиты за 7 дней", report.totalHitsLast7Days, `${report.hitsWithoutVersionLast7Days} без версии`), + metric("Последний запрос", report.latestHitAt ? formatDate(report.latestHitAt) : "нет данных", "мониторинг обновлений"), + ].join(""); + + elements.updateRiskList.innerHTML = [ + focus(report.hitsWithoutVersionLast24Hours > 0 ? "warn" : "ok", "Без versionCode за 24 часа", `${report.hitsWithoutVersionLast24Hours} обращений.`), + focus(report.hitsWithoutVersionLast7Days > 0 ? "warn" : "ok", "Без versionCode за 7 дней", `${report.hitsWithoutVersionLast7Days} обращений.`), + focus(report.totalHitsLast7Days > 0 ? "ok" : "warn", "Сигнал обновлений", `${report.totalHitsLast7Days} обращений за неделю.`), + ].join(""); + + elements.compatibilityRoutesTable.innerHTML = report.routesLast7Days.length === 0 + ? emptyRow("Данных по маршрутам пока нет.", 4) + : report.routesLast7Days.map((route) => ` + + ${escapeHtml(route.route)} + ${route.hitsLast24Hours} + ${route.hitsLast7Days} + ${route.latestHitAt ? formatDate(route.latestHitAt) : "—"} + + `).join(""); + + elements.compatibilityVersionsTable.innerHTML = report.versionsLast7Days.length === 0 + ? emptyRow("Данных по версиям пока нет.", 6) + : report.versionsLast7Days.map((version) => ` + + ${escapeHtml(version.clientPlatform || "unknown")} + ${version.clientVersionCode ?? "—"} + ${escapeHtml(version.clientVersionName || "—")} + ${version.hitsLast24Hours} + ${version.hitsLast7Days} + ${version.latestHitAt ? formatDate(version.latestHitAt) : "—"} + + `).join(""); + + elements.compatibilityHitsTable.innerHTML = report.recentHits.length === 0 + ? emptyRow("Последних обращений нет.", 6) + : report.recentHits.map((hit) => ` + + ${formatDate(hit.hitAt)} + ${escapeHtml(hit.route)} + ${escapeHtml(hit.requestPath)} + ${escapeHtml(hit.clientPlatform || "—")} + ${formatVersion(hit.clientVersionCode, hit.clientVersionName)} + ${escapeHtml(hit.remoteIp || "—")} + + `).join(""); +} + +async function handleDocumentClick(event) { + const nav = event.target.closest("[data-section]"); + if (nav) { + setSection(nav.dataset.section); + return; + } + + const jump = event.target.closest("[data-section-jump]"); + if (jump) { + setSection(jump.dataset.sectionJump); + return; + } + + const chatRow = event.target.closest("tr[data-chat-id]"); + if (chatRow && !event.target.closest("button,a,input,select")) { + await loadChatDetails(chatRow.dataset.chatId); + return; + } + + const button = event.target.closest("[data-action]"); + if (!button) { + return; + } + + const action = button.dataset.action; + try { + if (action === "refresh") { + await refreshAll(); + showToast("Данные обновлены."); + return; + } + + if (action === "open-chat") { + await loadChatDetails(button.dataset.chatId); + return; + } + + if (action === "block-user" || action === "unblock-user") { + await fetchJson(`/admin/api/users/${button.dataset.userId}/${action === "block-user" ? "block" : "unblock"}`, { method: "POST" }); + showToast(action === "block-user" ? "Пользователь заблокирован." : "Пользователь разблокирован."); + await refreshAll(); + return; + } + + if (action === "delete-user") { + if (!window.confirm(`Удалить пользователя @${button.dataset.userName}? Будут затронуты его сообщения, сессии, push-устройства и личные чаты.`)) { + return; + } + await fetchJson(`/admin/api/users/${button.dataset.userId}`, { method: "DELETE" }); + showToast("Пользователь удален."); + await refreshAll(); + return; + } + + if (action === "delete-chat") { + if (!window.confirm(`Удалить чат «${button.dataset.chatTitle}» вместе с сообщениями и вложениями?`)) { + return; + } + await fetchJson(`/admin/api/chats/${button.dataset.chatId}`, { method: "DELETE" }); + if (state.selectedChatId === button.dataset.chatId) { + state.selectedChatId = null; + state.selectedChatDetails = null; + } + showToast("Чат удален."); + await refreshAll(); + return; + } + + if (action === "delete-attachment") { + if (!window.confirm(`Удалить вложение «${button.dataset.fileName}» из базы и файлового хранилища?`)) { + return; + } + await fetchJson(`/admin/api/attachments/${button.dataset.attachmentId}`, { method: "DELETE" }); + showToast("Вложение удалено."); + await refreshAll(); + return; + } + + if (action === "save-chat-settings") { + const ttlRaw = document.getElementById("chatTtlSeconds").value.trim(); + state.selectedChatDetails = await fetchJson(`/admin/api/chats/${button.dataset.chatId}/settings`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + publicUsername: document.getElementById("chatPublicUsername").value.trim() || null, + topicsEnabled: document.getElementById("chatTopicsEnabled").checked, + hasProtectedContent: document.getElementById("chatProtected").checked, + defaultMessageTtlSeconds: ttlRaw ? Number(ttlRaw) : null, + }), + }); + showToast("Настройки чата сохранены."); + await refreshAll(); + return; + } + + if (action === "save-member-permissions") { + const row = button.closest(".permission-row"); + state.selectedChatDetails = await fetchJson(`/admin/api/chats/${button.dataset.chatId}/members/${button.dataset.memberId}/permissions`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + role: Number(row.querySelector('[data-field="role"]').value), + canPinMessages: row.querySelector('[data-field="pin"]').checked, + canDeleteMessages: row.querySelector('[data-field="delete"]').checked, + canInviteUsers: row.querySelector('[data-field="invite"]').checked, + canPostMessages: row.querySelector('[data-field="post"]').checked, + }), + }); + showToast("Права участника обновлены."); + await refreshAll(); + return; + } + + if (action === "create-invite") { + state.selectedChatDetails = await fetchJson(`/admin/api/chats/${button.dataset.chatId}/invite-links`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: document.getElementById("newInviteTitle").value.trim() || null, + createsJoinRequest: document.getElementById("newInviteRequiresApproval").checked, + }), + }); + showToast("Invite link создан."); + await refreshAll(); + return; + } + + if (action === "revoke-invite") { + state.selectedChatDetails = await fetchJson(`/admin/api/chats/${button.dataset.chatId}/invite-links/${button.dataset.inviteId}`, { + method: "DELETE", + }); + showToast("Invite link отозван."); + await refreshAll(); + return; + } + + if (action === "resolve-join") { + state.selectedChatDetails = await fetchJson(`/admin/api/chats/${button.dataset.chatId}/join-requests/${button.dataset.requestId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ approve: button.dataset.approve === "true" }), + }); + showToast(button.dataset.approve === "true" ? "Заявка одобрена." : "Заявка отклонена."); + await refreshAll(); + return; + } + + if (action === "cleanup-expired") { + if (!window.confirm("Очистить все истекшие TTL-сообщения на сервере?")) { + return; + } + const result = await fetchJson("/admin/api/messages/cleanup-expired", { method: "POST" }); + elements.operationResult.textContent = `Удалено сообщений: ${result.deletedMessages}; вложений: ${result.deletedAttachments}.`; + showToast("Очистка TTL-сообщений выполнена."); + await refreshAll(); + } + } catch (error) { + handleError(error); + } +} + +async function fetchJson(url, options = {}, useAuth = true) { + const response = await fetch(url, { + credentials: "same-origin", + headers: { + Accept: "application/json", + ...(options.headers || {}), + }, + ...options, + }); + + if (response.status === 401 && useAuth) { + state.session = { configured: true, authenticated: false, username: null }; + renderSession(); + showLogin("Сессия администратора истекла."); + throw new Error("Сессия администратора истекла."); + } + + if (!response.ok) { + let message = `HTTP ${response.status}`; + try { + message = await response.text(); + } catch { + // Тело ошибки не всегда доступно. + } + throw new Error(message || `HTTP ${response.status}`); + } + + if (response.status === 204) { + return null; + } + + const contentType = response.headers.get("content-type") || ""; + return contentType.includes("application/json") ? response.json() : response.text(); +} + +function handleError(error) { + const message = error instanceof Error ? error.message : String(error); + if (!elements.appView.classList.contains("hidden")) { + elements.noticeBar.textContent = message; + elements.noticeBar.classList.remove("hidden"); + showToast(message); + window.setTimeout(() => elements.noticeBar.classList.add("hidden"), 6000); + return; + } + + showLogin(message); +} + +function showToast(message) { + elements.toast.textContent = message; + elements.toast.classList.remove("hidden"); + window.clearTimeout(showToast.timeoutId); + showToast.timeoutId = window.setTimeout(() => elements.toast.classList.add("hidden"), 3600); +} + +function metric(label, value, note) { + return ` +
+
${escapeHtml(label)}
+
${escapeHtml(String(value ?? "—"))}
+
${escapeHtml(note || "")}
+
+ `; +} + +function focus(level, title, text) { + return ` +
+
${escapeHtml(title)}
+
${escapeHtml(text)}
+
+ `; +} + +function feature(title, value, enabled) { + return ` +
+ ${escapeHtml(title)} + ${escapeHtml(value)} + ${badge(enabled ? "активно" : "нет данных", enabled ? "ok" : "warn")} +
+ `; +} + +function badge(text, tone = "neutral") { + return `${escapeHtml(text)}`; +} + +function permissionCheckbox(field, label, checked) { + return ` + + `; +} + +function emptyRow(text, colspan) { + return `${escapeHtml(text)}`; +} + +function displayName(displayNameValue, username) { + return displayNameValue && displayNameValue.trim() ? displayNameValue : `@${username}`; +} + +function formatDate(value) { + if (!value) { + return "—"; + } + return new Date(value).toLocaleString("ru-RU"); +} + +function formatDuration(startValue, endValue) { + if (!startValue || !endValue) { + return "—"; + } + + const hours = Math.max(0, Math.round((new Date(endValue) - new Date(startValue)) / 3600000)); + if (hours < 24) { + return `${hours} ч`; + } + + const days = Math.floor(hours / 24); + return `${days} д ${hours % 24} ч`; +} + +function formatBytes(value) { + const number = Number(value || 0); + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = number; + let index = 0; + while (size >= 1024 && index < units.length - 1) { + size /= 1024; + index += 1; + } + return `${size.toFixed(size >= 10 || index === 0 ? 0 : 1)} ${units[index]}`; +} + +function formatVersion(versionCode, versionName) { + if (versionCode == null && !versionName) { + return "—"; + } + if (versionCode == null) { + return escapeHtml(versionName); + } + return escapeHtml(`${versionName || "без имени"} / ${versionCode}`); +} + +function formatChatType(type) { + if (type === "Direct" || type === 0) { + return "Личный"; + } + if (type === "Group" || type === 1) { + return "Группа"; + } + if (type === "Channel" || type === 2) { + return "Канал"; + } + return escapeHtml(String(type || "—")); +} + +function normalizeRole(role) { + if (role === "Owner" || role === 2) { + return "Owner"; + } + if (role === "Admin" || role === 1) { + return "Admin"; + } + return "Member"; +} + +function formatJoinStatus(status) { + if (status === "Approved" || status === 1) { + return "одобрена"; + } + if (status === "Declined" || status === 2) { + return "отклонена"; + } + return "ожидает"; +} + +function isPendingJoinRequest(status) { + return status === "Pending" || status === 0; +} + +function formatModerationTarget(target) { + const map = { + 0: "Чат", + 1: "Сообщение", + 2: "Пользователь", + 3: "Invite link", + 4: "Join request", + 5: "Story", + Chat: "Чат", + Message: "Сообщение", + User: "Пользователь", + InviteLink: "Invite link", + JoinRequest: "Join request", + Story: "Story", + }; + return map[target] || String(target || "—"); +} + +function shorten(value, max) { + const text = String(value || ""); + return text.length <= max ? text : `${text.slice(0, max - 1)}…`; +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function escapeAttribute(value) { + return escapeHtml(value); +} diff --git a/ikar_server/src/Ikar.Server/wwwroot/admin/index.html b/ikar_server/src/Ikar.Server/wwwroot/admin/index.html new file mode 100644 index 0000000..b4fc34c --- /dev/null +++ b/ikar_server/src/Ikar.Server/wwwroot/admin/index.html @@ -0,0 +1,406 @@ + + + + + + Икар — административная консоль + + + + + + + + + + + + diff --git a/ikar_server/src/Ikar.Server/wwwroot/index.html b/ikar_server/src/Ikar.Server/wwwroot/index.html new file mode 100644 index 0000000..d997e94 --- /dev/null +++ b/ikar_server/src/Ikar.Server/wwwroot/index.html @@ -0,0 +1,59 @@ + + + + + + Икар + + + + + +
+
+
+ + +

Икар

+

Скачайте последнюю версию клиента для Android

+

+ Прямая загрузка актуального APK с сервера Икар. +

+ +
+

Текущий релиз

+

Загрузка…

+

Получаем информацию о версии

+

+
+ + +
+ + +
+
+ + + + diff --git a/ikar_server/src/Ikar.Server/wwwroot/logo.png b/ikar_server/src/Ikar.Server/wwwroot/logo.png new file mode 100644 index 0000000..c386a28 Binary files /dev/null and b/ikar_server/src/Ikar.Server/wwwroot/logo.png differ diff --git a/ikar_server/src/Ikar.Server/wwwroot/logo_bg.png b/ikar_server/src/Ikar.Server/wwwroot/logo_bg.png new file mode 100644 index 0000000..9832247 Binary files /dev/null and b/ikar_server/src/Ikar.Server/wwwroot/logo_bg.png differ diff --git a/ikar_server/src/Ikar.Server/wwwroot/site.css b/ikar_server/src/Ikar.Server/wwwroot/site.css new file mode 100644 index 0000000..02b3480 --- /dev/null +++ b/ikar_server/src/Ikar.Server/wwwroot/site.css @@ -0,0 +1,236 @@ +:root { + color-scheme: light; + --bg: #eef4fb; + --surface: rgba(255, 255, 255, 0.82); + --surface-strong: #ffffff; + --text: #17243b; + --muted: #5f6f89; + --line: rgba(23, 36, 59, 0.1); + --accent: #2492ff; + --accent-deep: #1275d6; + --shadow: 0 24px 64px rgba(33, 68, 118, 0.14); + --radius-xl: 32px; + --radius-lg: 22px; + --radius-md: 16px; + --max-width: 1120px; + font-family: "Segoe UI", Arial, sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + color: var(--text); + background: + radial-gradient(circle at top left, rgba(36, 146, 255, 0.18), transparent 34%), + radial-gradient(circle at bottom right, rgba(18, 117, 214, 0.14), transparent 28%), + linear-gradient(180deg, #f7fbff 0%, var(--bg) 100%); +} + +.page { + width: min(calc(100% - 32px), var(--max-width)); + margin: 0 auto; + padding: 32px 0; +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.8fr); + gap: 28px; + align-items: stretch; + min-height: calc(100vh - 64px); +} + +.hero-copy, +.hero-panel { + border: 1px solid var(--line); + border-radius: var(--radius-xl); + box-shadow: var(--shadow); + backdrop-filter: blur(18px); +} + +.hero-copy { + padding: clamp(28px, 4vw, 56px); + background: linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.84) 100%); + display: flex; + flex-direction: column; + justify-content: center; +} + +.brand-hero-logo { + display: block; + width: min(100%, 520px); + margin: 0 0 18px; + filter: drop-shadow(0 18px 32px rgba(42, 88, 149, 0.12)); +} + +.hero-panel { + background: + linear-gradient(180deg, rgba(36, 146, 255, 0.96) 0%, rgba(18, 117, 214, 0.96) 100%); + color: #fff; + padding: clamp(24px, 3vw, 36px); + display: flex; + flex-direction: column; + justify-content: flex-end; + position: relative; + overflow: hidden; +} + +.hero-panel::before, +.hero-panel::after { + content: ""; + position: absolute; + border-radius: 999px; + background: rgba(255, 255, 255, 0.14); +} + +.hero-panel::before { + width: 220px; + height: 220px; + top: -72px; + right: -44px; +} + +.hero-panel::after { + width: 120px; + height: 120px; + bottom: 76px; + left: -34px; +} + +.eyebrow, +.panel-kicker, +.release-label { + margin: 0; + font-size: 0.82rem; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.eyebrow { + color: var(--accent-deep); + font-weight: 700; +} + +h1 { + margin: 18px 0 14px; + max-width: 11ch; + font-size: clamp(2.6rem, 5vw, 4.8rem); + line-height: 0.94; + letter-spacing: -0.04em; +} + +.lead { + max-width: 36rem; + margin: 0; + color: var(--muted); + font-size: 1.08rem; + line-height: 1.7; +} + +.release-card { + margin-top: 28px; + padding: 22px 24px; + border-radius: var(--radius-lg); + background: var(--surface-strong); + border: 1px solid rgba(36, 146, 255, 0.16); +} + +.release-version { + margin: 10px 0 8px; + font-size: clamp(1.6rem, 2vw, 2rem); + font-weight: 700; + letter-spacing: -0.03em; +} + +.release-meta, +.release-notes { + margin: 0; + color: var(--muted); + line-height: 1.55; +} + +.release-notes:not(:empty) { + margin-top: 10px; +} + +.actions { + margin-top: 28px; + display: flex; + gap: 14px; + flex-wrap: wrap; +} + +.button { + appearance: none; + border: 0; + border-radius: 999px; + padding: 15px 24px; + text-decoration: none; + font-weight: 700; + transition: transform 180ms ease, background-color 180ms ease, box-shadow 180ms ease; +} + +.button:hover { + transform: translateY(-1px); +} + +.button-primary { + background: var(--accent); + color: #fff; + box-shadow: 0 16px 40px rgba(36, 146, 255, 0.28); +} + +.button-primary:hover { + background: var(--accent-deep); +} + +.button-secondary { + background: rgba(23, 36, 59, 0.06); + color: var(--text); +} + +.panel-block { + position: relative; + z-index: 1; + padding: 18px 0; + border-top: 1px solid rgba(255, 255, 255, 0.18); +} + +.panel-block:first-child { + border-top: 0; +} + +.panel-block strong { + display: block; + margin-top: 8px; + font-size: 1.12rem; + line-height: 1.4; +} + +.inline-link { + color: #fff; +} + +@media (max-width: 900px) { + .page { + width: min(calc(100% - 24px), var(--max-width)); + padding: 12px 0 24px; + } + + .hero { + grid-template-columns: 1fr; + min-height: auto; + } + + .hero-panel { + min-height: 280px; + } + + h1 { + max-width: none; + } +} diff --git a/ikar_server/src/Ikar.Server/wwwroot/site.js b/ikar_server/src/Ikar.Server/wwwroot/site.js new file mode 100644 index 0000000..252a23c --- /dev/null +++ b/ikar_server/src/Ikar.Server/wwwroot/site.js @@ -0,0 +1,46 @@ +(function () { + const downloadLink = document.getElementById("download-link"); + const releaseVersion = document.getElementById("release-version"); + const releaseMeta = document.getElementById("release-meta"); + const releaseNotes = document.getElementById("release-notes"); + + const fallbackText = "Последняя версия APK пока не опубликована"; + + async function loadRelease() { + try { + const response = await fetch("/api/app-updates/android/latest", { + headers: { + "Accept": "application/json" + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const release = await response.json(); + const publishedAt = new Date(release.publishedAt); + const publishedLabel = Number.isNaN(publishedAt.getTime()) + ? "Дата публикации не указана" + : publishedAt.toLocaleString("ru-RU", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + }); + + releaseVersion.textContent = `Версия ${release.versionName}`; + releaseMeta.textContent = `Build ${release.versionCode} • опубликовано ${publishedLabel}`; + releaseNotes.textContent = release.notes ?? ""; + downloadLink.href = release.downloadPath; + downloadLink.removeAttribute("aria-disabled"); + } catch (error) { + releaseVersion.textContent = "APK недоступен"; + releaseMeta.textContent = fallbackText; + releaseNotes.textContent = ""; + } + } + + loadRelease(); +})(); diff --git a/ikar_server/src/Ikar.Server/wwwroot/stickers/1f44b.png b/ikar_server/src/Ikar.Server/wwwroot/stickers/1f44b.png new file mode 100644 index 0000000..da94bdc Binary files /dev/null and b/ikar_server/src/Ikar.Server/wwwroot/stickers/1f44b.png differ diff --git a/ikar_server/src/Ikar.Server/wwwroot/stickers/1f44c.png b/ikar_server/src/Ikar.Server/wwwroot/stickers/1f44c.png new file mode 100644 index 0000000..5d49e94 Binary files /dev/null and b/ikar_server/src/Ikar.Server/wwwroot/stickers/1f44c.png differ diff --git a/ikar_server/src/Ikar.Server/wwwroot/stickers/1f44d.png b/ikar_server/src/Ikar.Server/wwwroot/stickers/1f44d.png new file mode 100644 index 0000000..02079e5 Binary files /dev/null and b/ikar_server/src/Ikar.Server/wwwroot/stickers/1f44d.png differ diff --git a/ikar_server/src/Ikar.Server/wwwroot/stickers/1f49b.png b/ikar_server/src/Ikar.Server/wwwroot/stickers/1f49b.png new file mode 100644 index 0000000..81f7fa4 Binary files /dev/null and b/ikar_server/src/Ikar.Server/wwwroot/stickers/1f49b.png differ diff --git a/ikar_server/src/Ikar.Server/wwwroot/stickers/1f525.png b/ikar_server/src/Ikar.Server/wwwroot/stickers/1f525.png new file mode 100644 index 0000000..d2185aa Binary files /dev/null and b/ikar_server/src/Ikar.Server/wwwroot/stickers/1f525.png differ diff --git a/ikar_server/src/Ikar.Server/wwwroot/stickers/1f600.png b/ikar_server/src/Ikar.Server/wwwroot/stickers/1f600.png new file mode 100644 index 0000000..4cc7060 Binary files /dev/null and b/ikar_server/src/Ikar.Server/wwwroot/stickers/1f600.png differ diff --git a/ikar_server/src/Ikar.Server/wwwroot/stickers/1f607.png b/ikar_server/src/Ikar.Server/wwwroot/stickers/1f607.png new file mode 100644 index 0000000..fa2a6f7 Binary files /dev/null and b/ikar_server/src/Ikar.Server/wwwroot/stickers/1f607.png differ diff --git a/ikar_server/src/Ikar.Server/wwwroot/stickers/1f60a.png b/ikar_server/src/Ikar.Server/wwwroot/stickers/1f60a.png new file mode 100644 index 0000000..ee515f1 Binary files /dev/null and b/ikar_server/src/Ikar.Server/wwwroot/stickers/1f60a.png differ diff --git a/ikar_server/src/Ikar.Server/wwwroot/stickers/LICENSE-NOTO.txt b/ikar_server/src/Ikar.Server/wwwroot/stickers/LICENSE-NOTO.txt new file mode 100644 index 0000000..d09d3d0 --- /dev/null +++ b/ikar_server/src/Ikar.Server/wwwroot/stickers/LICENSE-NOTO.txt @@ -0,0 +1,93 @@ +Copyright 2013 Google LLC + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/ikar_server/src/Ikar.Shared/AppUpdateContracts.cs b/ikar_server/src/Ikar.Shared/AppUpdateContracts.cs new file mode 100644 index 0000000..d6103c2 --- /dev/null +++ b/ikar_server/src/Ikar.Shared/AppUpdateContracts.cs @@ -0,0 +1,10 @@ +namespace Ikar.Shared; + +public sealed record AndroidAppReleaseDto( + int VersionCode, + string VersionName, + string DownloadPath, + DateTimeOffset PublishedAt, + string? Notes, + bool IsRequired = false, + int? MinimumSupportedVersionCode = null); diff --git a/ikar_server/src/Ikar.Shared/AttachmentKind.cs b/ikar_server/src/Ikar.Shared/AttachmentKind.cs new file mode 100644 index 0000000..a7e2574 --- /dev/null +++ b/ikar_server/src/Ikar.Shared/AttachmentKind.cs @@ -0,0 +1,7 @@ +namespace Ikar.Shared; + +public enum AttachmentKind +{ + File = 1, + VoiceNote = 2 +} diff --git a/ikar_server/src/Ikar.Shared/AuthContracts.cs b/ikar_server/src/Ikar.Shared/AuthContracts.cs new file mode 100644 index 0000000..fdbe771 --- /dev/null +++ b/ikar_server/src/Ikar.Shared/AuthContracts.cs @@ -0,0 +1,13 @@ +namespace Ikar.Shared; + +public sealed record RegisterRequest(string Username, string DisplayName, string Password, string PhoneNumber); + +public sealed record LoginRequest(string Username, string Password); + +public sealed record RefreshSessionRequest(string RefreshToken); + +public sealed record AuthSessionDto( + string AccessToken, + string RefreshToken, + DateTimeOffset AccessTokenExpiresAt, + UserSummaryDto User); diff --git a/ikar_server/src/Ikar.Shared/BotContracts.cs b/ikar_server/src/Ikar.Shared/BotContracts.cs new file mode 100644 index 0000000..760b73d --- /dev/null +++ b/ikar_server/src/Ikar.Shared/BotContracts.cs @@ -0,0 +1,61 @@ +namespace Ikar.Shared; + +public sealed record BotSummaryDto( + Guid Id, + Guid UserId, + string Username, + string DisplayName, + string? About, + bool IsEnabled, + DateTimeOffset CreatedAt, + IReadOnlyList Commands); + +public sealed record CreateBotRequest( + string Username, + string DisplayName, + string? About); + +public sealed record UpdateBotRequest( + string DisplayName, + string? About, + bool IsEnabled); + +public sealed record CreatedBotDto( + BotSummaryDto Bot, + string AccessToken); + +public sealed record RegenerateBotTokenResponseDto(string AccessToken); + +public sealed record BotCommandDto( + string Command, + string Description); + +public sealed record SetBotCommandsRequest(IReadOnlyList Commands); +public sealed record SetBotWebAppRequest(string? Url); + +public enum BotUpdateKind +{ + Message = 0, + CallbackQuery = 1 +} + +public sealed record BotUpdateDto( + long Id, + BotUpdateKind Kind, + MessageDto? Message, + BotCallbackQueryDto? CallbackQuery = null); + +public sealed record BotCallbackQueryDto( + string CallbackId, + string? Payload, + UserSummaryDto From, + Guid ChatId, + Guid MessageId); + +public sealed record BotUpdatesResponseDto( + long NextOffset, + IReadOnlyList Updates); + +public sealed record BotSendMessageRequest( + Guid ChatId, + string Text); diff --git a/ikar_server/src/Ikar.Shared/CallContracts.cs b/ikar_server/src/Ikar.Shared/CallContracts.cs new file mode 100644 index 0000000..4ea1dd9 --- /dev/null +++ b/ikar_server/src/Ikar.Shared/CallContracts.cs @@ -0,0 +1,54 @@ +namespace Ikar.Shared; + +public enum CallMediaType +{ + Audio = 0, + Video = 1 +} + +public sealed record OutgoingCallDto( + Guid CallId, + Guid ChatId, + UserSummaryDto Peer, + CallMediaType MediaType); + +public sealed record IncomingCallDto( + Guid CallId, + Guid ChatId, + UserSummaryDto Caller, + CallMediaType MediaType); + +public sealed record CallConnectedDto( + Guid CallId, + Guid ChatId, + DateTimeOffset ConnectedAt, + CallMediaType MediaType, + IReadOnlyList IceServers, + bool ShouldCreateOffer); + +public sealed record CallEndedDto( + Guid CallId, + Guid ChatId, + string Reason, + CallMediaType MediaType); + +public sealed record CallIceServerDto( + IReadOnlyList Urls, + string? Username = null, + string? Credential = null); + +public sealed record CallOfferDto( + Guid CallId, + string Type, + string Sdp); + +public sealed record CallAnswerDto( + Guid CallId, + string Type, + string Sdp); + +public sealed record CallIceCandidateDto( + Guid CallId, + string Candidate, + string? SdpMid, + int SdpMLineIndex); diff --git a/ikar_server/src/Ikar.Shared/ChatContracts.cs b/ikar_server/src/Ikar.Shared/ChatContracts.cs new file mode 100644 index 0000000..5db5631 --- /dev/null +++ b/ikar_server/src/Ikar.Shared/ChatContracts.cs @@ -0,0 +1,246 @@ +namespace Ikar.Shared; + +public enum ChatMemberRole +{ + Member = 0, + Admin = 1, + Owner = 2 +} + +public sealed record ChatPermissionsDto( + bool CanPostMessages, + bool CanPinMessages, + bool CanDeleteMessages, + bool CanInviteUsers, + bool CanManageMembers); + +public sealed record ChatMemberDto( + UserSummaryDto User, + ChatMemberRole Role, + bool IsOwner, + ChatPermissionsDto Permissions); + +public sealed record ChatSummaryDto( + Guid Id, + string Title, + ChatType Type, + string? SecondaryText, + DateTimeOffset? LastActivityAt, + string? LastMessagePreview, + IReadOnlyList Participants, + bool CanSendMessages, + int UnreadCount, + bool IsPinned = false, + bool IsArchived = false, + bool IsMuted = false, + string? FolderKey = null, + bool HasProtectedContent = false, + int? DefaultMessageTtlSeconds = null, + AttachmentDto? LastMessageAttachment = null, + int SubscriberCount = 0, + int AdminCount = 0, + bool IsSubscribed = false, + bool CanSubscribe = false, + bool CanManageChannel = false, + bool CanPostAsChannel = false, + string? PublicUsername = null, + Guid? LinkedDiscussionChatId = null, + bool ChannelSignaturesEnabled = false, + string? Description = null, + bool CanDeleteForEveryone = false); + +public sealed record ChatDetailsDto( + Guid Id, + string Title, + ChatType Type, + IReadOnlyList Participants, + bool CanSendMessages, + IReadOnlyList Messages, + Guid? FirstUnreadMessageId = null, + bool HasMoreMessages = false, + IReadOnlyList? PinnedMessages = null, + ChatPermissionsDto? CurrentUserPermissions = null, + IReadOnlyList? Members = null, + bool HasProtectedContent = false, + int? DefaultMessageTtlSeconds = null, + int SubscriberCount = 0, + int AdminCount = 0, + bool IsSubscribed = false, + bool CanSubscribe = false, + bool CanManageChannel = false, + bool CanPostAsChannel = false, + string? PublicUsername = null, + Guid? LinkedDiscussionChatId = null, + bool ChannelSignaturesEnabled = false, + string? Description = null, + bool CanDeleteForEveryone = false); + +public sealed record ChatInfoDto( + ChatDetailsDto Chat, + int MediaCount, + int FileCount, + int LinkCount, + int VoiceCount, + int PinnedCount, + int TopicCount, + string? PublicUsername, + IReadOnlyList InviteLinks, + IReadOnlyList Topics, + int SubscriberCount = 0, + int AdminCount = 0, + Guid? LinkedDiscussionChatId = null, + bool ChannelSignaturesEnabled = false, + bool CanManageChannel = false, + bool CanPostAsChannel = false, + bool CanInviteUsers = false, + string? Description = null); + +public sealed record ChatMessagesPageDto( + IReadOnlyList Messages, + bool HasMoreMessages); + +public sealed record ChatFolderDto( + string Key, + string Title, + int TotalCount, + int UnreadCount); + +public sealed record ChatStateRequest( + bool? IsPinned = null, + bool? IsArchived = null, + DateTimeOffset? MutedUntil = null, + string? FolderKey = null, + bool? IsMuted = null, + bool? HasProtectedContent = null, + int? DefaultMessageTtlSeconds = null, + bool? ChannelSignaturesEnabled = null, + Guid? LinkedDiscussionChatId = null, + bool ClearLinkedDiscussionChat = false, + string? Title = null, + string? Description = null, + string? PublicUsername = null, + bool ClearDescription = false, + bool ClearPublicUsername = false); + +public sealed record UpdateChatMemberPermissionsRequest( + ChatMemberRole Role, + bool CanPinMessages, + bool CanDeleteMessages, + bool CanInviteUsers, + bool CanPostMessages); + +public sealed record AddChatMembersRequest( + IReadOnlyList MemberIds, + ChatMemberRole Role = ChatMemberRole.Member, + bool CanPinMessages = false, + bool CanDeleteMessages = false, + bool CanInviteUsers = false, + bool CanPostMessages = false); + +public sealed record ChatInviteLinkDto( + Guid Id, + Guid ChatId, + string Token, + string Link, + string? Title, + bool CreatesJoinRequest, + bool IsRevoked, + DateTimeOffset CreatedAt, + DateTimeOffset? RevokedAt = null, + DateTimeOffset? ExpiresAt = null, + int? MaxUses = null, + int UsesCount = 0, + int? MemberLimit = null); + +public sealed record CreateChatInviteLinkRequest( + string? Title = null, + bool CreatesJoinRequest = false, + DateTimeOffset? ExpiresAt = null, + int? MaxUses = null, + int? MemberLimit = null); + +public enum JoinChatResultStatus +{ + Joined = 0, + PendingApproval = 1 +} + +public sealed record JoinChatResultDto( + JoinChatResultStatus Status, + ChatDetailsDto? Chat = null, + Guid? JoinRequestId = null, + ChatJoinRequestStatus? JoinRequestStatus = null); + +public sealed record ChatTopicDto( + Guid Id, + Guid ChatId, + string Title, + string Icon, + bool IsPinned, + bool IsClosed, + DateTimeOffset CreatedAt, + DateTimeOffset LastActivityAt, + int MessageCount); + +public sealed record CreateChatTopicRequest( + string Title, + string? Icon = null); + +public sealed record ChatSearchResultDto( + ChatSummaryDto Chat, + MessageDto? Message, + string MatchText, + DateTimeOffset? MatchedAt, + Guid? ChatId = null, + Guid? MessageId = null, + Guid? SenderUserId = null, + DateTimeOffset? MessageSentAt = null); + +public sealed record ChatSearchPageDto( + IReadOnlyList Results, + int Page, + int Limit, + bool HasMore); + +public sealed record MessageSearchPageDto( + IReadOnlyList Messages, + int Page, + int Limit, + bool HasMore); + +public sealed record TypingIndicatorDto( + Guid ChatId, + UserSummaryDto User, + bool IsTyping, + DateTimeOffset TypedAt); + +public sealed record CreateDirectChatRequest(Guid UserId); + +public sealed record CreateGroupChatRequest(string Title, IReadOnlyList MemberIds); + +public sealed record CreateChannelRequest( + string Title, + IReadOnlyList MemberIds, + string? Description = null, + string? PublicUsername = null, + bool CreateInviteLink = false, + bool ChannelSignaturesEnabled = false); + +public enum ChatJoinRequestStatus +{ + Pending = 0, + Approved = 1, + Declined = 2 +} + +public sealed record ChatJoinRequestDto( + Guid Id, + Guid ChatId, + Guid InviteLinkId, + UserSummaryDto User, + ChatJoinRequestStatus Status, + DateTimeOffset RequestedAt, + DateTimeOffset? ResolvedAt, + UserSummaryDto? ResolvedBy); + +public sealed record ResolveChatJoinRequestRequest(bool Approve); diff --git a/ikar_server/src/Ikar.Shared/ChatType.cs b/ikar_server/src/Ikar.Shared/ChatType.cs new file mode 100644 index 0000000..ab8fca6 --- /dev/null +++ b/ikar_server/src/Ikar.Shared/ChatType.cs @@ -0,0 +1,8 @@ +namespace Ikar.Shared; + +public enum ChatType +{ + Direct = 1, + Group = 2, + Channel = 3 +} diff --git a/ikar_server/src/Ikar.Shared/ClientVersionContracts.cs b/ikar_server/src/Ikar.Shared/ClientVersionContracts.cs new file mode 100644 index 0000000..dc0ded6 --- /dev/null +++ b/ikar_server/src/Ikar.Shared/ClientVersionContracts.cs @@ -0,0 +1,17 @@ +namespace Ikar.Shared; + +public static class ClientVersionMetadata +{ + public const string PlatformHeaderName = "X-Ikar-Client-Platform"; + public const string VersionCodeHeaderName = "X-Ikar-Client-Version-Code"; + public const string VersionNameHeaderName = "X-Ikar-Client-Version-Name"; + public const string PlatformQueryName = "clientPlatform"; + public const string VersionCodeQueryName = "clientVersionCode"; + public const string VersionNameQueryName = "clientVersionName"; +} + +public static class ClientPlatformNames +{ + public const string Android = "android"; + public const string Windows = "windows"; +} diff --git a/ikar_server/src/Ikar.Shared/EmailAuthContracts.cs b/ikar_server/src/Ikar.Shared/EmailAuthContracts.cs new file mode 100644 index 0000000..9dc55a0 --- /dev/null +++ b/ikar_server/src/Ikar.Shared/EmailAuthContracts.cs @@ -0,0 +1,15 @@ +namespace Ikar.Shared; + +public sealed record RequestEmailCodeRequest(string Email, string PhoneNumber); + +public sealed record EmailCodeChallengeDto( + string ChallengeId, + DateTimeOffset ExpiresAt, + bool IsRegistered); + +public sealed record VerifyEmailCodeRequest( + string ChallengeId, + string Email, + string PhoneNumber, + string Code, + string? DisplayName); diff --git a/ikar_server/src/Ikar.Shared/Ikar.Shared.csproj b/ikar_server/src/Ikar.Shared/Ikar.Shared.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/ikar_server/src/Ikar.Shared/Ikar.Shared.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/ikar_server/src/Ikar.Shared/MessageContracts.cs b/ikar_server/src/Ikar.Shared/MessageContracts.cs new file mode 100644 index 0000000..163c42b --- /dev/null +++ b/ikar_server/src/Ikar.Shared/MessageContracts.cs @@ -0,0 +1,81 @@ +namespace Ikar.Shared; + +public enum MessageDeliveryState +{ + Sent = 0, + Delivered = 1, + Read = 2 +} + +public sealed record AttachmentDto( + Guid Id, + string FileName, + string ContentType, + long FileSizeBytes, + string DownloadPath, + AttachmentKind Kind, + int SortOrder = 0); + +public sealed record MessageReplyDto( + Guid MessageId, + Guid SenderId, + string SenderDisplayName, + string PreviewText); + +public sealed record StoryReplyDto( + Guid StoryId, + Guid AuthorUserId, + string PreviewText, + string? AttachmentKind = null); + +public sealed record MessageReactionDto( + string Emoji, + int Count, + bool IsMine); + +public sealed record MessageDto( + Guid Id, + Guid ChatId, + ChatType ChatType, + UserSummaryDto Sender, + string Text, + IReadOnlyList Attachments, + DateTimeOffset SentAt, + DateTimeOffset? EditedAt, + DateTimeOffset? DeletedAt, + MessageDeliveryState DeliveryState, + string? ForwardedFromDisplayName = null, + MessageReplyDto? ReplyTo = null, + DateTimeOffset? PinnedAt = null, + UserSummaryDto? PinnedBy = null, + Guid? MediaAlbumId = null, + IReadOnlyList? Reactions = null, + bool HasProtectedContent = false, + DateTimeOffset? ExpiresAt = null, + int? TtlSeconds = null, + bool PostedAsChannel = false, + string? DisplayAuthorName = null, + int? ViewCount = null, + StoryReplyDto? StoryReply = null, + Guid? DiscussionMessageId = null, + int? CommentCount = null, + Guid? ClientMessageId = null); + +public sealed record SendMessageRequest( + string Text, + Guid? ReplyToMessageId = null, + Guid? TopicId = null, + bool? HasProtectedContent = null, + int? TtlSeconds = null, + Guid? StoryReplyStoryId = null, + Guid? ClientMessageId = null); + +public sealed record UpdateMessageRequest(string Text); + +public sealed record UpdateMessageReceiptRequest(Guid MessageId); + +public sealed record ForwardMessageRequest(Guid TargetChatId); + +public sealed record SetMessageReactionRequest(string Emoji); + +public sealed record ExpiredMessagesCleanupDto(int DeletedMessages, DateTimeOffset CleanedUpAt); diff --git a/ikar_server/src/Ikar.Shared/ModerationContracts.cs b/ikar_server/src/Ikar.Shared/ModerationContracts.cs new file mode 100644 index 0000000..cb6ffe1 --- /dev/null +++ b/ikar_server/src/Ikar.Shared/ModerationContracts.cs @@ -0,0 +1,23 @@ +namespace Ikar.Shared; + +public enum ModerationLogTargetType +{ + Chat = 0, + Message = 1, + User = 2, + InviteLink = 3, + JoinRequest = 4, + Story = 5 +} + +public sealed record ModerationLogDto( + long Id, + Guid? ChatId, + Guid ActorUserId, + string ActorDisplayName, + ModerationLogTargetType TargetType, + Guid? TargetId, + string Action, + string? Reason, + DateTimeOffset CreatedAt); + diff --git a/ikar_server/src/Ikar.Shared/PhoneAuthContracts.cs b/ikar_server/src/Ikar.Shared/PhoneAuthContracts.cs new file mode 100644 index 0000000..27982bd --- /dev/null +++ b/ikar_server/src/Ikar.Shared/PhoneAuthContracts.cs @@ -0,0 +1,15 @@ +namespace Ikar.Shared; + +public sealed record RequestPhoneCodeRequest(string PhoneNumber); + +public sealed record PhoneCodeChallengeDto( + string ChallengeId, + DateTimeOffset ExpiresAt, + string? TestCode, + bool IsRegistered); + +public sealed record VerifyPhoneCodeRequest( + string ChallengeId, + string PhoneNumber, + string Code, + string? DisplayName); diff --git a/ikar_server/src/Ikar.Shared/PhoneNumberNormalizer.cs b/ikar_server/src/Ikar.Shared/PhoneNumberNormalizer.cs new file mode 100644 index 0000000..3af3aeb --- /dev/null +++ b/ikar_server/src/Ikar.Shared/PhoneNumberNormalizer.cs @@ -0,0 +1,22 @@ +namespace Ikar.Shared; + +public static class PhoneNumberNormalizer +{ + public static string? Normalize(string? phoneNumber) + { + if (string.IsNullOrWhiteSpace(phoneNumber)) + { + return null; + } + + var digits = new string(phoneNumber.Where(char.IsDigit).ToArray()); + if (digits.Length < 10) + { + return null; + } + + return digits.Length == 10 + ? digits + : digits[^10..]; + } +} diff --git a/ikar_server/src/Ikar.Shared/PushContracts.cs b/ikar_server/src/Ikar.Shared/PushContracts.cs new file mode 100644 index 0000000..d36ff68 --- /dev/null +++ b/ikar_server/src/Ikar.Shared/PushContracts.cs @@ -0,0 +1,70 @@ +namespace Ikar.Shared; + +public enum PushPlatform +{ + Unknown = 0, + Android = 1, + Windows = 2, + Web = 3 +} + +public sealed record RegisterPushDeviceRequest( + PushPlatform Platform, + string InstallationId, + string DeviceToken, + string? DeviceName, + bool NotificationsEnabled); + +public sealed record PushDeviceDto( + Guid Id, + PushPlatform Platform, + string InstallationId, + string? DeviceName, + bool NotificationsEnabled, + DateTimeOffset UpdatedAt); + +public sealed record QlyraIncomingCallPushDto( + string Vcp, + string ConversationId, + int CallerClientId, + string CallerName, + int CalleeClientId, + bool IsVideo); + +public static class PushDataKeys +{ + public const string Kind = "kind"; + public const string CallId = "callId"; + public const string ChatId = "chatId"; + public const string ChatType = "chatType"; + public const string MessageId = "messageId"; + public const string Title = "title"; + public const string Body = "body"; + public const string SenderId = "senderId"; + public const string SenderName = "senderName"; + public const string SenderPhoneNumber = "senderPhoneNumber"; + public const string ReactionEmoji = "reactionEmoji"; + public const string CallAction = "callAction"; + public const string CallMediaType = "callMediaType"; + public const string VersionCode = "versionCode"; + public const string VersionName = "versionName"; + public const string LoginChallengeId = "loginChallengeId"; + public const string Type = "type"; + public const string Vcp = "vcp"; + public const string ConversationId = "conversationId"; + public const string AccountId = "c"; + public const string QlyraSenderId = "suid"; + public const string IsVideo = "iv"; +} + +public static class PushDataKinds +{ + public const string ChatMessage = "chat_message"; + public const string MessageReaction = "message_reaction"; + public const string AudioCallInvite = "audio_call_invite"; + public const string VideoCallInvite = "video_call_invite"; + public const string AudioCallEnded = "audio_call_ended"; + public const string VideoCallEnded = "video_call_ended"; + public const string AppUpdateAvailable = "app_update_available"; + public const string LoginCode = "login_code"; +} diff --git a/ikar_server/src/Ikar.Shared/StoryContracts.cs b/ikar_server/src/Ikar.Shared/StoryContracts.cs new file mode 100644 index 0000000..59dad53 --- /dev/null +++ b/ikar_server/src/Ikar.Shared/StoryContracts.cs @@ -0,0 +1,34 @@ +namespace Ikar.Shared; + +public enum StoryVisibility +{ + Everyone = 0, + Contacts = 1, + CloseFriends = 2, + Private = 3 +} + +public sealed record StoryDto( + Guid Id, + UserSummaryDto Author, + string Text, + IReadOnlyList Attachments, + StoryVisibility Visibility, + bool HasProtectedContent, + DateTimeOffset CreatedAt, + DateTimeOffset ExpiresAt, + DateTimeOffset? DeletedAt, + int ViewCount, + bool ViewedByMe); + +public sealed record CreateStoryRequest( + string? Text, + StoryVisibility Visibility = StoryVisibility.Everyone, + bool HasProtectedContent = false, + int TtlSeconds = 86400); + +public sealed record StoryViewDto( + Guid StoryId, + UserSummaryDto Viewer, + DateTimeOffset ViewedAt); + diff --git a/ikar_server/src/Ikar.Shared/UserContracts.cs b/ikar_server/src/Ikar.Shared/UserContracts.cs new file mode 100644 index 0000000..164d06e --- /dev/null +++ b/ikar_server/src/Ikar.Shared/UserContracts.cs @@ -0,0 +1,53 @@ +namespace Ikar.Shared; + +public sealed record UserSummaryDto( + Guid Id, + string Username, + string DisplayName, + bool IsOnline, + string? PhoneNumber, + DateTimeOffset? LastSeenAt, + string? About, + string? AvatarPath, + bool IsBot = false); + +public sealed record ResolveContactsRequest(IReadOnlyList PhoneNumbers); + +public sealed record ContactMatchDto( + string PhoneNumber, + UserSummaryDto User); + +public sealed record UpdateMyProfileRequest( + string DisplayName, + string? About); + +public sealed record PrivacySettingsDto( + string PhoneNumberVisibility, + string LastSeenVisibility, + string ProfilePhotoVisibility, + IReadOnlyList BlockedUserIds, + IReadOnlyList AlwaysAllowUserIds, + IReadOnlyList NeverAllowUserIds, + IReadOnlyList BlockedUsers, + IReadOnlyList AlwaysAllowUsers, + IReadOnlyList NeverAllowUsers, + bool PasscodeLockEnabled, + bool ShowMessagePreview, + DateTimeOffset UpdatedAt); + +public sealed record UpdatePrivacySettingsRequest( + string PhoneNumberVisibility, + string LastSeenVisibility, + string ProfilePhotoVisibility, + IReadOnlyList? BlockedUserIds = null, + IReadOnlyList? AlwaysAllowUserIds = null, + IReadOnlyList? NeverAllowUserIds = null, + bool PasscodeLockEnabled = false, + bool ShowMessagePreview = true); + +public sealed record UserSessionDto( + Guid Id, + DateTimeOffset CreatedAt, + DateTimeOffset LastSeenAt, + bool IsCurrent, + string DeviceName); diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..ca828de --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,165 @@ +# Uncomment this line to define a global platform for your project +platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +require 'json' + +# Firebase is Android-only: FCM is used solely by the `oneme` flavor, and on +# iOS there is a single `ru.qlyra.app` bundle that never initialises push. +# Keep the Firebase SDK out of the iOS build entirely so iOS is not forced up +# to the iOS 15 deployment target that firebase_core's podspec requires. +FIREBASE_IOS_PLUGINS = %w[firebase_core firebase_messaging].freeze + +deps_file = File.expand_path('../.flutter-plugins-dependencies', __dir__) +if File.exist?(deps_file) + deps = JSON.parse(File.read(deps_file)) + ios_plugins = deps.dig('plugins', 'ios') + if ios_plugins + ios_plugins.reject! { |p| FIREBASE_IOS_PLUGINS.include?(p['name']) } + File.write(deps_file, JSON.generate(deps)) + end +end + +registrant = File.expand_path('Runner/GeneratedPluginRegistrant.m', __dir__) +if File.exist?(registrant) + src = File.read(registrant) + FIREBASE_IOS_PLUGINS.each do |mod| + cls = "FLT#{mod.split('_').map(&:capitalize).join}Plugin" + m = Regexp.escape(mod) + src = src.gsub(/#if __has_include\(<#{m}\/[^>]+>\).*?#endif\n/m, '') + src = src.gsub(/^\s*#import <#{m}\/[^>]+>\n/, '') + src = src.gsub(/^\s*@import #{m};\n/, '') + src = src.gsub(/^\s*\[#{cls} registerWithRegistrar:.*\n/, '') + end + File.write(registrant, src) +end + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +# libopus ships with ogg_opus_player as a static xcframework. Nothing in the app +# references its encoder entry points, so the linker would drop them and +# `DynamicLibrary.process()` (OpusOggEncoder) would find nothing. Force-loading +# the slice keeps the whole library, which is what voice-message encoding needs. +# +# The slice is referenced in the plugin source tree rather than through +# `PODS_XCFRAMEWORKS_BUILD_DIR`: with `use_frameworks!` CocoaPods copies vendored +# xcframeworks into the pod target's own build dir, so for Runner that variable +# expands to a file nothing ever produces and Xcode fails on the missing input. +OPUS_XCFRAMEWORK = 'ogg_opus_player/darwin/Frameworks/libopus.xcframework'.freeze +OPUS_LIB_VAR = 'QLYRA_OPUS_LIB'.freeze +OPUS_FORCE_LOAD = %(-force_load "$(#{OPUS_LIB_VAR})").freeze +OPUS_XCFRAMEWORK_DIR = + File.expand_path(File.join('.symlinks', 'plugins', OPUS_XCFRAMEWORK), __dir__).freeze + +def opus_slice(simulator) + slice = Dir.glob(File.join(OPUS_XCFRAMEWORK_DIR, 'ios-*')).find do |dir| + name = File.basename(dir) + next false if name.include?('maccatalyst') + next false unless File.exist?(File.join(dir, 'libopus.a')) + + name.end_with?('-simulator') == simulator + end + kind = simulator ? 'simulator' : 'device' + raise "libopus.xcframework has no ios #{kind} slice in #{OPUS_XCFRAMEWORK_DIR}" if slice.nil? + + File.basename(slice) +end + +# permission_handler compiles every permission handler unless told otherwise; +# the unused ones reference APIs that make App Store review ask for usage +# descriptions the app has no reason to declare. +PERMISSION_MACROS = %w[ + PERMISSION_CAMERA=1 + PERMISSION_MICROPHONE=1 + PERMISSION_PHOTOS=1 + PERMISSION_PHOTOS_ADD_ONLY=1 + PERMISSION_LOCATION=1 + PERMISSION_LOCATION_WHENINUSE=1 + PERMISSION_CONTACTS=1 + PERMISSION_NOTIFICATIONS=1 + PERMISSION_LOCATION_ALWAYS=0 + PERMISSION_MEDIA_LIBRARY=0 + PERMISSION_EVENTS=0 + PERMISSION_EVENTS_FULL_ACCESS=0 + PERMISSION_REMINDERS=0 + PERMISSION_SPEECH_RECOGNIZER=0 + PERMISSION_SENSORS=0 + PERMISSION_BLUETOOTH=0 + PERMISSION_APP_TRACKING_TRANSPARENCY=0 + PERMISSION_CRITICAL_ALERTS=0 + PERMISSION_ASSISTANT=0 +].freeze + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + + next unless target.name == 'permission_handler_apple' + + target.build_configurations.each do |config| + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] = + ['$(inherited)'] + PERMISSION_MACROS + end + end + + opus_xcframework = "$(SRCROOT)/.symlinks/plugins/#{OPUS_XCFRAMEWORK}" + opus_device_lib = "#{opus_xcframework}/#{opus_slice(false)}/libopus.a" + opus_simulator_lib = "#{opus_xcframework}/#{opus_slice(true)}/libopus.a" + + installer.aggregate_targets.each do |target| + next unless target.name == 'Pods-Runner' + + %w[debug profile release].each do |name| + xcconfig = target.xcconfig_path(name) + next unless File.exist?(xcconfig) + + contents = File.read(xcconfig) + next if contents.include?(OPUS_LIB_VAR) + + contents += "\n" unless contents.end_with?("\n") + contents += "#{OPUS_LIB_VAR}[sdk=iphoneos*] = #{opus_device_lib}\n" + contents += "#{OPUS_LIB_VAR}[sdk=iphonesimulator*] = #{opus_simulator_lib}\n" + + if contents =~ /^OTHER_LDFLAGS = .*$/ + contents = contents.sub(/^OTHER_LDFLAGS = (.*)$/) do + "OTHER_LDFLAGS = #{Regexp.last_match(1)} #{OPUS_FORCE_LOAD}" + end + else + contents += "OTHER_LDFLAGS = $(inherited) #{OPUS_FORCE_LOAD}\n" + end + File.write(xcconfig, contents) + end + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..2926e76 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,209 @@ +PODS: + - app_links (7.0.0): + - Flutter + - device_info_plus (0.0.1): + - Flutter + - DKImagePickerController/Core (4.3.9): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.9) + - DKImagePickerController/PhotoGallery (4.3.9): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.9) + - DKPhotoGallery (0.0.19): + - DKPhotoGallery/Core (= 0.0.19) + - DKPhotoGallery/Model (= 0.0.19) + - DKPhotoGallery/Preview (= 0.0.19) + - DKPhotoGallery/Resource (= 0.0.19) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.19): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.19): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter + - Flutter (1.0.0) + - flutter_inappwebview_ios (0.0.1): + - Flutter + - flutter_inappwebview_ios/Core (= 0.0.1) + - OrderedSet (~> 6.0.3) + - flutter_inappwebview_ios/Core (0.0.1): + - Flutter + - OrderedSet (~> 6.0.3) + - flutter_local_notifications (0.0.1): + - Flutter + - flutter_secure_storage_darwin (10.0.0): + - Flutter + - FlutterMacOS + - flutter_timezone (0.0.1): + - Flutter + - flutter_webrtc (1.4.0): + - Flutter + - WebRTC-SDK (= 144.7559.09) + - geolocator_apple (1.2.0): + - Flutter + - FlutterMacOS + - media_kit_video (0.0.1): + - Flutter + - mobile_scanner (7.0.0): + - Flutter + - FlutterMacOS + - ogg_opus_player (0.0.1): + - Flutter + - FlutterMacOS + - open_filex (0.0.2): + - Flutter + - OrderedSet (6.0.3) + - package_info_plus (0.4.5): + - Flutter + - photo_manager (3.9.0): + - Flutter + - FlutterMacOS + - record_ios (1.2.1): + - Flutter + - SDWebImage (5.21.7): + - SDWebImage/Core (= 5.21.7) + - SDWebImage/Core (5.21.7) + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - SwiftyGif (5.4.5) + - url_launcher_ios (0.0.1): + - Flutter + - video_player_avfoundation (0.0.1): + - Flutter + - FlutterMacOS + - wakelock_plus (0.0.1): + - Flutter + - WebRTC-SDK (144.7559.09) + +DEPENDENCIES: + - app_links (from `.symlinks/plugins/app_links/ios`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - file_picker (from `.symlinks/plugins/file_picker/ios`) + - Flutter (from `Flutter`) + - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) + - flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`) + - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`) + - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) + - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) + - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) + - ogg_opus_player (from `.symlinks/plugins/ogg_opus_player/darwin`) + - open_filex (from `.symlinks/plugins/open_filex/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - photo_manager (from `.symlinks/plugins/photo_manager/darwin`) + - record_ios (from `.symlinks/plugins/record_ios/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) + - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) + +SPEC REPOS: + trunk: + - DKImagePickerController + - DKPhotoGallery + - OrderedSet + - SDWebImage + - SwiftyGif + - WebRTC-SDK + +EXTERNAL SOURCES: + app_links: + :path: ".symlinks/plugins/app_links/ios" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" + file_picker: + :path: ".symlinks/plugins/file_picker/ios" + Flutter: + :path: Flutter + flutter_inappwebview_ios: + :path: ".symlinks/plugins/flutter_inappwebview_ios/ios" + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" + flutter_secure_storage_darwin: + :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" + flutter_timezone: + :path: ".symlinks/plugins/flutter_timezone/ios" + flutter_webrtc: + :path: ".symlinks/plugins/flutter_webrtc/ios" + geolocator_apple: + :path: ".symlinks/plugins/geolocator_apple/darwin" + media_kit_video: + :path: ".symlinks/plugins/media_kit_video/ios" + mobile_scanner: + :path: ".symlinks/plugins/mobile_scanner/darwin" + ogg_opus_player: + :path: ".symlinks/plugins/ogg_opus_player/darwin" + open_filex: + :path: ".symlinks/plugins/open_filex/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + photo_manager: + :path: ".symlinks/plugins/photo_manager/darwin" + record_ios: + :path: ".symlinks/plugins/record_ios/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + video_player_avfoundation: + :path: ".symlinks/plugins/video_player_avfoundation/darwin" + wakelock_plus: + :path: ".symlinks/plugins/wakelock_plus/ios" + +SPEC CHECKSUMS: + app_links: a754cbec3c255bd4bbb4d236ecc06f28cd9a7ce8 + device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe + DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c + DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 + file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 + flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214 + flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 + flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 + flutter_webrtc: 3ee86aa10e0313c38c93fdb215dd86e77014f96a + geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e + media_kit_video: f3b0d035d89def15cfbbcf7dc2ae278f201e2f83 + mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 + ogg_opus_player: 954784304f4e2722780018c4abf284e4f93cddf5 + open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 + OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + photo_manager: 25fd77df14f4f0ba5ef99e2c61814dde77e2bceb + record_ios: 980fd386a97a35987d0fce3dfda4b26a38c90f4c + SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52 + wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 + WebRTC-SDK: e6006119cd730d6315d875e4a421b6cc8bb88833 + +PODFILE CHECKSUM: 527e8cb59c3d722cc71f78b05ab13f192b9335d3 + +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..fef42a3 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,779 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 4B1A4BBCD56B3CE42AD0A480 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 789A1AEF2F4FF7DEED202476 /* Pods_Runner.framework */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + AA11BB22CC33DD44EE550101 /* QlyraVideo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE550001 /* QlyraVideo.swift */; }; + AA11BB22CC33DD44EE550102 /* QlyraVideoNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE550002 /* QlyraVideoNote.swift */; }; + AA11BB22CC33DD44EE550103 /* QlyraNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE550003 /* QlyraNotifications.swift */; }; + AA11BB22CC33DD44EE550105 /* QlyraClipboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE550005 /* QlyraClipboard.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B1B2C3D41234567890ABCDEF /* MinimalIcon@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = A1B2C3D41234567890ABCDEF /* MinimalIcon@2x.png */; }; + B1B2C3D41234567890ABCDF0 /* MinimalIcon@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = A1B2C3D41234567890ABCDF0 /* MinimalIcon@3x.png */; }; + E835CC948F2F948DC3BBF405 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2AE8CB2654009FE276CACA4B /* Pods_RunnerTests.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 1615A82960D98F780416FDE7 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 28C4A8DBB53D5AC72DDB74A4 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 2AE8CB2654009FE276CACA4B /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 366C243BBAFE3F87FBDF57A1 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + AA11BB22CC33DD44EE550001 /* QlyraVideo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QlyraVideo.swift; sourceTree = ""; }; + AA11BB22CC33DD44EE550002 /* QlyraVideoNote.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QlyraVideoNote.swift; sourceTree = ""; }; + AA11BB22CC33DD44EE550003 /* QlyraNotifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QlyraNotifications.swift; sourceTree = ""; }; + AA11BB22CC33DD44EE550005 /* QlyraClipboard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QlyraClipboard.swift; sourceTree = ""; }; + AA11BB22CC33DD44EE550004 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; + 782E66FE4E292DCFD0D1B7D2 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 789A1AEF2F4FF7DEED202476 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 83CE6DC2BACBB6F59AEA57B6 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A1B2C3D41234567890ABCDEF /* MinimalIcon@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "MinimalIcon@2x.png"; sourceTree = ""; }; + A1B2C3D41234567890ABCDF0 /* MinimalIcon@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "MinimalIcon@3x.png"; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 9D7B0FFE63E0BD5D67D7758D /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 2594AD7A0161CA1A66191F05 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E835CC948F2F948DC3BBF405 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + 4B1A4BBCD56B3CE42AD0A480 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 3E066142FD515067AADAC58D /* Frameworks */ = { + isa = PBXGroup; + children = ( + 789A1AEF2F4FF7DEED202476 /* Pods_Runner.framework */, + 2AE8CB2654009FE276CACA4B /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + C4C44F130EEB07A77FD05026 /* Pods */, + 3E066142FD515067AADAC58D /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + A1B2C3D41234567890ABCDEF /* MinimalIcon@2x.png */, + A1B2C3D41234567890ABCDF0 /* MinimalIcon@3x.png */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + AA11BB22CC33DD44EE550001 /* QlyraVideo.swift */, + AA11BB22CC33DD44EE550002 /* QlyraVideoNote.swift */, + AA11BB22CC33DD44EE550003 /* QlyraNotifications.swift */, + AA11BB22CC33DD44EE550005 /* QlyraClipboard.swift */, + AA11BB22CC33DD44EE550004 /* Runner.entitlements */, + ); + path = Runner; + sourceTree = ""; + }; + C4C44F130EEB07A77FD05026 /* Pods */ = { + isa = PBXGroup; + children = ( + 83CE6DC2BACBB6F59AEA57B6 /* Pods-Runner.debug.xcconfig */, + 366C243BBAFE3F87FBDF57A1 /* Pods-Runner.release.xcconfig */, + 28C4A8DBB53D5AC72DDB74A4 /* Pods-Runner.profile.xcconfig */, + 1615A82960D98F780416FDE7 /* Pods-RunnerTests.debug.xcconfig */, + 782E66FE4E292DCFD0D1B7D2 /* Pods-RunnerTests.release.xcconfig */, + 9D7B0FFE63E0BD5D67D7758D /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 942752100DC29827FE94D2B2 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 2594AD7A0161CA1A66191F05 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 8FBC28ABA859CAFFB7CFEFA3 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 122816C3E1F98387D8AA58D3 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + B1B2C3D41234567890ABCDEF /* MinimalIcon@2x.png in Resources */, + B1B2C3D41234567890ABCDF0 /* MinimalIcon@3x.png in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 122816C3E1F98387D8AA58D3 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 8FBC28ABA859CAFFB7CFEFA3 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 942752100DC29827FE94D2B2 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + AA11BB22CC33DD44EE550101 /* QlyraVideo.swift in Sources */, + AA11BB22CC33DD44EE550102 /* QlyraVideoNote.swift in Sources */, + AA11BB22CC33DD44EE550103 /* QlyraNotifications.swift in Sources */, + AA11BB22CC33DD44EE550105 /* QlyraClipboard.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + PRODUCT_BUNDLE_IDENTIFIER = ru.ikar.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1615A82960D98F780416FDE7 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = ru.ikar.app.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 782E66FE4E292DCFD0D1B7D2 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = ru.ikar.app.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9D7B0FFE63E0BD5D67D7758D /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = ru.ikar.app.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + PRODUCT_BUNDLE_IDENTIFIER = ru.ikar.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + PRODUCT_BUNDLE_IDENTIFIER = ru.ikar.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..9121136 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,185 @@ +import Flutter +import UIKit + +final class QlyraStreamHandler: NSObject, FlutterStreamHandler { + private let onSink: (FlutterEventSink?) -> Void + + init(onSink: @escaping (FlutterEventSink?) -> Void) { + self.onSink = onSink + } + + func onListen( + withArguments arguments: Any?, + eventSink events: @escaping FlutterEventSink + ) -> FlutterError? { + onSink(events) + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + onSink(nil) + return nil + } +} + +@main +@objc class AppDelegate: FlutterAppDelegate { + private var channels: [FlutterMethodChannel] = [] + private var eventChannels: [FlutterEventChannel] = [] + private var streamHandlers: [QlyraStreamHandler] = [] + private var videoNote: QlyraVideoNote? + + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + QlyraNotifications.shared.start() + + if let controller = window?.rootViewController as? FlutterViewController { + let messenger = controller.binaryMessenger + registerAppIcon(messenger) + registerVideo(messenger) + registerVideoNote(messenger) + registerNotifications(messenger) + registerScreen(messenger) + registerClipboard(messenger) + } + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + private func method(_ name: String, _ messenger: FlutterBinaryMessenger, + _ handler: @escaping FlutterMethodCallHandler) { + let channel = FlutterMethodChannel(name: name, binaryMessenger: messenger) + channel.setMethodCallHandler(handler) + channels.append(channel) + } + + private func events(_ name: String, _ messenger: FlutterBinaryMessenger, + _ onSink: @escaping (FlutterEventSink?) -> Void) { + let handler = QlyraStreamHandler(onSink: onSink) + let channel = FlutterEventChannel(name: name, binaryMessenger: messenger) + channel.setStreamHandler(handler) + streamHandlers.append(handler) + eventChannels.append(channel) + } + + private func registerAppIcon(_ messenger: FlutterBinaryMessenger) { + method("ru.qlyra.app/app_icon", messenger) { call, result in + switch call.method { + case "getAppIcon": + result(UIApplication.shared.alternateIconName) + case "setAppIcon": + let requested = (call.arguments as? [String: Any])?["name"] as? String + let iconName: String? = (requested?.isEmpty ?? true) ? nil : requested + guard UIApplication.shared.supportsAlternateIcons else { + result(FlutterError(code: "UNSUPPORTED", + message: "Alternate icons are not supported", + details: nil)) + return + } + guard UIApplication.shared.alternateIconName != iconName else { + result(nil) + return + } + UIApplication.shared.setAlternateIconName(iconName) { error in + DispatchQueue.main.async { + if let error = error { + result(FlutterError(code: "APPLY_FAILED", + message: error.localizedDescription, + details: nil)) + } else { + result(nil) + } + } + } + default: + result(FlutterMethodNotImplemented) + } + } + } + + private func registerVideo(_ messenger: FlutterBinaryMessenger) { + method("ru.qlyra.app/video", messenger) { call, result in + QlyraVideo.shared.handle(call, result: result) + } + } + + private func registerVideoNote(_ messenger: FlutterBinaryMessenger) { + guard let textures = registrar(forPlugin: "QlyraVideoNote")?.textures() else { return } + + method("ru.qlyra.app/video_note", messenger) { [weak self] call, result in + guard let self = self else { return } + switch call.method { + case "permission": + QlyraVideoNote.requestPermission(result) + case "init": + let args = call.arguments as? [String: Any] ?? [:] + self.videoNote?.dispose() + let recorder = QlyraVideoNote(registry: textures) + self.videoNote = recorder + recorder.initialize( + front: (args["front"] as? NSNumber)?.boolValue ?? true, + cameraId: args["cameraId"] as? String, + edge: (args["size"] as? NSNumber)?.intValue ?? 480, + fps: (args["fps"] as? NSNumber)?.intValue ?? 30, + result: result) + case "start": + self.withRecorder(result) { $0.start(result: result) } + case "switch": + self.withRecorder(result) { $0.switchCamera(result: result) } + case "torch": + let on = ((call.arguments as? [String: Any])?["on"] as? NSNumber)?.boolValue ?? false + self.withRecorder(result) { $0.setTorch(on: on, result: result) } + case "stop": + self.withRecorder(result) { $0.stop(result: result) } + case "dispose": + self.videoNote?.dispose() + self.videoNote = nil + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + } + + private func withRecorder(_ result: @escaping FlutterResult, + _ body: (QlyraVideoNote) -> Void) { + guard let recorder = videoNote else { + result(FlutterError(code: "NOT_READY", message: "recorder not initialized", details: nil)) + return + } + body(recorder) + } + + private func registerScreen(_ messenger: FlutterBinaryMessenger) { + method("ru.qlyra.app/screen", messenger) { call, result in + switch call.method { + case "setKeepAwake": + let enabled = ((call.arguments as? [String: Any])?["enabled"] as? NSNumber)?.boolValue ?? false + DispatchQueue.main.async { + UIApplication.shared.isIdleTimerDisabled = enabled + result(nil) + } + default: + result(FlutterMethodNotImplemented) + } + } + } + + private func registerClipboard(_ messenger: FlutterBinaryMessenger) { + method("ru.qlyra.app/clipboard", messenger) { call, result in + QlyraClipboard.handle(call, result: result) + } + } + + private func registerNotifications(_ messenger: FlutterBinaryMessenger) { + method("ru.qlyra.app/notifications", messenger) { call, result in + QlyraNotifications.shared.handle(call, result: result) + } + events("ru.qlyra.app/notification_events", messenger) { sink in + QlyraNotifications.shared.attach(sink) + } + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d0d98aa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..39a6c10 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..688aa27 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..5eb3f45 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..8f47d1a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..8be303b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..236ff14 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..b6f1101 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..5eb3f45 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..c91493d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..7915106 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..409a1dc Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..2d739a9 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..2ed78bc Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..82444eb Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..7915106 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..98c42ab Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..be7a197 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..fc76b4f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..c8c8ea3 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..00226da Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..6df7d16 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..d4915b3 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,118 @@ + + + + + FlutterDeepLinkingEnabled + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + ИКАР + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ИКАР + CFBundleIcons + + CFBundleAlternateIcons + + MinimalIcon + + CFBundleIconFiles + + MinimalIcon + + UIPrerenderedIcon + + + + + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + NSFaceIDUsageDescription + Face ID разблокирует ИКАР вместо кода-пароля. + NSCameraUsageDescription + Камера нужна для съёмки фото и видео в чатах, сканирования QR-кода входа и работы веб-приложений. + NSMicrophoneUsageDescription + Микрофон нужен для записи голосовых сообщений, видео и звонков. + NSLocationWhenInUseUsageDescription + Геолокация нужна, чтобы отправлять ваше текущее местоположение в чатах. + NSPhotoLibraryUsageDescription + Доступ к галерее нужен, чтобы отправлять фото и видео в чатах. + NSPhotoLibraryAddUsageDescription + Доступ к галерее нужен, чтобы сохранять полученные фото и видео. + NSContactsUsageDescription + Доступ к контактам нужен, чтобы показывать имена собеседников так, как они записаны в вашей телефонной книге. + CFBundleURLTypes + + + CFBundleURLName + ru.ikar.app + CFBundleURLSchemes + + qlyra + max + + + + CFBundleLocalizations + + ru + en + + UIBackgroundModes + + audio + + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + + ITSAppUsesNonExemptEncryption + + LSApplicationQueriesSchemes + + tel + telprompt + sms + mailto + maps + comgooglemaps + yandexmaps + yandexnavi + + + diff --git a/ios/Runner/MinimalIcon@2x.png b/ios/Runner/MinimalIcon@2x.png new file mode 100644 index 0000000..9f0435a Binary files /dev/null and b/ios/Runner/MinimalIcon@2x.png differ diff --git a/ios/Runner/MinimalIcon@3x.png b/ios/Runner/MinimalIcon@3x.png new file mode 100644 index 0000000..51fa290 Binary files /dev/null and b/ios/Runner/MinimalIcon@3x.png differ diff --git a/ios/Runner/QlyraClipboard.swift b/ios/Runner/QlyraClipboard.swift new file mode 100644 index 0000000..804cdac --- /dev/null +++ b/ios/Runner/QlyraClipboard.swift @@ -0,0 +1,115 @@ +import Flutter +import UIKit + +enum QlyraClipboard { + private static let cacheDirectory = "clipboard_in" + private static let retention: TimeInterval = 24 * 60 * 60 + private static let recodeQuality: CGFloat = 0.95 + private static let passthrough: [(type: String, ext: String)] = [ + ("public.png", "png"), + ("public.jpeg", "jpg"), + ("com.compuserve.gif", "gif"), + ("org.webmproject.webp", "webp"), + ] + + static func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "hasMedia": + result(UIPasteboard.general.hasImages) + case "read": + read(result) + default: + result(FlutterMethodNotImplemented) + } + } + + private static func read(_ result: @escaping FlutterResult) { + let pasteboard = UIPasteboard.general + guard pasteboard.hasImages else { + result(nil) + return + } + + let items = pasteboard.items + DispatchQueue.global(qos: .userInitiated).async { + let images = items.compactMap(image(in:)) + let paths = images.isEmpty ? [] : store(images) + DispatchQueue.main.async { + result(paths.isEmpty ? nil : ["files": paths]) + } + } + } + + private static func image(in item: [String: Any]) -> (data: Data, ext: String)? { + for entry in passthrough { + guard let data = item[entry.type] as? Data, !data.isEmpty else { continue } + return (data, entry.ext) + } + for value in item.values { + if let image = value as? UIImage, let jpeg = recoded(image) { + return (jpeg, "jpg") + } + if let data = value as? Data, + let image = UIImage(data: data), + let jpeg = recoded(image) { + return (jpeg, "jpg") + } + } + return nil + } + + private static func recoded(_ image: UIImage) -> Data? { + guard let jpeg = image.jpegData(compressionQuality: recodeQuality), !jpeg.isEmpty else { + return nil + } + return jpeg + } + + private static func store(_ images: [(data: Data, ext: String)]) -> [String] { + guard let root = cacheRoot() else { return [] } + prune(root) + + let stamp = Int(Date().timeIntervalSince1970 * 1000) + var paths: [String] = [] + for (index, payload) in images.enumerated() { + let file = root.appendingPathComponent("paste_\(stamp)_\(index).\(payload.ext)") + do { + try payload.data.write(to: file, options: .atomic) + paths.append(file.path) + } catch { + NSLog("QlyraClipboard: cannot store a pasted image: \(error)") + } + } + return paths + } + + private static func cacheRoot() -> URL? { + let manager = FileManager.default + guard let caches = manager.urls(for: .cachesDirectory, in: .userDomainMask).first else { + return nil + } + let root = caches.appendingPathComponent(cacheDirectory, isDirectory: true) + do { + try manager.createDirectory(at: root, withIntermediateDirectories: true) + } catch { + NSLog("QlyraClipboard: cannot create the paste cache: \(error)") + return nil + } + return root + } + + private static func prune(_ root: URL) { + let manager = FileManager.default + let cutoff = Date().addingTimeInterval(-retention) + guard let entries = try? manager.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.contentModificationDateKey] + ) else { return } + + for entry in entries { + let values = try? entry.resourceValues(forKeys: [.contentModificationDateKey]) + if let modified = values?.contentModificationDate, modified > cutoff { continue } + try? manager.removeItem(at: entry) + } + } +} diff --git a/ios/Runner/QlyraNotifications.swift b/ios/Runner/QlyraNotifications.swift new file mode 100644 index 0000000..589f147 --- /dev/null +++ b/ios/Runner/QlyraNotifications.swift @@ -0,0 +1,105 @@ +import Flutter +import UIKit +import UserNotifications + +final class QlyraNotifications: NSObject { + static let shared = QlyraNotifications() + + private static let chatKeys = ["qlyra_chat", "chatId", "chat_id"] + + private var sink: FlutterEventSink? + private var pendingChatId: Int64 = 0 + private var activeChatId: Int64 = 0 + + func start() { + UNUserNotificationCenter.current().delegate = self + } + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "consumeInitialChat": + let chatId = pendingChatId + pendingChatId = 0 + result(chatId > 0 ? NSNumber(value: chatId) : nil) + case "setActiveChat": + activeChatId = Self.chatId(from: call.arguments) + dismissDelivered(chatId: activeChatId) + result(nil) + case "clearActiveChat": + activeChatId = 0 + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + + func attach(_ sink: FlutterEventSink?) { + self.sink = sink + } + + func deliver(chatId: Int64) { + guard chatId > 0 else { return } + if let sink = sink { + sink(NSNumber(value: chatId)) + } else { + pendingChatId = chatId + } + } + + private func dismissDelivered(chatId: Int64) { + guard chatId > 0 else { return } + let center = UNUserNotificationCenter.current() + center.getDeliveredNotifications { delivered in + let identifiers = delivered + .filter { Self.chatId(from: $0.request.content.userInfo) == chatId } + .map { $0.request.identifier } + guard !identifiers.isEmpty else { return } + center.removeDeliveredNotifications(withIdentifiers: identifiers) + } + } + + private static func chatId(from raw: Any?) -> Int64 { + if let number = raw as? NSNumber { return number.int64Value } + if let text = raw as? String { return Int64(text) ?? 0 } + if let map = raw as? [AnyHashable: Any] { + for key in chatKeys { + if let value = map[key], let parsed = optionalChatId(value) { return parsed } + } + } + return 0 + } + + private static func optionalChatId(_ raw: Any) -> Int64? { + if let number = raw as? NSNumber { return number.int64Value } + if let text = raw as? String { return Int64(text) } + return nil + } +} + +extension QlyraNotifications: UNUserNotificationCenterDelegate { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + let chatId = Self.chatId(from: notification.request.content.userInfo) + if chatId > 0, chatId == activeChatId { + completionHandler([]) + return + } + if #available(iOS 14.0, *) { + completionHandler([.banner, .list, .sound, .badge]) + } else { + completionHandler([.alert, .sound, .badge]) + } + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + deliver(chatId: Self.chatId(from: response.notification.request.content.userInfo)) + completionHandler() + } +} diff --git a/ios/Runner/QlyraVideo.swift b/ios/Runner/QlyraVideo.swift new file mode 100644 index 0000000..672fe02 --- /dev/null +++ b/ios/Runner/QlyraVideo.swift @@ -0,0 +1,333 @@ +import AVFoundation +import CoreImage +import Flutter +import UIKit + +private struct VideoExportSpec { + let input: String + let output: String + let startMs: Int? + let endMs: Int? + let removeAudio: Bool + let rotationDegrees: Double + let flipH: Bool + let crop: [Double]? + let outWidth: Int + let outHeight: Int + let rgbMatrix: [Double]? + let overlay: String? + let centerSquare: Bool + + init?(_ arguments: Any?) { + guard let args = arguments as? [String: Any], + let input = args["input"] as? String, + let output = args["output"] as? String else { return nil } + self.input = input + self.output = output + startMs = (args["startMs"] as? NSNumber)?.intValue + endMs = (args["endMs"] as? NSNumber)?.intValue + removeAudio = (args["removeAudio"] as? NSNumber)?.boolValue ?? false + rotationDegrees = (args["rotationDegrees"] as? NSNumber)?.doubleValue ?? 0 + flipH = (args["flipH"] as? NSNumber)?.boolValue ?? false + crop = (args["crop"] as? [NSNumber])?.map { $0.doubleValue } + outWidth = (args["outWidth"] as? NSNumber)?.intValue ?? 0 + outHeight = (args["outHeight"] as? NSNumber)?.intValue ?? 0 + rgbMatrix = (args["rgbMatrix"] as? [NSNumber])?.map { $0.doubleValue } + overlay = args["overlay"] as? String + centerSquare = false + } + + init(input: String, output: String, edge: Int) { + self.input = input + self.output = output + startMs = nil + endMs = nil + removeAudio = false + rotationDegrees = 0 + flipH = false + crop = nil + outWidth = edge + outHeight = edge + rgbMatrix = nil + overlay = nil + centerSquare = true + } +} + +final class QlyraVideo { + static let shared = QlyraVideo() + + private let queue = DispatchQueue(label: "ru.qlyra.app.video", qos: .userInitiated) + private var session: AVAssetExportSession? + private var cancelled = false + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "probe": + probe(call.arguments, result) + case "frames": + frames(call.arguments, result) + case "cropSquare": + cropSquare(call.arguments, result) + case "edit": + guard let spec = VideoExportSpec(call.arguments) else { + result(FlutterError(code: "BAD_ARGS", message: "input/output required", details: nil)) + return + } + export(spec) { ok in result(NSNumber(value: ok)) } + case "editProgress": + let value = session.map { Int(($0.progress * 100).rounded()) } ?? -1 + result(NSNumber(value: value)) + case "editCancel": + cancelled = true + session?.cancelExport() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + + private func probe(_ arguments: Any?, _ result: @escaping FlutterResult) { + guard let args = arguments as? [String: Any], let input = args["input"] as? String else { + result(FlutterError(code: "BAD_ARGS", message: "input required", details: nil)) + return + } + queue.async { + let asset = AVURLAsset(url: URL(fileURLWithPath: input)) + guard let track = asset.tracks(withMediaType: .video).first else { + Self.reply(result, nil) + return + } + let size = track.naturalSize.applying(track.preferredTransform) + let seconds = CMTimeGetSeconds(asset.duration) + let durationMs = seconds.isFinite && seconds > 0 ? Int((seconds * 1000).rounded()) : 0 + let fps = Double(track.nominalFrameRate) + let payload: [String: Any] = [ + "width": Int(abs(size.width).rounded()), + "height": Int(abs(size.height).rounded()), + "durationMs": durationMs, + "fps": fps > 0 ? fps : 30.0, + "hasAudio": !asset.tracks(withMediaType: .audio).isEmpty, + ] + Self.reply(result, payload) + } + } + + private func frames(_ arguments: Any?, _ result: @escaping FlutterResult) { + guard let args = arguments as? [String: Any], + let input = args["input"] as? String, + let times = args["times"] as? [NSNumber] else { + result(FlutterError(code: "BAD_ARGS", message: "input/times required", details: nil)) + return + } + let edge = (args["size"] as? NSNumber)?.intValue ?? 256 + let precise = (args["precise"] as? NSNumber)?.boolValue ?? false + queue.async { + let asset = AVURLAsset(url: URL(fileURLWithPath: input)) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.maximumSize = CGSize(width: edge, height: edge) + if precise { + generator.requestedTimeToleranceBefore = .zero + generator.requestedTimeToleranceAfter = .zero + } + var output: [Any] = [] + for time in times { + let at = CMTime(value: CMTimeValue(time.int64Value), timescale: 1000) + guard let cgImage = try? generator.copyCGImage(at: at, actualTime: nil), + let data = UIImage(cgImage: cgImage).jpegData(compressionQuality: 0.9) else { + output.append(NSNull()) + continue + } + output.append(FlutterStandardTypedData(bytes: data)) + } + Self.reply(result, output) + } + } + + private func cropSquare(_ arguments: Any?, _ result: @escaping FlutterResult) { + guard let args = arguments as? [String: Any], + let input = args["input"] as? String, + let output = args["output"] as? String else { + result(FlutterError(code: "BAD_ARGS", message: "input/output required", details: nil)) + return + } + let edge = (args["size"] as? NSNumber)?.intValue ?? 480 + export(VideoExportSpec(input: input, output: output, edge: edge)) { ok in + if ok { + result(output) + } else { + result(FlutterError(code: "TRANSCODE_FAILED", message: "export failed", details: nil)) + } + } + } + + private func export(_ spec: VideoExportSpec, completion: @escaping (Bool) -> Void) { + queue.async { + self.cancelled = false + let asset = AVURLAsset(url: URL(fileURLWithPath: spec.input)) + guard let videoTrack = asset.tracks(withMediaType: .video).first else { + Self.reply { completion(false) } + return + } + + let totalSeconds = CMTimeGetSeconds(asset.duration) + let start = CMTime(value: CMTimeValue(spec.startMs ?? 0), timescale: 1000) + let endMs = spec.endMs ?? (totalSeconds.isFinite ? Int((totalSeconds * 1000).rounded()) : 0) + let end = CMTime(value: CMTimeValue(max(endMs, spec.startMs ?? 0)), timescale: 1000) + let range = CMTimeRange(start: start, end: end) + guard range.duration.seconds > 0 else { + Self.reply { completion(false) } + return + } + + let composition = AVMutableComposition() + guard let compositionVideo = composition.addMutableTrack( + withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else { + Self.reply { completion(false) } + return + } + do { + try compositionVideo.insertTimeRange(range, of: videoTrack, at: .zero) + } catch { + Self.reply { completion(false) } + return + } + compositionVideo.preferredTransform = videoTrack.preferredTransform + + if !spec.removeAudio, + let audioTrack = asset.tracks(withMediaType: .audio).first, + let compositionAudio = composition.addMutableTrack( + withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) { + try? compositionAudio.insertTimeRange(range, of: audioTrack, at: .zero) + } + + let natural = videoTrack.naturalSize.applying(videoTrack.preferredTransform) + let outWidth = spec.outWidth > 0 ? spec.outWidth : Int(abs(natural.width).rounded()) + let outHeight = spec.outHeight > 0 ? spec.outHeight : Int(abs(natural.height).rounded()) + guard outWidth > 0, outHeight > 0 else { + Self.reply { completion(false) } + return + } + + let transform = videoTrack.preferredTransform + let overlay = spec.overlay.flatMap { UIImage(contentsOfFile: $0) }.flatMap { CIImage(image: $0) } + let renderSize = CGSize(width: outWidth, height: outHeight) + + let videoComposition = AVMutableVideoComposition(asset: composition) { request in + let image = Self.render( + request.sourceImage, + spec: spec, + transform: transform, + overlay: overlay, + renderSize: renderSize) + request.finish(with: image, context: nil) + } + videoComposition.renderSize = renderSize + + let outputURL = URL(fileURLWithPath: spec.output) + try? FileManager.default.removeItem(at: outputURL) + + guard let session = AVAssetExportSession( + asset: composition, presetName: AVAssetExportPresetHighestQuality) else { + Self.reply { completion(false) } + return + } + session.outputURL = outputURL + session.outputFileType = .mp4 + session.videoComposition = videoComposition + session.shouldOptimizeForNetworkUse = true + self.session = session + + session.exportAsynchronously { + let ok = session.status == .completed && !self.cancelled + self.session = nil + if !ok { try? FileManager.default.removeItem(at: outputURL) } + Self.reply { completion(ok) } + } + } + } + + private static func render( + _ source: CIImage, + spec: VideoExportSpec, + transform: CGAffineTransform, + overlay: CIImage?, + renderSize: CGSize + ) -> CIImage { + var image = normalized(source.transformed(by: transform)) + + if spec.flipH { + image = normalized(image.transformed(by: CGAffineTransform(scaleX: -1, y: 1))) + } + if abs(spec.rotationDegrees) > 0.01 { + let radians = CGFloat(spec.rotationDegrees * .pi / 180) + image = normalized(image.transformed(by: CGAffineTransform(rotationAngle: radians))) + } + if spec.centerSquare { + let extent = image.extent + let side = min(extent.width, extent.height) + image = normalized(image.cropped(to: CGRect( + x: extent.midX - side / 2, + y: extent.midY - side / 2, + width: side, + height: side))) + } else if let crop = spec.crop, crop.count == 4 { + let extent = image.extent + let left = CGFloat((crop[0] + 1) / 2) + let right = CGFloat((crop[1] + 1) / 2) + let bottom = CGFloat((1 - crop[2]) / 2) + let top = CGFloat((1 - crop[3]) / 2) + let rect = CGRect( + x: extent.minX + left * extent.width, + y: extent.minY + (1 - bottom) * extent.height, + width: max(1, (right - left) * extent.width), + height: max(1, (bottom - top) * extent.height)) + image = normalized(image.cropped(to: rect)) + } + + let extent = image.extent + if extent.width > 0, extent.height > 0 { + image = image.transformed(by: CGAffineTransform( + scaleX: renderSize.width / extent.width, + y: renderSize.height / extent.height)) + image = normalized(image) + } + + if let matrix = spec.rgbMatrix, matrix.count == 16, + let filter = CIFilter(name: "CIColorMatrix") { + filter.setValue(image, forKey: kCIInputImageKey) + filter.setValue(vector(matrix, 0, 4, 8), forKey: "inputRVector") + filter.setValue(vector(matrix, 1, 5, 9), forKey: "inputGVector") + filter.setValue(vector(matrix, 2, 6, 10), forKey: "inputBVector") + filter.setValue(CIVector(x: 0, y: 0, z: 0, w: 1), forKey: "inputAVector") + filter.setValue(vector(matrix, 12, 13, 14), forKey: "inputBiasVector") + if let output = filter.outputImage { image = output } + } + + if let overlay = overlay { + image = overlay.composited(over: image) + } + + return image.cropped(to: CGRect(origin: .zero, size: renderSize)) + } + + private static func vector(_ m: [Double], _ x: Int, _ y: Int, _ z: Int) -> CIVector { + CIVector(x: CGFloat(m[x]), y: CGFloat(m[y]), z: CGFloat(m[z]), w: 0) + } + + private static func normalized(_ image: CIImage) -> CIImage { + let extent = image.extent + guard extent.origin != .zero else { return image } + return image.transformed( + by: CGAffineTransform(translationX: -extent.origin.x, y: -extent.origin.y)) + } + + private static func reply(_ result: @escaping FlutterResult, _ value: Any?) { + DispatchQueue.main.async { result(value) } + } + + private static func reply(_ block: @escaping () -> Void) { + DispatchQueue.main.async(execute: block) + } +} diff --git a/ios/Runner/QlyraVideoNote.swift b/ios/Runner/QlyraVideoNote.swift new file mode 100644 index 0000000..c641891 --- /dev/null +++ b/ios/Runner/QlyraVideoNote.swift @@ -0,0 +1,414 @@ +import AVFoundation +import CoreImage +import Flutter +import UIKit + +final class QlyraVideoNoteTexture: NSObject, FlutterTexture { + private let lock = NSLock() + private var latest: CVPixelBuffer? + + func push(_ buffer: CVPixelBuffer) { + lock.lock() + latest = buffer + lock.unlock() + } + + func copyPixelBuffer() -> Unmanaged? { + lock.lock() + defer { lock.unlock() } + guard let buffer = latest else { return nil } + return Unmanaged.passRetained(buffer) + } +} + +final class QlyraVideoNote: NSObject { + private let registry: FlutterTextureRegistry + private let queue = DispatchQueue(label: "ru.qlyra.app.videonote", qos: .userInitiated) + private let ciContext = CIContext(options: [.useSoftwareRenderer: false]) + + private let session = AVCaptureSession() + private let videoOutput = AVCaptureVideoDataOutput() + private let audioOutput = AVCaptureAudioDataOutput() + private let texture = QlyraVideoNoteTexture() + + private var textureId: Int64 = 0 + private var deviceInput: AVCaptureDeviceInput? + private var audioInput: AVCaptureDeviceInput? + private var position: AVCaptureDevice.Position = .front + private var preferredCameraId: String? + private var edge: Int = 480 + private var fps: Int = 30 + + private var pixelBufferPool: CVPixelBufferPool? + private var writer: AVAssetWriter? + private var writerVideo: AVAssetWriterInput? + private var writerAudio: AVAssetWriterInput? + private var adaptor: AVAssetWriterInputPixelBufferAdaptor? + private var outputURL: URL? + private var recording = false + private var sessionStarted = false + + init(registry: FlutterTextureRegistry) { + self.registry = registry + super.init() + } + + static func requestPermission(_ result: @escaping FlutterResult) { + AVCaptureDevice.requestAccess(for: .video) { video in + AVCaptureDevice.requestAccess(for: .audio) { audio in + DispatchQueue.main.async { + result(["camera": NSNumber(value: video), "microphone": NSNumber(value: audio)]) + } + } + } + } + + func initialize( + front: Bool, cameraId: String?, edge: Int, fps: Int, + result: @escaping FlutterResult + ) { + self.position = front ? .front : .back + self.preferredCameraId = cameraId?.isEmpty == false ? cameraId : nil + self.edge = max(16, edge) + self.fps = max(1, fps) + + queue.async { + guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized else { + Self.fail(result, "NO_CAMERA_PERMISSION", "camera permission required") + return + } + guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else { + Self.fail(result, "NO_MIC_PERMISSION", "microphone permission required") + return + } + do { + try self.configureSession() + } catch { + Self.fail(result, "NO_CAMERA", error.localizedDescription) + return + } + self.session.startRunning() + + DispatchQueue.main.async { + if self.textureId == 0 { + self.textureId = self.registry.register(self.texture) + } + result(["textureId": NSNumber(value: self.textureId), "hasFlash": self.hasTorch()]) + } + } + } + + func switchCamera(result: @escaping FlutterResult) { + queue.async { + self.position = self.position == .front ? .back : .front + self.preferredCameraId = nil + do { + try self.configureSession() + } catch { + Self.fail(result, "NO_CAMERA", error.localizedDescription) + return + } + DispatchQueue.main.async { result(nil) } + } + } + + func setTorch(on: Bool, result: @escaping FlutterResult) { + queue.async { + guard let device = self.deviceInput?.device, device.hasTorch else { + DispatchQueue.main.async { result(NSNumber(value: false)) } + return + } + var applied = false + if (try? device.lockForConfiguration()) != nil { + device.torchMode = on ? .on : .off + device.unlockForConfiguration() + applied = on + } + DispatchQueue.main.async { result(NSNumber(value: applied)) } + } + } + + func start(result: @escaping FlutterResult) { + queue.async { + guard !self.recording else { + DispatchQueue.main.async { result(nil) } + return + } + guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else { + Self.fail(result, "NO_MIC_PERMISSION", "microphone permission required") + return + } + do { + try self.prepareWriter() + } catch { + Self.fail(result, "START_FAILED", error.localizedDescription) + return + } + self.sessionStarted = false + self.recording = true + DispatchQueue.main.async { result(nil) } + } + } + + func stop(result: @escaping FlutterResult) { + queue.async { + guard self.recording, let writer = self.writer else { + Self.fail(result, "NOT_RECORDING", "no active recording") + return + } + self.recording = false + self.writerVideo?.markAsFinished() + self.writerAudio?.markAsFinished() + let url = self.outputURL + writer.finishWriting { + let ok = writer.status == .completed + self.writer = nil + self.writerVideo = nil + self.writerAudio = nil + self.adaptor = nil + self.outputURL = nil + let path: String? = ok ? url?.path : nil + DispatchQueue.main.async { + result(path) + } + } + } + } + + func dispose() { + queue.sync { + if self.recording { + self.recording = false + self.writerVideo?.markAsFinished() + self.writerAudio?.markAsFinished() + self.writer?.cancelWriting() + self.writer = nil + } + if self.session.isRunning { self.session.stopRunning() } + for input in self.session.inputs { self.session.removeInput(input) } + for output in self.session.outputs { self.session.removeOutput(output) } + self.deviceInput = nil + self.audioInput = nil + self.pixelBufferPool = nil + } + if textureId != 0 { + registry.unregisterTexture(textureId) + textureId = 0 + } + } + + private func hasTorch() -> Bool { + deviceInput?.device.hasTorch ?? false + } + + private func configureSession() throws { + session.beginConfiguration() + defer { session.commitConfiguration() } + + if let existing = deviceInput { + session.removeInput(existing) + deviceInput = nil + } + + if let id = preferredCameraId, + let chosen = AVCaptureDevice(uniqueID: id), + chosen.position != .unspecified { + position = chosen.position + } + guard let device = preferredCameraId.flatMap({ AVCaptureDevice(uniqueID: $0) }) + ?? AVCaptureDevice.default( + .builtInWideAngleCamera, for: .video, position: position) + ?? AVCaptureDevice.default(for: .video) else { + throw NSError(domain: "QlyraVideoNote", code: 1, + userInfo: [NSLocalizedDescriptionKey: "no camera found"]) + } + let input = try AVCaptureDeviceInput(device: device) + guard session.canAddInput(input) else { + throw NSError(domain: "QlyraVideoNote", code: 2, + userInfo: [NSLocalizedDescriptionKey: "camera input rejected"]) + } + session.addInput(input) + deviceInput = input + + if session.canSetSessionPreset(.hd1280x720) { + session.sessionPreset = .hd1280x720 + } + + if audioInput == nil, + let microphone = AVCaptureDevice.default(for: .audio), + let input = try? AVCaptureDeviceInput(device: microphone), + session.canAddInput(input) { + session.addInput(input) + audioInput = input + } + + if !session.outputs.contains(videoOutput) { + videoOutput.videoSettings = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + ] + videoOutput.alwaysDiscardsLateVideoFrames = true + videoOutput.setSampleBufferDelegate(self, queue: queue) + if session.canAddOutput(videoOutput) { session.addOutput(videoOutput) } + } + if !session.outputs.contains(audioOutput) { + audioOutput.setSampleBufferDelegate(self, queue: queue) + if session.canAddOutput(audioOutput) { session.addOutput(audioOutput) } + } + + if let connection = videoOutput.connection(with: .video) { + if connection.isVideoOrientationSupported { + connection.videoOrientation = .portrait + } + if connection.isVideoMirroringSupported { + connection.automaticallyAdjustsVideoMirroring = false + connection.isVideoMirrored = position == .front + } + } + + if (try? device.lockForConfiguration()) != nil { + let duration = CMTimeMake(value: 1, timescale: Int32(fps)) + if device.activeFormat.videoSupportedFrameRateRanges.contains(where: { + $0.minFrameRate <= Double(fps) && Double(fps) <= $0.maxFrameRate + }) { + device.activeVideoMinFrameDuration = duration + device.activeVideoMaxFrameDuration = duration + } + device.unlockForConfiguration() + } + + pixelBufferPool = Self.makePool(edge: edge) + } + + private func prepareWriter() throws { + let directory = FileManager.default.temporaryDirectory + let url = directory.appendingPathComponent( + "qlyra_note_\(Int(Date().timeIntervalSince1970 * 1000)).mp4") + try? FileManager.default.removeItem(at: url) + + let writer = try AVAssetWriter(outputURL: url, fileType: .mp4) + let videoSettings: [String: Any] = [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: edge, + AVVideoHeightKey: edge, + AVVideoCompressionPropertiesKey: [ + AVVideoAverageBitRateKey: 1_024_000, + AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel, + ], + ] + let video = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings) + video.expectsMediaDataInRealTime = true + guard writer.canAdd(video) else { + throw NSError(domain: "QlyraVideoNote", code: 3, + userInfo: [NSLocalizedDescriptionKey: "video input rejected"]) + } + writer.add(video) + + let audioSettings: [String: Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVNumberOfChannelsKey: 1, + AVSampleRateKey: 44100, + AVEncoderBitRateKey: 64000, + ] + let audio = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings) + audio.expectsMediaDataInRealTime = true + if writer.canAdd(audio) { writer.add(audio) } + + adaptor = AVAssetWriterInputPixelBufferAdaptor( + assetWriterInput: video, + sourcePixelBufferAttributes: [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + kCVPixelBufferWidthKey as String: edge, + kCVPixelBufferHeightKey as String: edge, + ]) + + guard writer.startWriting() else { + throw NSError(domain: "QlyraVideoNote", code: 4, + userInfo: [NSLocalizedDescriptionKey: "writer refused to start"]) + } + + self.writer = writer + self.writerVideo = video + self.writerAudio = writer.inputs.contains(audio) ? audio : nil + self.outputURL = url + } + + private static func makePool(edge: Int) -> CVPixelBufferPool? { + let attributes: [String: Any] = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + kCVPixelBufferWidthKey as String: edge, + kCVPixelBufferHeightKey as String: edge, + kCVPixelBufferIOSurfacePropertiesKey as String: [String: Any](), + ] + var pool: CVPixelBufferPool? + CVPixelBufferPoolCreate(kCFAllocatorDefault, nil, attributes as CFDictionary, &pool) + return pool + } + + private func squareBuffer(from source: CVPixelBuffer) -> CVPixelBuffer? { + guard let pool = pixelBufferPool else { return nil } + var target: CVPixelBuffer? + guard CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &target) + == kCVReturnSuccess, let output = target else { return nil } + + let image = CIImage(cvPixelBuffer: source) + let extent = image.extent + let side = min(extent.width, extent.height) + let cropped = image.cropped(to: CGRect( + x: extent.midX - side / 2, + y: extent.midY - side / 2, + width: side, + height: side)) + let scale = CGFloat(edge) / side + let scaled = cropped + .transformed(by: CGAffineTransform(translationX: -cropped.extent.origin.x, + y: -cropped.extent.origin.y)) + .transformed(by: CGAffineTransform(scaleX: scale, y: scale)) + + ciContext.render(scaled, to: output) + return output + } + + private static func fail(_ result: @escaping FlutterResult, _ code: String, _ message: String) { + DispatchQueue.main.async { + result(FlutterError(code: code, message: message, details: nil)) + } + } +} + +extension QlyraVideoNote: AVCaptureVideoDataOutputSampleBufferDelegate, + AVCaptureAudioDataOutputSampleBufferDelegate { + func captureOutput( + _ output: AVCaptureOutput, + didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection + ) { + if output === audioOutput { + appendAudio(sampleBuffer) + return + } + guard let source = CMSampleBufferGetImageBuffer(sampleBuffer), + let square = squareBuffer(from: source) else { return } + + texture.push(square) + let id = textureId + if id != 0 { + DispatchQueue.main.async { self.registry.textureFrameAvailable(id) } + } + + guard recording, let writer = writer, let input = writerVideo, + let adaptor = adaptor else { return } + let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) + if !sessionStarted { + writer.startSession(atSourceTime: timestamp) + sessionStarted = true + } + guard input.isReadyForMoreMediaData else { return } + adaptor.append(square, withPresentationTime: timestamp) + } + + private func appendAudio(_ sampleBuffer: CMSampleBuffer) { + guard recording, sessionStarted, let input = writerAudio, + input.isReadyForMoreMediaData else { return } + input.append(sampleBuffer) + } +} diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..e68e741 --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,10 @@ + + + + + keychain-access-groups + + $(AppIdentifierPrefix)ru.ikar.app + + + diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..1437ccc --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,4 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +output-class: AppLocalizations diff --git a/lib/backend/api.dart b/lib/backend/api.dart new file mode 100644 index 0000000..ffacea8 --- /dev/null +++ b/lib/backend/api.dart @@ -0,0 +1,367 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:http/http.dart' as http; +import '../core/config/config.dart'; +import '../core/config/countries.dart'; +import '../core/protocol/opcode_map.dart'; +import '../core/protocol/packet.dart'; +import '../core/storage/token_storage.dart'; +import '../core/storage/ikar_device_key.dart'; +import '../core/transport/dispatcher.dart'; +import '../core/utils/logger.dart'; +import 'ikar_transport.dart'; +import 'ikar_media_session.dart'; +import 'login_gate.dart'; + +enum SessionState { disconnected, connecting, connected, online } + +/// Client for ikar_server. +class Api { + Api({IkarTransport Function(Uri)? transportFactory}) + : _transportFactory = transportFactory ?? IkarTransport.new; + + final IkarTransport Function(Uri) _transportFactory; + final PacketDispatcher _dispatcher = PacketDispatcher(); + final _states = StreamController.broadcast(); + final _expired = StreamController.broadcast(); + final _handshakes = StreamController.broadcast(); + final _errors = StreamController.broadcast(); + final LoginGate _loginGate = LoginGate(); + SessionState _state = SessionState.disconnected; + IkarTransport? _transport; + IkarMediaSession? _media; + StreamSubscription>? _events; + Timer? _heartbeat; + Timer? _reconnect; + Future Function()? _onReconnect; + Future? _refreshing; + bool _autoReconnect = false; + bool _disposed = false; + int _epoch = 0; + int _generation = 0; + String? _deviceId; + final _challenges = {}; + String? spoofScope; + Map? get userAgent => { + 'deviceType': 'ANDROID', + 'deviceName': 'ИКАР', + 'appVersion': '1.0.4', + }; + int? get callsSeed => null; + String? get deviceId => _deviceId; + String? get callsDevice => 'ИКАР'; + String? get callsOsVersion => 'Android'; + String get architecture => 'arm64-v8a'; + IkarMediaSession? get session => _media; + SessionState get state => _state; + int get sessionEpoch => _epoch; + bool get hasLoginAttempt => _loginGate.loginAttempted; + List get registrationCountries => allCountries; + Stream get stateStream => _states.stream; + Stream get sessionExpiredStream => _expired.stream; + Stream get handshakeSuccessStream => _handshakes.stream; + Stream get errorStream => _errors.stream; + Stream get pushStream => _dispatcher.pushStream; + void _setState(SessionState state) { + _state = state; + if (!_states.isClosed) _states.add(state); + } + + Future connect({bool allowAutomaticLogin = true}) async { + if (_disposed || _state != SessionState.disconnected) return; + final generation = ++_generation; + _autoReconnect = true; + _loginGate.close(); + _setState(SessionState.connecting); + try { + final endpoint = await ServerConfig.loadEndpoint(); + final baseUri = Uri( + scheme: 'https', + host: endpoint.host, + port: endpoint.port, + ); + final transport = _transportFactory(baseUri); + _transport = transport; + final ping = await transport.request(Opcode.ping, {}); + if (ping['cmd'] != 1) throw StateError('Сервер ИКАР недоступен'); + if (generation != _generation) { + await transport.close(); + return; + } + _media = IkarMediaSession(baseUri); + _events = transport.events.listen( + (event) => _dispatcher.dispatch( + Packet( + cmd: CmdType.push, + opcode: (event['opcode'] as num).toInt(), + payload: event['payload'], + ), + ), + ); + _epoch++; + _state = SessionState.online; + if (allowAutomaticLogin && _onReconnect != null) await _onReconnect!(); + _loginGate.open(); + _setState(SessionState.online); + _handshakes.add('ИКАР'); + _heartbeat = Timer.periodic( + const Duration(seconds: 10), + (_) => unawaited(_checkConnection()), + ); + } catch (error) { + if (generation != _generation) return; + logger.w('IKAR reconnect failed: $error'); + await _clear(); + _setState(SessionState.disconnected); + _scheduleReconnect(); + } + } + + Future _checkConnection() async { + final transport = _transport; + if (transport == null || _state != SessionState.online) return; + try { + await sendRequest(Opcode.ping, {}, silent: true); + if (transport.token != null && !transport.eventsConnected) + await transport.connectEvents(); + } catch (_) { + if (identical(_transport, transport)) { + await _clear(); + _setState(SessionState.disconnected); + _scheduleReconnect(); + } + } + } + + void _scheduleReconnect() { + if (!_autoReconnect || _disposed) return; + _reconnect?.cancel(); + _reconnect = Timer(const Duration(seconds: 5), () => unawaited(connect())); + } + + Future sendRequest( + int opcode, + Map payload, { + bool silent = false, + void Function()? beforeSend, + }) async { + final initial = _transport; + if (initial == null && _state != SessionState.connecting) + throw StateError('Нет соединения (${Opcode.name(opcode)})'); + if (opcode != Opcode.login) + await _loginGate.wait(ServerConfig.requestTimeout, Opcode.name(opcode)); + final transport = _transport; + if (transport == null) + throw StateError('Нет соединения (${Opcode.name(opcode)})'); + if (beforeSend != null && !identical(transport, initial)) + throw StateError('Сессия изменилась до отправки'); + beforeSend?.call(); + final body = Map.from(payload); + if (opcode == Opcode.authRequest) { + final identity = await IkarDeviceKey.identity(body['phone'].toString()); + _deviceId = identity['installationId']; + body.addAll(identity); + } + if (opcode == Opcode.auth && _challenges.containsKey(body['token'])) { + final challenge = _challenges.remove(body['token'])!; + body['deviceSignature'] = await IkarDeviceKey.sign( + challenge.payload, + challenge.scope, + ); + } + if (opcode == Opcode.login || opcode == Opcode.authConfirm) + transport.token = body['token'] as String? ?? transport.token; + if (opcode == Opcode.login) _loginGate.noteLoginSent(); + var result = await _request(transport, opcode, body); + if (result['cmd'] == 3 && + (result['payload'] as Map?)?['message'] == 'FAIL_LOGIN_TOKEN') { + if (await _refreshToken(transport)) + result = await _request(transport, opcode, body); + } + final packet = Packet( + cmd: (result['cmd'] as num?)?.toInt() ?? 3, + opcode: opcode, + payload: result['payload'], + ); + if (opcode == Opcode.login) _loginGate.noteLoginAnswer(ok: packet.isOk); + if (packet.isError) { + if (isSessionExpiredPayload(packet.payload)) { + final error = SessionExpiredException( + messageFromErrorPayload(packet.payload), + ); + _expired.add(error); + throw error; + } + final message = messageFromErrorPayload(packet.payload); + if (!silent) _errors.add(message); + throw PacketError( + message, + errorKey: + result['errorKey'] as String? ?? + (packet.payload as Map?)?['error'] as String?, + ); + } + if (packet.payload is Map) { + final data = packet.payload as Map; + if (opcode == Opcode.authRequest && data['signaturePayload'] is String) + _challenges[data['token'] as String] = ( + payload: data['signaturePayload'] as String, + scope: body['phone'].toString(), + ); + await _saveIssuedTokens(data, transport); + } + if (opcode == Opcode.login) await transport.connectEvents(); + return packet; + } + + Future> _request( + IkarTransport transport, + int opcode, + Map body, + ) async { + try { + return await transport.request(opcode, body); + } catch (error) { + if (error is SocketException || + error is HttpException || + error is HandshakeException || + error is TimeoutException || + error is http.ClientException) { + if (identical(_transport, transport) && + _state != SessionState.disconnected) { + _setState(SessionState.disconnected); + await _clear(); + _scheduleReconnect(); + } + } + rethrow; + } + } + + Future _saveIssuedTokens(Map data, IkarTransport transport) async { + final attrs = data['tokenAttrs']; + Map? issued; + if (attrs is Map) issued = (attrs['LOGIN'] ?? attrs['REGISTER']) as Map?; + if (data['token'] is String && data['refreshToken'] is String) + issued = data; + if (issued == null) return; + final token = issued['token'] as String?; + final refresh = issued['refreshToken'] as String?; + final id = + (data['accountId'] as num?)?.toInt() ?? + ((data['profile'] as Map?)?['contact'] as Map?)?['id'] as int?; + if (token != null) transport.token = token; + if (id != null && token != null) { + await TokenStorage.saveToken(token, id); + if (refresh != null) + await TokenStorage.writeSecure('ikar_refresh_$id', refresh); + } + } + + Future _refreshToken(IkarTransport transport) async { + if (_refreshing != null) return _refreshing!; + final future = () async { + final id = await TokenStorage.getActiveAccountId(); + if (id == null) return false; + final refresh = await TokenStorage.readSecure('ikar_refresh_$id'); + if (refresh == null) return false; + final session = await transport.refresh(refresh); + if (session == null) return false; + final access = session['accessToken'] as String?; + final nextRefresh = session['refreshToken'] as String?; + if (access == null || nextRefresh == null) return false; + transport.token = access; + await TokenStorage.saveToken(access, id); + await TokenStorage.writeSecure('ikar_refresh_$id', nextRefresh); + return true; + }(); + _refreshing = future; + try { + return await future; + } finally { + if (identical(_refreshing, future)) _refreshing = null; + } + } + + Future?> sendRequestMap( + int opcode, + Map payload, + ) async => (await sendRequest(opcode, payload)).payload as Map?; + Future sendRequestOk(int opcode, Map payload) async => + (await sendRequest(opcode, payload)).isOk; + Future sendRequestOrThrow( + int opcode, + Map payload, + ) => sendRequest(opcode, payload); + void registerPushHandler(int opcode, void Function(Packet) handler) => + _dispatcher.registerHandler(opcode, handler); + void unregisterPushHandler(int opcode) => + _dispatcher.unregisterHandler(opcode); + void setReconnectCallback(Future Function() callback) => + _onReconnect = callback; + void wakeUp() { + if (_state == SessionState.disconnected) + unawaited(connect()); + else + unawaited(_checkConnection()); + } + + void sendPing({required bool interactive}) => unawaited(_checkConnection()); + Future reconnectToEndpoint({ + String? host, + int? port, + bool trustMincifryCa = false, + }) async { + if (host != null && port != null) { + await ServerConfig.saveEndpoint( + host: host, + port: port, + trustMincifryCa: false, + ); + } + await reconnectAndLogin(); + return _state == SessionState.online; + } + + Future reconnectAndLogin() async { + await disconnect(); + await connect(); + } + + Future reconnectPreLogin() async { + await disconnect(); + await connect(allowAutomaticLogin: false); + if (state != SessionState.online) + throw StateError('Не удалось подключиться к ИКАР'); + } + + Future _clear() async { + _heartbeat?.cancel(); + await _events?.cancel(); + _events = null; + final transport = _transport; + _transport = null; + _media?.dispose(); + _media = null; + await transport?.close(); + _loginGate.close(); + } + + Future disconnect() async { + _generation++; + _autoReconnect = false; + _reconnect?.cancel(); + await _clear(); + _setState(SessionState.disconnected); + } + + Future dispose() async { + _disposed = true; + await disconnect(); + _dispatcher.dispose(); + await _states.close(); + await _expired.close(); + await _handshakes.close(); + await _errors.close(); + } +} diff --git a/lib/backend/ikar_media_session.dart b/lib/backend/ikar_media_session.dart new file mode 100644 index 0000000..bb3891e --- /dev/null +++ b/lib/backend/ikar_media_session.dart @@ -0,0 +1,101 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:kusoft/kusoft.dart' as kb; +import '../core/transport/ikar_network.dart'; + +/// Media reservations are short-lived URLs on ikar_server. +class IkarMediaSession { + IkarMediaSession(this.baseUri); + final Uri baseUri; + String userAgent() => 'IKAR/1.0.0 Android'; + final _client = IkarNetwork.client(); + Stream _upload( + String url, + Stream> bytes, + int size, + String filename, + String contentType, + ) async* { + try { + final uri = Uri.parse(url); + if (uri.host != baseUri.host || + uri.scheme != baseUri.scheme || + uri.port != baseUri.port) + throw StateError('Адрес загрузки принадлежит другому серверу'); + final request = await _client.postUrl( + uri.replace( + queryParameters: {...uri.queryParameters, 'filename': filename}, + ), + ); + request.headers.set('Content-Type', contentType); + request.contentLength = size; + var sent = 0; + await for (final chunk in bytes) { + request.add(chunk); + sent += chunk.length; + yield kb.UploadEvent_Progress( + sent: BigInt.from(sent), + total: BigInt.from(size), + ); + } + final response = await request.close().timeout( + const Duration(minutes: 5), + ); + final body = await response.fold>( + [], + (buffer, chunk) => buffer..addAll(chunk), + ); + yield kb.UploadEvent_Done( + status: response.statusCode, + body: Uint8List.fromList(body), + ); + } catch (error) { + yield kb.UploadEvent_Error(message: '$error'); + } + } + + Stream uploadFilePath({ + required String url, + required String path, + required String filename, + String? contentType, + String? connection, + }) async* { + final file = File(path); + yield* _upload( + url, + file.openRead(), + await file.length(), + filename, + contentType ?? 'application/octet-stream', + ); + } + + Stream uploadPhoto({ + required String url, + required Uint8List data, + required String filename, + }) => _upload(url, Stream.value(data), data.length, filename, 'image/jpeg'); + Stream uploadPhotoPath({ + required String url, + required String path, + required String filename, + }) => uploadFilePath( + url: url, + path: path, + filename: filename, + contentType: 'image/jpeg', + ); + Stream uploadVideoPath({ + required String url, + required String path, + required int chunkSize, + required int concurrency, + }) => uploadFilePath( + url: url, + path: path, + filename: 'video.mp4', + contentType: 'video/mp4', + ); + void dispose() => _client.close(force: true); +} diff --git a/lib/backend/ikar_transport.dart b/lib/backend/ikar_transport.dart new file mode 100644 index 0000000..c665ad7 --- /dev/null +++ b/lib/backend/ikar_transport.dart @@ -0,0 +1,146 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:http/http.dart' as http; +import 'package:http/io_client.dart'; +import '../core/transport/ikar_network.dart'; + +/// HTTPS requests and authenticated SignalR events from ikar_server. +class IkarTransport { + IkarTransport(this.baseUri, {http.Client? client}) + : _client = client ?? IOClient(IkarNetwork.client()) { + if (baseUri.scheme != 'https' && + !['localhost', '127.0.0.1', '::1'].contains(baseUri.host)) + throw ArgumentError('ИКАР требует HTTPS'); + } + final Uri baseUri; + final http.Client _client; + String? token; + WebSocket? _socket; + Timer? _keepAlive; + final _events = StreamController>.broadcast(); + Stream> get events => _events.stream; + bool get eventsConnected => _socket?.readyState == WebSocket.open; + Future> request( + int opcode, + Map payload, + ) async { + final response = await _client + .post( + baseUri.resolve('/api/ikar/requests/$opcode'), + headers: { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + 'X-Ikar-Client': 'ikar', + 'X-Ikar-Client-Version': '1.0.4', + }, + body: jsonEncode({'payload': _json(payload)}), + ) + .timeout(const Duration(seconds: 30)); + if (response.statusCode == 401) + return { + 'cmd': 3, + 'opcode': opcode, + 'payload': {'message': 'FAIL_LOGIN_TOKEN'}, + }; + if ([502, 503, 504].contains(response.statusCode)) { + throw HttpException('Сервер ИКАР недоступен', uri: response.request?.url); + } + final result = jsonDecode(response.body); + if (result is! Map) + throw const FormatException('Некорректный ответ сервера ИКАР'); + return Map.from(result); + } + + Future?> refresh(String refreshToken) async { + final response = await _client + .post( + baseUri.resolve('/api/auth/refresh'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'refreshToken': refreshToken}), + ) + .timeout(const Duration(seconds: 30)); + if (response.statusCode != 200) return null; + return Map.from(jsonDecode(response.body) as Map); + } + + Future connectEvents() async { + await closeEvents(); + if (token == null) return; + final uri = baseUri + .resolve('/hubs/messenger') + .replace( + scheme: baseUri.scheme == 'https' ? 'wss' : 'ws', + port: baseUri.port, + queryParameters: {'access_token': token!}, + ); + final socket = await IkarNetwork.connectWebSocket( + uri.toString(), + ).timeout(const Duration(seconds: 15)); + _socket = socket; + final handshake = Completer(); + var buffer = ''; + socket.listen( + (frame) { + buffer += frame is String ? frame : utf8.decode(frame as List); + while (buffer.contains('\u001e')) { + final index = buffer.indexOf('\u001e'); + final record = buffer.substring(0, index); + buffer = buffer.substring(index + 1); + if (record.isEmpty) continue; + final message = jsonDecode(record) as Map; + if (!handshake.isCompleted && message['type'] == null) { + if (message['error'] != null) { + handshake.completeError(StateError('${message['error']}')); + } else { + handshake.complete(); + } + } + if (message['type'] == 1 && message['target'] == 'QlyraPacket') { + final args = message['arguments'] as List; + if (args.isNotEmpty && !_events.isClosed) + _events.add(Map.from(args.first as Map)); + } + if (message['type'] == 7) unawaited(socket.close()); + } + }, + onDone: () { + if (!handshake.isCompleted) + handshake.completeError(StateError('Соединение событий закрыто')); + if (identical(_socket, socket)) { + _socket = null; + _keepAlive?.cancel(); + } + }, + onError: (Object error) { + if (!handshake.isCompleted) handshake.completeError(error); + }, + ); + socket.add('${jsonEncode({'protocol': 'json', 'version': 1})}\u001e'); + await handshake.future.timeout(const Duration(seconds: 15)); + _keepAlive = Timer.periodic(const Duration(seconds: 10), (_) { + if (socket.readyState == WebSocket.open) socket.add('{"type":6}\u001e'); + }); + } + + static Object? _json(Object? value) { + if (value is Map) + return value.map((key, item) => MapEntry(key.toString(), _json(item))); + if (value is Iterable) return value.map(_json).toList(); + if (value is BigInt) return value.toInt(); + return value; + } + + Future closeEvents() async { + _keepAlive?.cancel(); + final socket = _socket; + _socket = null; + await socket?.close(); + } + + Future close() async { + await closeEvents(); + _client.close(); + await _events.close(); + } +} diff --git a/lib/backend/login_gate.dart b/lib/backend/login_gate.dart new file mode 100644 index 0000000..8073c2b --- /dev/null +++ b/lib/backend/login_gate.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +class LoginGate { + Completer? _gate; + bool _loginSent = false; + bool _loginAnswered = false; + bool _loginSucceeded = false; + + bool get loginUnanswered => _loginSent && !_loginAnswered; + bool get loginAttempted => _loginSent; + bool get loginRejected => _loginSent && _loginAnswered && !_loginSucceeded; + + void close() { + fail(); + final gate = Completer(); + gate.future.ignore(); + _gate = gate; + _loginSent = false; + _loginAnswered = false; + _loginSucceeded = false; + } + + void open() { + if (_loginSent && !_loginSucceeded) return; + final gate = _gate; + if (gate != null && !gate.isCompleted) gate.complete(); + } + + void fail() { + final gate = _gate; + if (gate != null && !gate.isCompleted) { + gate.completeError(StateError('Нет соединения')); + } + } + + void noteLoginSent() { + _loginSent = true; + _loginAnswered = false; + _loginSucceeded = false; + } + + void noteLoginAnswer({required bool ok}) { + _loginAnswered = true; + _loginSucceeded = ok; + if (ok) open(); + } + + Future wait(Duration timeout, String request) async { + final gate = _gate; + if (gate == null || gate.isCompleted) return; + await gate.future.timeout( + timeout, + onTimeout: () => throw TimeoutException('$request: вход не завершён'), + ); + } +} diff --git a/lib/backend/models/chat_folder.dart b/lib/backend/models/chat_folder.dart new file mode 100644 index 0000000..e171b77 --- /dev/null +++ b/lib/backend/models/chat_folder.dart @@ -0,0 +1,253 @@ +class FolderFilter { + static const int unread = 0; + static const int read = 1; + static const int channel = 2; + static const int chat = 3; + static const int dialog = 4; + static const int owner = 5; + static const int admin = 6; + static const int muted = 7; + static const int contact = 8; + static const int notContact = 9; + static const int bot = 10; + static const int notMuted = 11; + static const int markedUnread = 12; + static const int org = 13; + + static const Set chatTypes = { + contact, + notContact, + chat, + channel, + bot, + dialog, + org, + }; + + static const Set roles = {owner, admin}; + + static const Set showOnly = { + unread, + read, + muted, + notMuted, + markedUnread, + }; + + static const Map _byName = { + 'UNREAD': unread, + 'READ': read, + 'CHANNEL': channel, + 'CHAT': chat, + 'GROUP': chat, + 'DIALOG': dialog, + 'OWNER': owner, + 'ADMIN': admin, + 'MUTED': muted, + 'CONTACT': contact, + 'NOT_CONTACT': notContact, + 'BOT': bot, + 'NOT_MUTED': notMuted, + 'MARKED_UNREAD': markedUnread, + 'ORG': org, + }; + + static int? parse(dynamic raw) { + if (raw is int) return raw; + if (raw is String) return int.tryParse(raw) ?? _byName[raw]; + return null; + } +} + +class FolderOption { + static const int hideEmpty = 0; + static const int noDelete = 1; + static const int noTitleEdit = 2; + static const int noFiltersEdit = 3; + static const int chatSuggest = 4; + + static const Map _byName = { + 'HIDE_EMPTY': hideEmpty, + 'NO_DELETE': noDelete, + 'NO_TITLE_EDIT': noTitleEdit, + 'NO_FILTERS_EDIT': noFiltersEdit, + 'CHAT_SUGGEST': chatSuggest, + }; + + static int? parse(dynamic raw) { + if (raw is int) return raw; + if (raw is String) return int.tryParse(raw) ?? _byName[raw]; + return null; + } +} + +class ChatFolder { + final String id; + final String title; + final String? emoji; + final List include; + final List filters; + final List options; + final List favorites; + final List widgets; + final Map? filterSubjects; + final int updateTime; + final int? sourceId; + + const ChatFolder({ + required this.id, + required this.title, + this.emoji, + this.include = const [], + this.filters = const [], + this.options = const [], + this.favorites = const [], + this.widgets = const [], + this.filterSubjects, + this.updateTime = 0, + this.sourceId, + }); + + bool get hideEmpty => options.contains(FolderOption.hideEmpty); + + bool get canDelete => !options.contains(FolderOption.noDelete); + + bool get canEditTitle => !options.contains(FolderOption.noTitleEdit); + + bool get canEditFilters => !options.contains(FolderOption.noFiltersEdit); + + static List _parseIds(dynamic raw) { + if (raw is! List) return []; + return raw + .map((e) { + if (e is int) return e; + if (e is String) return int.tryParse(e); + return null; + }) + .whereType() + .toList(); + } + + static List _parseCodes(dynamic raw, int? Function(dynamic) parse) { + if (raw is! List) return []; + return raw.map(parse).whereType().toList(); + } + + static Map? _parseMap(dynamic raw) { + if (raw is Map) return raw; + if (raw is Map) return Map.from(raw); + return null; + } + + factory ChatFolder.fromJson(Map json) { + final options = _parseCodes(json['options'], FolderOption.parse); + if (json['hideEmpty'] == true && + !options.contains(FolderOption.hideEmpty)) { + options.add(FolderOption.hideEmpty); + } + return ChatFolder( + id: json['id']?.toString() ?? '', + title: json['title']?.toString() ?? '', + emoji: json['emoji']?.toString(), + include: _parseIds(json['include']), + filters: _parseCodes(json['filters'], FolderFilter.parse), + options: options, + favorites: _parseIds(json['favorites']), + widgets: + (json['widgets'] as List?) + ?.map(_parseMap) + .whereType>() + .map(ChatFolderWidget.fromJson) + .toList() ?? + const [], + filterSubjects: _parseMap(json['filterSubjects']), + updateTime: json['updateTime'] is int ? json['updateTime'] as int : 0, + sourceId: json['sourceId'] is int ? json['sourceId'] as int : null, + ); + } + + ChatFolder copyWith({ + String? title, + String? emoji, + List? include, + List? filters, + List? options, + List? favorites, + int? updateTime, + }) => ChatFolder( + id: id, + title: title ?? this.title, + emoji: emoji ?? this.emoji, + include: include ?? this.include, + filters: filters ?? this.filters, + options: options ?? this.options, + favorites: favorites ?? this.favorites, + widgets: widgets, + filterSubjects: filterSubjects, + updateTime: updateTime ?? this.updateTime, + sourceId: sourceId, + ); + + Map toJson() => { + 'id': id, + 'title': title, + if (emoji != null) 'emoji': emoji, + 'include': include, + 'filters': filters, + 'options': options, + 'favorites': favorites, + 'widgets': widgets.map((w) => w.toJson()).toList(), + if (filterSubjects != null) 'filterSubjects': filterSubjects, + 'updateTime': updateTime, + if (sourceId != null) 'sourceId': sourceId, + }; +} + +class ChatFolderWidget { + final int id; + final String name; + final String description; + final String? iconUrl; + final String? url; + final String? startParam; + final String? background; + final int? appId; + + ChatFolderWidget({ + required this.id, + required this.name, + required this.description, + this.iconUrl, + this.url, + this.startParam, + this.background, + this.appId, + }); + + factory ChatFolderWidget.fromJson(Map json) { + final rawId = json['id']; + return ChatFolderWidget( + id: rawId is int ? rawId : int.tryParse(rawId?.toString() ?? '') ?? 0, + name: json['name']?.toString() ?? '', + description: json['description']?.toString() ?? '', + iconUrl: json['iconUrl']?.toString(), + url: json['url']?.toString(), + startParam: json['startParam']?.toString(), + background: json['background']?.toString(), + appId: json['appId'] is int + ? json['appId'] as int + : int.tryParse(json['appId']?.toString() ?? ''), + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'description': description, + if (iconUrl != null) 'iconUrl': iconUrl, + if (url != null) 'url': url, + if (startParam != null) 'startParam': startParam, + if (background != null) 'background': background, + if (appId != null) 'appId': appId, + }; +} diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart new file mode 100644 index 0000000..a4ae2be --- /dev/null +++ b/lib/backend/modules/account.dart @@ -0,0 +1,906 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; +import '../api.dart'; +import '../../core/config/debug_test.dart'; +import '../../core/config/qlyra_settings.dart'; +import '../../core/protocol/chat_cache_fingerprint.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; +import '../../core/media/media_playback.dart'; +import '../../core/crypto/e2ee_service.dart'; +import '../../core/storage/app_database.dart'; +import '../../core/storage/profile_deletion_store.dart'; +import '../../core/storage/spoofing_service.dart'; +import '../../core/storage/token_storage.dart'; +import '../../core/utils/logger.dart'; +import '../../models/login_info.dart'; +import 'banners.dart'; +import 'chats.dart'; +import 'complaints.dart'; +import 'contacts.dart'; +import 'folders.dart'; +import 'messages.dart'; +import 'webapp.dart'; + +import 'account/account_models.dart'; +import 'account/privacy_module.dart'; +import 'account/profile_module.dart'; +import 'account/sessions_module.dart'; +import 'account/two_factor_module.dart'; +export 'account/account_models.dart'; + +// #***! номер к виду который ждёт сервер +String _normalizeAuthPhone(String phone) { + final digits = phone.replaceAll(RegExp(r'\D'), ''); + return '+$digits'; +} + +// #***! маскируем номер, в логе ему делать нечего +String _maskPhone(String phone) { + if (phone.length <= 5) return '***'; + return '${phone.substring(0, 3)}***${phone.substring(phone.length - 2)}'; +} + +// #***! аккаунт целиком, вход регистрация мультиаккаунт +class AccountModule { + final Api _api; + late final SessionsModule _sessions = SessionsModule(_api); + late final PrivacyModule _privacy = PrivacyModule(_api); + late final ProfileModule _profile = ProfileModule(_api); + late final TwoFactorModule _twoFactor = TwoFactorModule(_api, _profile); + late final BannersModule banners = BannersModule(_api); + final _loginStatusController = StreamController.broadcast(); + final _noticeController = StreamController.broadcast(); + bool _loggedIn = false; + int? _loginEpoch; + + // #***! тяжёлое в подмодулях, тут только оркестрация + AccountModule(this._api) { + _api.stateStream.listen((state) { + if (state != SessionState.online) _loggedIn = false; + }); + } + + Stream get loginStatusStream => _loginStatusController.stream; + + Stream get noticeStream => _noticeController.stream; + + /// `true`, только когда сервер считает сессию ONLINE — после успешного + /// login (opcode 19), а не просто после хэндшейка (opcode 6). + bool get isLoggedIn => + _loggedIn && + _loginEpoch == _api.sessionEpoch && + _api.state == SessionState.online; + + // #***! дальше обёртки над подмодулями, единая точка для юишки + Future getPrivacyConfig() => _privacy.getPrivacyConfig(); + + Future> getBlockedContacts() => + _privacy.getBlockedContacts(); + + Future updatePrivacyConfig(Map settings) => + _privacy.updatePrivacyConfig(settings); + + Future setSafeMode(bool value) => _privacy.setSafeMode(value); + + Future setChatsPushNotification(bool value) => + _privacy.setChatsPushNotification(value); + + Future setMessagePreview(bool value) => + _privacy.setMessagePreview(value); + + Future setNotificationSound(bool value) => + _privacy.setNotificationSound(value); + + Future setCallNotifications(bool value) => + _privacy.setCallNotifications(value); + + Future setNewContacts(bool value) => + _privacy.setNewContacts(value); + + Future registerPushToken(String pushToken) => + _privacy.registerPushToken(pushToken); + + Future unregisterPushToken(String pushToken) => + _privacy.unregisterPushToken(pushToken); + + Future updateProfile( + String firstName, + String? lastName, { + String? description, + }) => _profile.updateProfile(firstName, lastName, description: description); + + Future updateProfileAvatar( + String photoToken, { + String avatarType = 'USER_AVATAR', + }) => _profile.updateProfileAvatar(photoToken, avatarType: avatarType); + + Future getAvatarUploadUrl() => _profile.getAvatarUploadUrl(); + + Future removeProfilePhoto(int photoId) => + _profile.removeProfilePhoto(photoId); + + Future create2faTrack() => _twoFactor.create2faTrack(); + + Future set2faPassword(String trackId, String password) => + _twoFactor.set2faPassword(trackId, password); + + Future set2faHint(String trackId, String hint) => + _twoFactor.set2faHint(trackId, hint); + + Future verify2faEmail(String trackId, String email) => + _twoFactor.verify2faEmail(trackId, email); + + Future verify2faCode(String trackId, String code) => + _twoFactor.verify2faCode(trackId, code); + + Future confirm2fa({ + required String trackId, + required String password, + String? hint, + bool withEmail = true, + }) => _twoFactor.confirm2fa( + trackId: trackId, + password: password, + hint: hint, + withEmail: withEmail, + ); + + Future enter2faPanel() => _twoFactor.enter2faPanel(); + + Future get2faDetails(String trackId) => + _twoFactor.get2faDetails(trackId); + + Future get2faStatus() => _twoFactor.get2faStatus(); + + Future check2faPassword(String trackId, String password) => + _twoFactor.check2faPassword(trackId, password); + + Future update2faPassword({ + required String trackId, + required String newPassword, + String? hint, + }) => _twoFactor.update2faPassword( + trackId: trackId, + newPassword: newPassword, + hint: hint, + ); + + Future commit2faEmailChange(String trackId) => + _twoFactor.commit2faEmailChange(trackId); + + Future remove2fa(String trackId) => + _twoFactor.remove2fa(trackId); + + // #***! запрос кода, первый и повторный отличаются типом + Future requestCode( + String phone, { + String language = 'ru', + }) => _requestCodeInternal(phone, AuthRequestType.startAuth, language); + + Future resendCode( + String phone, { + String language = 'ru', + }) => _requestCodeInternal(phone, AuthRequestType.resend, language); + + // #***! проверка кода, дальше вход регистрация или 2FA + Future verifyCode(String code, String token) async { + _ensureOnline(); + + final payload = { + 'token': token, + 'verifyCode': code, + 'authTokenType': AuthRequestType.checkCode.value, + }; + + logger.i('Отправка OTP-кода (opcode=${Opcode.auth})'); + + final packet = await _api.sendRequest(Opcode.auth, payload); + + final data = _requireMapPayload(packet, 'verifyCode'); + + final result = VerifyCodeResult(payload: data.cast()); + + final sessionToken = result.loginToken; + final verifiedProfile = _profileFromVerifyPayload(result.payload); + final accountId = result.accountId ?? verifiedProfile?.id; + + // #***! токен сохраняем сразу и переносим спуф профиль + if (sessionToken != null && accountId != null) { + if (result.loginToken != null && verifiedProfile != null) { + await AppDatabase.saveProfile(verifiedProfile, isActive: true); + } + await TokenStorage.saveToken(sessionToken, accountId); + await TokenStorage.setActiveAccount(accountId); + await SpoofingService.commitPendingSpoof(accountId); + } + + return result; + } + + ProfileData? _profileFromVerifyPayload(Map payload) { + final profileMap = payload['profile']; + if (profileMap is! Map) return null; + return ProfileData.fromServerProfile(profileMap.cast()); + } + + // #***! регистрация после кода + Future completeRegistration({ + required String token, + required String firstName, + String? lastName, + int? photoId, + }) async { + _ensureOnline(); + + final payload = { + 'token': token, + 'tokenType': AuthRequestType.register.value, + 'firstName': firstName, + }; + if (lastName != null && lastName.isNotEmpty) { + payload['lastName'] = lastName; + } + if (photoId != null) { + payload['photoId'] = photoId; + payload['avatarType'] = 'PRESET_AVATAR'; + } + + logger.i('Завершение регистрации (opcode=${Opcode.authConfirm})'); + + final packet = await _api.sendRequest(Opcode.authConfirm, payload); + + final data = _requireMapPayload(packet, 'completeRegistration'); + + final loginToken = data['token']; + if (loginToken is! String || loginToken.trim().isEmpty) { + throw const FormatException( + 'completeRegistration: отсутствует токен входа в ответе сервера', + ); + } + if (data['tokenType'] != null && data['tokenType'] != 'LOGIN') { + throw const FormatException( + 'completeRegistration: сервер не выдал токен типа LOGIN', + ); + } + + final profileMap = data['profile']; + if (profileMap is! Map) { + throw Exception('completeRegistration: отсутствует profile в ответе'); + } + final contact = profileMap['contact']; + if (contact is! Map) { + throw Exception('completeRegistration: отсутствует profile.contact'); + } + final accountId = contact['id'] as int?; + if (accountId == null) { + throw Exception('completeRegistration: отсутствует id аккаунта'); + } + + final profile = ProfileData.fromServerProfile( + profileMap.cast(), + ); + await TokenStorage.saveToken(loginToken, accountId); + await AppDatabase.saveProfile(profile, isActive: true); + await TokenStorage.setActiveAccount(accountId); + await SpoofingService.commitPendingSpoof(accountId); + + logger.i('Регистрация завершена, accountId=$accountId'); + return accountId; + } + + // #***! основной вход, syncParams говорят серверу что у нас есть + Future login({ + int? accountId, + String? token, + LoginSyncParams? syncParams, + }) async { + _ensureOnline(); + + int? resolvedAccountId = + accountId ?? await TokenStorage.getActiveAccountId(); + + String? authToken = token; + if (authToken == null) { + if (resolvedAccountId == null) { + throw StateError('login: нет активного аккаунта'); + } + authToken = await TokenStorage.readToken(resolvedAccountId); + if (authToken == null) { + throw StateError('login: нет токена для аккаунта $resolvedAccountId'); + } + } + + if (authToken.trim().isEmpty) { + throw StateError('login: пустой токен входа'); + } + + final requestPayload = buildLoginPayload(authToken, sync: syncParams); + + final loginEpoch = _api.sessionEpoch; + _loggedIn = false; + _loginStatusController.add(LoginStatus.loading); + try { + final packet = await _api.sendRequest(Opcode.login, requestPayload); + + final data = _requireMapPayload(packet, 'login'); + + final dataMap = data.cast(); + + // #***! вошли по чужому токену, id узнаём из ответа + if (resolvedAccountId == null) { + resolvedAccountId = extractAccountId(dataMap); + if (resolvedAccountId == null) { + logger.e( + 'login: accountId не найден в ответе; ' + '${describeResponseShape(dataMap)}', + ); + throw Exception('login: не удалось определить accountId из ответа'); + } + logger.i('login: accountId=$resolvedAccountId определён из ответа'); + await TokenStorage.saveToken(authToken, resolvedAccountId); + await TokenStorage.setActiveAccount(resolvedAccountId); + await SpoofingService.commitPendingSpoof(resolvedAccountId); + } + + final result = await _processLoginResponse(dataMap, resolvedAccountId); + if (_api.sessionEpoch != loginEpoch || + _api.state != SessionState.online) { + throw StateError('Session changed during login'); + } + _loginEpoch = loginEpoch; + _loggedIn = true; + _loginStatusController.add(LoginStatus.success); + return result; + } catch (e) { + _loginStatusController.add(LoginStatus.error); + rethrow; + } + } + + Future> getSessions() => _sessions.getSessions(); + + Future terminateOtherSessions() => _sessions.terminateOtherSessions(); + + Future authorizeWebQrLogin(String qrLink) => + _sessions.authorizeWebQrLogin(qrLink); + + // #***! второй аккаунт, рвём сессию чистим кэши готовим спуф + Future beginAddAccount() async { + final existing = await AppDatabase.loadAllProfiles(); + await SpoofingService.prepareNewAccountSpoof( + existing.map((p) => p.id).toList(growable: false), + ); + + try { + await _api.disconnect(); + } catch (_) {} + + await TokenStorage.clearActiveAccount(); + + ContactCache.clear(); + TranscriptionCache.clear(); + ComplaintsModule.clear(); + ContactsModule.clearBlockedCache(); + banners.clear(); + chats.resetForAccountSwitch(); + + logger.i('Добавление аккаунта: сессия сброшена, активный аккаунт очищен'); + } + + // #***! вход по чужому токену из дев меню + Future loginWithToken(String token) async { + await TokenStorage.clearActiveAccount(); + try { + await _api.disconnect(); + } catch (_) {} + + ContactCache.clear(); + TranscriptionCache.clear(); + ComplaintsModule.clear(); + ContactsModule.clearBlockedCache(); + banners.clear(); + chats.resetForAccountSwitch(); + + await _api.connect(); + if (_api.state != SessionState.online) { + throw StateError('loginWithToken: нет соединения с сервером'); + } + + logger.i('Вход по токену: сессия поднята со спуфом, выполняю login'); + return login(token: token); + } + + // #***! переключение аккаунта, реконнект с другим спуфом + Future switchAccount(int accountId) async { + final profile = await AppDatabase.loadProfile(accountId); + final token = await TokenStorage.tryReadToken(accountId); + if (profile == null || token == null) { + logger.w( + 'switchAccount: у аккаунта $accountId нет сохранённой сессии ' + '(профиль: ${profile != null}, токен: ${token != null})', + ); + throw AccountSessionLostException(accountId); + } + MediaPlayback.instance.closeAudioFile(); + + try { + await _api.disconnect(); + } catch (_) {} + + await AppDatabase.setActiveAccount(accountId); + await TokenStorage.setActiveAccount(accountId); + + ContactCache.clear(); + TranscriptionCache.clear(); + ComplaintsModule.clear(); + ContactsModule.clearBlockedCache(); + banners.clear(); + chats.resetForAccountSwitch(); + await ContactsModule.primeCacheFromDb(accountId); + + try { + await _api.connect(); + } catch (e) { + logger.e( + 'switchAccount: ошибка соединения при переключении на $accountId: $e', + ); + throw StateError('switchAccount: не удалось подключиться к серверу'); + } + if (_api.state != SessionState.online) { + logger.w( + 'switchAccount: $accountId переключён без связи, сессия поднимется ' + 'при переподключении', + ); + } + + logger.i('Активный аккаунт переключён на $accountId'); + return profile; + } + + Future> listAccounts() async { + return AppDatabase.loadAllProfiles(); + } + + Future hasStoredSession(int accountId) async => + await TokenStorage.tryReadToken(accountId) != null; + + // #***! удаляем локально, база токен спуф и заявка + Future removeAccount(int accountId) async { + await E2eeService.instance.eraseAccount(accountId); + await AppDatabase.deleteAccount(accountId); + await TokenStorage.deleteAccount(accountId); + await SpoofingService.clearAccountSpoof(accountId); + await ProfileDeletionStore.clear(accountId); + logger.i('Аккаунт $accountId удалён локально'); + } + + Future profileDeletionScheduledAt() async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return null; + return ProfileDeletionStore.scheduledAt(accountId); + } + + // #***! заявка на удаление профиля, сервер вернёт дату + Future setProfileDeletion(bool delete) async { + _ensureOnline(); + + final packet = await _api.sendRequest(Opcode.profileDelete, { + 'delete': delete, + 'type': 0, + }); + final data = _requireMapPayload(packet, 'setProfileDeletion'); + + final raw = data['timestamp']; + final millis = raw is int ? raw : 0; + + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await ProfileDeletionStore.save(accountId, millis); + } + + logger.i( + 'Профиль: заявка на удаление ' + '${delete ? 'создана' : 'отменена'} (timestamp=$millis)', + ); + + if (millis <= 0) return null; + return DateTime.fromMillisecondsSinceEpoch(millis); + } + + // #***! выход, сначала сервер потом чистка + Future logout() async { + MediaPlayback.instance.closeAudioFile(); + final accountId = await TokenStorage.getActiveAccountId(); + try { + await _logoutOnServer(accountId); + } on SessionExpiredException catch (e) { + logger.w('logout: сервер отклонил сессию: ${e.message}'); + } + _loggedIn = false; + try { + await _api.disconnect(); + } catch (_) {} + if (accountId != null) { + await removeAccount(accountId); + } + ContactCache.clear(); + TranscriptionCache.clear(); + ComplaintsModule.clear(); + ContactsModule.clearBlockedCache(); + banners.clear(); + chats.resetForAccountSwitch(); + } + + Future _logoutOnServer(int? accountId) async { + await _ensureLogoutSession(accountId); + await _api.sendRequestOrThrow(Opcode.logout, {}); + } + + // #***! чтоб выйти нужна живая сессия, при чём логинимся заново + Future _ensureLogoutSession(int? accountId) async { + if (_api.state == SessionState.disconnected) { + await _api.connect(); + } + if (_api.state != SessionState.online) { + await _api.stateStream + .firstWhere((state) => state == SessionState.online) + .timeout(const Duration(seconds: 20)); + } + if (_loggedIn) return; + if (accountId == null) return; + final token = await TokenStorage.readToken(accountId); + if (token == null || token.isEmpty) { + throw StateError('logout: нет токена для серверного выхода'); + } + await _api.sendRequestOrThrow( + Opcode.login, + buildLoginPayload(token, interactive: false), + ); + _loggedIn = true; + } + + // #***! второй шаг входа при 2FA + Future checkPassword({ + required String password, + required String trackId, + }) async { + _ensureOnline(); + + final payload = { + 'trackId': trackId, + 'password': password, + }; + + logger.i('Проверка 2FA-пароля'); + + final packet = await _api.sendRequest( + Opcode.authLoginCheckPassword, + payload, + ); + + final data = _requireMapPayload(packet, 'checkPassword'); + + if (data['error'] != null) { + throw const WrongPasswordException(); + } + + final tokenAttrs = data['tokenAttrs']; + if (tokenAttrs is! Map) { + throw Exception('checkPassword: отсутствует tokenAttrs в ответе'); + } + + final loginEntry = tokenAttrs['LOGIN']; + if (loginEntry is! Map) { + throw Exception('checkPassword: отсутствует tokenAttrs.LOGIN в ответе'); + } + + final loginToken = loginEntry['token'] as String?; + if (loginToken == null || loginToken.isEmpty) { + throw Exception('checkPassword: отсутствует токен в ответе'); + } + + final accountId = extractAccountId(data); + if (accountId == null) { + throw Exception('checkPassword: отсутствует accountId в ответе'); + } + + final profileMap = data['profile']; + if (profileMap is Map) { + final profile = ProfileData.fromServerProfile( + profileMap.cast(), + ); + await AppDatabase.saveProfile(profile, isActive: true); + } + + await TokenStorage.saveToken(loginToken, accountId); + await TokenStorage.setActiveAccount(accountId); + await SpoofingService.commitPendingSpoof(accountId); + + logger.i('2FA пройдена, получен login-токен'); + return TwoFactorResult(loginToken: loginToken, accountId: accountId); + } + + // #***! тело login, токен невидимка и маркеры синхры + Map buildLoginPayload( + String token, { + LoginSyncParams? sync, + bool? interactive, + }) { + final payload = { + 'token': token, + 'interactive': interactive ?? !QlyraSettings.ghostMode.value, + // #***! exp это экспериментальные фичи которые просим + 'exp': { + 'chatsCountGroups': Uint8List.fromList([0x0b, 0x32]), + }, + }; + + final callsSeed = _api.callsSeed; + final deviceId = _api.deviceId; + // #***! без отпечатка сборки сервер не отдаст кэш чатов + if (callsSeed != null && deviceId != null) { + payload['chatCacheFingerprint'] = ChatCacheFingerprint.compute( + callsSeed, + deviceId, + arch: _api.architecture, + ); + } + + if (sync != null) { + payload['presenceSync'] = sync.presenceSync; + payload['chatsSync'] = sync.chatsSync; + payload['contactsSync'] = sync.contactsSync; + payload['callsSync'] = sync.callsSync; + payload['draftsSync'] = sync.draftsSync; + payload['bannersSync'] = sync.bannersSync; + payload['lastLogin'] = sync.lastLogin; + if (sync.serverConfigSeen && sync.configHash != null) { + payload['configHash'] = sync.configHash; + } + // #***! нет маркеров, просим всё с нуля + } else { + payload['presenceSync'] = -1; + payload['chatsSync'] = -1; + } + + return payload; + } + + // #***! разбор login, профиль чаты контакты папки баннеры + Future _processLoginResponse( + Map data, + int accountId, + ) async { + final serverTime = + (data['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch; + + final updatedToken = data['token'] as String?; + if (updatedToken != null) { + await TokenStorage.saveToken(updatedToken, accountId); + } + + ProfileData profile; + final profileMap = data['profile']; + // #***! берсерк ломает профиль специально чтоб проверить восстановление + if (!DebugTest.berserk && + profileMap is Map && + profileMap['contact'] is Map) { + profile = ProfileData.fromServerProfile( + profileMap.cast(), + ); + } else { + profile = await _resurrectProfile(accountId); + } + await AppDatabase.saveProfile(profile, isActive: true); + await AppDatabase.setActiveAccount(profile.id); + + await _saveSyncState(data, serverTime, profile.id); + await ContactsModule.syncFromLoginPayload(data, profile.id); + await chats.syncFromLoginPayload(data, profile.id, profile.id); + // #***! остальные страницы чатов в фоне, вход не ждёт + unawaited(chats.paginateChats(_api, profile.id, profile.id, data)); + + // #***! каждый блок в try, упавший кусок не должен ронять вход + try { + await ContactsModule.syncFromServer(_api, profile.id); + } catch (e) { + logger.w('Контакты: $e'); + } + + final config = data['config']; + if (config is Map) { + await FoldersModule.applyFromLoginConfig( + profile.id, + config.cast(), + ); + await chats.applyFavorites(profile.id); + final userConfig = config['user']; + if (userConfig is Map) { + await AppDatabase.savePrivacyConfig(profile.id, jsonEncode(userConfig)); + } + } + try { + await FoldersModule.syncFromServer(_api, profile.id); + } catch (e) { + logger.w('Папки чатов: $e'); + } + await chats.applyFavorites(profile.id); + + try { + await banners.initFromLogin(profile.id, data); + } catch (e) { + logger.w('Баннеры: $e'); + } + + try { + await _saveLoginInfo(data, profile.id); + } catch (e) { + logger.w('Info: $e'); + } + + return LoginResult( + profile: profile, + updatedToken: updatedToken, + serverTime: serverTime, + raw: data, + ); + } + + // #***! профиля нет нигде, тянем через контакт иначе заглушка + Future _resurrectProfile(int accountId) async { + if (DebugTest.berserk) { + await AppDatabase.deleteAccount(accountId); + logger.w( + 'login: [BERSERK] профиль удалён из БД, форсирую регенерацию (id=$accountId)', + ); + } else { + final cached = await AppDatabase.loadProfile(accountId); + if (cached != null) return cached; + } + + _noticeController.add(AccountNotice.resurrectingProfile); + + try { + final fetched = await ContactsModule.fetchSelfProfile(_api, accountId); + if (fetched != null) { + logger.i( + 'login: профиль восстановлен через CONTACT_INFO (id=$accountId)', + ); + return fetched; + } + } catch (e) { + logger.w( + 'login: восстановление профиля через CONTACT_INFO не удалось: $e', + ); + } + + logger.w('login: профиль недоступен, использую заглушку (id=$accountId)'); + return ProfileData.stub(accountId); + } + + // #***! маркеры синхры, считаем что знаем всё до serverTime + Future _saveSyncState( + Map data, + int serverTime, + int accountId, + ) async { + final ts = serverTime.toString(); + + Future set(String key, String value) => + AppDatabase.setSyncValue(accountId, key, value); + + await set(SyncKey.serverTime, ts); + await set(SyncKey.lastLogin, ts); + await set(SyncKey.chatsSync, ts); + await set(SyncKey.contactsSync, ts); + await set(SyncKey.callsSync, ts); + await set(SyncKey.draftsSync, ts); + await set(SyncKey.bannersSync, ts); + await set(SyncKey.presenceSync, '-1'); + + final config = data['config']; + if (config is Map) { + final hash = config['hash'] as String?; + if (hash != null) await set(SyncKey.configHash, hash); + } + } + + // #***! сводка login для отладочного экрана + Future _saveLoginInfo(Map data, int accountId) async { + final config = data['config'] as Map?; + final serverConfig = config?['server'] as Map?; + if (serverConfig != null) { + await AppDatabase.setSyncValue(accountId, SyncKey.serverConfigSeen, '1'); + await AppDatabase.setSyncValue( + accountId, + SyncKey.profileInviteLink, + serverConfig['invite-link']?.toString().trim() ?? '', + ); + await _persistEntryBannerApps(accountId, serverConfig); + } + final info = LoginInfo.fromPayload(data); + + await AppDatabase.saveLoginInfo(accountId, jsonEncode(info)); + } + + Future _persistEntryBannerApps(int accountId, Map serverConfig) async { + final resolved = EntryBannerApps.appIdsFrom(serverConfig); + for (final entry in resolved.entries) { + await AppDatabase.setSyncValue( + accountId, + entry.key, + entry.value.toString(), + ); + } + } + + // #***! общий запрос кода + Future _requestCodeInternal( + String phone, + AuthRequestType type, + String language, + ) async { + _ensureOnline(); + + final normalizedPhone = _normalizeAuthPhone(phone); + + final payload = { + 'phone': normalizedPhone, + 'type': type.value, + 'language': language, + }; + + final callsSeed = _api.callsSeed; + final deviceId = _api.deviceId; + if (callsSeed != null && deviceId != null) { + payload['mode'] = ChatCacheFingerprint.compute( + callsSeed, + deviceId, + arch: _api.architecture, + ); + } + + logger.i( + 'Запрос OTP-кода: phone=${_maskPhone(normalizedPhone)} type=${type.value}', + ); + + final packet = await _api.sendRequest(Opcode.authRequest, payload); + + final data = _requireMapPayload(packet, 'requestCode'); + + final token = data['token']; + if (token is! String || token.isEmpty) { + throw Exception('requestCode: отсутствует token в ответе сервера'); + } + + logger.i('OTP-код запрошен, получен временный токен'); + return RequestCodeResult( + token: token, + deviceKey: data['authMethod'] == 'DEVICE_KEY', + ); + } + + // #***! дальше три помощника + void _ensureOnline() { + if (_api.state != SessionState.online) { + throw StateError( + 'AccountModule: сессия не онлайн (текущее состояние: ${_api.state.name})', + ); + } + } + + Map _requireMapPayload(Packet packet, String method) { + _checkPacketError(packet, method); + final data = packet.payload; + if (data is! Map) { + throw Exception('$method: неожиданный тип payload: ${data.runtimeType}'); + } + return data; + } + + void _checkPacketError(Packet packet, String method) { + throwIfPacketError(packet); + } +} diff --git a/lib/backend/modules/account/account_base.dart b/lib/backend/modules/account/account_base.dart new file mode 100644 index 0000000..53a65e6 --- /dev/null +++ b/lib/backend/modules/account/account_base.dart @@ -0,0 +1,28 @@ +import '../../api.dart'; +import '../../../core/protocol/packet.dart'; + +abstract class AccountApiBase { + final Api api; + const AccountApiBase(this.api); + + void ensureOnline() { + if (api.state != SessionState.online) { + throw StateError( + 'AccountModule: сессия не онлайн (текущее состояние: ${api.state.name})', + ); + } + } + + void checkPacketError(Packet packet, String method) { + throwIfPacketError(packet); + } + + Map requireMapPayload(Packet packet, String method) { + checkPacketError(packet, method); + final data = packet.payload; + if (data is! Map) { + throw Exception('$method: неожиданный тип payload: ${data.runtimeType}'); + } + return data; + } +} diff --git a/lib/backend/modules/account/account_models.dart b/lib/backend/modules/account/account_models.dart new file mode 100644 index 0000000..e8366f2 --- /dev/null +++ b/lib/backend/modules/account/account_models.dart @@ -0,0 +1,528 @@ +import 'dart:convert'; + +import '../../../core/storage/app_database.dart'; + +// #***! модели аккаунта, авторизация приватность сессии +int? _coerceAccountId(dynamic value) { + if (value is int) return value; + if (value is double) return value.toInt(); + if (value is String) return int.tryParse(value.trim()); + return null; +} + +// #***! id аккаунта лежит в разных местах, ищем во всех +int? extractAccountId(dynamic response) { + if (response is! Map) return null; + + final profile = response['profile']; + if (profile is Map) { + final contact = profile['contact']; + if (contact is Map) { + final cid = + _coerceAccountId(contact['id']) ?? + _coerceAccountId(contact['contactId']) ?? + _coerceAccountId(contact['accountId']); + if (cid != null) return cid; + } + final pid = + _coerceAccountId(profile['id']) ?? + _coerceAccountId(profile['accountId']); + if (pid != null) return pid; + } + + final contact = response['contact']; + if (contact is Map) { + final cid = + _coerceAccountId(contact['id']) ?? + _coerceAccountId(contact['contactId']); + if (cid != null) return cid; + } + + final account = response['account']; + if (account is Map) { + final aid = + _coerceAccountId(account['id']) ?? + _coerceAccountId(account['accountId']); + if (aid != null) return aid; + } + + return _coerceAccountId(response['accountId']) ?? + _coerceAccountId(response['account_id']); +} + +// #***! описание формы ответа для лога когда id не нашли +String describeResponseShape(dynamic response) { + if (response is! Map) return 'не-Map (${response.runtimeType})'; + final sb = StringBuffer('keys=${response.keys.toList()}'); + final profile = response['profile']; + if (profile is Map) { + sb.write(' profile.keys=${profile.keys.toList()}'); + final contact = profile['contact']; + if (contact is Map) { + sb.write(' contact.keys=${contact.keys.toList()}'); + } else { + sb.write(' profile.contact=${contact.runtimeType}'); + } + } else { + sb.write(' profile=${profile.runtimeType}'); + } + return sb.toString(); +} + +// #***! приватность одним объектом, ключи как у сервера капсом +class PrivacyConfig { + final String searchByPhone; + final String incomingCall; + final bool doubleTapReactionDisabled; + final bool safeModeNoPin; + final String? doubleTapReactionValue; + final String familyProtection; + final bool pushDetails; + final bool hidden; + final String chatsInvite; + final bool pushNewContacts; + final bool unsafeFiles; + final String phoneNumberPrivacy; + final String inactiveTtl; + final bool showReadMark; + final bool altKeyboard; + final bool contentLevelAccess; + final String stickersSuggest; + final bool safeMode; + final bool audioTranscriptionEnabled; + final String chatsPushNotification; + final String mCallPushNotification; + final String pushSound; + final String chatsPushSound; + final String hash; + + const PrivacyConfig({ + required this.searchByPhone, + required this.incomingCall, + required this.doubleTapReactionDisabled, + required this.safeModeNoPin, + this.doubleTapReactionValue, + required this.familyProtection, + required this.pushDetails, + required this.hidden, + required this.chatsInvite, + required this.pushNewContacts, + required this.unsafeFiles, + required this.phoneNumberPrivacy, + required this.inactiveTtl, + required this.showReadMark, + required this.altKeyboard, + required this.contentLevelAccess, + required this.stickersSuggest, + required this.safeMode, + required this.audioTranscriptionEnabled, + required this.chatsPushNotification, + required this.mCallPushNotification, + required this.pushSound, + required this.chatsPushSound, + required this.hash, + }); + + // #***! незнакомое игнорим, пустое берём дефолтом + factory PrivacyConfig.fromMap(Map map) { + return PrivacyConfig( + searchByPhone: map['SEARCH_BY_PHONE']?.toString() ?? 'ALL', + incomingCall: map['INCOMING_CALL']?.toString() ?? 'CONTACTS', + doubleTapReactionDisabled: map['DOUBLE_TAP_REACTION_DISABLED'] ?? false, + safeModeNoPin: map['SAFE_MODE_NO_PIN'] ?? false, + doubleTapReactionValue: map['DOUBLE_TAP_REACTION_VALUE']?.toString(), + familyProtection: map['FAMILY_PROTECTION']?.toString() ?? 'OFF', + pushDetails: map['PUSH_DETAILS'] ?? false, + hidden: map['HIDDEN'] ?? true, + chatsInvite: map['CHATS_INVITE']?.toString() ?? 'CONTACTS', + pushNewContacts: map['PUSH_NEW_CONTACTS'] ?? false, + unsafeFiles: map['UNSAFE_FILES'] ?? true, + phoneNumberPrivacy: map['PHONE_NUMBER_PRIVACY']?.toString() ?? 'ALL', + inactiveTtl: map['INACTIVE_TTL']?.toString() ?? '6M', + showReadMark: map['SHOW_READ_MARK'] ?? true, + altKeyboard: map['ALT_KEYBOARD'] ?? false, + contentLevelAccess: map['CONTENT_LEVEL_ACCESS'] ?? false, + stickersSuggest: map['STICKERS_SUGGEST']?.toString() ?? 'ON', + safeMode: map['SAFE_MODE'] ?? false, + audioTranscriptionEnabled: map['AUDIO_TRANSCRIPTION_ENABLED'] ?? true, + chatsPushNotification: map['CHATS_PUSH_NOTIFICATION']?.toString() ?? 'ON', + mCallPushNotification: + map['M_CALL_PUSH_NOTIFICATION']?.toString() ?? 'ON', + pushSound: map['PUSH_SOUND']?.toString() ?? 'oki.aiff', + chatsPushSound: map['CHATS_PUSH_SOUND']?.toString() ?? 'oki.aiff', + hash: map['hash']?.toString() ?? '', + ); + } + + // #***! в базу строкой джейсона + String toJson() => jsonEncode({ + 'SEARCH_BY_PHONE': searchByPhone, + 'INCOMING_CALL': incomingCall, + 'DOUBLE_TAP_REACTION_DISABLED': doubleTapReactionDisabled, + 'SAFE_MODE_NO_PIN': safeModeNoPin, + 'DOUBLE_TAP_REACTION_VALUE': doubleTapReactionValue, + 'FAMILY_PROTECTION': familyProtection, + 'PUSH_DETAILS': pushDetails, + 'HIDDEN': hidden, + 'CHATS_INVITE': chatsInvite, + 'PUSH_NEW_CONTACTS': pushNewContacts, + 'UNSAFE_FILES': unsafeFiles, + 'PHONE_NUMBER_PRIVACY': phoneNumberPrivacy, + 'INACTIVE_TTL': inactiveTtl, + 'SHOW_READ_MARK': showReadMark, + 'ALT_KEYBOARD': altKeyboard, + 'CONTENT_LEVEL_ACCESS': contentLevelAccess, + 'STICKERS_SUGGEST': stickersSuggest, + 'SAFE_MODE': safeMode, + 'AUDIO_TRANSCRIPTION_ENABLED': audioTranscriptionEnabled, + 'CHATS_PUSH_NOTIFICATION': chatsPushNotification, + 'M_CALL_PUSH_NOTIFICATION': mCallPushNotification, + 'PUSH_SOUND': pushSound, + 'CHATS_PUSH_SOUND': chatsPushSound, + 'hash': hash, + }); + + factory PrivacyConfig.fromJson(String json) { + try { + final map = jsonDecode(json) as Map; + return PrivacyConfig.fromMap(map); + } catch (_) { + return PrivacyConfig.empty(); + } + } + + // #***! пустой конфиг до ответа сервера + static PrivacyConfig empty() { + return const PrivacyConfig( + searchByPhone: 'ALL', + incomingCall: 'CONTACTS', + doubleTapReactionDisabled: false, + safeModeNoPin: false, + familyProtection: 'OFF', + pushDetails: false, + hidden: true, + chatsInvite: 'CONTACTS', + pushNewContacts: false, + unsafeFiles: true, + phoneNumberPrivacy: 'ALL', + inactiveTtl: '6M', + showReadMark: true, + altKeyboard: false, + contentLevelAccess: false, + stickersSuggest: 'ON', + safeMode: false, + audioTranscriptionEnabled: true, + chatsPushNotification: 'ON', + mCallPushNotification: 'ON', + pushSound: 'oki.aiff', + chatsPushSound: 'oki.aiff', + hash: '', + ); + } +} + +// #***! заблокированный контакт для настроек +class BlockedContact { + final int id; + final String? firstName; + final String? lastName; + final String? baseUrl; + final int? photoId; + final String status; + final int registrationTime; + final int updateTime; + + const BlockedContact({ + required this.id, + this.firstName, + this.lastName, + this.baseUrl, + this.photoId, + required this.status, + required this.registrationTime, + required this.updateTime, + }); + + factory BlockedContact.fromMap(Map map) { + String? firstName; + String? lastName; + final names = map['names'] as List?; + if (names != null && names.isNotEmpty) { + for (final n in names) { + if (n is Map) { + firstName = n['firstName'] as String?; + lastName = n['lastName'] as String?; + if (n['type'] == 'ONEME') break; + } + } + } + + return BlockedContact( + id: map['id'] as int? ?? 0, + firstName: firstName, + lastName: lastName, + baseUrl: map['baseUrl'] as String?, + photoId: map['photoId'] as int?, + status: map['status']?.toString() ?? 'BLOCKED', + registrationTime: map['registrationTime'] as int? ?? 0, + updateTime: map['updateTime'] as int? ?? 0, + ); + } +} + +// #***! состояние 2FA +class TwoFactorDetails { + final bool enabled; + final String? email; + final String? hint; + + const TwoFactorDetails({required this.enabled, this.email, this.hint}); +} + +// #***! тип запроса кода +enum AuthRequestType { + startAuth('START_AUTH'), + resend('RESEND'), + checkCode('CHECK_CODE'), + register('REGISTER'); + + const AuthRequestType(this.value); + final String value; +} + +enum LoginStatus { idle, loading, success, error } + +enum AccountNotice { resurrectingProfile } + +// #***! протухший токен FCM, надо перевыпустить +class WrongDeviceTokenException implements Exception { + const WrongDeviceTokenException(); + @override + String toString() => 'WrongDeviceTokenException'; +} + +class AccountSessionLostException implements Exception { + final int accountId; + + const AccountSessionLostException(this.accountId); + + @override + String toString() => 'AccountSessionLostException($accountId)'; +} + +class WrongPasswordException implements Exception { + const WrongPasswordException(); + @override + String toString() => 'WrongPasswordException'; +} + +// #***! временный токен сценария авторизации +class RequestCodeResult { + final String token; + final bool deviceKey; + + const RequestCodeResult({required this.token, this.deviceKey = false}); +} + +class PresetAvatar { + final int id; + final String url; + + const PresetAvatar({required this.id, required this.url}); +} + +class PresetAvatarCategory { + final String name; + final List avatars; + + const PresetAvatarCategory({required this.name, required this.avatars}); +} + +// #***! ответ на код, тут же решается вход это или регистрация +class VerifyCodeResult { + final Map payload; + + const VerifyCodeResult({required this.payload}); + + // #***! токены вложенно в tokenAttrs + String? get loginToken => _nestedToken('LOGIN'); + + String? get registerToken => _nestedToken('REGISTER'); + + bool get isRegistration => registerToken != null && loginToken == null; + + // #***! при регистрации сервер даёт готовые аватарки + List get presetAvatars { + final raw = payload['presetAvatars']; + if (raw is! List) return const []; + final categories = []; + for (final cat in raw) { + if (cat is! Map) continue; + final avatarsRaw = cat['avatars']; + if (avatarsRaw is! List) continue; + final avatars = []; + for (final a in avatarsRaw) { + if (a is! Map) continue; + final id = a['id']; + final url = a['url']; + if (id is int && url is String && url.isNotEmpty) { + avatars.add(PresetAvatar(id: id, url: url)); + } + } + if (avatars.isNotEmpty) { + categories.add( + PresetAvatarCategory( + name: cat['name']?.toString() ?? '', + avatars: avatars, + ), + ); + } + } + return categories; + } + + // #***! аккаунт с 2FA, дальше пароль + bool get requiresPassword => payload['passwordChallenge'] != null; + + Map? get passwordChallenge { + final c = payload['passwordChallenge']; + return c is Map ? c.cast() : null; + } + + String? get challengeTrackId => passwordChallenge?['trackId'] as String?; + + String? get challengeHint => passwordChallenge?['hint'] as String?; + + int? get accountId => extractAccountId(payload); + + String? _nestedToken(String key) { + final attrs = payload['tokenAttrs']; + if (attrs is! Map) return null; + final entry = attrs[key]; + if (entry is! Map) return null; + final token = entry['token']; + return token is String && token.trim().isNotEmpty ? token : null; + } +} + +class TwoFactorResult { + final String loginToken; + final int accountId; + + const TwoFactorResult({required this.loginToken, required this.accountId}); +} + +// #***! маркеры синхры для login, докуда мы уже знаем +class LoginSyncParams { + final int chatsSync; + final int contactsSync; + final int callsSync; + final int draftsSync; + final int bannersSync; + final int presenceSync; + final int lastLogin; + final String? configHash; + final String? chatCacheFingerprint; + final bool serverConfigSeen; + + static Future forgetServerConfig(int accountId) => + AppDatabase.setSyncValue(accountId, SyncKey.serverConfigSeen, ''); + + const LoginSyncParams({ + required this.chatsSync, + required this.contactsSync, + required this.callsSync, + required this.draftsSync, + required this.bannersSync, + required this.presenceSync, + required this.lastLogin, + this.configHash, + this.chatCacheFingerprint, + this.serverConfigSeen = false, + }); + + // #***! нет lastLogin значит первый вход + static Future fromDatabase(int accountId) async { + final values = await AppDatabase.getAllSyncValues(accountId); + final lastLogin = values[SyncKey.lastLogin]; + if (lastLogin == null) return null; + + return LoginSyncParams( + chatsSync: int.tryParse(values[SyncKey.chatsSync] ?? '') ?? 0, + contactsSync: int.tryParse(values[SyncKey.contactsSync] ?? '') ?? 0, + callsSync: int.tryParse(values[SyncKey.callsSync] ?? '') ?? 0, + draftsSync: int.tryParse(values[SyncKey.draftsSync] ?? '') ?? 0, + bannersSync: int.tryParse(values[SyncKey.bannersSync] ?? '') ?? 0, + presenceSync: int.tryParse(values[SyncKey.presenceSync] ?? '') ?? -1, + lastLogin: int.tryParse(lastLogin) ?? 0, + configHash: values[SyncKey.configHash], + chatCacheFingerprint: values[SyncKey.chatCacheFingerprint], + serverConfigSeen: values[SyncKey.serverConfigSeen] == '1', + ); + } +} + +// #***! активная сессия на другом устройстве +class SessionInfo { + final int? id; + final String client; + final String location; + final bool current; + final int time; + final String info; + + const SessionInfo({ + this.id, + required this.client, + required this.location, + required this.current, + required this.time, + required this.info, + }); + + factory SessionInfo.fromMap(Map map) { + return SessionInfo( + id: map['id'] is int + ? map['id'] + : (int.tryParse(map['id']?.toString() ?? '')), + client: map['client'] ?? '', + location: map['location'] ?? '', + current: map['current'] ?? false, + time: map['time'] ?? 0, + info: map['info'] ?? '', + ); + } + + // #***! сервер не всегда даёт id, ключ собираем из всех полей + int get uniqueId => Object.hash(id, client, time, info); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SessionInfo && + runtimeType == other.runtimeType && + id == other.id && + client == other.client && + location == other.location && + current == other.current && + time == other.time && + info == other.info; + + @override + int get hashCode => Object.hash(id, client, location, current, time, info); +} + +// #***! итог успешного входа +class LoginResult { + final ProfileData profile; + final String? updatedToken; + final int serverTime; + final Map raw; + + const LoginResult({ + required this.profile, + required this.updatedToken, + required this.serverTime, + required this.raw, + }); +} diff --git a/lib/backend/modules/account/privacy_module.dart b/lib/backend/modules/account/privacy_module.dart new file mode 100644 index 0000000..0f8f35f --- /dev/null +++ b/lib/backend/modules/account/privacy_module.dart @@ -0,0 +1,119 @@ +import '../../api.dart'; +import '../../../core/protocol/opcode_map.dart'; +import '../../../core/protocol/packet.dart'; +import '../../../core/storage/app_database.dart'; +import '../../../core/storage/token_storage.dart'; +import 'account_base.dart'; +import 'account_models.dart'; + +// #***! приватность и уведомления +class PrivacyModule extends AccountApiBase { + PrivacyModule(super.api); + + static const String _defaultPushSound = 'oki.aiff'; + + // #***! приватность из базы, приходит в login и живёт локально + Future getPrivacyConfig() async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + final saved = await AppDatabase.getPrivacyConfig(accountId); + if (saved != null) return PrivacyConfig.fromJson(saved); + } + return PrivacyConfig.empty(); + } + + // #***! список заблокированных + Future> getBlockedContacts() async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.contactList, { + 'status': 'BLOCKED', + 'count': 100, + 'from': 0, + }); + final data = requireMapPayload(packet, 'getBlockedContacts'); + final contacts = data['contacts'] as List?; + if (contacts == null) return []; + return contacts + .whereType() + .map((c) => BlockedContact.fromMap(c.cast())) + .toList(); + } + + // #***! обновление одним config, ответ в базу + Future updatePrivacyConfig( + Map settings, + ) async { + ensureOnline(); + final payload = { + 'settings': {'user': settings}, + }; + final packet = await api.sendRequest(Opcode.config, payload); + final data = requireMapPayload(packet, 'updatePrivacyConfig'); + final user = data['user']; + if (user is! Map) { + throw Exception('updatePrivacyConfig: отсутствует user в payload'); + } + final config = PrivacyConfig.fromMap(user.cast()); + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await AppDatabase.savePrivacyConfig(accountId, config.toJson()); + } + return config; + } + + // #***! безопасный режим это пачка настроек разом + Future setSafeMode(bool value) => updatePrivacyConfig( + value + ? { + 'INCOMING_CALL': 'CONTACTS', + 'SEARCH_BY_PHONE': 'CONTACTS', + 'SAFE_MODE_NO_PIN': true, + 'CONTENT_LEVEL_ACCESS': true, + 'CHATS_INVITE': 'CONTACTS', + 'SAFE_MODE': true, + } + : {'SAFE_MODE_NO_PIN': false, 'SAFE_MODE': false}, + ); + + // #***! дальше по методу на каждый переключатель + Future setChatsPushNotification(bool value) => + updatePrivacyConfig({'CHATS_PUSH_NOTIFICATION': value ? 'ON' : 'OFF'}); + + Future setMessagePreview(bool value) => + updatePrivacyConfig({'PUSH_DETAILS': value}); + + Future setNotificationSound(bool value) => + updatePrivacyConfig({ + 'PUSH_SOUND': value ? _defaultPushSound : '', + 'CHATS_PUSH_SOUND': value ? _defaultPushSound : '', + }); + + Future setCallNotifications(bool value) => + updatePrivacyConfig({'M_CALL_PUSH_NOTIFICATION': value ? 'ON' : 'OFF'}); + + Future setNewContacts(bool value) => + updatePrivacyConfig({'PUSH_NEW_CONTACTS': value}); + + // #***! регистрация push токена, ловим протухший FCM + Future registerPushToken(String pushToken) async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.config, { + 'pushToken': pushToken, + 'pushOptions': 0, + }); + if (packet.isError) { + final msg = messageFromErrorPayload(packet.payload).toUpperCase(); + if (msg.contains('WRONG_DEVICE_TOKEN') || + msg.contains('WRONG.DEVICE.TOKEN')) { + throw const WrongDeviceTokenException(); + } + throw PacketError(messageFromErrorPayload(packet.payload)); + } + } + + Future unregisterPushToken(String _) async { + if (api.state != SessionState.online) return; + final packet = await api.sendRequest(Opcode.logout, {}); + throwIfPacketError(packet); + } +} diff --git a/lib/backend/modules/account/profile_module.dart b/lib/backend/modules/account/profile_module.dart new file mode 100644 index 0000000..d9eff80 --- /dev/null +++ b/lib/backend/modules/account/profile_module.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +import '../../../core/protocol/opcode_map.dart'; +import '../../../core/protocol/packet.dart'; +import '../../../core/storage/app_database.dart'; +import '../contacts.dart'; +import 'account_base.dart'; + +class ProfileModule extends AccountApiBase { + ProfileModule(super.api); + + Future _applyProfileResponse(Packet packet) async { + if (packet.isError) { + throw Exception(packet.payload?.toString() ?? 'Server error'); + } + final data = packet.payload as Map?; + if (data == null) throw Exception('Empty response'); + final profile = data['profile'] as Map?; + if (profile == null) throw Exception('No profile in response'); + final contact = profile['contact'] as Map?; + if (contact == null) throw Exception('No contact in response'); + final newProfile = ProfileData.fromServerProfile( + profile.cast(), + ); + await AppDatabase.saveProfile(newProfile, isActive: true); + return newProfile; + } + + Future updateProfile( + String firstName, + String? lastName, { + String? description, + }) async { + ensureOnline(); + final payload = {'firstName': firstName}; + if (lastName != null) payload['lastName'] = lastName; + if (description != null) payload['description'] = description; + final packet = await api.sendRequest(Opcode.profile, payload); + return _applyProfileResponse(packet); + } + + Future updateProfileAvatar( + String photoToken, { + String avatarType = 'USER_AVATAR', + }) async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.profile, { + 'photoToken': photoToken, + 'avatarType': avatarType, + }); + final profile = await _applyProfileResponse(packet); + ContactsModule.invalidatePhotos(profile.id); + return profile; + } + + Future getAvatarUploadUrl() async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.photoUpload, { + 'count': 1, + 'profile': true, + }); + if (packet.isError) { + throw Exception(packet.payload?.toString() ?? 'Server error'); + } + final data = packet.payload as Map?; + if (data == null) throw Exception('Empty response'); + final url = data['url'] as String?; + if (url == null) throw Exception('No url in response'); + return url; + } + + Future removeProfilePhoto(int photoId) async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.removeContactPhoto, { + 'photoId': photoId, + }); + final profile = await _applyProfileResponse(packet); + ContactsModule.invalidatePhotos(profile.id); + return profile; + } + + Future processProfileUpdate( + Future requestFuture, + String tag, + ) async { + final completer = Completer(); + final sub = api.pushStream + .where((p) => p.opcode == Opcode.notifProfile) + .listen((push) { + if (completer.isCompleted) return; + final payload = push.payload; + if (payload is! Map) return; + final profile = payload['profile']; + if (profile is! Map) return; + final contact = profile['contact']; + if (contact is! Map) return; + completer.complete( + ProfileData.fromServerProfile(profile.cast()), + ); + }); + final timer = Timer(const Duration(seconds: 15), () { + if (!completer.isCompleted) { + completer.completeError( + Exception('Таймаут ожидания обновления профиля'), + ); + } + }); + try { + final packet = await requestFuture; + checkPacketError(packet, tag); + return await completer.future; + } finally { + timer.cancel(); + await sub.cancel(); + } + } +} diff --git a/lib/backend/modules/account/sessions_module.dart b/lib/backend/modules/account/sessions_module.dart new file mode 100644 index 0000000..4776081 --- /dev/null +++ b/lib/backend/modules/account/sessions_module.dart @@ -0,0 +1,53 @@ +import '../../../core/protocol/opcode_map.dart'; +import '../../../core/storage/token_storage.dart'; +import 'account_base.dart'; +import 'account_models.dart'; + +class SessionsModule extends AccountApiBase { + SessionsModule(super.api); + + Future> getSessions() async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.sessionsInfo, {}); + checkPacketError(packet, 'getSessions'); + final data = packet.payload; + if (data is! Map || data['sessions'] is! List) return []; + final sessions = data['sessions'] as List; + return sessions + .map((s) => SessionInfo.fromMap(s as Map)) + .toList(); + } + + Future terminateOtherSessions() async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.sessionsClose, {}); + checkPacketError(packet, 'terminateOtherSessions'); + final data = packet.payload; + final token = data is Map ? data['token'] : null; + if (token is String && token.isNotEmpty) { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) { + throw StateError( + 'terminateOtherSessions: нет активного аккаунта для нового токена', + ); + } + await TokenStorage.saveToken(token, accountId); + } + } + + Future authorizeWebQrLogin(String qrLink) async { + ensureOnline(); + final link = qrLink.trim(); + if (link.isEmpty) { + throw ArgumentError('Пустая ссылка из QR'); + } + + await api.sendRequest(Opcode.ping, {'interactive': true}); + await api.sendRequest(Opcode.sessionsInfo, {}); + await Future.delayed(const Duration(milliseconds: 300)); + final packet = await api.sendRequest(Opcode.authQrApprove, { + 'qrLink': link, + }); + checkPacketError(packet, 'authorizeWebQrLogin'); + } +} diff --git a/lib/backend/modules/account/two_factor_module.dart b/lib/backend/modules/account/two_factor_module.dart new file mode 100644 index 0000000..eb57144 --- /dev/null +++ b/lib/backend/modules/account/two_factor_module.dart @@ -0,0 +1,191 @@ +import '../../../core/protocol/opcode_map.dart'; +import '../../../core/storage/app_database.dart'; +import 'account_base.dart'; +import 'account_models.dart'; +import 'profile_module.dart'; + +class TwoFactorModule extends AccountApiBase { + final ProfileModule _profile; + TwoFactorModule(super.api, this._profile); + + Future create2faTrack() async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.authCreateTrack, {'type': 0}); + final data = requireMapPayload(packet, 'create2faTrack'); + final trackId = data['trackId'] as String?; + if (trackId == null) { + throw Exception('create2faTrack: отсутствует trackId'); + } + return trackId; + } + + Future set2faPassword(String trackId, String password) async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.authValidatePassword, { + 'trackId': trackId, + 'password': password, + }); + checkPacketError(packet, 'set2faPassword'); + if (packet.payload != null && packet.payload is! Map) { + throw Exception('set2faPassword: неожиданный ответ'); + } + } + + Future set2faHint(String trackId, String hint) async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.authValidateHint, { + 'trackId': trackId, + 'hint': hint, + }); + checkPacketError(packet, 'set2faHint'); + if (packet.payload != null && packet.payload is! Map) { + throw Exception('set2faHint: неожиданный ответ'); + } + } + + Future verify2faEmail(String trackId, String email) async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.authVerifyEmail, { + 'trackId': trackId, + 'email': email, + }); + final data = requireMapPayload(packet, 'verify2faEmail'); + final blockingDuration = data['blockingDuration'] as int? ?? 60; + return blockingDuration; + } + + Future verify2faCode(String trackId, String code) async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.authCheckEmail, { + 'trackId': trackId, + 'verifyCode': code, + }); + final data = requireMapPayload(packet, 'verify2faCode'); + final email = data['email'] as String? ?? ''; + return email; + } + + Future confirm2fa({ + required String trackId, + required String password, + String? hint, + bool withEmail = true, + }) async { + ensureOnline(); + final capabilities = [0, if (hint != null) 3, if (withEmail) 4]; + final payload = { + 'expectedCapabilities': capabilities, + 'trackId': trackId, + 'password': password, + }; + if (hint != null) payload['hint'] = hint; + return _profile.processProfileUpdate( + api.sendRequest(Opcode.authSet2fa, payload), + 'confirm2fa', + ); + } + + Future enter2faPanel() async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.authCreateTrack, {'type': 0}); + final data = requireMapPayload(packet, 'enter2faPanel'); + final trackId = data['trackId'] as String?; + if (trackId == null) { + throw Exception('enter2faPanel: отсутствует trackId'); + } + return trackId; + } + + Future get2faDetails(String trackId) async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.auth2faDetails, { + 'trackId': trackId, + }); + final data = requireMapPayload(packet, 'get2faDetails'); + final password = data['password'] as Map?; + return TwoFactorDetails( + enabled: password?['enabled'] ?? false, + email: password?['email'] as String?, + hint: password?['hint'] as String?, + ); + } + + Future get2faStatus() async { + final trackId = await enter2faPanel(); + return get2faDetails(trackId); + } + + Future check2faPassword(String trackId, String password) async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.authCheckPassword, { + 'trackId': trackId, + 'password': password, + }); + checkPacketError(packet, 'check2faPassword'); + final data = packet.payload; + if (data is Map && data['error'] != null) { + throw const WrongPasswordException(); + } + } + + Future update2faPassword({ + required String trackId, + required String newPassword, + String? hint, + }) async { + ensureOnline(); + final validatePacket = await api.sendRequest(Opcode.authValidatePassword, { + 'trackId': trackId, + 'password': newPassword, + }); + checkPacketError(validatePacket, 'update2faPassword: validate'); + if (validatePacket.payload != null && validatePacket.payload is! Map) { + throw Exception('update2faPassword: неожиданный ответ при валидации'); + } + + if (hint != null) { + final hintPacket = await api.sendRequest(Opcode.authValidateHint, { + 'trackId': trackId, + 'hint': hint, + }); + checkPacketError(hintPacket, 'update2faPassword: hint'); + } + + final payload = { + 'expectedCapabilities': [1, if (hint != null) 3], + 'trackId': trackId, + 'password': newPassword, + }; + if (hint != null) payload['hint'] = hint; + + return _profile.processProfileUpdate( + api.sendRequest(Opcode.authSet2fa, payload), + 'update2faPassword', + ); + } + + Future commit2faEmailChange(String trackId) async { + ensureOnline(); + final payload = { + 'expectedCapabilities': [4], + 'trackId': trackId, + }; + return _profile.processProfileUpdate( + api.sendRequest(Opcode.authSet2fa, payload), + 'commit2faEmailChange', + ); + } + + Future remove2fa(String trackId) async { + ensureOnline(); + final payload = { + 'expectedCapabilities': [5], + 'trackId': trackId, + 'remove2fa': true, + }; + return _profile.processProfileUpdate( + api.sendRequest(Opcode.authSet2fa, payload), + 'remove2fa', + ); + } +} diff --git a/lib/backend/modules/animoji.dart b/lib/backend/modules/animoji.dart new file mode 100644 index 0000000..711e50a --- /dev/null +++ b/lib/backend/modules/animoji.dart @@ -0,0 +1,194 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/utils/emoji_keyword_index.dart'; +import '../../core/utils/logger.dart'; +import '../../models/animoji.dart'; + +class AnimojiModule { + final Api _api; + + AnimojiModule(this._api); + + static const List fallbackReactions = [ + '👍', + '❤️', + '🔥', + '🤣', + '😭', + '😍', + ]; + + static const String _recentsKey = 'qlyra_recent_animoji'; + static const int _maxRecents = 24; + + final Map _byId = {}; + final Map _byEmoji = {}; + List _orderedIds = []; + List _recentIds = []; + bool _recentsLoaded = false; + Future? _loading; + + bool get isLoaded => _orderedIds.isNotEmpty; + + List get animojis => + _orderedIds.map((id) => _byId[id]).whereType().toList(); + + List get recentAnimojis => + _recentIds.map((id) => _byId[id]).whereType().toList(); + + List get emojis => animojis.map((a) => a.emoji).toList(); + + Future ensureRecentsLoaded() async { + if (_recentsLoaded) return; + _recentsLoaded = true; + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getStringList(_recentsKey) ?? const []; + _recentIds = raw.map(int.tryParse).whereType().toList(); + } catch (_) {} + } + + Future noteUsed(Animoji animoji) async { + await ensureRecentsLoaded(); + _remember(animoji); + _recentIds.remove(animoji.id); + _recentIds.insert(0, animoji.id); + if (_recentIds.length > _maxRecents) { + _recentIds = _recentIds.sublist(0, _maxRecents); + } + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList( + _recentsKey, + _recentIds.map((e) => e.toString()).toList(), + ); + } catch (_) {} + } + + List get quickAnimojis { + final list = animojis; + return list.length <= 6 ? list : list.sublist(0, 6); + } + + Animoji? cached(int id) => _byId[id]; + + Animoji? findByEmoji(String emoji) => + _byEmoji[EmojiKeywordIndex.normalize(emoji)]; + + Future fetchById(int id) async { + final known = _byId[id]; + if (known != null) return known; + final map = await _api.sendRequestMap(Opcode.assetsGetByIds, { + 'type': 'ANIMOJI', + 'ids': [id], + }); + final list = map?['animojis']; + if (list is! List) return null; + for (final e in list) { + if (e is! Map) continue; + final animoji = Animoji.fromMap(e); + if (animoji != null) _remember(animoji); + } + return _byId[id]; + } + + Future ensureLoaded() { + return _loading ??= _load().catchError((Object e) { + _loading = null; + throw e; + }); + } + + Future _load() async { + final setIds = []; + final fallbackIds = []; + + final sync = await _api.sendRequestMap(Opcode.assetsUpdate, { + 'type': 'ANIMOJI_SET', + 'sync': 0, + }); + if (sync != null) { + final sections = sync['sections']; + if (sections is List) { + for (final s in sections) { + if (s is Map) _appendIntList(setIds, s['animojiSetIds']); + } + } + final updates = sync['animojiUpdates']; + if (updates is Map) { + for (final key in updates.keys) { + final id = key is int ? key : int.tryParse(key.toString()); + if (id != null) fallbackIds.add(id); + } + } + } + + final orderedIds = []; + if (setIds.isNotEmpty) { + final setMap = await _api.sendRequestMap(Opcode.assetsGetByIds, { + 'type': 'ANIMOJI_SET', + 'ids': setIds, + }); + if (setMap != null) { + final sets = setMap['animojiSets']; + if (sets is List) { + for (final set in sets) { + if (set is! Map) continue; + _appendIntList(orderedIds, set['animojis']); + _appendIntList(orderedIds, set['animojiIds']); + } + } + } + } + + final ids = _dedup(orderedIds.isNotEmpty ? orderedIds : fallbackIds); + if (ids.isEmpty) return; + + for (final batch in _chunk(ids, 100)) { + final map = await _api.sendRequestMap(Opcode.assetsGetByIds, { + 'type': 'ANIMOJI', + 'ids': batch, + }); + if (map == null) continue; + final list = map['animojis']; + if (list is! List) continue; + for (final e in list) { + if (e is! Map) continue; + final animoji = Animoji.fromMap(e); + if (animoji != null) _remember(animoji); + } + } + + _orderedIds = ids.where(_byId.containsKey).toList(); + logger.i('Анимодзи: ${_orderedIds.length} доступно для реакций'); + } + + void _remember(Animoji animoji) { + _byId[animoji.id] = animoji; + _byEmoji[EmojiKeywordIndex.normalize(animoji.emoji)] = animoji; + } + + List _dedup(List ids) { + final seen = {}; + final result = []; + for (final id in ids) { + if (seen.add(id)) result.add(id); + } + return result; + } + + void _appendIntList(List target, dynamic raw) { + if (raw is! List) return; + for (final e in raw) { + if (e is int) target.add(e); + } + } + + Iterable> _chunk(List list, int size) sync* { + for (var i = 0; i < list.length; i += size) { + yield list.sublist(i, i + size > list.length ? list.length : i + size); + } + } +} diff --git a/lib/backend/modules/banners.dart b/lib/backend/modules/banners.dart new file mode 100644 index 0000000..cc81d7e --- /dev/null +++ b/lib/backend/modules/banners.dart @@ -0,0 +1,337 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; +import '../../core/storage/app_database.dart'; +import '../../core/utils/logger.dart'; +import '../../models/informer_banner.dart'; + +class BannersModule { + static const _snapshotKey = 'informer_banners'; + static const _stateKey = 'informer_state'; + static const _enabledKey = 'informer_enabled'; + static const _serverFlag = 'informer-enabled'; + static const int _resyncMask = 1; + static const int _defaultShowTime = 86400000; + + final Api _api; + + BannersModule(this._api) { + _api.pushStream + .where((p) => p.opcode == Opcode.notifBanners) + .listen(_handlePush); + } + + final ValueNotifier activeBanner = ValueNotifier(null); + + int? _accountId; + bool _enabled = true; + int _updateTime = 0; + int _showTime = _defaultShowTime; + List _banners = const []; + final Map _showState = {}; + String? _lastShownId; + String? _pinnedId; + Future? _syncing; + + bool get isEnabled => _enabled; + int get showTime => _showTime; + List get banners => List.unmodifiable(_banners); + + BannerShowState stateOf(String bannerId) => + _showState[bannerId] ?? const BannerShowState(); + + Future initFromLogin( + int accountId, + Map loginData, + ) async { + await load(accountId); + + final config = loginData['config']; + final serverConfig = config is Map ? config['server'] : null; + if (serverConfig is Map && serverConfig.containsKey(_serverFlag)) { + await _setEnabled(accountId, _parseFlag(serverConfig[_serverFlag])); + } + if (!_enabled) return; + + var applied = false; + final inline = loginData['banners']; + if (inline is Map) { + applied = await applyPayload(accountId, inline.cast()); + } + + if (applied && !_needsResync(loginData['updates'])) return; + await syncFromServer(); + } + + Future load(int accountId) async { + _accountId = accountId; + _pinnedId = null; + await _restoreSnapshot(accountId); + await _restoreShowState(accountId); + final enabled = await AppDatabase.getSyncValue(accountId, _enabledKey); + _enabled = enabled != '0'; + _recompute(); + } + + Future syncFromServer() { + return _syncing ??= _sync().whenComplete(() => _syncing = null); + } + + Future applyPayload( + int accountId, + Map payload, + ) async { + final raw = payload['banners']; + if (raw is! List) return false; + + final updateTime = _int(payload['updateTime']); + if (_updateTime != 0 && updateTime != null && updateTime <= _updateTime) { + return true; + } + + final parsed = []; + for (final entry in raw) { + if (entry is! Map) continue; + final banner = InformerBanner.fromMap(entry); + if (banner != null) parsed.add(banner); + } + + _banners = parsed; + if (updateTime != null) _updateTime = updateTime; + final showTime = _int(payload['showTime']); + if (showTime != null && showTime > 0) _showTime = showTime; + + final known = parsed.map((b) => b.id).toSet(); + _showState.removeWhere((id, _) => !known.contains(id)); + if (_pinnedId != null && !known.contains(_pinnedId)) _pinnedId = null; + if (_lastShownId != null && !known.contains(_lastShownId)) { + _lastShownId = null; + } + + await _persistSnapshot(accountId); + await _persistShowState(accountId); + _recompute(); + return true; + } + + Future markShown(InformerBanner banner) async { + if (_pinnedId == banner.id) return; + final now = DateTime.now().millisecondsSinceEpoch; + final current = stateOf(banner.id); + _showState[banner.id] = current.copyWith( + showCounter: current.showCounter + 1, + showAt: current.showAt ?? now, + ); + _lastShownId = banner.id; + _pinnedId = banner.id; + await _persistShowState(_accountId); + } + + Future close(InformerBanner banner) async { + final now = DateTime.now().millisecondsSinceEpoch; + _showState[banner.id] = stateOf(banner.id).copyWith(closedAt: now); + if (_pinnedId == banner.id) _pinnedId = null; + await _persistShowState(_accountId); + _recompute(); + } + + Future markClicked(InformerBanner banner) async { + if (!banner.closesOnClick) return; + await close(banner); + } + + void refresh() { + _pinnedId = null; + _recompute(); + } + + void clear() { + _accountId = null; + _enabled = true; + _updateTime = 0; + _showTime = _defaultShowTime; + _banners = const []; + _showState.clear(); + _lastShownId = null; + _pinnedId = null; + activeBanner.value = null; + } + + Future _sync() async { + final accountId = _accountId; + if (accountId == null || !_enabled) return; + try { + final packet = await _api.sendRequest(Opcode.bannersSync, { + 'bannersSync': 0, + }); + throwIfPacketError(packet); + final payload = packet.payload; + if (payload is Map) { + await applyPayload(accountId, payload.cast()); + } + } catch (e) { + logger.w('Баннеры: синхронизация не удалась: $e'); + } + } + + void _handlePush(Packet packet) { + final accountId = _accountId; + if (accountId == null || !_enabled) return; + final payload = packet.payload; + if (payload is Map && payload['banners'] is List) { + unawaited(applyPayload(accountId, payload.cast())); + return; + } + unawaited(syncFromServer()); + } + + void _recompute() { + if (!_enabled) { + activeBanner.value = null; + return; + } + + final pinned = _pinnedId; + if (pinned != null) { + for (final banner in _banners) { + if (banner.id == pinned) { + activeBanner.value = banner; + return; + } + } + _pinnedId = null; + } + + final now = DateTime.now().millisecondsSinceEpoch; + var candidates = _banners.where((b) => _canShow(b, now)).toList(); + if (candidates.length > 1 && _lastShownId != null) { + final others = candidates.where((b) => b.id != _lastShownId).toList(); + if (others.isNotEmpty) candidates = others; + } + if (candidates.isEmpty) { + activeBanner.value = null; + return; + } + activeBanner.value = candidates.reduce( + (a, b) => b.priority > a.priority ? b : a, + ); + } + + bool _canShow(InformerBanner banner, int now) { + final state = stateOf(banner.id); + if (state.showCounter > 0 && state.showCounter >= banner.repeat) { + return false; + } + final showAt = state.showAt; + if (showAt != null && now - showAt > _showTime) return false; + final closedAt = state.closedAt; + if (closedAt != null) { + if (banner.rerun <= 0) return false; + if (now - closedAt <= banner.rerun) return false; + } + return true; + } + + bool _needsResync(dynamic updates) { + final mask = _int(updates); + return mask != null && mask & _resyncMask != 0; + } + + Future _setEnabled(int accountId, bool value) async { + _enabled = value; + await AppDatabase.setSyncValue(accountId, _enabledKey, value ? '1' : '0'); + if (!value) activeBanner.value = null; + } + + Future _restoreSnapshot(int accountId) async { + _banners = const []; + _updateTime = 0; + _showTime = _defaultShowTime; + final raw = await AppDatabase.getSyncValue(accountId, _snapshotKey); + if (raw == null || raw.isEmpty) return; + try { + final map = jsonDecode(raw) as Map; + final list = map['banners']; + final parsed = []; + if (list is List) { + for (final entry in list) { + if (entry is! Map) continue; + final banner = InformerBanner.fromMap(entry); + if (banner != null) parsed.add(banner); + } + } + _banners = parsed; + _updateTime = _int(map['updateTime']) ?? 0; + final showTime = _int(map['showTime']); + if (showTime != null && showTime > 0) _showTime = showTime; + } catch (_) {} + } + + Future _restoreShowState(int accountId) async { + _showState.clear(); + _lastShownId = null; + final raw = await AppDatabase.getSyncValue(accountId, _stateKey); + if (raw == null || raw.isEmpty) return; + try { + final map = jsonDecode(raw) as Map; + _lastShownId = map['lastShowedBannerId']?.toString(); + final entries = map['banners']; + if (entries is Map) { + entries.forEach((key, value) { + if (value is Map) { + _showState[key.toString()] = BannerShowState.fromJson(value); + } + }); + } + } catch (_) {} + } + + Future _persistSnapshot(int accountId) async { + await AppDatabase.setSyncValue( + accountId, + _snapshotKey, + jsonEncode({ + 'updateTime': _updateTime, + 'showTime': _showTime, + 'banners': _banners.map((b) => b.toJson()).toList(), + }), + ); + } + + Future _persistShowState(int? accountId) async { + if (accountId == null) return; + await AppDatabase.setSyncValue( + accountId, + _stateKey, + jsonEncode({ + 'lastShowedBannerId': _lastShownId, + 'banners': { + for (final entry in _showState.entries) + entry.key: entry.value.toJson(), + }, + }), + ); + } + + static bool _parseFlag(dynamic value) { + if (value is bool) return value; + if (value is num) return value != 0; + if (value is String) { + final v = value.trim().toLowerCase(); + return v == 'true' || v == '1' || v == 'yes' || v == 'on'; + } + return true; + } + + static int? _int(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; + } +} diff --git a/lib/backend/modules/calls.dart b/lib/backend/modules/calls.dart new file mode 100644 index 0000000..1e33413 --- /dev/null +++ b/lib/backend/modules/calls.dart @@ -0,0 +1,444 @@ +import 'dart:convert'; + +import 'contacts.dart'; +import '../api.dart'; +import '../../core/calls/call_link.dart'; +import '../../core/calls/ws2_signaling.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/utils/ids.dart'; +import '../../core/utils/logger.dart'; + +enum CallStatus { missed, canceled, outgoing, incoming } + +class OutgoingCallParams { + final String conversationId; + + final String endpoint; + + final int callsUserId; + + final int peerExternalId; + final bool isVideo; + + const OutgoingCallParams({ + required this.conversationId, + required this.endpoint, + required this.callsUserId, + required this.peerExternalId, + required this.isVideo, + }); +} + +class CreatedCall { + final String conversationId; + final String joinToken; + final String? callName; + final int? chatId; + + const CreatedCall({ + required this.conversationId, + required this.joinToken, + this.callName, + this.chatId, + }); + + String get url => CallLink.url(joinToken); +} + +class CallLinkPreview { + final String? conferenceId; + final String? callName; + final int participantsCount; + final bool isVideo; + + const CallLinkPreview({ + this.conferenceId, + this.callName, + this.participantsCount = 0, + this.isVideo = false, + }); +} + +class CallLogEntry { + final String id; + final int accountId; + final int peerId; + final String name; + final String? avatarUrl; + final CallStatus status; + final int time; + final int count; + final bool isGroup; + + const CallLogEntry({ + required this.id, + required this.accountId, + required this.peerId, + required this.name, + this.avatarUrl, + required this.status, + required this.time, + this.count = 1, + this.isGroup = false, + }); +} + +class _CallerEndpointMissingException implements Exception { + final String message; + + const _CallerEndpointMissingException(this.message); + + @override + String toString() => 'Exception: $message'; +} + +typedef _CallerEndpoint = ({String endpoint, int callsUserId, int? external}); + +class CallsModule { + final Api _api; + + CallsModule(this._api); + + _CallerEndpoint _parseCallerEndpoint( + Map payload, + List keys, { + required String context, + }) { + for (final key in keys) { + final raw = payload[key]; + final parsed = raw is String + ? jsonDecode(raw) as Map + : const {}; + + final endpoint = parsed['endpoint'] as String?; + if (endpoint == null) continue; + + final id = parsed['id']; + final callsUserId = (id is Map ? id['internal'] as int? : null) ?? 0; + final external = id is Map ? int.tryParse('${id['external']}') : null; + + return (endpoint: endpoint, callsUserId: callsUserId, external: external); + } + + throw _CallerEndpointMissingException('$context: no endpoint'); + } + + Future initiateCall( + int calleeId, { + bool isVideo = false, + }) async { + final conversationId = uuidV4(); + + final payload = await _api.sendRequestMap(Opcode.videoChatStartActive, { + 'conversationId': conversationId, + 'calleeIds': [calleeId], + 'internalParams': _internalParams(), + 'isVideo': isVideo, + }); + + if (payload == null) { + throw Exception('initiateCall: bad response'); + } + + final parsed = _parseCallerEndpoint(payload, const [ + 'internalCallerParams', + ], context: 'initiateCall'); + + return OutgoingCallParams( + conversationId: (payload['conversationId'] as String?) ?? conversationId, + endpoint: parsed.endpoint, + callsUserId: parsed.callsUserId, + peerExternalId: parsed.external ?? calleeId, + isVideo: isVideo, + ); + } + + Future createConference() async { + final conversationId = uuidV4(); + logger.i('[call] VIDEO_CHAT_START conv=$conversationId'); + + final payload = await _api.sendRequestMap(Opcode.videoChatStart, { + 'conversationId': conversationId, + }); + logger.i('[call] VIDEO_CHAT_START keys=${payload?.keys.toList()}'); + + if (payload == null) { + throw Exception('createConference: bad response'); + } + + final id = (payload['conversationId'] as String?) ?? conversationId; + final rawLink = + (payload['joinLink'] as String?) ?? await createJoinLink(id) ?? ''; + final token = CallLink.normalizeToken(rawLink); + if (token == null) { + throw Exception('createConference: no joinLink'); + } + + final name = (payload['callName'] as String?)?.trim(); + + return CreatedCall( + conversationId: id, + joinToken: token, + callName: (name?.isEmpty ?? true) ? null : name, + chatId: payload['chatId'] is int ? payload['chatId'] as int : null, + ); + } + + Future createJoinLink(String conversationId) async { + if (conversationId.isEmpty) return null; + + final payload = await _api.sendRequestMap(Opcode.videoChatCreateJoinLink, { + 'conversationId': conversationId, + }); + + final link = payload?['joinLink']; + return link is String && link.isNotEmpty ? link : null; + } + + String _internalParams() => jsonEncode({ + 'platform': 'ANDROID', + 'sdkVersion': '0.2.1.3', + 'clientAppKey': 'CGPGAGLGDIHBABABA', + 'deviceId': _api.deviceId ?? '', + 'protocolVersion': 5, + 'onlyAdminCanRecord': false, + 'isWaitForAdminEnabled': false, + 'hexCapability': Ws2Config.defaultCapabilities, + }); + + Future resolveCallLink(String url) async { + final token = CallLink.normalizeToken(url); + final payload = await _api.sendRequestMap(Opcode.linkInfo, { + 'link': token == null ? url : CallLink.path(token), + }); + if (payload == null) return null; + + final vc = payload['videoConference']; + if (vc is! Map) return null; + + return CallLinkPreview( + conferenceId: vc['conferenceId']?.toString(), + callName: (vc['callName'] as String?)?.trim(), + participantsCount: (vc['participantsCount'] as int?) ?? 0, + isVideo: vc['callType'] == 'VIDEO', + ); + } + + Future joinByLink( + String token, { + bool isVideo = false, + }) async { + logger.i('[call] VIDEO_CHAT_JOIN link=$token isVideo=$isVideo'); + final payload = await _api.sendRequestMap(Opcode.videoChatJoinByLink, { + 'joinLink': token, + 'internalParams': _internalParams(), + 'isVideo': isVideo, + }); + logger.i('[call] VIDEO_CHAT_JOIN keys=${payload?.keys.toList()}'); + + if (payload == null) { + throw Exception('joinByLink: bad response'); + } + + final parsed = _parseCallerEndpoint(payload, const [ + 'internalParams', + 'internalCallerParams', + ], context: 'joinByLink'); + + return OutgoingCallParams( + conversationId: (payload['conversationId'] as String?) ?? '', + endpoint: parsed.endpoint, + callsUserId: parsed.callsUserId, + peerExternalId: 0, + isVideo: isVideo, + ); + } + + Future> fetchHistory( + int accountId, + int currentUserId, + ) async { + final payload = await _api.sendRequestMap(Opcode.videoChatHistory, {}); + if (payload == null) return []; + + return parseHistoryPayload( + payload, + accountId, + currentUserId, + resolver: resolveContacts, + ); + } + + Future>> resolveContacts(List ids) async { + if (ids.isEmpty) return const {}; + final out = >{}; + try { + final resp = await _api.sendRequest(Opcode.contactInfo, { + 'contactIds': ids, + }); + final data = resp.payload; + final contacts = data is Map ? data['contacts'] : null; + if (contacts is List) { + for (final c in contacts) { + if (c is Map) { + final id = c['id']; + if (id is int) out[id] = Map.from(c); + } + } + } + } catch (e) { + logger.w('resolveContacts: $e'); + } + return out; + } + + Future deleteHistory(List historyIds) async { + if (historyIds.isEmpty) return true; + return _api.sendRequestOk(Opcode.videoChatDeleteHistory, { + 'historyIds': historyIds, + }); + } + + static Future> parseHistoryPayload( + Map payload, + int accountId, + int currentUserId, { + Future>> Function(List ids)? resolver, + }) async { + final history = payload['history']; + if (history is! List || history.isEmpty) return []; + + final recentContacts = await ContactsModule.getContacts(accountId); + final contactsMap = {for (final c in recentContacts) c.id: c}; + + final parsed = <({int peerId, CallStatus status, int time, String id})>[]; + + for (final item in history.whereType()) { + final msg = item['message']; + if (msg is! Map) continue; + + final attaches = msg['attaches']; + if (attaches is! List || attaches.isEmpty) continue; + + final callAttach = attaches.firstWhere( + (a) => a is Map && a['_type'] == 'CALL', + orElse: () => null, + ); + + if (callAttach == null) continue; + + final senderId = (msg['sender'] as int?) ?? 0; + final isOutgoing = senderId == currentUserId; + + int peerId = 0; + if (isOutgoing) { + final contactIds = callAttach['contactIds']; + if (contactIds is List && contactIds.isNotEmpty) { + peerId = (contactIds.first as int?) ?? 0; + } + } else { + peerId = senderId; + } + + parsed.add(( + peerId: peerId, + status: _parseCallStatus(callAttach, isOutgoing), + time: (msg['time'] as int?) ?? 0, + id: + msg['id']?.toString() ?? + DateTime.now().millisecondsSinceEpoch.toString(), + )); + } + + bool localResolved(int id) { + final c = contactsMap[id]; + return c != null && c.firstName.isNotEmpty; + } + + final unresolvedIds = parsed + .map((e) => e.peerId) + .where((id) => id != 0 && !localResolved(id)) + .toSet() + .toList(); + + var fetched = const >{}; + if (unresolvedIds.isNotEmpty && resolver != null) { + fetched = await resolver(unresolvedIds); + } + + final List extractedCalls = []; + for (final e in parsed) { + final contact = contactsMap[e.peerId]; + String name; + String? avatarUrl; + bool isGroup = false; + if (contact != null && contact.firstName.isNotEmpty) { + name = '${contact.firstName} ${contact.lastName ?? ''}'.trim(); + avatarUrl = contact.baseUrl; + } else { + final info = fetched[e.peerId]; + final resolved = _nameFromInfo(info); + if (resolved != null) { + name = resolved; + avatarUrl = (info?['baseUrl'] as String?) ?? contact?.baseUrl; + } else { + name = 'Групповой звонок'; + isGroup = true; + } + } + + extractedCalls.add( + CallLogEntry( + id: e.id, + accountId: accountId, + peerId: e.peerId, + name: name, + avatarUrl: avatarUrl, + status: e.status, + time: e.time, + isGroup: isGroup, + ), + ); + } + + return extractedCalls; + } + + static String? _nameFromInfo(Map? info) { + if (info == null) return null; + final names = info['names']; + if (names is List && names.isNotEmpty) { + final n = names.first; + if (n is Map) { + final full = n['name']?.toString(); + if (full != null && full.isNotEmpty) return full; + final first = n['firstName']?.toString() ?? ''; + final last = n['lastName']?.toString() ?? ''; + final combined = '$first $last'.trim(); + if (combined.isNotEmpty) return combined; + } + } + return null; + } + + static CallStatus _parseCallStatus( + Map callAttach, + bool isOutgoing, + ) { + final hangupType = callAttach['hangupType']; + final duration = (callAttach['duration'] as int?) ?? 0; + + if (isOutgoing) { + if (hangupType == 'CANCELED' || duration == 0) return CallStatus.canceled; + return CallStatus.outgoing; + } else { + if (hangupType == 'CANCELED' || + hangupType == 'REJECTED' || + hangupType == 'MISSED' || + duration == 0) { + return CallStatus.missed; + } + return CallStatus.incoming; + } + } +} diff --git a/lib/backend/modules/chat_admin.dart b/lib/backend/modules/chat_admin.dart new file mode 100644 index 0000000..e5b5929 --- /dev/null +++ b/lib/backend/modules/chat_admin.dart @@ -0,0 +1,172 @@ +import 'dart:async'; + +import '../../core/cache/info_cache.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/storage/token_storage.dart'; +import '../../models/admin_rights.dart'; +import '../../models/chat_info.dart'; +import '../../models/chat_reaction_settings.dart'; +import '../api.dart'; +import 'chats.dart'; + +class ChatAdminModule { + final Api _api; + (int?, int)? _reactionScope; + + ChatAdminModule(this._api); + + late final InfoCache _reactionCache = InfoCache( + ttl: const Duration(minutes: 5), + fetcher: _fetchReactionSettings, + ); + + final StreamController<({int chatId, ChatReactionSettings settings})> + _reactionUpdates = StreamController.broadcast(); + + Stream<({int chatId, ChatReactionSettings settings})> get reactionUpdates => + _reactionUpdates.stream; + + Future reactionSettingsFor( + int chatId, { + bool forceRefresh = false, + }) async { + final scope = (await TokenStorage.getActiveAccountId(), _api.sessionEpoch); + if (_reactionScope != scope) { + _reactionCache.clear(); + _reactionScope = scope; + } + final result = await _reactionCache.get(chatId, forceRefresh: forceRefresh); + if ((await TokenStorage.getActiveAccountId(), _api.sessionEpoch) != scope) { + return null; + } + return result; + } + + Future setAdmin(int chatId, int userId, AdminRights rights) => + _apply(chatId, Opcode.chatMembersUpdate, { + 'userIds': [userId], + 'type': 'ADMIN', + 'operation': 'add', + 'permissions': rights.bits, + }); + + Future removeAdmin(int chatId, int userId) => + _apply(chatId, Opcode.chatMembersUpdate, { + 'userIds': [userId], + 'type': 'ADMIN', + 'operation': 'remove', + }); + + Future removeMember(int chatId, int userId) => + _apply(chatId, Opcode.chatMembersUpdate, { + 'userIds': [userId], + 'operation': 'remove', + 'cleanMsgPeriod': 0, + }); + + Future transferOwnership(int chatId, int userId) => + _apply(chatId, Opcode.chatUpdate, {'changeOwnerId': userId}); + + Future revokeInviteLink(int chatId) => + _apply(chatId, Opcode.chatUpdate, {'revokePrivateLink': true}); + + Future setOptions(int chatId, Map options) => + _apply(chatId, Opcode.chatUpdate, {'options': options}); + + Future updateInfo( + int chatId, { + required String title, + required String description, + }) => _apply(chatId, Opcode.chatUpdate, { + 'theme': title, + 'description': description, + }); + + Future setPhoto(int chatId, String photoToken) => + _apply(chatId, Opcode.chatUpdate, {'photoToken': photoToken}); + + Future _fetchReactionSettings(int chatId) async { + final packet = await _api.sendRequest(Opcode.reactionsSettingsGetByChatId, { + 'chatIds': [chatId], + }, silent: true); + final payload = packet.payload; + final list = payload is Map ? payload['chatReactionsSettings'] : null; + if (list is! List) return null; + for (final entry in list) { + if (entry is Map && entry['chatId'] == chatId) { + return ChatReactionSettings.fromMap(entry); + } + } + return null; + } + + Future disableReactions(int chatId) => + _applyReactions(chatId, {'chatId': chatId, 'value': false}); + + Future setReactions( + int chatId, { + required int count, + required List forbidden, + }) => _applyReactions(chatId, { + 'chatId': chatId, + 'value': true, + 'count': count, + 'reactionIds': forbidden, + 'included': false, + }); + + Future _applyReactions( + int chatId, + Map payload, + ) async { + final epoch = _api.sessionEpoch; + final accountId = await TokenStorage.getActiveAccountId(); + final packet = await _api.sendRequest( + Opcode.chatReactionsSettingsSet, + payload, + silent: true, + ); + if (_api.sessionEpoch != epoch || + await TokenStorage.getActiveAccountId() != accountId) { + throw StateError('Account changed during reaction settings update'); + } + final data = packet.payload; + final settings = ChatReactionSettings.fromMap( + data is Map ? data['chatReactionsSettings'] : null, + ); + if (settings == null) { + throw StateError('reaction settings missing from the answer'); + } + _reactionCache.putValue(chatId, settings); + _reactionUpdates.add((chatId: chatId, settings: settings)); + return settings; + } + + Future _apply( + int chatId, + int opcode, + Map change, + ) async { + final epoch = _api.sessionEpoch; + final accountId = await TokenStorage.getActiveAccountId(); + final packet = await _api.sendRequest(opcode, { + 'chatId': chatId, + ...change, + }, silent: true); + if (_api.sessionEpoch != epoch || + await TokenStorage.getActiveAccountId() != accountId) { + throw StateError('Account changed during chat update'); + } + final payload = packet.payload; + final chat = payload is Map ? payload['chat'] : null; + if (chat is! Map) { + final refreshed = await ChatInfoFetch.get(chatId, forceRefresh: true); + if (refreshed == null) throw StateError('chat $chatId not refreshed'); + return refreshed; + } + final info = ChatInfo.fromMap(Map.from(chat)); + ChatInfoFetch.put(chatId, info); + if (accountId != null) await chats.cacheServerChat(chat, accountId); + return info; + } +} diff --git a/lib/backend/modules/chat_parsing.dart b/lib/backend/modules/chat_parsing.dart new file mode 100644 index 0000000..0a16478 --- /dev/null +++ b/lib/backend/modules/chat_parsing.dart @@ -0,0 +1,371 @@ +import '../../core/utils/logger.dart'; +import '../../models/chat_call.dart'; +import 'chat_preview.dart'; +import 'chats.dart'; + +Map> buildContactsMap(dynamic contacts) { + if (contacts is! List) return {}; + final result = >{}; + for (final c in contacts.whereType()) { + final id = c['id']; + if (id is int) result[id] = c.cast(); + } + return result; +} + +CachedChat? parseChatRow( + Map chat, + int accountId, + int currentUserId, + Map> contactsMap, + Map chatsConfig, + Map presenceMap, + Map existing, + int cachedAt, +) { + try { + final id = chat['id']; + if (id is! int) return null; + + final type = (chat['type'] as String?) ?? 'DIALOG'; + final otherId = type == 'DIALOG' + ? _otherParticipantId(chat['participants'], currentUserId) + : null; + + final titleIcon = _resolveTitleAndIcon( + chat, + id, + type, + otherId, + contactsMap, + existing, + ); + final lastMessage = _resolveLastMessage(chat['lastMessage']); + final previous = existing[id]; + final sameLastMessage = + previous != null && + lastMessage.id != null && + previous.lastMsgId == lastMessage.id; + final muteFav = _resolveMuteAndFavorite(chatsConfig, id, existing); + final presence = _resolvePresence(type, otherId, presenceMap); + final adminsOwner = _resolveAdmins(chat); + final pinned = _resolvePinnedMessage(chat['pinnedMessage']); + final mentionId = int.tryParse( + chat['lastMentionMessageId']?.toString() ?? '', + ); + + return CachedChat( + id: id, + accountId: accountId, + type: type, + title: titleIcon.title, + iconUrl: titleIcon.iconUrl, + lastMsgId: lastMessage.id, + lastMsgTime: lastMessage.time, + lastMsgText: lastMessage.text, + lastMsgElements: lastMessage.elements, + lastMsgPreview: lastMessage.preview, + lastMsgSenderId: + lastMessage.senderId ?? + (sameLastMessage ? previous.lastMsgSenderId : null), + lastMsgStatus: sameLastMessage ? previous.lastMsgStatus : null, + unreadCount: (chat['newMessages'] as int?) ?? 0, + lastEventTime: (chat['lastEventTime'] as int?) ?? 0, + cachedAt: cachedAt, + favIndex: muteFav.favIndex, + dontDisturbUntil: muteFav.dontDisturbUntil, + isOnline: presence.isOnline, + seenTime: presence.seenTime, + participants: parseParticipants(chat['participants']), + options: titleIcon.options, + owner: adminsOwner.owner, + admins: adminsOwner.admins, + pinnedMsgId: pinned.id, + pinnedMsgText: pinned.text, + pinnedMsgTime: pinned.time, + pinnedMsgIsPreview: pinned.isPreview, + lastMentionMsgId: mentionId ?? existing[id]?.lastMentionMsgId, + activeCallData: ChatCall.fromServer(chat['videoConversation'])?.encode(), + publicLink: _resolvePublicLink(chat, previous), + ); + } catch (e) { + logger.e("Ошибка при парсинге чата: $e"); + return null; + } +} + +String? _resolvePublicLink(Map chat, CachedChat? previous) { + final access = chat['access']; + if (access == null) return previous?.publicLink; + if (access != 'PUBLIC') return null; + final link = chat['link']?.toString().trim() ?? ''; + return link.isEmpty ? null : link; +} + +({String? title, String? iconUrl, Set options}) _resolveTitleAndIcon( + Map chat, + int id, + String type, + int? otherId, + Map> contactsMap, + Map existing, +) { + if (type == 'DIALOG') { + final contact = otherId != null ? contactsMap[otherId] : null; + if (contact != null) { + Set options = const {}; + final contactOpts = contact['options']; + if (contactOpts is List) { + options = contactOpts.whereType().toSet(); + } + return ( + title: _nameFromContact(contact), + iconUrl: contact['baseUrl'] as String?, + options: options, + ); + } + return ( + title: existing[id]?.title, + iconUrl: existing[id]?.iconUrl, + options: existing[id]?.options ?? const {}, + ); + } + Set options = const {}; + final chatOpts = chat['options']; + if (chatOpts is Map) { + options = { + for (final entry in chatOpts.entries) + if (entry.value == true && entry.key is String) entry.key as String, + }; + } + return ( + title: chat['title'] as String?, + iconUrl: chat['baseIconUrl'] as String?, + options: options, + ); +} + +({ + int? id, + int? time, + String? text, + String? elements, + String? preview, + int? senderId, +}) +_resolveLastMessage(dynamic lastMsg) { + if (lastMsg is! Map) { + return ( + id: null, + time: null, + text: null, + elements: null, + preview: null, + senderId: null, + ); + } + return ( + id: _asIntOrNull(lastMsg['id']), + time: _asIntOrNull(lastMsg['time']), + text: messagePreviewText(lastMsg), + elements: messagePreviewElements(lastMsg), + preview: messagePreviewMedia(lastMsg), + senderId: _asIntOrNull(lastMsg['sender']), + ); +} + +int? _asIntOrNull(Object? value) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; +} + +({int? id, String? text, int? time, bool isPreview}) _resolvePinnedMessage( + dynamic pinned, +) { + if (pinned is! Map) { + return (id: null, text: null, time: null, isPreview: false); + } + final rawId = pinned['id']; + final id = rawId is int ? rawId : int.tryParse(rawId?.toString() ?? ''); + if (id == null) return (id: null, text: null, time: null, isPreview: false); + final preview = pinnedMessagePreview(pinned); + return ( + id: id, + text: preview.text, + time: pinned['time'] as int?, + isPreview: preview.isPreview, + ); +} + +({int? favIndex, int dontDisturbUntil}) _resolveMuteAndFavorite( + Map chatsConfig, + int id, + Map existing, +) { + final config = chatsConfig[id.toString()] ?? chatsConfig[id]; + final ex = existing[id]; + if (config is Map) { + final configFav = config['favIndex'] as int?; + return ( + favIndex: (configFav != null && configFav > 0) ? configFav : ex?.favIndex, + dontDisturbUntil: (config['dontDisturbUntil'] as int?) ?? 0, + ); + } + if (ex != null) { + return (favIndex: ex.favIndex, dontDisturbUntil: ex.dontDisturbUntil); + } + return (favIndex: null, dontDisturbUntil: 0); +} + +({int seenTime, bool isOnline}) _resolvePresence( + String type, + int? otherId, + Map presenceMap, +) { + if (type != 'DIALOG' || otherId == null) { + return (seenTime: 0, isOnline: false); + } + final presence = presenceMap[otherId.toString()] ?? presenceMap[otherId]; + if (presence is Map) { + return ( + seenTime: (presence['seen'] as int?) ?? 0, + isOnline: (presence['status'] as int?) == 1, + ); + } + return (seenTime: 0, isOnline: false); +} + +({int? owner, Set admins}) _resolveAdmins(Map chat) { + int? owner; + final ownerRaw = chat['owner']; + if (ownerRaw is int) { + owner = ownerRaw; + } else if (ownerRaw is String) { + owner = int.tryParse(ownerRaw); + } + + Set admins = const {}; + final adminsRaw = chat['admins']; + if (adminsRaw is List) { + admins = adminsRaw + .map((e) => e is int ? e : int.tryParse(e.toString())) + .whereType() + .toSet(); + } else { + final adminParticipants = chat['adminParticipants']; + if (adminParticipants is Map) { + admins = adminParticipants.keys + .map((k) => k is int ? k : int.tryParse(k.toString())) + .whereType() + .toSet(); + } + } + return (owner: owner, admins: admins); +} + +int? _otherParticipantId(dynamic participants, int currentUserId) { + if (participants is! Map) return null; + for (final key in participants.keys) { + final id = key is int ? key : int.tryParse(key.toString()); + if (id != null && id != currentUserId) return id; + } + return null; +} + +String? _nameFromContact(Map contact) { + final names = contact['names']; + if (names is! List || names.isEmpty) return null; + Map? fallback; + for (final entry in names) { + if (entry is! Map) continue; + final name = entry.cast(); + fallback ??= name; + if (name['type'] == 'ONEME') return name['name'] as String?; + } + return fallback?['name'] as String?; +} + +List parseSearchResult(dynamic payload) { + final result = (payload as Map?)?['result']; + if (result is! List) return const []; + final hits = []; + for (final item in result) { + if (item is! Map) continue; + final chat = item['chat']; + if (chat is! Map) continue; + final id = chat['id']; + if (id is! int) continue; + final last = chat['lastMessage']; + final link = chat['link']; + hits.add( + ChatSearchHit( + id: id, + type: (chat['type'] as String?) ?? 'CHAT', + title: chat['title'] as String?, + avatarUrl: chat['baseIconUrl'] as String?, + subtitle: link is String && link.isNotEmpty + ? '@$link' + : (last is Map ? last['text'] as String? : null), + ), + ); + } + return hits; +} + +List parseMessageResult(dynamic payload) { + final result = (payload as Map?)?['result']; + if (result is! List) return const []; + final hits = []; + for (final item in result) { + if (item is! Map) continue; + final message = item['message']; + if (message is! Map) continue; + final chatId = item['chatId']; + if (chatId is! int || chatId == 0) continue; + hits.add( + MessageSearchHit( + chatId: chatId, + messageId: message['id']?.toString(), + text: message['text'] as String?, + time: (message['time'] as int?) ?? 0, + senderId: (message['sender'] as int?) ?? 0, + ), + ); + } + return hits; +} + +bool sameChatContent(CachedChat a, CachedChat b) { + if (a.title != b.title) return false; + if (a.iconUrl != b.iconUrl) return false; + if (a.owner != b.owner) return false; + if (a.pinnedMsgId != b.pinnedMsgId) return false; + if (a.pinnedMsgText != b.pinnedMsgText) return false; + if (a.pinnedMsgTime != b.pinnedMsgTime) return false; + if (a.pinnedMsgIsPreview != b.pinnedMsgIsPreview) return false; + if (a.dontDisturbUntil != b.dontDisturbUntil) return false; + if (a.favIndex != b.favIndex) return false; + if (a.lastMsgId != b.lastMsgId) return false; + if (a.lastMsgTime != b.lastMsgTime) return false; + if (a.lastMsgText != b.lastMsgText) return false; + if (a.lastMsgElements != b.lastMsgElements) return false; + if (a.lastMsgPreview != b.lastMsgPreview) return false; + if (a.lastMsgSenderId != b.lastMsgSenderId) return false; + if (a.unreadCount != b.unreadCount) return false; + if (a.lastEventTime != b.lastEventTime) return false; + if (a.isOnline != b.isOnline) return false; + if (a.seenTime != b.seenTime) return false; + if (a.activeCallData != b.activeCallData) return false; + if (a.publicLink != b.publicLink) return false; + if (a.admins.length != b.admins.length) return false; + if (!a.admins.containsAll(b.admins)) return false; + if (a.options.length != b.options.length) return false; + if (!a.options.containsAll(b.options)) return false; + if (a.participants.length != b.participants.length) return false; + for (final e in a.participants.entries) { + if (b.participants[e.key] != e.value) return false; + } + return true; +} diff --git a/lib/backend/modules/chat_preview.dart b/lib/backend/modules/chat_preview.dart new file mode 100644 index 0000000..2967418 --- /dev/null +++ b/lib/backend/modules/chat_preview.dart @@ -0,0 +1,314 @@ +import 'dart:convert'; + +import '../../models/attachment.dart'; +import '../../models/chat_preview_media.dart'; + +const int _maxPreviewThumbs = 3; +const int _maxThumbLength = 20000; + +String? attachPreviewLabel(dynamic attaches) { + final parts = _attachPreviewParts(attaches); + if (parts == null) return null; + final detail = parts.detail; + return detail == null ? parts.label : '${parts.label}: $detail'; +} + +({String label, String? detail})? _attachPreviewParts(dynamic attaches) { + final first = _firstPreviewAttach(attaches); + if (first == null) return null; + final type = (first['_type'] as String? ?? '').toUpperCase(); + switch (type) { + case 'PHOTO': + return ( + label: _mediaAttachCount(attaches) > 1 ? 'Изображения' : 'Изображение', + detail: null, + ); + case 'VIDEO': + if (_isVideoNote(first)) return (label: 'Видео-сообщение', detail: null); + return (label: 'Видео', detail: null); + case 'AUDIO': + return (label: 'Голосовое сообщение', detail: null); + case 'FILE': + return (label: 'Файл', detail: _nonEmpty(first['name'])); + case 'STICKER': + return (label: 'Стикер', detail: null); + case 'SHARE': + return (label: 'Ссылка', detail: _nonEmpty(first['title'])); + case 'POLL': + return (label: 'Опрос', detail: _nonEmpty(first['title'])); + case 'LOCATION': + return (label: 'Геопозиция', detail: null); + case 'CONTACT': + return (label: 'Контакт', detail: null); + case 'CONTROL': + final label = _controlPreviewLabel(first); + return label == null ? null : (label: label, detail: null); + case 'INLINE_KEYBOARD': + return null; + case 'CALL': + return (label: _callPreviewLabel(first), detail: null); + default: + return (label: 'Вложение', detail: null); + } +} + +ChatPreviewKind? _attachPreviewKind(Map attach) { + switch ((attach['_type'] as String? ?? '').toUpperCase()) { + case 'PHOTO': + return ChatPreviewKind.photo; + case 'VIDEO': + return _isVideoNote(attach) + ? ChatPreviewKind.videoNote + : ChatPreviewKind.video; + case 'AUDIO': + return ChatPreviewKind.audio; + case 'FILE': + return ChatPreviewKind.file; + case 'STICKER': + return ChatPreviewKind.sticker; + case 'SHARE': + return ChatPreviewKind.share; + case 'POLL': + return ChatPreviewKind.poll; + case 'LOCATION': + return ChatPreviewKind.location; + case 'CONTACT': + return ChatPreviewKind.contact; + case 'CONTROL': + return ChatPreviewKind.control; + case 'INLINE_KEYBOARD': + return null; + case 'CALL': + final video = attach['callType']?.toString().toUpperCase() == 'VIDEO'; + if (_isFailedCall(attach)) { + return video + ? ChatPreviewKind.missedVideoCall + : ChatPreviewKind.missedCall; + } + return video ? ChatPreviewKind.videoCall : ChatPreviewKind.call; + default: + return ChatPreviewKind.other; + } +} + +String _callPreviewLabel(Map attach) { + final video = attach['callType']?.toString().toUpperCase() == 'VIDEO'; + if (attach['joinLink'] != null) { + return video ? 'Групповой видеозвонок' : 'Групповой звонок'; + } + if (_isFailedCall(attach)) { + return video ? 'Пропущенный видеозвонок' : 'Пропущенный звонок'; + } + return video ? 'Видеозвонок' : 'Звонок'; +} + +bool _isFailedCall(Map attach) { + final duration = (attach['duration'] as num?)?.toInt() ?? 0; + final hangup = attach['hangupType']?.toString(); + return duration == 0 || + hangup == 'CANCELED' || + hangup == 'REJECTED' || + hangup == 'MISSED'; +} + +String? _nonEmpty(dynamic raw) { + final value = raw?.toString(); + return value != null && value.isNotEmpty ? value : null; +} + +Map? _firstPreviewAttach(dynamic attaches) { + if (attaches is! List || attaches.isEmpty) return null; + for (final attach in attaches) { + if (attach is! Map) continue; + final type = (attach['_type'] as String? ?? '').toUpperCase(); + if (type == 'INLINE_KEYBOARD') continue; + return attach; + } + return null; +} + +int _mediaAttachCount(dynamic attaches) { + if (attaches is! List) return 0; + var count = 0; + for (final attach in attaches.whereType()) { + final type = (attach['_type'] as String? ?? '').toUpperCase(); + if (type == 'PHOTO' || (type == 'VIDEO' && !_isVideoNote(attach))) count++; + } + return count; +} + +bool _isVideoNote(Map attach) { + final raw = attach['videoType']; + if (raw is int) return raw == 1; + return raw?.toString() == '1'; +} + +String? _controlPreviewLabel(Map c) { + final title = c['title']?.toString(); + switch (c['event']?.toString()) { + case 'new': + return 'Чат создан'; + case 'title': + return title == null || title.isEmpty + ? 'Название чата изменено' + : 'Название чата изменено на «$title»'; + case 'icon': + return 'Фото чата обновлено'; + } + if (title != null && title.isNotEmpty) return title; + final short = c['shortMessage']?.toString(); + if (short != null && short.isNotEmpty) return short; + switch (c['event']?.toString()) { + case 'add': + case 'joinByLink': + return 'Новый участник'; + case 'leave': + return 'Участник вышел'; + case 'remove': + return 'Участник удалён'; + case 'pin': + return 'Закреплённое сообщение'; + default: + return 'Системное сообщение'; + } +} + +String? messagePreviewText(Map msg) { + final original = _forwardOrigin(msg); + if (original != null) { + final inner = original is Map ? _bodyPreviewText(original) : null; + return inner != null && inner.isNotEmpty + ? '↪ $inner' + : '↪ Пересланное сообщение'; + } + return _bodyPreviewText(msg); +} + +String? messagePreviewMedia(Map msg) { + final origin = _forwardOrigin(msg); + final body = origin ?? msg; + if (body is! Map) return null; + final first = _firstPreviewAttach(body['attaches']); + if (first == null) return null; + final kind = _attachPreviewKind(first); + if (kind == null) return null; + + final text = body['text']?.toString(); + final captioned = text != null && text.isNotEmpty; + final parts = captioned ? null : _attachPreviewParts(body['attaches']); + final label = parts == null + ? null + : (origin == null ? parts.label : '↪ ${parts.label}'); + + return ChatPreviewMedia( + kind: kind, + thumbs: _previewThumbs(body['attaches']), + label: label, + detail: parts?.detail, + ).encode(); +} + +dynamic _forwardOrigin(Map msg) { + final link = msg['link']; + if (link is! Map) return null; + if (link['type']?.toString().toUpperCase() != 'FORWARD') return null; + return link['message']; +} + +List _previewThumbs(dynamic attaches) { + if (attaches is! List) return const []; + final thumbs = []; + for (final attach in attaches.whereType()) { + if (thumbs.length >= _maxPreviewThumbs) break; + final type = (attach['_type'] as String? ?? '').toUpperCase(); + final isVideo = type == 'VIDEO'; + if (type != 'PHOTO' && !isVideo) continue; + final source = _thumbSource(attach, isVideo); + if (source == null) continue; + thumbs.add(ChatPreviewThumb(source: source, video: isVideo)); + } + return thumbs; +} + +String? _thumbSource(Map attach, bool isVideo) { + final data = decodeAttachPreview(attach['previewData']); + if (data != null && data.length <= _maxThumbLength) return data; + final url = isVideo + ? _nonEmpty(attach['thumbnail']) + : _nonEmpty(attach['baseUrl']); + if (url != null && url.startsWith('http')) return url; + if (isVideo) return null; + final local = _nonEmpty(attach['localPath']); + return local == null ? null : Uri.file(local).toString(); +} + +({String? text, bool isPreview}) pinnedMessagePreview(Map msg) { + final link = msg['link']; + if (link is Map && link['type']?.toString().toUpperCase() == 'FORWARD') { + final original = link['message']; + if (original is Map) { + final inner = pinnedMessagePreview(original); + return inner.text != null && inner.text!.isNotEmpty + ? (text: '↪ ${inner.text}', isPreview: inner.isPreview) + : (text: '↪ пересланное сообщение', isPreview: true); + } + return (text: '↪ пересланное сообщение', isPreview: true); + } + return _pinnedBodyPreview(msg); +} + +({String? text, bool isPreview}) _pinnedBodyPreview(Map msg) { + final text = msg['text']?.toString(); + if (text != null && text.isNotEmpty) return (text: text, isPreview: false); + final label = _pinnedAttachPreviewLabel(msg['attaches']); + return (text: label, isPreview: label != null); +} + +String? _pinnedAttachPreviewLabel(dynamic attaches) { + final first = _firstPreviewAttach(attaches); + if (first == null) return null; + final type = (first['_type'] as String? ?? '').toUpperCase(); + switch (type) { + case 'PHOTO': + return 'фото'; + case 'VIDEO': + return _isVideoNote(first) ? 'кружок' : 'видео'; + case 'AUDIO': + return 'голосовое сообщение'; + case 'FILE': + return 'файл'; + case 'STICKER': + return 'стикер'; + case 'SHARE': + return 'ссылка'; + case 'POLL': + return 'голосование'; + case 'LOCATION': + return 'геопозиция'; + case 'CONTACT': + return 'контакт'; + case 'CALL': + return 'звонок'; + case 'CONTROL': + final label = _controlPreviewLabel(first); + return label == null || label.isEmpty + ? label + : label[0].toLowerCase() + label.substring(1); + default: + return 'вложение'; + } +} + +String? _bodyPreviewText(Map msg) { + final text = msg['text']?.toString(); + if (text != null && text.isNotEmpty) return text; + return attachPreviewLabel(msg['attaches']); +} + +String? messagePreviewElements(Map msg) { + final text = msg['text']; + if (text is! String || text.isEmpty) return null; + final elements = msg['elements']; + if (elements is List && elements.isNotEmpty) return jsonEncode(elements); + return null; +} diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart new file mode 100644 index 0000000..eecf8a2 --- /dev/null +++ b/lib/backend/modules/chats.dart @@ -0,0 +1,2398 @@ +import '../../core/media/deleted_media_keeper.dart'; +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +import '../../core/config/qlyra_settings.dart'; +import '../../core/push/push_service.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; +import '../../core/cache/info_cache.dart'; +import '../../core/cache/message_session_cache.dart'; +import 'shared_content.dart'; +import '../../core/crypto/e2ee_service.dart'; +import '../../core/storage/app_database.dart'; +import '../../core/storage/chat_activity_store.dart'; +import '../../core/storage/chat_members_store.dart'; +import '../../core/storage/token_storage.dart'; +import '../../core/utils/logger.dart'; +import '../../core/utils/text_format.dart'; +import '../../models/attachment.dart' show ForwardedMessageAttachment; +import '../../models/chat_call.dart'; +import '../../models/chat_preview_media.dart'; +import '../../models/contact_info.dart'; +import '../api.dart'; +import 'chat_parsing.dart'; +import 'chat_preview.dart'; +import 'folders.dart'; +import 'messages.dart' show ContactCache, CachedMessage; + +Map parseParticipants(dynamic raw) { + try { + final decoded = raw is String ? jsonDecode(raw) : raw; + if (decoded is Map) { + return decoded.map( + (k, v) => MapEntry( + k is int ? k : int.parse(k.toString()), + v is int ? v : int.tryParse(v.toString()) ?? 0, + ), + ); + } + } catch (e) { + logger.e('Failed to parse participants: $e'); + } + return {}; +} + +/// Server message ids carry their timestamp in the high bits: the low 16 bits +/// are an intra-millisecond sequence number. +int messageIdToTime(int messageId) => messageId >> 16; + +bool messageMentionsUser(Map message, int userId) { + final elements = message['elements']; + if (elements is! List) return false; + for (final element in elements.whereType()) { + if (element['type']?.toString() != 'USER_MENTION') continue; + if (element['entityId'] == userId) return true; + } + return false; +} + +class CachedChat { + final int id; + final int accountId; + final String type; + final String? title; + final String? iconUrl; + final int? lastMsgId; + final int? lastMsgTime; + final String? lastMsgText; + final String? lastMsgTextOneLine; + final String? lastMsgElements; + final String? lastMsgPreview; + final int? lastMsgSenderId; + final String? lastMsgStatus; + final int unreadCount; + final int lastEventTime; + final int cachedAt; + final int? favIndex; + final int dontDisturbUntil; + final bool isOnline; + final int seenTime; + final Map participants; + final Set options; + final int? owner; + final Set admins; + final int? pinnedMsgId; + final String? pinnedMsgText; + final int? pinnedMsgTime; + final bool pinnedMsgIsPreview; + final int? lastMentionMsgId; + final String? activeCallData; + final String? publicLink; + + CachedChat({ + required this.id, + required this.accountId, + required this.type, + this.title, + this.iconUrl, + this.lastMsgId, + this.lastMsgTime, + this.lastMsgText, + this.lastMsgElements, + this.lastMsgPreview, + this.lastMsgSenderId, + this.lastMsgStatus, + required this.unreadCount, + required this.lastEventTime, + required this.cachedAt, + this.favIndex, + required this.dontDisturbUntil, + required this.isOnline, + required this.seenTime, + required this.participants, + this.options = const {}, + this.owner, + this.admins = const {}, + this.pinnedMsgId, + this.pinnedMsgText, + this.pinnedMsgTime, + this.pinnedMsgIsPreview = false, + this.lastMentionMsgId, + this.activeCallData, + this.publicLink, + }) : lastMsgTextOneLine = lastMsgText != null && lastMsgText.contains('\n') + ? lastMsgText.replaceAll('\n', ' ') + : lastMsgText; + + bool get isOfficial => options.contains('OFFICIAL'); + + late final ChatPreviewMedia? lastMsgMedia = ChatPreviewMedia.decode( + lastMsgPreview, + ); + + late final ChatCall? activeCall = ChatCall.decode(activeCallData); + + List get lastMsgFormatRanges { + final raw = lastMsgElements; + if (raw == null || raw.isEmpty) return const []; + try { + return parseFormatElements(jsonDecode(raw)); + } catch (_) { + return const []; + } + } + + bool get lastMsgReadByOthers { + final t = lastMsgTime; + if (t == null) return false; + for (final entry in participants.entries) { + if (entry.key != accountId && entry.value >= t) return true; + } + return false; + } + + bool iAmAdmin(int myId) => owner == myId || admins.contains(myId); + + bool get hasPinnedMessage => pinnedMsgId != null; + + bool get isGroupChat => type == 'CHAT' || type == 'GROUP'; + + bool canPinMessages(int myId) { + if (!isGroupChat) return false; + return iAmAdmin(myId) || options.contains('ALL_CAN_PIN_MESSAGE'); + } + + bool get forwardDisabled => options.contains('DISABLE_FORWARD'); + + bool get copyDisabled => options.contains('MESSAGE_COPY_NOT_ALLOWED'); + + bool get confirmBeforeSend => options.contains('CONFIRM_BEFORE_SEND'); + + bool get commentsEnabled => options.contains('COMMENTS'); + + // #***! 0 звук есть, минус навсегда, иначе время до которого молчим + bool get isMuted { + if (dontDisturbUntil == ChatsModule.muteOff) return false; + if (dontDisturbUntil < 0) return true; + return dontDisturbUntil > DateTime.now().millisecondsSinceEpoch; + } + + bool get isLastMsgDeleted => lastMsgText == ChatsModule.lastMsgPlaceholder; + + bool get hasUnreadMention { + final mentionId = lastMentionMsgId; + if (mentionId == null || mentionId <= 0) return false; + return messageIdToTime(mentionId) > (participants[accountId] ?? 0); + } + + factory CachedChat.fromDbRow(Map row) => CachedChat( + id: row['id'] as int, + accountId: row['account_id'] as int, + type: row['type'] as String, + title: row['title'] as String?, + iconUrl: row['icon_url'] as String?, + lastMsgId: row['last_msg_id'] as int?, + lastMsgTime: row['last_msg_time'] as int?, + lastMsgText: row['last_msg_text'] as String?, + lastMsgElements: row['last_msg_elements'] as String?, + lastMsgPreview: row['last_msg_preview'] as String?, + lastMsgSenderId: row['last_msg_sender'] as int?, + lastMsgStatus: row['last_msg_status'] as String?, + unreadCount: row['unread_count'] as int, + lastEventTime: row['last_event_time'] as int, + cachedAt: row['cached_at'] as int, + favIndex: row['fav_index'] as int?, + dontDisturbUntil: row['dont_disturb_until'] as int, + isOnline: (row['is_online'] as int) == 1, + seenTime: row['seen_time'] as int, + participants: parseParticipants(row['participants']), + options: _decodeOptions(row['options']), + owner: row['owner'] as int?, + admins: _decodeAdmins(row['admins']), + pinnedMsgId: row['pinned_msg_id'] as int?, + pinnedMsgText: row['pinned_msg_text'] as String?, + pinnedMsgTime: row['pinned_msg_time'] as int?, + pinnedMsgIsPreview: (row['pinned_msg_is_preview'] as int? ?? 0) == 1, + lastMentionMsgId: row['last_mention_msg_id'] as int?, + activeCallData: row['active_call'] as String?, + publicLink: row['public_link'] as String?, + ); + + static Set _decodeOptions(dynamic raw) { + if (raw is! String || raw.isEmpty) return const {}; + return raw.split(',').where((s) => s.isNotEmpty).toSet(); + } + + static Set _decodeAdmins(dynamic raw) { + if (raw is! String || raw.isEmpty) return const {}; + return raw + .split(',') + .map((s) => int.tryParse(s.trim())) + .whereType() + .toSet(); + } + + Map toDbRow() => { + 'id': id, + 'account_id': accountId, + 'type': type, + 'title': title, + 'icon_url': iconUrl, + 'last_msg_id': lastMsgId, + 'last_msg_time': lastMsgTime, + 'last_msg_text': lastMsgText, + 'last_msg_elements': lastMsgElements, + 'last_msg_preview': lastMsgPreview, + 'last_msg_sender': lastMsgSenderId, + 'last_msg_status': lastMsgStatus, + 'unread_count': unreadCount, + 'last_event_time': lastEventTime, + 'cached_at': cachedAt, + 'fav_index': favIndex, + 'dont_disturb_until': dontDisturbUntil, + 'is_online': isOnline ? 1 : 0, + 'seen_time': seenTime, + 'participants': jsonEncode( + participants.map((k, v) => MapEntry(k.toString(), v)), + ), + 'options': options.isEmpty ? null : options.join(','), + 'owner': owner, + 'admins': admins.isEmpty ? null : admins.join(','), + 'pinned_msg_id': pinnedMsgId, + 'pinned_msg_text': pinnedMsgText, + 'pinned_msg_time': pinnedMsgTime, + 'pinned_msg_is_preview': pinnedMsgIsPreview ? 1 : 0, + 'last_mention_msg_id': lastMentionMsgId, + 'active_call': activeCallData, + 'public_link': publicLink, + }; + + static const Object _keep = Object(); + + CachedChat copyWith({ + String? type, + Object? title = _keep, + Object? iconUrl = _keep, + Object? lastMsgId = _keep, + Object? lastMsgTime = _keep, + Object? lastMsgText = _keep, + Object? lastMsgElements = _keep, + Object? lastMsgPreview = _keep, + Object? lastMsgSenderId = _keep, + Object? lastMsgStatus = _keep, + int? unreadCount, + int? lastEventTime, + int? cachedAt, + Object? favIndex = _keep, + int? dontDisturbUntil, + bool? isOnline, + int? seenTime, + Map? participants, + Set? options, + Object? owner = _keep, + Set? admins, + Object? pinnedMsgId = _keep, + Object? pinnedMsgText = _keep, + Object? pinnedMsgTime = _keep, + bool? pinnedMsgIsPreview, + Object? lastMentionMsgId = _keep, + Object? activeCallData = _keep, + Object? publicLink = _keep, + }) { + return CachedChat( + id: id, + accountId: accountId, + type: type ?? this.type, + title: identical(title, _keep) ? this.title : title as String?, + iconUrl: identical(iconUrl, _keep) ? this.iconUrl : iconUrl as String?, + lastMsgId: identical(lastMsgId, _keep) + ? this.lastMsgId + : lastMsgId as int?, + lastMsgTime: identical(lastMsgTime, _keep) + ? this.lastMsgTime + : lastMsgTime as int?, + lastMsgText: identical(lastMsgText, _keep) + ? this.lastMsgText + : lastMsgText as String?, + lastMsgElements: identical(lastMsgElements, _keep) + ? this.lastMsgElements + : lastMsgElements as String?, + lastMsgPreview: identical(lastMsgPreview, _keep) + ? this.lastMsgPreview + : lastMsgPreview as String?, + lastMsgSenderId: identical(lastMsgSenderId, _keep) + ? this.lastMsgSenderId + : lastMsgSenderId as int?, + lastMsgStatus: identical(lastMsgStatus, _keep) + ? this.lastMsgStatus + : lastMsgStatus as String?, + unreadCount: unreadCount ?? this.unreadCount, + lastEventTime: lastEventTime ?? this.lastEventTime, + cachedAt: cachedAt ?? this.cachedAt, + favIndex: identical(favIndex, _keep) ? this.favIndex : favIndex as int?, + dontDisturbUntil: dontDisturbUntil ?? this.dontDisturbUntil, + isOnline: isOnline ?? this.isOnline, + seenTime: seenTime ?? this.seenTime, + participants: participants ?? this.participants, + options: options ?? this.options, + owner: identical(owner, _keep) ? this.owner : owner as int?, + admins: admins ?? this.admins, + pinnedMsgId: identical(pinnedMsgId, _keep) + ? this.pinnedMsgId + : pinnedMsgId as int?, + pinnedMsgText: identical(pinnedMsgText, _keep) + ? this.pinnedMsgText + : pinnedMsgText as String?, + pinnedMsgTime: identical(pinnedMsgTime, _keep) + ? this.pinnedMsgTime + : pinnedMsgTime as int?, + pinnedMsgIsPreview: pinnedMsgIsPreview ?? this.pinnedMsgIsPreview, + lastMentionMsgId: identical(lastMentionMsgId, _keep) + ? this.lastMentionMsgId + : lastMentionMsgId as int?, + activeCallData: identical(activeCallData, _keep) + ? this.activeCallData + : activeCallData as String?, + publicLink: identical(publicLink, _keep) + ? this.publicLink + : publicLink as String?, + ); + } +} + +class ChatSearchHit { + final int id; + final String type; + final String? title; + final String? avatarUrl; + final String? subtitle; + + const ChatSearchHit({ + required this.id, + required this.type, + this.title, + this.avatarUrl, + this.subtitle, + }); +} + +class ChatMemberEntry { + final int id; + final String? name; + final String? fullName; + final String? avatarUrl; + final int? seenTime; + final int presenceStatus; + final bool blocked; + final bool isContact; + + const ChatMemberEntry({ + required this.id, + this.name, + this.fullName, + this.avatarUrl, + this.seenTime, + required this.presenceStatus, + this.blocked = false, + this.isContact = false, + }); + + bool get isOnline => presenceStatus == 1; +} + +class ChatMembersPage { + final List members; + final int marker; + + const ChatMembersPage({required this.members, required this.marker}); +} + +class MessageSearchHit { + final int chatId; + final String? messageId; + final String? text; + final int time; + final int senderId; + + const MessageSearchHit({ + required this.chatId, + this.messageId, + this.text, + required this.time, + required this.senderId, + }); +} + +sealed class MessageEvent { + final int chatId; + const MessageEvent(this.chatId); +} + +class MessageAddedEvent extends MessageEvent { + final CachedMessage message; + const MessageAddedEvent(super.chatId, this.message); +} + +class MessageEditedEvent extends MessageEvent { + final CachedMessage message; + const MessageEditedEvent(super.chatId, this.message); +} + +class MessageRemovedEvent extends MessageEvent { + final String messageId; + const MessageRemovedEvent(super.chatId, this.messageId); +} + +class MessageMarkedDeletedEvent extends MessageEvent { + final String messageId; + const MessageMarkedDeletedEvent(super.chatId, this.messageId); +} + +class MessageReactionsChangedEvent extends MessageEvent { + final String messageId; + final Map? reactionInfo; + const MessageReactionsChangedEvent( + super.chatId, + this.messageId, + this.reactionInfo, + ); +} + +class MessageSentEvent extends MessageEvent { + final String tempId; + final CachedMessage message; + const MessageSentEvent(super.chatId, this.tempId, this.message); +} + +abstract class ChatPermission { + static const int addRemoveMember = 2; + static const int addAdmin = 4; + static const int changeChatInfo = 8; + static const int pinMessage = 16; + static const int postMessage = 256; + static const int editMessage = 512; + static const int deleteMessage = 1024; +} + +class ChatsModule { + static const int muteOff = 0; + static const int muteForever = -1; + + /// Sentinel в `lastMsgText` когда последнее сообщение в чате удалено, + /// а кеша истории нет — UI должен отрисовать курсивную плашку. + static const String lastMsgPlaceholder = '__qlyra_lastmsg_placeholder__'; + + ChatsModule._(); + + final _messageEventsController = StreamController.broadcast(); + Stream get messageEvents => _messageEventsController.stream; + + final _departedChatsController = StreamController.broadcast(); + Stream get departedChats => _departedChatsController.stream; + + int? _paginatedAccountId; + + Future _extendHistoryCoverage( + int accountId, + int chatId, + Map chatRowBefore, + int time, + ) async { + final previousLast = CachedChat.fromDbRow(chatRowBefore).lastMsgTime; + if (previousLast == null || previousLast <= 0) return; + try { + await AppDatabase.extendMessageRange( + accountId, + chatId, + previousLastTime: previousLast, + time: time, + ); + } catch (e) { + logger.w('extendMessageRange error: $e'); + } + } + + Future storeSentMessages( + int accountId, + int chatId, + List messages, + ) async { + if (messages.isEmpty) return; + final rows = await AppDatabase.loadChat(accountId, chatId); + await AppDatabase.saveMessages([ + for (final message in messages) message.toDbRow(), + ]); + if (rows.isEmpty) return; + final newest = messages.map((m) => m.time).reduce((a, b) => a > b ? a : b); + await _extendHistoryCoverage(accountId, chatId, rows.first, newest); + } + + void emitMessageSent(int chatId, String tempId, CachedMessage message) { + _messageEventsController.add(MessageSentEvent(chatId, tempId, message)); + } + + Future markRead( + Api api, + int accountId, + int chatId, + String messageId, + int mark, + ) async { + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) return; + final row = Map.from(rows.first); + if ((row['unread_count'] as int? ?? 0) == 0) return; + + final msgIdNum = int.tryParse(messageId); + if (msgIdNum != null && !QlyraSettings.antiRead.value) { + try { + await api.sendRequest(Opcode.chatMark, { + 'type': 'READ_MESSAGE', + 'chatId': chatId, + 'messageId': msgIdNum, + 'mark': mark, + }, silent: true); + } catch (_) {} + } + + final cached = CachedChat.fromDbRow(row); + final currentMark = cached.participants[accountId] ?? 0; + final participants = Map.from(cached.participants) + ..[accountId] = mark > currentMark ? mark : currentMark; + final updated = cached.copyWith(unreadCount: 0, participants: participants); + await AppDatabase.saveChats([updated.toDbRow()]); + _bump(); + } + + Future markReadUpTo( + Api api, + int accountId, + int chatId, + String messageId, + int mark, { + required int remaining, + }) async { + final msgIdNum = int.tryParse(messageId); + if (msgIdNum != null && !QlyraSettings.antiRead.value) { + try { + await api.sendRequest(Opcode.chatMark, { + 'type': 'READ_MESSAGE', + 'chatId': chatId, + 'messageId': msgIdNum, + 'mark': mark, + }, silent: true); + } catch (_) {} + } + + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) return; + final cached = CachedChat.fromDbRow(rows.first); + final next = remaining < 0 ? 0 : remaining; + final currentMark = cached.participants[accountId] ?? 0; + final nextMark = mark > currentMark ? mark : currentMark; + if (cached.unreadCount == next && nextMark == currentMark) { + if (next == 0) unawaited(PushService.clearChatNotification(chatId)); + return; + } + final participants = Map.from(cached.participants) + ..[accountId] = nextMark; + final updated = cached.copyWith( + unreadCount: next, + participants: participants, + ); + await AppDatabase.saveChats([updated.toDbRow()]); + if (next == 0) unawaited(PushService.clearChatNotification(chatId)); + _bump(); + } + + Future markUnread(Api api, int accountId, int chatId, int mark) async { + int? unread; + try { + final resp = await api.sendRequest(Opcode.chatMark, { + 'type': 'SET_AS_UNREAD', + 'chatId': chatId, + 'mark': mark, + }); + final payload = resp.payload; + if (payload is Map) unread = payload['unread'] as int?; + } catch (_) { + return null; + } + if (unread == null) return null; + + await _updateChat(accountId, chatId, (chat) { + final participants = Map.from(chat.participants) + ..[accountId] = mark - 1; + return chat.copyWith(unreadCount: unread, participants: participants); + }); + return unread; + } + + Future applyOutgoing( + int accountId, + int chatId, { + required String messageId, + required int time, + required String text, + required String status, + List>? elements, + String? preview, + int? replacesTime, + }) async { + final thisId = int.tryParse(messageId); + await _updateChat(accountId, chatId, (chat) { + final existingTime = chat.lastMsgTime ?? 0; + final confirmsPreview = + replacesTime != null && + chat.lastMsgId == null && + chat.lastMsgTime == replacesTime; + if (time < existingTime && + (thisId == null || chat.lastMsgId != thisId) && + !confirmsPreview) { + return null; + } + return chat.copyWith( + lastMsgId: thisId, + lastMsgText: text, + lastMsgElements: (elements != null && elements.isNotEmpty) + ? jsonEncode(elements) + : null, + lastMsgPreview: preview, + lastMsgTime: time, + lastEventTime: time, + lastMsgSenderId: accountId, + lastMsgStatus: status, + ); + }); + } + + // #***! своё медиа в строку чата на всех стадиях: часики пока грузится, + // галочка когда сервер принял, крестик если не ушло + Future applyOutgoingMessage( + CachedMessage message, { + required String status, + int? replacesTime, + }) { + final payload = { + ...message.previewPayload, + if (message.e2ee != CachedMessage.e2eeNone) 'text': null, + }; + return applyOutgoing( + message.accountId, + message.chatId, + messageId: message.id, + time: message.time, + text: messagePreviewText(payload) ?? '', + preview: messagePreviewMedia(payload), + status: status, + replacesTime: replacesTime, + ); + } + + final ValueNotifier chatsChanged = ValueNotifier(0); + void _bump() => chatsChanged.value = chatsChanged.value + 1; + + @visibleForTesting + static bool reshapesChatList(CachedChat old, CachedChat updated) => + old.favIndex != updated.favIndex || + old.lastEventTime != updated.lastEventTime || + (old.unreadCount > 0) != (updated.unreadCount > 0) || + old.isMuted != updated.isMuted || + old.owner != updated.owner || + !setEquals(old.admins, updated.admins); + + // #***! события меняющие состав участников + static const Set _membershipEvents = { + 'add', + 'joinByLink', + 'leave', + 'remove', + }; + + void _applyMembershipControl( + int accountId, + int chatId, + CachedMessage message, + ) { + final control = message.controlAttachment; + final event = control?.event; + if (control == null || event == null) return; + if (!_membershipEvents.contains(event)) return; + + if (message.senderId != accountId) { + final affected = control.userIds?.length ?? 1; + ChatMembersStore.instance.adjust(chatId, switch (event) { + 'add' => affected, + 'joinByLink' => 1, + 'leave' => -1, + 'remove' => -affected, + _ => 0, + }); + } + _refreshChatInfo(chatId); + } + + void _refreshChatInfo(int chatId) { + ChatInfoFetch.invalidate(chatId); + unawaited(ChatInfoFetch.get(chatId)); + } + + Future canAccessForwardSource( + int accountId, + ForwardedMessageAttachment forwarded, + ) async { + if (!forwarded.isFromPrivateChat) return true; + final chatId = forwarded.originalChatId; + if (chatId == null) return false; + return AppDatabase.isChatInList(accountId, chatId); + } + + Future canOpenForwardSource( + Api api, + int accountId, + ForwardedMessageAttachment forwarded, + ) async { + if (await canAccessForwardSource(accountId, forwarded)) return true; + final chatId = forwarded.originalChatId; + if (chatId == null) return false; + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isNotEmpty && rows.first['in_list'] == 1) { + return true; + } + final Map? info; + try { + info = await getChatInfo(api, chatId); + } on PacketError { + return false; + } catch (_) { + return true; + } + if (info == null) return false; + if (!parseParticipants(info['participants']).containsKey(accountId)) { + return false; + } + await cacheServerChat(info, accountId); + return true; + } + + // #***! снимок текущего кэша, без похода в базу + Future _updateChat( + int accountId, + int chatId, + CachedChat? Function(CachedChat chat) mutate, + ) async { + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) return false; + final updated = mutate(CachedChat.fromDbRow(rows.first)); + if (updated == null) return false; + final row = Map.from(rows.first) + ..addAll(updated.toDbRow()); + await AppDatabase.saveChats([row]); + _bump(); + return true; + } + + static Map? _decodePayload(dynamic raw) { + if (raw is String && raw.isNotEmpty) { + try { + return Map.from(jsonDecode(raw) as Map); + } catch (_) {} + } + return null; + } + + StreamSubscription? _globalPushSub; + StreamSubscription? _globalStateSub; + Future _pushQueue = Future.value(); + + final Set _historyFetched = {}; + + bool wasHistoryFetched(int chatId) => _historyFetched.contains(chatId); + void markHistoryFetched(int chatId) => _historyFetched.add(chatId); + + void attachGlobalPushHandlers(Api api) { + _globalPushSub?.cancel(); + _globalStateSub?.cancel(); + _globalPushSub = api.pushStream.listen(_enqueueGlobalPush); + _globalStateSub = api.stateStream.listen(_handleSessionState); + } + + void dispose() { + _globalPushSub?.cancel(); + _globalStateSub?.cancel(); + _globalPushSub = null; + _globalStateSub = null; + _contactFlushTimer?.cancel(); + _contactFlushTimer = null; + _messageEventsController.close(); + chatsChanged.dispose(); + } + + void _handleSessionState(SessionState state) { + if (state == SessionState.disconnected) { + ContactInfoFetch.clear(); + PresenceFetch.clear(); + ChatInfoFetch.clear(); + _historyFetched.clear(); + } + } + + void resetForAccountSwitch() { + E2eeService.instance.lock(); + _historyFetched.clear(); + ContactInfoFetch.clear(); + PresenceFetch.clear(); + ChatInfoFetch.clear(); + SharedContentModule.clearMediaIndex(); + _bump(); + } + + void _enqueueGlobalPush(Packet packet) { + _pushQueue = _pushQueue.then((_) => _handleGlobalPush(packet)).catchError(( + Object e, + ) { + logger.w('Ошибка обработки пуша: $e'); + }); + } + + Future _handleGlobalPush(Packet packet) async { + switch (packet.opcode) { + case Opcode.notifChat: + await _handleNotifChat(packet); + case Opcode.notifMessage: + await _handleNotifMessage(packet); + case Opcode.notifMark: + await _handleNotifMark(packet); + case Opcode.notifMsgReactionsChanged: + await _handleNotifMsgReactionsChanged(packet); + case Opcode.notifMsgDelete: + await _handleNotifMsgDelete(packet); + case Opcode.notifPresence: + _handlePresence(packet); + } + } + + Future _handleNotifChat(Packet packet) async { + final payload = packet.payload; + if (payload is! Map) return; + final chat = payload['chat']; + if (chat is! Map) return; + final chatId = chat['id']; + if (chatId is! int) return; + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + final activeCallData = ChatCall.fromServer( + chat['videoConversation'], + )?.encode(); + await _updateChat( + accountId, + chatId, + (cached) => cached.activeCallData == activeCallData + ? null + : cached.copyWith(activeCallData: activeCallData), + ); + } + + // #***! присутствие собеседника + void _handlePresence(Packet packet) { + final payload = packet.payload; + if (payload is! Map) return; + final userId = payload['userId']; + if (userId is! int) return; + final presence = payload['presence']; + if (presence is! Map) return; + PresenceFetch.apply(userId, Map.from(presence)); + } + + Future _handleNotifMsgDelete(Packet packet) async { + final payload = packet.payload; + if (payload is! Map) return; + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + + final chatMap = payload['chat']; + int? chatId; + if (chatMap is Map && chatMap['id'] is int) { + chatId = chatMap['id'] as int; + await cacheServerChat(chatMap.cast(), accountId); + } else if (payload['chatId'] is int) { + chatId = payload['chatId'] as int; + } + if (chatId == null) return; + + final keepDeleted = QlyraSettings.viewDeleted.value; + final ids = payload['messageIds']; + if (ids is List) { + for (final raw in ids) { + final id = raw?.toString(); + if (id == null || id.isEmpty) continue; + if (keepDeleted) { + await AppDatabase.markMessageDeleted(accountId, chatId, id); + unawaited(keepDeletedMedia(accountId, chatId, [id])); + _messageEventsController.add(MessageMarkedDeletedEvent(chatId, id)); + } else { + await AppDatabase.deleteMessage(accountId, chatId, id); + _messageEventsController.add(MessageRemovedEvent(chatId, id)); + } + } + } + _bump(); + } + + int? _takeTypingTime(int chatId, Map msg) { + if (!QlyraSettings.showTypingTime.value) return null; + final status = msg['status']; + if (status == 'EDITED' || status == 'REMOVED') return null; + final senderId = msg['sender']; + final text = msg['text']; + if (senderId is! int || text is! String || text.isEmpty) return null; + return ChatActivityStore.instance.takeTypingTime(chatId, senderId); + } + + // #***! новое сообщение, комментарии отсеиваем у них свой модуль + Future _handleNotifMessage(Packet packet) async { + final payload = packet.payload; + if (payload is! Map) return; + final chatId = payload['chatId']; + if (chatId is! int) return; + final msg = payload['message']; + if (msg is! Map) return; + + final msgLink = msg['link']; + final linkPostId = (msgLink is Map) ? msgLink['postId'] : null; + final payloadPostId = payload['postId']; + final isCommentPush = + payloadPostId is String || + (linkPostId is String) || + (msg['postId'] is String); + if (isCommentPush) return; + + final typingMs = _takeTypingTime(chatId, msg); + + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + + final senderId = msg['sender'] as int?; + final msgIdStr = msg['id']?.toString(); + final msgIdInt = (msg['id'] is int) + ? msg['id'] as int + : int.tryParse(msgIdStr ?? ''); + final msgTime = msg['time'] as int?; + final msgText = msg['text'] as String?; + final status = msg['status'] as String?; + final unread = payload['unread'] as int?; + + var rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) { + try { + final chatInfo = await ChatInfoFetch.get(chatId); + if (chatInfo != null) { + await cacheServerChat(chatInfo.raw, accountId); + } + } catch (e) { + logger.w( + 'notifMessage: fetch info for unknown chat $chatId failed: $e', + ); + return; + } + rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) return; + } + + if (status == 'REMOVED' && msgIdStr != null) { + final keepDeleted = QlyraSettings.viewDeleted.value; + if (keepDeleted) { + await AppDatabase.markMessageDeleted(accountId, chatId, msgIdStr); + unawaited(keepDeletedMedia(accountId, chatId, [msgIdStr])); + } else { + await AppDatabase.deleteMessage(accountId, chatId, msgIdStr); + } + final cachedChat = CachedChat.fromDbRow(rows.first); + if (cachedChat.lastMsgId == msgIdInt) { + await _reconcileLastMessage( + accountId, + chatId, + rows.first, + unread: unread, + ); + } else if (unread != null) { + final newRow = Map.from(rows.first); + newRow['unread_count'] = unread; + await AppDatabase.saveChats([newRow]); + } + _messageEventsController.add( + keepDeleted + ? MessageMarkedDeletedEvent(chatId, msgIdStr) + : MessageRemovedEvent(chatId, msgIdStr), + ); + _bump(); + return; + } + + CachedMessage? emittedMessage; + final existing = msgIdStr == null + ? null + : await AppDatabase.loadMessage(accountId, chatId, msgIdStr); + if (status == 'EDITED' && existing != null) { + final mergedPayload = + _decodePayload(existing['payload']) ?? Map.from(msg); + for (final entry in msg.entries) { + if (entry.key == 'reactionInfo') continue; + mergedPayload[entry.key.toString()] = entry.value; + } + final newRow = Map.from(existing); + if (QlyraSettings.viewRedacted.value) { + final oldText = existing['text']?.toString(); + if ((oldText ?? '') != (msgText ?? '') && + oldText != null && + oldText.isNotEmpty) { + final history = CachedMessage.appendEditHistory( + CachedMessage.parseEditHistory(existing['edit_history']), + oldText, + DateTime.now().millisecondsSinceEpoch, + ); + newRow['edit_history'] = jsonEncode(history); + } + } + final wasEncrypted = + (existing['e2ee'] as int? ?? CachedMessage.e2eeNone) != + CachedMessage.e2eeNone; + newRow['text'] = msgText; + newRow['status'] = status; + newRow['payload'] = jsonEncode(mergedPayload); + newRow['text_sealed'] = null; + newRow['e2ee'] = CachedMessage.e2eeNone; + final inspected = await E2eeService.instance.inspect( + CachedMessage.fromDbRow(newRow), + ); + // #***! правка без расшифровки не от собеседника, применять нельзя + if (wasEncrypted && inspected.e2ee != CachedMessage.e2eeText) { + logger.w('notifMessage: отклонена правка $msgIdStr без расшифровки'); + return; + } + newRow['text_sealed'] = inspected.sealedText; + newRow['e2ee'] = inspected.e2ee; + await AppDatabase.saveMessages([newRow]); + emittedMessage = CachedMessage.fromDbRow(newRow); + _messageEventsController.add(MessageEditedEvent(chatId, emittedMessage)); + } else if (msgIdStr != null && existing == null) { + final cached = await E2eeService.instance.inspect( + CachedMessage.fromPushPayload( + accountId, + chatId, + msg, + ).copyWith(typingMs: typingMs), + commit: (decrypted) => AppDatabase.saveMessages([decrypted.toDbRow()]), + ); + await AppDatabase.saveMessages([cached.toDbRow()]); + final previousLast = CachedChat.fromDbRow(rows.first).lastMsgTime; + if (previousLast != null && previousLast > 0) { + await AppDatabase.extendMessageRange( + accountId, + chatId, + previousLastTime: previousLast, + time: cached.time, + ); + } + emittedMessage = cached; + _applyMembershipControl(accountId, chatId, cached); + _messageEventsController.add(MessageAddedEvent(chatId, cached)); + } + + final cached = CachedChat.fromDbRow(rows.first); + final isStaleLast = + status != 'REMOVED' && + msgIdInt != null && + cached.lastMsgId == msgIdInt && + status != 'EDITED'; + if (isStaleLast) { + _bump(); + return; + } + + final newRow = Map.from(rows.first); + if (status != 'REMOVED') { + if (msgIdInt != null) newRow['last_msg_id'] = msgIdInt; + if (msgTime != null) { + newRow['last_msg_time'] = msgTime; + if (status != 'EDITED') { + newRow['last_event_time'] = msgTime; + } + } + newRow['last_msg_text'] = messagePreviewText(msg); + newRow['last_msg_elements'] = messagePreviewElements(msg); + newRow['last_msg_preview'] = messagePreviewMedia(msg); + if (senderId != null) newRow['last_msg_sender'] = senderId; + newRow['last_msg_status'] = 'sent'; + } + if (unread != null) newRow['unread_count'] = unread; + + if (msgIdInt != null && + status != 'REMOVED' && + senderId != accountId && + messageMentionsUser(msg, accountId)) { + newRow['last_mention_msg_id'] = msgIdInt; + } + + final pinned = _extractPinnedMessage(msg); + if (pinned != null) { + newRow['pinned_msg_id'] = pinned.id; + newRow['pinned_msg_text'] = pinned.text; + newRow['pinned_msg_time'] = pinned.time; + newRow['pinned_msg_is_preview'] = pinned.isPreview ? 1 : 0; + } + + await AppDatabase.saveChats([newRow]); + _bump(); + } + + ({int? id, String? text, int? time, bool isPreview})? _extractPinnedMessage( + Map msg, + ) { + final attaches = msg['attaches']; + if (attaches is! List) return null; + for (final a in attaches.whereType()) { + if ((a['_type'] as String?) != 'CONTROL') continue; + final event = a['event']?.toString(); + if (event != 'pin' && event != 'unpin') continue; + final pinned = a['pinnedMessage']; + if (event == 'unpin' || pinned is! Map) { + return (id: null, text: null, time: null, isPreview: false); + } + final rawId = pinned['id']; + final id = rawId is int ? rawId : int.tryParse(rawId?.toString() ?? ''); + if (id == null) return null; + final preview = pinnedMessagePreview(pinned.cast()); + return ( + id: id, + text: preview.text, + time: pinned['time'] as int?, + isPreview: preview.isPreview, + ); + } + return null; + } + + Future _reconcileLastMessage( + int accountId, + int chatId, + Map chatRow, { + int? unread, + }) async { + final latest = await AppDatabase.loadMessages( + accountId, + chatId, + limit: 1, + onlyVisible: true, + ); + final newRow = Map.from(chatRow); + if (latest.isNotEmpty) { + final m = latest.first; + final rawText = m['text']?.toString(); + String? previewText = rawText; + String? elementsJson; + String? previewMedia; + final payload = _decodePayload(m['payload']); + if (payload != null) previewMedia = messagePreviewMedia(payload); + if (rawText == null || rawText.isEmpty) { + if (payload != null) previewText = messagePreviewText(payload); + } else { + if (payload != null) elementsJson = messagePreviewElements(payload); + } + newRow['last_msg_id'] = int.tryParse(m['id']?.toString() ?? ''); + newRow['last_msg_text'] = previewText ?? m['text']; + newRow['last_msg_elements'] = elementsJson; + newRow['last_msg_preview'] = previewMedia; + newRow['last_msg_time'] = m['time']; + newRow['last_msg_sender'] = m['sender_id']; + newRow['last_msg_status'] = m['status']; + } else { + newRow['last_msg_id'] = null; + newRow['last_msg_text'] = lastMsgPlaceholder; + newRow['last_msg_elements'] = null; + newRow['last_msg_preview'] = null; + newRow['last_msg_sender'] = null; + newRow['last_msg_status'] = null; + } + if (unread != null) newRow['unread_count'] = unread; + await AppDatabase.saveChats([newRow]); + } + + /// Вызывается после успешного фетча истории чата — + /// если в превью был placeholder, заменяем его на актуальное + /// последнее сообщение из кеша. + Future reconcileLastMessageIfPlaceholder( + int accountId, + int chatId, + ) async { + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) return; + final chat = CachedChat.fromDbRow(rows.first); + if (!chat.isLastMsgDeleted) return; + await _reconcileLastMessage(accountId, chatId, rows.first); + _bump(); + } + + Future reconcileLastMessage(int accountId, int chatId) async { + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) return; + await _reconcileLastMessage(accountId, chatId, rows.first); + _bump(); + } + + Future> reconcileDeletedFromFetch( + int accountId, + int chatId, + List serverMessages, + ) async { + if (serverMessages.isEmpty) return const []; + + final serverIds = {}; + var minTime = serverMessages.first.time; + var maxTime = serverMessages.first.time; + for (final m in serverMessages) { + serverIds.add(m.id); + if (m.time < minTime) minTime = m.time; + if (m.time > maxTime) maxTime = m.time; + } + + final cached = await AppDatabase.loadMessages( + accountId, + chatId, + limit: 300, + onlyVisible: true, + ); + + final newlyDeleted = []; + for (final row in cached) { + final id = row['id']?.toString(); + if (id == null || id.isEmpty || id.startsWith('temp_')) continue; + if (serverIds.contains(id)) continue; + + final status = row['status']?.toString(); + if (status == 'pending' || status == 'sending' || status == 'error') { + continue; + } + + final time = row['time'] is int + ? row['time'] as int + : int.tryParse(row['time']?.toString() ?? '') ?? 0; + if (time < minTime || time > maxTime) continue; + + newlyDeleted.add(id); + } + + if (newlyDeleted.isNotEmpty) { + await AppDatabase.markMessagesDeleted(accountId, chatId, newlyDeleted); + unawaited(keepDeletedMedia(accountId, chatId, newlyDeleted)); + } + return newlyDeleted; + } + + Future _handleNotifMsgReactionsChanged(Packet packet) async { + final payload = packet.payload; + if (payload is! Map) return; + final chatId = payload['chatId']; + if (chatId is! int) return; + final messageId = payload['messageId']?.toString(); + if (messageId == null || messageId.isEmpty) return; + + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + + final existing = await AppDatabase.loadMessage( + accountId, + chatId, + messageId, + ); + if (existing == null) return; + + final payloadMap = + _decodePayload(existing['payload']) ?? {}; + + final counters = payload['counters']; + final totalCount = payload['totalCount']; + final reactionInfo = {}; + final prev = payloadMap['reactionInfo']; + if (prev is Map && prev['yourReaction'] != null) { + reactionInfo['yourReaction'] = prev['yourReaction']; + } + if (counters is List) reactionInfo['counters'] = counters; + if (totalCount is int) reactionInfo['totalCount'] = totalCount; + if (reactionInfo['counters'] == null || + (counters is List && counters.isEmpty)) { + payloadMap.remove('reactionInfo'); + } else { + payloadMap['reactionInfo'] = reactionInfo; + } + + final newRow = Map.from(existing); + newRow['payload'] = jsonEncode(payloadMap); + await AppDatabase.saveMessages([newRow]); + final emitted = payloadMap['reactionInfo'] as Map?; + _messageEventsController.add( + MessageReactionsChangedEvent(chatId, messageId, emitted), + ); + _bump(); + } + + Future _handleNotifMark(Packet packet) async { + final payload = packet.payload; + if (payload is! Map) return; + final chatId = payload['chatId']; + if (chatId is! int) return; + final userId = payload['userId']; + if (userId is! int) return; + final mark = payload['mark']; + if (mark is! int) return; + if (payload['setAsUnread'] == true) return; + + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) return; + final cached = CachedChat.fromDbRow(rows.first); + if (cached.participants[userId] == mark) return; + cached.participants[userId] = mark; + await AppDatabase.saveChats([cached.toDbRow()]); + _bump(); + } + + final Set _pendingContactUpdates = {}; + Timer? _contactFlushTimer; + Future? _contactFlushFuture; + static const _contactFlushDelay = Duration(milliseconds: 250); + + void applyContactUpdate(int contactId) { + _pendingContactUpdates.add(contactId); + if (_contactFlushTimer != null) return; + if (_contactFlushFuture != null) return; + _contactFlushTimer = Timer(_contactFlushDelay, _kickFlush); + } + + void _kickFlush() { + _contactFlushTimer = null; + if (_contactFlushFuture != null) return; + _contactFlushFuture = _flushContactUpdates().whenComplete(() { + _contactFlushFuture = null; + if (_pendingContactUpdates.isNotEmpty) { + _contactFlushTimer ??= Timer(_contactFlushDelay, _kickFlush); + } + }); + } + + Future _flushContactUpdates() async { + if (_pendingContactUpdates.isEmpty) return; + final ids = _pendingContactUpdates.toList(); + _pendingContactUpdates.clear(); + + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + + final dialogRows = await AppDatabase.loadDialogChats(accountId); + final byParticipant = + row, CachedChat cached})>>{}; + for (final row in dialogRows) { + final cached = CachedChat.fromDbRow(row); + for (final pid in cached.participants.keys) { + if (pid == accountId) continue; + byParticipant.putIfAbsent(pid, () => []).add(( + row: row, + cached: cached, + )); + } + } + + final updates = >[]; + for (final contactId in ids) { + final name = ContactCache.get(contactId); + if (name == null) continue; + final avatar = ContactCache.getAvatar(contactId); + final options = ContactCache.getOptions(contactId) ?? const {}; + final affected = byParticipant[contactId]; + if (affected == null) continue; + for (final entry in affected) { + final row = entry.row; + final cached = entry.cached; + final sameTitle = cached.title == name; + final sameAvatar = (cached.iconUrl ?? '') == (avatar ?? ''); + final sameOptions = + cached.options.length == options.length && + cached.options.containsAll(options); + if (sameTitle && sameAvatar && sameOptions) continue; + final newRow = Map.from(row); + newRow['title'] = name; + newRow['icon_url'] = avatar; + newRow['options'] = options.isEmpty ? null : options.join(','); + updates.add(newRow); + } + } + if (updates.isNotEmpty) { + await AppDatabase.saveChats(updates); + _bump(); + } + } + + Future cacheServerChat( + Map chat, + int accountId, { + Map? preloadedExisting, + bool inList = true, + }) async { + final cachedAt = DateTime.now().millisecondsSinceEpoch; + final id = chat['id']; + ChatMembersStore.instance.applyChatPayload(chat); + Map existing = const {}; + Map? existingRow; + if (preloadedExisting != null) { + existing = preloadedExisting; + } else if (id is int) { + final rows = await AppDatabase.loadChat(accountId, id); + if (rows.isNotEmpty) { + existingRow = rows.first; + existing = {id: CachedChat.fromDbRow(existingRow)}; + } + } + final parsed = parseChatRow( + chat, + accountId, + accountId, + const {}, + const {}, + const {}, + existing, + cachedAt, + ); + if (parsed == null) { + logger.w('cacheServerChat: parse returned null for chat=${chat['id']}'); + return null; + } + final ex = existing[parsed.id]; + final listState = !inList ? 0 : (chat['status'] == 'HIDDEN' ? 2 : 1); + final membershipUnchanged = + existingRow == null || existingRow['in_list'] == listState; + if (ex != null && sameChatContent(ex, parsed) && membershipUnchanged) { + return parsed; + } + final row = parsed.toDbRow(); + row['in_list'] = listState; + await AppDatabase.saveChats([row]); + _bump(); + return parsed; + } + + /// Парсит и кэширует чаты из payload opcode 19. + /// + /// Для диалогов разрезолвит имя и аватар из списка [contacts] того же + /// ответа. На warm start контакты не приходят — используется существующий + /// кэш. + Future syncFromLoginPayload( + Map data, + int accountId, + int currentUserId, + ) async { + try { + final chats = data['chats']; + if (chats is! List || chats.isEmpty) return; + + final contactsMap = buildContactsMap(data['contacts']); + // Config contains mute setup and fav indexes: config -> chats -> id + final configMap = data['config'] is Map ? data['config'] as Map : {}; + final chatsConfig = configMap['chats'] is Map + ? configMap['chats'] as Map + : {}; + + // Presence for online statuses + final presenceMap = data['presence'] is Map + ? data['presence'] as Map + : {}; + PresenceFetch.primeAll(presenceMap); + + await _persistChatMaps( + chats, + accountId, + currentUserId, + contactsMap: contactsMap, + chatsConfig: chatsConfig, + presenceMap: presenceMap, + ); + } catch (e) { + logger.e("Ошибка при синке: $e"); + } + } + + Future _persistChatMaps( + List chats, + int accountId, + int currentUserId, { + Map> contactsMap = const {}, + Map chatsConfig = const {}, + Map presenceMap = const {}, + }) async { + final cachedAt = DateTime.now().millisecondsSinceEpoch; + final existingRows = await AppDatabase.loadChats( + accountId, + includeHidden: true, + ); + final existing = { + for (final row in existingRows) + row['id'] as int: CachedChat.fromDbRow(row), + }; + + final rows = >[]; + for (final c in chats.whereType()) { + final map = c.cast(); + ChatMembersStore.instance.applyChatPayload(map); + final parsed = parseChatRow( + map, + accountId, + currentUserId, + contactsMap, + chatsConfig, + presenceMap, + existing, + cachedAt, + ); + if (parsed == null) continue; + final row = parsed.toDbRow(); + row['in_list'] = map['status'] == 'HIDDEN' ? 2 : 1; + rows.add(row); + } + + if (rows.isNotEmpty) { + await AppDatabase.saveChats(rows); + _bump(); + } + return rows.length; + } + + Future paginateChats( + Api api, + int accountId, + int currentUserId, + Map loginData, + ) async { + if (_paginatedAccountId == accountId) return; + _paginatedAccountId = accountId; + try { + var marker = loginData['chatMarker']; + if (marker is! int || marker <= 0) return; + + const count = 50; + var page = 0; + while (page < 200) { + page++; + final resp = await api.sendRequestMap(Opcode.chatsList, { + 'marker': marker, + 'count': count, + }); + if (resp == null) break; + final chats = resp['chats']; + if (chats is! List || chats.isEmpty) break; + + await _persistChatMaps(chats, accountId, currentUserId); + + final next = resp['marker']; + if (next is! int || next == marker || chats.length < count) break; + marker = next; + } + await applyFavorites(accountId); + } catch (e) { + _paginatedAccountId = null; + logger.w('Пагинация чатов: $e'); + } + } + + final Set _repairedSenders = {}; + final Set<(int, int)> _awaitingRemoval = {}; + void holdForRemoval(int accountId, Iterable ids) { + _awaitingRemoval.addAll(ids.map((id) => (accountId, id))); + _bump(); + } + + void releaseRemoval(int accountId, Iterable ids) { + for (final id in ids) { + _awaitingRemoval.remove((accountId, id)); + } + _bump(); + } + + Future> getChats( + int accountId, { + bool includeHidden = false, + }) async { + try { + if (_repairedSenders.add(accountId)) { + await AppDatabase.repairLastMessageSenders(accountId); + } + final rows = await AppDatabase.loadChats( + accountId, + includeHidden: includeHidden, + ); + final chats = rows + .map(CachedChat.fromDbRow) + .where((c) => !_awaitingRemoval.contains((accountId, c.id))) + .toList(); + return chats; + } catch (e) { + logger.e("Ошибка при получении чатов: $e"); + return []; + } + } + + Future> getChat(int accountId, int chatId) async { + try { + final rows = await AppDatabase.loadChat(accountId, chatId); + + return rows.map(CachedChat.fromDbRow).toList(); + } catch (e) { + logger.e("Ошибка при получении чата: $e"); + + return []; + } + } + + Future clearCache(int accountId) => + AppDatabase.clearChatsCache(accountId); + + Future?> getChatInfo(Api api, int chatId) async { + final packet = await api.sendRequest(Opcode.chatInfo, { + 'chatIds': [chatId], + }); + if (packet.isError) return null; + final payload = packet.payload as Map?; + final chats = payload?['chats'] as List?; + if (chats == null || chats.isEmpty) return null; + final info = Map.from(chats.first as Map); + ChatMembersStore.instance.applyChatPayload(info); + return info; + } + + Future> getReadMarks(Api api, int accountId, int chatId) async { + try { + final info = await getChatInfo(api, chatId); + final fresh = parseParticipants(info?['participants']); + if (fresh.isNotEmpty) return fresh; + } catch (e) { + logger.w('Не удалось получить отметки прочтения для $chatId: $e'); + } + final rows = await getChat(accountId, chatId); + return rows.isEmpty ? const {} : rows.first.participants; + } + + Future searchById(Api api, int userId) async { + final packet = await api.sendRequest(Opcode.publicSearch, { + 'query': userId.toString(), + 'from': 0, + 'count': 10, + }); + return packet.payload; + } + + Future> searchMessages( + Api api, + String query, { + int count = 50, + }) async { + final term = query.trim(); + if (term.isEmpty) return const []; + try { + final packet = await api.sendRequest(Opcode.chatSearch, { + 'count': count, + 'query': term, + }); + if (packet.isError) return const []; + return parseMessageResult(packet.payload); + } catch (e) { + logger.w('searchMessages failed: $e'); + return const []; + } + } + + Future> searchPublic( + Api api, + String query, { + int count = 20, + }) async { + final term = query.trim(); + if (term.isEmpty) return const []; + try { + final packet = await api.sendRequest(Opcode.publicSearch, { + 'type': 'ALL', + 'count': count, + 'query': term, + }); + if (packet.isError) return const []; + return parseSearchResult(packet.payload); + } catch (e) { + logger.w('searchPublic failed: $e'); + return const []; + } + } + + Future subscribeChat( + Api api, + int chatId, { + bool subscribe = true, + }) async { + try { + await api.sendRequest(Opcode.chatSubscribe, { + 'chatId': chatId, + 'subscribe': subscribe, + }, silent: true); + } catch (e) { + logger.w('subscribeChat failed: $e'); + } + } + + Future<({CachedChat chat, int? subscribersCount})> joinChannel( + Api api, + String link, + int accountId, + ) async { + final packet = await api.sendRequest(Opcode.chatJoin, { + 'link': link, + }, silent: true); + if (!packet.isOk) { + throw PacketError(messageFromErrorPayload(packet.payload)); + } + final payload = packet.payload; + final chatMap = payload is Map ? payload['chat'] : null; + if (chatMap is! Map) { + throw const PacketError('Не удалось подписаться'); + } + final cached = await cacheServerChat(chatMap, accountId); + if (cached == null) { + throw const PacketError('Не удалось подписаться'); + } + final count = chatMap['participantsCount']; + if (count is! int) ChatMembersStore.instance.adjust(cached.id, 1); + _refreshChatInfo(cached.id); + return (chat: cached, subscribersCount: count is int ? count : null); + } + + Future ensureChatCached(Api api, int accountId, int chatId) async { + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isNotEmpty) return true; + try { + final info = await getChatInfo(api, chatId); + if (info == null) return false; + await cacheServerChat(info, accountId, inList: false); + return true; + } catch (e) { + logger.w('ensureChatCached failed for $chatId: $e'); + return false; + } + } + + Future createGroupChat( + Api api, { + required String title, + required List userIds, + bool notify = true, + }) => _createChat( + api, + chatType: 'CHAT', + title: title, + userIds: userIds, + notify: notify, + ); + + Future createChannel( + Api api, { + required String title, + List userIds = const [], + bool notify = true, + }) => _createChat( + api, + chatType: 'CHANNEL', + title: title, + userIds: userIds, + notify: notify, + ); + + Future _createChat( + Api api, { + required String chatType, + required String title, + required List userIds, + required bool notify, + }) async { + final payload = { + 'message': { + 'cid': DateTime.now().millisecondsSinceEpoch, + 'attaches': [ + { + '_type': 'CONTROL', + 'event': 'new', + 'chatType': chatType, + 'title': title, + 'userIds': userIds, + }, + ], + }, + 'notify': notify, + }; + final packet = await api.sendRequest(Opcode.msgSend, payload); + if (!packet.isOk) { + logger.w( + '_createChat($chatType): server error payload=${packet.payload}', + ); + return null; + } + final data = packet.payload; + if (data is! Map) { + logger.w('_createChat($chatType): payload is not a Map: $data'); + return null; + } + final chat = data['chat']; + if (chat is! Map) { + logger.w('_createChat($chatType): response has no chat field: $data'); + return null; + } + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) { + logger.w('_createChat($chatType): no active account id'); + return null; + } + return cacheServerChat(chat, accountId); + } + + Future requestChatPhotoUploadUrl(Api api) async { + final packet = await api.sendRequest(Opcode.photoUpload, {'count': 1}); + if (!packet.isOk) return null; + final data = packet.payload; + if (data is! Map) return null; + return data['url'] as String?; + } + + Future setChatPhoto( + Api api, { + required int chatId, + required String photoToken, + }) async { + return api.sendRequestOk(Opcode.chatUpdate, { + 'chatId': chatId, + 'photoToken': photoToken, + }); + } + + Future setChatOptions( + Api api, { + required int chatId, + required Map options, + }) async { + return api.sendRequestOk(Opcode.chatUpdate, { + 'chatId': chatId, + 'options': options, + }); + } + + Future setChatTitle( + Api api, { + required int chatId, + required String title, + }) async { + final packet = await api.sendRequest(Opcode.chatUpdate, { + 'chatId': chatId, + 'theme': title, + }); + if (!packet.isOk) return false; + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return true; + await _updateChat(accountId, chatId, (chat) => chat.copyWith(title: title)); + return true; + } + + Future setPinnedMessage( + Api api, { + required int chatId, + required int? messageId, + bool notify = true, + }) async { + try { + final packet = await api.sendRequest(Opcode.chatUpdate, { + 'chatId': chatId, + 'notifyPin': notify, + 'pinMessageId': messageId ?? 0, + }); + if (!packet.isOk) { + return messageFromErrorPayload(packet.payload); + } + final data = packet.payload; + final chat = data is Map ? data['chat'] : null; + if (chat is Map) { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await cacheServerChat(chat.cast(), accountId); + } + } + return null; + } on PacketError catch (e) { + logger.w('setPinnedMessage $chatId: ${e.message}'); + return e.message; + } catch (e) { + logger.w('setPinnedMessage $chatId: $e'); + return 'Не удалось изменить закрепление'; + } + } + + Future togglePin( + Api api, { + required List chatIds, + required bool pin, + }) async { + if (chatIds.isEmpty) return null; + try { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return 'Нет активного аккаунта'; + final folders = await FoldersModule.loadFolders(accountId); + final allFolder = folders.firstWhere( + FoldersModule.isAllChatsFolder, + orElse: () => folders.isEmpty + ? throw StateError('Папка "Все" не найдена') + : folders.first, + ); + + final favorites = List.from(allFolder.favorites); + if (pin) { + for (final id in chatIds) { + if (!favorites.contains(id)) favorites.add(id); + } + } else { + favorites.removeWhere((id) => chatIds.contains(id)); + } + + await FoldersModule.setFolderFavorites( + api, + accountId, + allFolder, + favorites, + ); + + final existingRows = await AppDatabase.loadChatsByIds(accountId, chatIds); + final updates = >[]; + for (final row in existingRows) { + final id = row['id'] as int; + final isFav = favorites.contains(id); + final currentFav = row['fav_index'] as int?; + final newFav = isFav + ? ((currentFav ?? 0) > 0 ? currentFav : favorites.indexOf(id) + 1) + : 0; + if (currentFav == newFav) continue; + final newRow = Map.from(row); + newRow['fav_index'] = newFav; + updates.add(newRow); + } + if (updates.isNotEmpty) { + await AppDatabase.saveChats(updates); + _bump(); + } + return null; + } on PacketError catch (e) { + logger.w('togglePin: ${e.message}'); + return e.message; + } catch (e) { + logger.w('togglePin: $e'); + return 'Не удалось изменить закрепление'; + } + } + + Future applyFavorites(int accountId) async { + try { + final folders = await FoldersModule.loadFolders(accountId); + if (folders.isEmpty) return; + final allFolder = folders.firstWhere( + FoldersModule.isAllChatsFolder, + orElse: () => folders.first, + ); + final favorites = allFolder.favorites; + final favIndexById = {}; + for (var i = 0; i < favorites.length; i++) { + favIndexById[favorites[i]] = i + 1; + } + + final rows = await AppDatabase.loadChats(accountId, includeHidden: true); + final updates = >[]; + for (final row in rows) { + final id = row['id'] as int; + final current = (row['fav_index'] as int?) ?? 0; + final next = favIndexById[id] ?? 0; + if (current == next) continue; + final newRow = Map.from(row); + newRow['fav_index'] = next; + updates.add(newRow); + } + if (updates.isNotEmpty) { + await AppDatabase.saveChats(updates); + _bump(); + } + } catch (e) { + logger.w('applyFavorites: $e'); + } + } + + Future setChatMute( + Api api, { + required int chatId, + required int dontDisturbUntil, + }) async { + try { + await api.sendRequest(Opcode.config, { + 'settings': { + 'chats': { + chatId: {'dontDisturbUntil': dontDisturbUntil}, + }, + }, + }); + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await _updateChat( + accountId, + chatId, + (chat) => chat.copyWith(dontDisturbUntil: dontDisturbUntil), + ); + } + return null; + } on PacketError catch (e) { + logger.w('setChatMute $chatId: ${e.message}'); + return e.message; + } catch (e) { + logger.w('setChatMute $chatId: $e'); + return 'Не удалось изменить уведомления'; + } + } + + Future deleteChat( + Api api, { + required int chatId, + required int lastEventTime, + required bool forAll, + }) async { + try { + await api.sendRequest(Opcode.chatDelete, { + 'chatId': chatId, + 'lastEventTime': lastEventTime, + 'forAll': forAll, + }); + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await AppDatabase.deleteChat(chatId, accountId); + _bump(); + } + _departedChatsController.add(chatId); + return null; + } on PacketError catch (e) { + logger.w('deleteChat $chatId: ${e.message}'); + return e.message; + } catch (e) { + logger.w('deleteChat $chatId: $e'); + return 'Не удалось удалить чат'; + } + } + + Future clearHistory( + Api api, { + required int chatId, + required int lastEventTime, + bool forAll = false, + }) async { + try { + await api.sendRequest(Opcode.chatClear, { + 'chatId': chatId, + 'lastEventTime': lastEventTime, + 'forAll': forAll, + }); + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await AppDatabase.clearMessages(accountId, chatId); + MessageSessionCache.remove(accountId, chatId); + _historyFetched.remove(chatId); + await reconcileLastMessage(accountId, chatId); + } + return null; + } on PacketError catch (e) { + logger.w('clearHistory $chatId: ${e.message}'); + return e.message; + } catch (e) { + logger.w('clearHistory $chatId: $e'); + return 'Не удалось очистить историю'; + } + } + + Future leaveChat(Api api, {required int chatId}) async { + try { + await api.sendRequest(Opcode.chatLeave, {'chatId': chatId}); + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await AppDatabase.deleteChat(chatId, accountId); + _bump(); + } + _departedChatsController.add(chatId); + return true; + } catch (_) { + return false; + } + } + + Future addMembers( + Api api, { + required int chatId, + required List userIds, + bool showHistory = true, + }) async { + if (userIds.isEmpty) return false; + try { + final packet = await api.sendRequest(Opcode.chatMembersUpdate, { + 'chatId': chatId, + 'userIds': userIds, + 'showHistory': showHistory, + 'operation': 'add', + }); + if (!packet.isOk) { + logger.w( + 'addMembers $chatId: ${messageFromErrorPayload(packet.payload)}', + ); + return false; + } + final data = packet.payload; + final chat = data is Map ? data['chat'] : null; + if (chat is Map) { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await cacheServerChat(chat.cast(), accountId); + } + } + if (chat is! Map || chat['participantsCount'] is! int) { + ChatMembersStore.instance.adjust(chatId, userIds.length); + } + _refreshChatInfo(chatId); + return true; + } on PacketError catch (e) { + logger.w('addMembers $chatId: ${e.message}'); + return false; + } catch (e) { + logger.w('addMembers $chatId: $e'); + return false; + } + } + + Future getChatMembers( + Api api, + int chatId, { + int marker = 0, + int count = 50, + }) async { + try { + final packet = await api.sendRequest(Opcode.chatMembers, { + 'type': 'MEMBER', + 'marker': marker, + 'chatId': chatId, + 'count': count, + }); + if (!packet.isOk) return null; + final payload = packet.payload; + if (payload is! Map) return null; + return _parseMembersPage(payload, marker); + } on PacketError catch (e) { + logger.w('getChatMembers $chatId: ${e.message}'); + return null; + } catch (e) { + logger.w('getChatMembers $chatId: $e'); + return null; + } + } + + // #***! разбор страницы участников, общий для members и join-request + ChatMembersPage _parseMembersPage(Map payload, int fallbackMarker) { + final entries = []; + final presenceById = >{}; + final rawMembers = payload['members']; + if (rawMembers is List) { + for (final m in rawMembers.whereType()) { + final contact = m['contact']; + if (contact is! Map) continue; + final id = contact['id']; + if (id is! int) continue; + + final info = ContactInfo.fromMap(Map.from(contact)); + final name = info.displayName; + if (name != null && name.isNotEmpty) ContactCache.put(id, name); + final avatar = info.avatarUrl; + if (avatar != null && avatar.isNotEmpty) { + ContactCache.putAvatar(id, avatar); + } + final phone = contact['phone']; + if (phone is int && phone > 0) ContactCache.putPhone(id, phone); + ContactInfoFetch.putContact(id, contact.cast()); + + final presence = m['presence']; + var status = 0; + int? seen; + if (presence is Map) { + final p = Map.from(presence); + presenceById[id] = p; + status = (p['status'] as int?) ?? 0; + final s = p['seen']; + seen = s is int ? s : null; + } + + entries.add( + ChatMemberEntry( + id: id, + name: name, + fullName: info.fullName, + avatarUrl: avatar, + seenTime: seen, + presenceStatus: status, + blocked: info.isDeleted, + isContact: info.isSavedContact, + ), + ); + } + } + if (presenceById.isNotEmpty) PresenceFetch.primeAll(presenceById); + + final next = payload['marker']; + return ChatMembersPage( + members: entries, + marker: next is int ? next : fallbackMarker, + ); + } + + // #***! входящие заявки на вступление (закрытые группы и каналы) + Future getJoinRequests( + Api api, + int chatId, { + int count = 100, + }) async { + try { + final packet = await api.sendRequest(Opcode.chatMembers, { + 'type': 'JOIN_REQUEST', + 'chatId': chatId, + 'count': count, + }); + if (!packet.isOk) return null; + final payload = packet.payload; + if (payload is! Map) return null; + return _parseMembersPage(payload, 0); + } on PacketError catch (e) { + logger.w('getJoinRequests $chatId: ${e.message}'); + return null; + } catch (e) { + logger.w('getJoinRequests $chatId: $e'); + return null; + } + } + + // #***! одобрить заявки, showHistory открывает историю до вступления + Future approveJoinRequests( + Api api, { + required int chatId, + required List userIds, + bool showHistory = true, + }) async { + if (userIds.isEmpty) return false; + try { + final ok = await api.sendRequestOk(Opcode.chatMembersUpdate, { + 'chatId': chatId, + 'userIds': userIds, + 'type': 'JOIN_REQUEST', + 'showHistory': showHistory, + 'operation': 'add', + }); + if (ok) _refreshChatInfo(chatId); + return ok; + } catch (e) { + logger.w('approveJoinRequests $chatId: $e'); + return false; + } + } + + // #***! отклонить заявки + Future declineJoinRequests( + Api api, { + required int chatId, + required List userIds, + }) async { + if (userIds.isEmpty) return false; + try { + return await api.sendRequestOk(Opcode.chatMembersUpdate, { + 'chatId': chatId, + 'userIds': userIds, + 'type': 'JOIN_REQUEST', + 'operation': 'remove', + }); + } catch (e) { + logger.w('declineJoinRequests $chatId: $e'); + return false; + } + } + + // #***! назначить админом с набором прав из ChatPermission (битмаска) + Future addAdmin( + Api api, { + required int chatId, + required int userId, + required int permissions, + }) async { + try { + final ok = await api.sendRequestOk(Opcode.chatMembersUpdate, { + 'chatId': chatId, + 'userIds': [userId], + 'type': 'ADMIN', + 'operation': 'add', + 'permissions': permissions, + }); + if (ok) _refreshChatInfo(chatId); + return ok; + } catch (e) { + logger.w('addAdmin $chatId: $e'); + return false; + } + } + + Future> refreshChats(Api api, List chatIds) async { + if (chatIds.isEmpty) return const []; + try { + final packet = await api.sendRequest(Opcode.chatInfo, { + 'chatIds': chatIds, + }); + final payload = packet.payload; + if (payload is! Map) return const []; + final list = payload['chats']; + if (list is! List) return const []; + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return const []; + final existingRows = await AppDatabase.loadChatsByIds(accountId, chatIds); + final preloadedExisting = { + for (final row in existingRows) + row['id'] as int: CachedChat.fromDbRow(row), + }; + final out = []; + for (final c in list) { + if (c is Map) { + final cached = await cacheServerChat( + c, + accountId, + preloadedExisting: preloadedExisting, + ); + if (cached != null) out.add(cached); + } + } + return out; + } on PacketError catch (e) { + logger.w('refreshChats: ${e.message}'); + return const []; + } catch (e) { + logger.w('refreshChats: $e'); + return const []; + } + } +} + +final chats = ChatsModule._(); diff --git a/lib/backend/modules/cloud_storage.dart b/lib/backend/modules/cloud_storage.dart new file mode 100644 index 0000000..3f64b24 --- /dev/null +++ b/lib/backend/modules/cloud_storage.dart @@ -0,0 +1,207 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../models/attachment.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../api.dart'; +import 'chats.dart'; +import 'messages.dart'; + +class CloudFile { + final String name; + final int? size; + final int time; + final int? fileId; + final String messageId; + final int chatId; + final int accountId; + + const CloudFile({ + required this.name, + this.size, + required this.time, + this.fileId, + required this.messageId, + required this.chatId, + required this.accountId, + }); +} + +class CloudStorageModule { + static const _prefix = 'CLST'; + static const _tempName = 'Облачное хранилище'; + + // Key: "$accountId:$fileId" — scoped per account + static final Map _linkCache = {}; + + static int _computeSpecialNumber(int groupId) { + final s = groupId.abs().toString(); + final len = s.length; + if (len < 4) { + final n = int.parse(s); + return n + n; + } + final first = int.parse(s.substring(0, 4)); + final last = int.parse(s.substring(len - 4)); + return first + last; + } + + static bool isCloudStorageGroup(CachedChat chat) { + if (chat.type != 'CHAT') return false; + final title = chat.title; + if (title == null || !title.startsWith(_prefix)) return false; + final numStr = title.substring(_prefix.length); + final provided = int.tryParse(numStr); + if (provided == null) return false; + return provided == _computeSpecialNumber(chat.id); + } + + static CachedChat? findEnvGroup(List chats) { + for (final c in chats) { + if (isCloudStorageGroup(c)) return c; + } + return null; + } + + static List findOrphanGroups(List chats) => + chats.where((c) => c.type == 'CHAT' && c.title == _tempName).toList(); + + // Env group ID cache — avoids scanning all chats on every screen open + static Future cacheEnvGroupId(int accountId, int groupId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('cloud_storage_env_$accountId', groupId); + } + + static Future getCachedEnvGroupId(int accountId) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt('cloud_storage_env_$accountId'); + } + + static Future clearEnvGroupCache(int accountId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('cloud_storage_env_$accountId'); + } + + static Future _configurePrivacy(Api api, int chatId) async { + await chats.setChatOptions( + api, + chatId: chatId, + options: { + 'ONLY_OWNER_CAN_CHANGE_ICON_TITLE': true, + 'ONLY_ADMIN_CAN_ADD_MEMBER': true, + 'ALL_CAN_PIN_MESSAGE': false, + 'ONLY_ADMIN_CAN_CALL': true, + }, + ); + } + + static Future setupEnv(Api api) async { + final temp = await chats.createGroupChat( + api, + title: _tempName, + userIds: [], + ); + if (temp == null) return null; + final name = '$_prefix${_computeSpecialNumber(temp.id)}'; + final ok = await chats.setChatTitle(api, chatId: temp.id, title: name); + if (!ok) return null; + await _configurePrivacy(api, temp.id); + return temp; + } + + // Turns an orphan "Облачное хранилище" group into a valid env group + static Future repairOrphan(Api api, CachedChat orphan) async { + final name = '$_prefix${_computeSpecialNumber(orphan.id)}'; + final ok = await chats.setChatTitle(api, chatId: orphan.id, title: name); + if (!ok) return null; + await _configurePrivacy(api, orphan.id); + return orphan; + } + + static Iterable _cloudFilesFrom( + Iterable msgs, + int chatId, + int accountId, + ) sync* { + for (final msg in msgs) { + for (final a in msg.attachments ?? []) { + if (a is FileAttachment && a.name != null) { + yield CloudFile( + name: a.name!, + size: a.size, + time: msg.time, + fileId: a.fileId, + messageId: msg.id, + chatId: chatId, + accountId: accountId, + ); + } + } + } + } + + static Future> fetchFiles( + MessagesModule messages, + int accountId, + int chatId, { + int count = 200, + }) async { + final msgs = await messages.fetchHistory(accountId, chatId, count: count); + return _cloudFilesFrom(msgs, chatId, accountId).toList(); + } + + // Fetches only the last few messages to find a newly uploaded file — avoids full 200-msg reload + static Future fetchLatestFile( + MessagesModule messages, + int accountId, + int chatId, { + int? expectedFileId, + }) async { + final msgs = await messages.fetchHistory(accountId, chatId, count: 5); + for (final file in _cloudFilesFrom(msgs, chatId, accountId)) { + if (expectedFileId == null || file.fileId == expectedFileId) { + return file; + } + } + return null; + } + + static ({String url, int expires})? getCachedLink(int accountId, int fileId) { + final key = '$accountId:$fileId'; + final entry = _linkCache[key]; + if (entry == null) return null; + if (entry.expires <= DateTime.now().millisecondsSinceEpoch) { + _linkCache.remove(key); + return null; + } + return entry; + } + + static Future<({String url, int expires})?> fetchFileUrl( + Api api, { + required int accountId, + required int fileId, + required int chatId, + required String messageId, + }) async { + try { + final packet = await api.sendRequest(Opcode.fileDownload, { + 'fileId': fileId, + 'chatId': chatId, + 'messageId': int.tryParse(messageId) ?? messageId, + }); + if (!packet.isOk) return null; + final data = packet.payload; + if (data is! Map) return null; + final url = data['url'] as String?; + if (url == null) return null; + final uri = Uri.tryParse(url); + final expiresStr = uri?.queryParameters['expires']; + final expires = int.tryParse(expiresStr ?? '') ?? 0; + final entry = (url: url, expires: expires); + _linkCache['$accountId:$fileId'] = entry; + return entry; + } catch (_) { + return null; + } + } +} diff --git a/lib/backend/modules/comments.dart b/lib/backend/modules/comments.dart new file mode 100644 index 0000000..5ff9827 --- /dev/null +++ b/lib/backend/modules/comments.dart @@ -0,0 +1,326 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; +import '../../core/utils/channel_comments.dart'; +import '../../core/utils/logger.dart'; +import '../api.dart'; +import 'messages.dart' show CachedMessage; + +class CommentsInfo { + final String postId; + final int? totalCount; + final int? updatedAt; + const CommentsInfo({required this.postId, this.totalCount, this.updatedAt}); + + factory CommentsInfo.fromPayload(String postId, Map payload) { + final raw = payload['totalCount']; + int? count; + if (raw is int) { + count = raw; + } else if (raw is String) { + count = int.tryParse(raw); + } + return CommentsInfo( + postId: postId, + totalCount: count, + updatedAt: DateTime.now().millisecondsSinceEpoch, + ); + } +} + +class CommentAddedEvent { + final int chatId; + final String postId; + final CachedMessage comment; + const CommentAddedEvent(this.chatId, this.postId, this.comment); +} + +class CommentsModule { + final Api _api; + + CommentsModule(this._api); + + final ValueNotifier revision = ValueNotifier(0); + + final _infoController = + StreamController>.broadcast(); + Stream> get infoStream => _infoController.stream; + + final _commentController = StreamController.broadcast(); + Stream get commentStream => _commentController.stream; + + Map _info = {}; + Map get infoSnapshot => Map.unmodifiable(_info); + + CommentsInfo? infoFor(String postId) => _info[postId]; + + int _accountId = 0; + + void dispose() { + _pushSub?.cancel(); + _pushSub = null; + _infoController.close(); + _commentController.close(); + revision.dispose(); + } + + void attachPushHandlers(Api api) { + _pushSub?.cancel(); + _pushSub = api.pushStream.listen(_handlePush); + } + + StreamSubscription? _pushSub; + + void _handlePush(Packet packet) { + switch (packet.opcode) { + case Opcode.commentsInfo: + final payload = packet.payload; + if (payload is! Map) return; + final updates = payload['commentsInfoUpdates']; + if (updates is List) handleInfoUpdate(updates); + case Opcode.notifMessage: + _handleCommentPush(packet); + } + } + + void _handleCommentPush(Packet packet) { + final payload = packet.payload; + if (payload is! Map) return; + final chatId = payload['chatId']; + if (chatId is! int) return; + final msg = payload['message']; + if (msg is! Map) return; + + final link = msg['link']; + final postId = + (payload['postId'] ?? + (link is Map ? link['postId'] : null) ?? + msg['postId']) + ?.toString(); + if (postId == null || postId.isEmpty) return; + + final comment = _parseComment( + msg.cast(), + _accountId, + chatId, + postId, + ); + if (comment == null) return; + _commentController.add(CommentAddedEvent(chatId, postId, comment)); + } + + void handleInfoUpdate(List updates) { + if (updates.isEmpty) return; + Map? next; + for (final raw in updates) { + if (raw is! Map) continue; + final postId = raw['postId']?.toString(); + final commentsInfo = raw['commentsInfo']; + if (postId == null || commentsInfo is! Map) continue; + final updated = CommentsInfo.fromPayload( + postId, + Map.from(commentsInfo.cast()), + ); + next ??= Map.from(_info); + next[postId] = updated; + } + if (next == null) return; + _info = next; + revision.value = revision.value + 1; + _infoController.add(Map.unmodifiable(_info)); + } + + Future?> fetchInfo({ + required int accountId, + required int chatId, + required List postIds, + }) async { + _accountId = accountId; + if (postIds.isEmpty) return const {}; + final byPost = {}; + var failed = false; + for (final batch in commentsInfoBatches(postIds)) { + final updates = await _fetchInfoBatch(chatId, batch); + if (updates == null) { + failed = true; + continue; + } + handleInfoUpdate(updates); + for (final raw in updates.whereType()) { + final postId = raw['postId']?.toString(); + final commentsInfo = raw['commentsInfo']; + if (postId == null || commentsInfo is! Map) continue; + byPost[postId] = CommentsInfo.fromPayload( + postId, + Map.from(commentsInfo.cast()), + ); + } + } + return failed ? null : byPost; + } + + Future _fetchInfoBatch(int chatId, List postIds) async { + try { + final response = await _api.sendRequest(Opcode.commentsInfo, { + 'chatId': chatId, + 'postIds': postIds.map((id) => int.tryParse(id) ?? id).toList(), + }); + if (!response.isOk) { + // #***! ответ с ошибкой повторять бессмысленно, в отличие от обрыва + logger.w('commentsInfo $chatId: ${response.payload}'); + return const []; + } + final payload = response.payload; + if (payload is! Map) return const []; + final updates = payload['commentsInfoUpdates']; + return updates is List ? updates : const []; + } catch (e) { + logger.w('commentsInfo $chatId: $e'); + return null; + } + } + + Future> fetchHistory( + int accountId, + int chatId, + String postId, { + required int fromTime, + int forward = 30, + int backward = 15, + }) async { + _accountId = accountId; + final payload = { + 'chatId': chatId, + 'postId': int.tryParse(postId) ?? postId, + 'from': fromTime, + 'forward': forward, + 'backward': backward, + 'getMessages': true, + }; + + final response = await _api.sendRequest(Opcode.chatHistory, payload); + if (!response.isOk) return const []; + final data = response.payload; + if (data is! Map) return const []; + + final messagesData = data['messages']; + if (messagesData is! List) return const []; + + final results = []; + for (var i = 0; i < messagesData.length; i++) { + final m = messagesData[i]; + if (m is! Map) continue; + final parsed = _parseComment( + m.cast(), + accountId, + chatId, + postId, + ); + if (parsed != null) results.add(parsed); + if (i > 0 && i % 20 == 0) await Future.delayed(Duration.zero); + } + + return results; + } + + Future sendComment( + int accountId, + int chatId, + String postId, + String text, { + bool notify = true, + int? replyToMessageId, + List> elements = const [], + }) async { + _accountId = accountId; + final Object postIdField = int.tryParse(postId) ?? postId; + final message = { + 'text': text, + 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'elements': elements, + 'attaches': [], + }; + if (replyToMessageId != null) { + message['link'] = { + 'type': 'REPLY', + 'chatId': chatId, + 'postId': postIdField, + 'messageId': replyToMessageId, + }; + } + final payload = { + 'chatId': chatId, + 'postId': postIdField, + 'message': message, + 'notify': notify, + }; + + final response = await _api.sendRequest(Opcode.msgSend, payload); + if (!response.isOk) { + final raw = response.payload; + final msg = (raw is Map) + ? (raw['localizedMessage'] ?? raw['message'] ?? 'Ошибка отправки') + : 'Ошибка отправки'; + throw Exception(msg.toString()); + } + final data = response.payload; + if (data is Map) { + final msgMap = data['message']; + if (msgMap is Map) { + final id = msgMap['id']; + if (id != null) return id.toString(); + } + } + return ''; + } + + void sendTyping(int chatId, String postId, String type) { + unawaited(() async { + try { + await _api.sendRequest(Opcode.msgTyping, { + 'chatId': chatId, + 'postId': int.tryParse(postId) ?? postId, + 'type': type, + }); + } catch (_) {} + }()); + } + + CachedMessage? _parseComment( + Map m, + int accountId, + int chatId, + String postId, + ) { + final id = m['id']?.toString(); + if (id == null) return null; + + final full = Map.from(m.cast()); + full['postId'] = postId; + final parsed = CachedMessage.parseAttachments(full); + final senderId = _parseIntField(m['sender']); + + return CachedMessage( + id: id, + accountId: accountId, + chatId: chatId, + senderId: senderId, + text: m['text']?.toString(), + time: _parseIntField(m['time']), + status: m['status']?.toString(), + payload: full, + attachments: parsed.$1, + isControl: parsed.$2, + ); + } + + int _parseIntField(dynamic value) { + if (value == null) return 0; + if (value is int) return value; + if (value is String) return int.tryParse(value) ?? 0; + return int.tryParse(value.toString()) ?? 0; + } +} diff --git a/lib/backend/modules/complaints.dart b/lib/backend/modules/complaints.dart new file mode 100644 index 0000000..b8e10fb --- /dev/null +++ b/lib/backend/modules/complaints.dart @@ -0,0 +1,125 @@ +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; + +class ComplaintReason { + final int reasonId; + final String reasonTitle; + + const ComplaintReason({required this.reasonId, required this.reasonTitle}); +} + +class ComplaintsModule { + static const int channelTypeId = 2; + static const int userTypeId = 6; + + static Map>? _cache; + + static void clear() => _cache = null; + + static Future>> fetchReasons(Api api) async { + final cached = _cache; + if (cached != null) return cached; + + final Packet response; + try { + response = await api.sendRequest(Opcode.complainReasonsGet, { + 'complainSync': 0, + }, silent: true); + } catch (_) { + return const {}; + } + if (!response.isOk) return const {}; + + final payload = response.payload; + if (payload is! Map) return const {}; + + final complains = payload['complains']; + final map = >{}; + if (complains is List) { + for (final entry in complains) { + if (entry is! Map) continue; + final typeId = entry['typeId']; + final reasons = entry['reasons']; + if (typeId is! int || reasons is! List) continue; + map[typeId] = reasons + .whereType() + .map( + (r) => ComplaintReason( + reasonId: r['reasonId'] is int ? r['reasonId'] as int : 0, + reasonTitle: r['reasonTitle']?.toString() ?? '', + ), + ) + .where((r) => r.reasonId != 0) + .toList(); + } + } + + _cache = map; + return map; + } + + static Future> reasonsFor(Api api, int typeId) async { + final map = await fetchReasons(api); + final forType = map[typeId]; + if (forType != null && forType.isNotEmpty) return forType; + for (final list in map.values) { + if (list.isNotEmpty) return list; + } + return const []; + } + + /// COMPLAIN payload. `parentId` is sent only when given (the chat of a + /// reported message); a whole channel goes without it. + static Map complaintPayload({ + required int reasonId, + required int typeId, + required List ids, + int? parentId, + }) => { + 'reasonId': reasonId, + 'typeId': typeId, + 'ids': ids, + 'parentId': ?parentId, + }; + + // #***! жалоба на канал целиком: typeId 2, в ids id чата, без parentId + static Future sendChannelComplaint( + Api api, { + required int chatId, + required int reasonId, + }) => sendComplaint( + api, + reasonId: reasonId, + typeId: channelTypeId, + ids: [chatId], + ); + + // #***! silent, текст ошибки покажем сами в диалоге + static Future sendComplaint( + Api api, { + required int reasonId, + required int typeId, + required List ids, + int? parentId, + }) async { + final Packet response; + try { + response = await api.sendRequest( + Opcode.complain, + complaintPayload( + reasonId: reasonId, + typeId: typeId, + ids: ids, + parentId: parentId, + ), + silent: true, + ); + } catch (_) { + return false; + } + if (!response.isOk) return false; + final payload = response.payload; + return payload is Map && payload['success'] == true; + } +} diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart new file mode 100644 index 0000000..2550418 --- /dev/null +++ b/lib/backend/modules/contacts.dart @@ -0,0 +1,804 @@ +import 'package:flutter/foundation.dart'; + +import '../../core/cache/info_cache.dart'; +import '../../core/config/debug_test.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; +import '../../core/storage/app_database.dart'; +import '../../core/utils/logger.dart'; +import '../../models/contact_info.dart'; +import '../api.dart'; +import 'messages.dart'; + +class CachedContact { + final int id; + final int accountId; + final String firstName; + final String? lastName; + final int phone; + final int? photoId; + final String? baseUrl; + final String? baseRawUrl; + final int updateTime; + final Set options; + final int accountStatus; + + const CachedContact({ + required this.id, + required this.accountId, + required this.firstName, + this.lastName, + required this.phone, + this.photoId, + this.baseUrl, + this.baseRawUrl, + required this.updateTime, + this.options = const {}, + this.accountStatus = 0, + }); + + bool get isOfficial => options.contains('OFFICIAL'); + bool get isBot => options.contains('BOT'); + bool get isServiceAccount => options.contains('SERVICE_ACCOUNT'); + bool get isVerified => isOfficial; + bool get isDeleted => accountStatus != 0; + + factory CachedContact.fromDbRow(Map row) => CachedContact( + id: row['id'] as int, + accountId: row['account_id'] as int, + firstName: row['first_name'] as String, + lastName: row['last_name'] as String?, + phone: row['phone'] as int, + photoId: row['photo_id'] as int?, + baseUrl: row['base_url'] as String?, + baseRawUrl: row['base_raw_url'] as String?, + updateTime: row['update_time'] as int, + options: _decodeOptions(row['options']), + accountStatus: (row['account_status'] as int?) ?? 0, + ); + + static Set _decodeOptions(dynamic raw) { + if (raw is! String || raw.isEmpty) return const {}; + return raw.split(',').where((s) => s.isNotEmpty).toSet(); + } +} + +class PhoneLookupResult { + final int id; + final String? name; + final String? avatarUrl; + final int phone; + + const PhoneLookupResult({ + required this.id, + this.name, + this.avatarUrl, + this.phone = 0, + }); +} + +class ContactPhotos { + final List urls; + final List ids; + final int total; + + const ContactPhotos({ + required this.urls, + this.ids = const [], + required this.total, + }); + + static const empty = ContactPhotos(urls: [], total: 0); + + int? idAt(int index) => index >= 0 && index < ids.length ? ids[index] : null; +} + +enum AddContactStatus { added, notFound, error } + +class AddContactResult { + final AddContactStatus status; + final CachedContact? contact; + + const AddContactResult(this.status, {this.contact}); +} + +class ContactsModule { + static final ValueNotifier revision = ValueNotifier(0); + + static Future findByPhone( + Api api, + String phone, { + bool silent = false, + bool strict = false, + }) async { + final normalized = _normalizePhone(phone); + if (normalized == null) return null; + final Packet packet; + try { + packet = await api.sendRequest(Opcode.contactInfoByPhone, { + 'phone': normalized, + }, silent: silent); + } on PacketError catch (e) { + if (strict && e.errorKey != 'user.not.found') rethrow; + return null; + } + if (packet.isError) { + if (strict) throw StateError('Contact lookup failed'); + return null; + } + final contact = (packet.payload as Map?)?['contact']; + if (contact is! Map) return null; + final id = contact['id']; + if (id is! int) return null; + if (((contact['accountStatus'] as int?) ?? 0) != 0) return null; + + primeContactCache(contact); + + final payloadPhone = contact['phone']; + final resolvedPhone = payloadPhone is int && payloadPhone > 0 + ? payloadPhone + : int.tryParse(normalized.substring(1)) ?? 0; + ContactCache.putPhone(id, resolvedPhone); + + String? name; + final names = contact['names']; + if (names is List) { + final n = names.firstWhere((e) => e is Map, orElse: () => null); + if (n is Map) { + final first = + (n['firstName'] as String?) ?? (n['name'] as String?) ?? ''; + final last = (n['lastName'] as String?) ?? ''; + final full = '$first $last'.trim(); + if (full.isNotEmpty) name = full; + } + } + + return PhoneLookupResult( + id: id, + name: name, + avatarUrl: contact['baseUrl'] as String?, + phone: resolvedPhone, + ); + } + + static String? _normalizePhone(String raw) { + var digits = raw.replaceAll(RegExp(r'[^\d]'), ''); + if (digits.length < 10 || digits.length > 15) return null; + if (digits.length == 11 && digits.startsWith('8')) { + digits = '7${digits.substring(1)}'; + } + return '+$digits'; + } + + static Future addContact( + Api api, + int id, + String firstName, { + int phone = 0, + }) async { + final resp = await api.sendRequest(Opcode.contactUpdate, { + 'action': 'ADD', + 'contactId': id, + if (firstName.isNotEmpty) 'firstName': firstName, + }, silent: true); + if (!resp.isOk) return null; + + final profile = await AppDatabase.loadActiveProfile(); + if (profile == null) return null; + + final data = resp.payload; + final contact = (data is Map && data['contact'] is Map) + ? (data['contact'] as Map).cast() + : null; + + final row = contact != null + ? _parseContact(contact, profile.id) + : { + 'id': id, + 'account_id': profile.id, + 'first_name': firstName, + 'last_name': null, + 'phone': 0, + 'photo_id': null, + 'base_url': null, + 'base_raw_url': null, + 'update_time': 0, + 'options': null, + 'account_status': 0, + }; + + if (row == null) return null; + if (phone > 0 && ((row['phone'] as int?) ?? 0) == 0) { + row['phone'] = phone; + } + await AppDatabase.saveContacts([row]); + if (contact != null) primeContactCache(contact); + revision.value++; + return CachedContact.fromDbRow(row); + } + + static Future addContactByPhone( + Api api, { + required String phone, + required String firstName, + String lastName = '', + int? expectedAccountId, + }) async { + final epoch = api.sessionEpoch; + if (expectedAccountId != null && + ((await AppDatabase.loadActiveProfile())?.id != expectedAccountId || + api.sessionEpoch != epoch)) { + return const AddContactResult(AddContactStatus.error); + } + final normalized = _normalizePhone(phone); + if (normalized == null) { + return const AddContactResult(AddContactStatus.error); + } + + final Packet resp; + try { + resp = await api.sendRequest(Opcode.contactAddByPhone, { + 'phone': normalized, + 'firstName': firstName, + 'lastName': lastName, + }, silent: true); + } on PacketError catch (e) { + final notFound = e.errorKey == 'user.not.found'; + return AddContactResult( + notFound ? AddContactStatus.notFound : AddContactStatus.error, + ); + } catch (_) { + return const AddContactResult(AddContactStatus.error); + } + + final data = resp.payload; + final contact = (data is Map && data['contact'] is Map) + ? (data['contact'] as Map).cast() + : null; + if (contact == null) { + return const AddContactResult(AddContactStatus.error); + } + + final profile = await AppDatabase.loadActiveProfile(); + if (profile == null || + (expectedAccountId != null && + (profile.id != expectedAccountId || api.sessionEpoch != epoch))) { + return const AddContactResult(AddContactStatus.error); + } + + final row = _parseContact(contact, profile.id); + if (row == null) { + return const AddContactResult(AddContactStatus.error); + } + + await AppDatabase.saveContacts([row]); + primeContactCache(contact); + revision.value++; + return AddContactResult( + AddContactStatus.added, + contact: CachedContact.fromDbRow(row), + ); + } + + static Future updateContact( + Api api, { + required int contactId, + required String firstName, + required String lastName, + }) async { + final epoch = api.sessionEpoch; + final profile = await AppDatabase.loadActiveProfile(); + if (profile == null || api.sessionEpoch != epoch) return null; + void beforeSend() { + if (api.sessionEpoch != epoch) { + throw StateError('Сессия изменилась до сохранения контакта'); + } + } + + final payload = { + 'contactId': contactId, + 'action': 'UPDATE', + 'firstName': firstName, + 'lastName': lastName, + }; + Packet resp; + try { + resp = await api.sendRequest( + Opcode.contactUpdate, + payload, + silent: true, + beforeSend: beforeSend, + ); + } on PacketError catch (error) { + // A contact removed on another client can still be in the local cache. + // Saving its name explicitly restores that relation on IKAR. + if (error.errorKey != 'contact.not.found' || + !await _isCurrentSession(api, epoch, profile.id)) { + return null; + } + try { + resp = await api.sendRequest( + Opcode.contactUpdate, + {...payload, 'action': 'ADD'}, + silent: true, + beforeSend: beforeSend, + ); + } catch (_) { + return null; + } + } catch (_) { + return null; + } + + if (!resp.isOk || !await _isCurrentSession(api, epoch, profile.id)) { + return null; + } + + final data = resp.payload; + final contact = (data is Map && data['contact'] is Map) + ? (data['contact'] as Map).cast() + : null; + if (contact == null) return null; + + final row = _parseContact(contact, profile.id); + if (row == null) return null; + + await AppDatabase.saveContacts([row]); + primeContactCache(contact); + ContactInfoFetch.putContact(contactId, contact); + revision.value++; + return CachedContact.fromDbRow(row); + } + + static Future removeContact(Api api, int contactId) async { + try { + final packet = await api.sendRequest(Opcode.contactUpdate, { + 'contactId': contactId, + 'action': 'REMOVE', + }, silent: true); + if (!packet.isOk) return false; + } catch (_) { + return false; + } + + final profile = await AppDatabase.loadActiveProfile(); + if (profile != null) { + await AppDatabase.deleteContact(profile.id, contactId); + } + + ContactCache.remove(contactId); + + ContactInfo? info = ContactInfoFetch.peek(contactId); + if (info == null) { + ContactInfoFetch.invalidate(contactId); + info = await ContactInfoFetch.get(contactId, forceRefresh: true); + } + + final rawNames = info?.raw['names']; + if (info != null && rawNames is List) { + final stripped = rawNames + .where((n) => !(n is Map && n['type'] == 'CUSTOM')) + .toList(); + final newRaw = Map.from(info.raw)..['names'] = stripped; + ContactInfoFetch.putContact(contactId, newRaw); + primeContactCache(newRaw); + } else { + ContactInfoFetch.invalidate(contactId); + } + + revision.value++; + return true; + } + + static final Set _blockedIds = {}; + static bool _blockedLoaded = false; + static Future? _blockedLoading; + static int _blockedGeneration = 0; + + static Set get blockedIds => Set.unmodifiable(_blockedIds); + + static void clearBlockedCache() { + _blockedIds.clear(); + _blockedLoaded = false; + _blockedLoading = null; + _blockedGeneration++; + } + + static Future ensureBlockedLoaded(Api api) { + if (_blockedLoaded) return Future.value(); + return _blockedLoading ??= _loadBlockedIds( + api, + ).whenComplete(() => _blockedLoading = null); + } + + static const int _blockedPageSize = 100; + static const int _blockedMaxPages = 20; + + static Future isBlocked(Api api, int contactId) async { + await ensureBlockedLoaded(api); + return _blockedIds.contains(contactId); + } + + static Future _loadBlockedIds(Api api) async { + final generation = _blockedGeneration; + final ids = {}; + try { + for (var page = 0; page < _blockedMaxPages; page++) { + final map = await api.sendRequestMap(Opcode.contactList, { + 'status': 'BLOCKED', + 'count': _blockedPageSize, + 'from': page * _blockedPageSize, + }); + final contacts = map?['contacts']; + if (contacts is! List) return; + ids.addAll( + contacts.whereType().map((c) => c['id']).whereType(), + ); + if (contacts.length < _blockedPageSize) break; + } + } catch (e) { + logger.w('Не удалось получить список заблокированных: $e'); + return; + } + if (generation != _blockedGeneration) return; + final changed = !setEquals(_blockedIds, ids); + _blockedIds + ..clear() + ..addAll(ids); + _blockedLoaded = true; + if (changed) revision.value++; + } + + static Future setBlocked(Api api, int contactId, bool blocked) async { + try { + final packet = await api.sendRequest(Opcode.contactUpdate, { + 'contactId': contactId, + 'action': blocked ? 'BLOCK' : 'UNBLOCK', + }); + if (packet.isError) return false; + } catch (e) { + logger.w('setBlocked $contactId: $e'); + return false; + } + + if (blocked) { + _blockedIds.add(contactId); + } else { + _blockedIds.remove(contactId); + } + ContactInfoFetch.invalidate(contactId); + revision.value++; + return true; + } + + static Future syncFromLoginPayload( + Map data, + int accountId, + ) => _applyContacts(data, accountId, complete: false); + + static Future applyFullContactList( + Map data, + int accountId, + ) => _applyContacts(data, accountId, complete: true); + + static Future _applyContacts( + Map data, + int accountId, { + required bool complete, + Future Function()? isCurrent, + }) async { + final contacts = data['contacts']; + if (contacts is! List) { + logger.i('Контакты: сервер не прислал список (акк $accountId)'); + return; + } + if (contacts.isEmpty && !complete) { + logger.i('Контакты: сервер прислал пустой список (акк $accountId)'); + return; + } + + final rows = >[]; + final parsedContacts = >[]; + final removedIds = {}; + final presentIds = {}; + for (final raw in contacts.whereType()) { + final contact = raw.cast(); + parsedContacts.add(contact); + final id = contact['id']; + if (contact['status'] == 'REMOVED') { + if (id is int) removedIds.add(id); + continue; + } + if (id is int) presentIds.add(id); + final row = _parseContact(contact, accountId); + if (row != null) rows.add(row); + } + if (complete) { + final localIds = await AppDatabase.loadContactIds(accountId); + removedIds.addAll(localIds.where((id) => !presentIds.contains(id))); + } + if (isCurrent != null && !await isCurrent()) return; + + if (rows.isNotEmpty) await AppDatabase.saveContacts(rows); + if (removedIds.isNotEmpty) { + await AppDatabase.deleteContacts(accountId, removedIds.toList()); + } + if (isCurrent != null && !await isCurrent()) return; + for (final contact in parsedContacts) { + primeContactCache(contact); + } + if (removedIds.isNotEmpty) { + for (final id in removedIds) { + ContactCache.remove(id); + ContactInfoFetch.invalidate(id); + } + } + if (rows.isNotEmpty || removedIds.isNotEmpty) revision.value++; + logger.i( + 'Контакты: получено ${contacts.length}, сохранено ${rows.length} (акк $accountId)', + ); + } + + static Future syncFromServer(Api api, int accountId) async { + final epoch = api.sessionEpoch; + final revisionAtRequest = revision.value; + Future isCurrent() async => + await _isCurrentSession(api, epoch, accountId) && + revision.value == revisionAtRequest; + if (!await isCurrent()) return; + final packet = await api.sendRequest( + Opcode.contactsGet, + {'contactsSync': 0}, + beforeSend: () { + if (api.sessionEpoch != epoch) { + throw StateError('Сессия изменилась до синхронизации контактов'); + } + }, + ); + final map = packet.payload; + if (!packet.isOk || map is! Map || !await isCurrent()) return; + await _applyContacts( + map.cast(), + accountId, + complete: true, + isCurrent: isCurrent, + ); + } + + static Future _isCurrentSession( + Api api, + int epoch, + int accountId, + ) async { + final profile = await AppDatabase.loadActiveProfile(); + return profile?.id == accountId && api.sessionEpoch == epoch; + } + + static Future fetchSelfProfile(Api api, int accountId) async { + final map = await api.sendRequestMap(Opcode.contactInfo, { + 'contactIds': [accountId], + }); + final contacts = map?['contacts']; + if (contacts is! List) return null; + for (final raw in contacts.whereType()) { + if (raw['id'] != accountId) continue; + final contact = raw.cast(); + primeContactCache(contact); + return ProfileData.fromServerMap(contact); + } + return null; + } + + static void primeContactCache(Map contact) { + final id = contact['id']; + if (id is! int) return; + + final phone = contact['phone']; + if (phone is int) ContactCache.putPhone(id, phone); + + final names = contact['names']; + if (names is List && names.isNotEmpty) { + final nameRaw = _preferredNameEntry(names); + if (nameRaw != null) { + final firstName = (nameRaw['firstName'] as String?) ?? ''; + final lastName = nameRaw['lastName'] as String?; + final fullName = (lastName != null && lastName.isNotEmpty) + ? '$firstName $lastName' + : firstName; + if (fullName.isNotEmpty) ContactCache.put(id, fullName); + } + } + + final baseUrl = contact['baseUrl'] as String?; + if (baseUrl != null && baseUrl.isNotEmpty) { + ContactCache.putAvatar(id, baseUrl); + } + } + + static final Map _photosHead = {}; + + static ContactPhotos? cachedPhotos(int contactId) => _photosHead[contactId]; + + static Future fetchPhotos( + Api api, + int contactId, { + int from = 0, + int count = 25, + }) async { + if (contactId <= 0) return ContactPhotos.empty; + final map = await api.sendRequestMap(Opcode.contactPhotos, { + 'contactId': contactId, + 'from': from, + 'count': count, + }); + if (map == null) return ContactPhotos.empty; + final rawUrls = map['urls']; + final urls = rawUrls is List + ? rawUrls.whereType().toList() + : []; + final rawIds = map['ids']; + final ids = rawIds is List ? rawIds.whereType().toList() : []; + final total = map['total'] is int ? map['total'] as int : urls.length; + final photos = ContactPhotos( + urls: urls, + ids: ids.length == urls.length ? ids : const [], + total: total, + ); + if (from == 0) _photosHead[contactId] = photos; + return photos; + } + + static void invalidatePhotos(int contactId) { + _photosHead.remove(contactId); + } + + static Future> getContacts( + int accountId, { + bool includeDeleted = false, + }) async { + final rows = await AppDatabase.loadContacts( + accountId, + includeDeleted: includeDeleted, + ); + return rows.map(CachedContact.fromDbRow).toList(); + } + + static Future getContact(int accountId, int id) async { + final row = await AppDatabase.loadContact(accountId, id); + return row == null ? null : CachedContact.fromDbRow(row); + } + + static const List _debugFirstNames = [ + 'Алиса', + 'Борис', + 'Вера', + 'Глеб', + 'Дарья', + 'Егор', + 'Жанна', + 'Захар', + 'Ирина', + 'Кирилл', + 'Лия', + 'Максим', + 'Нина', + 'Олег', + 'Полина', + 'Роман', + 'София', + 'Тимур', + 'Ульяна', + 'Фёдор', + 'Ханна', + 'Цветана', + 'Чеслав', + 'Шура', + ]; + + static const List _debugLastNames = [ + 'Иванов', + 'Петров', + 'Сидоров', + 'Кузнецов', + 'Смирнов', + 'Попов', + 'Волков', + 'Соколов', + 'Морозов', + 'Новиков', + 'Фёдоров', + 'Козлов', + ]; + + static List debugContacts() { + final count = DebugTest.contactCount; + final out = []; + for (var i = 0; i < count; i++) { + final first = _debugFirstNames[i % _debugFirstNames.length]; + final last = + _debugLastNames[(i ~/ _debugFirstNames.length) % + _debugLastNames.length]; + out.add( + CachedContact( + id: 900000000 + i, + accountId: DebugTest.debugAccountId, + firstName: '$first ${i + 1}', + lastName: last, + phone: 79000000000 + i, + baseUrl: 'https://i.pravatar.cc/150?u=qlyra_debug_$i', + updateTime: 1, + options: i % 6 == 0 ? const {'OFFICIAL'} : const {}, + ), + ); + } + return out; + } + + /// Прогревает in-memory ContactCache из локальных контактов. + /// Нужно вызывать на cold start: иначе кэш пуст до следующего логина. + static Future primeCacheFromDb(int accountId) async { + final contacts = await getContacts(accountId, includeDeleted: true); + for (final c in contacts) { + ContactCache.putPhone(c.id, c.phone); + final fullName = (c.lastName != null && c.lastName!.isNotEmpty) + ? '${c.firstName} ${c.lastName}' + : c.firstName; + if (fullName.isNotEmpty) ContactCache.put(c.id, fullName); + if (c.baseUrl != null && c.baseUrl!.isNotEmpty) { + ContactCache.putAvatar(c.id, c.baseUrl); + } + } + } + + static Map? _preferredNameEntry(List names) { + Map? oneme; + Map? any; + for (final n in names) { + if (n is! Map) continue; + any ??= n; + final type = n['type']; + if (type == 'CUSTOM') return n; + if (type == 'ONEME') oneme ??= n; + } + return oneme ?? any; + } + + static Map? _parseContact( + Map contact, + int accountId, + ) { + final id = contact['id']; + if (id is! int) return null; + + String firstName = ''; + String? lastName; + + final names = contact['names']; + if (names is List && names.isNotEmpty) { + final nameRaw = _preferredNameEntry(names); + if (nameRaw == null) return null; + firstName = (nameRaw['firstName'] as String?) ?? ''; + lastName = nameRaw['lastName'] as String?; + } + + final optionsRaw = contact['options']; + String? optionsStr; + if (optionsRaw is List) { + optionsStr = optionsRaw.whereType().join(','); + } + + return { + 'id': id, + 'account_id': accountId, + 'first_name': firstName, + 'last_name': lastName, + 'phone': (contact['phone'] as int?) ?? 0, + 'photo_id': contact['photoId'] as int?, + 'base_url': contact['baseUrl'] as String?, + 'base_raw_url': contact['baseRawUrl'] as String?, + 'update_time': (contact['updateTime'] as int?) ?? 0, + 'options': optionsStr, + 'account_status': (contact['accountStatus'] as int?) ?? 0, + }; + } +} diff --git a/lib/backend/modules/digital_id.dart b/lib/backend/modules/digital_id.dart new file mode 100644 index 0000000..52ffe91 --- /dev/null +++ b/lib/backend/modules/digital_id.dart @@ -0,0 +1,429 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart' show kDebugMode; + +import 'package:device_info_plus/device_info_plus.dart'; + +import '../../core/storage/app_database.dart'; +import '../../core/storage/spoofing_service.dart'; +import '../../core/storage/token_storage.dart'; +import '../../core/utils/logger.dart'; +import '../../models/digital_id.dart'; +import 'webapp.dart'; + +class DigitalIdException implements Exception { + final String code; + final String message; + final int? statusCode; + + const DigitalIdException(this.code, this.message, {this.statusCode}); + + bool get isUnauthorized => code == 'UNAUTHORIZED'; + bool get isNoGosuslugiLink => code == 'NO_GOSUSLUGI_LINK'; + + @override + String toString() => message; +} + +class DigitalIdModule { + static const String _baseUrl = 'https://ikar.kusoft.xyz/api/ikar/digital-id'; + static const String _deviceIdKey = 'digital_id_device_id'; + static const String _tokenKey = 'digital_id_biometry_token'; + + final WebAppModule _webApp; + final HttpClient _http = HttpClient() + ..connectionTimeout = const Duration(seconds: 20); + + String? _webAppData; + String? _deviceId; + String? _realUserAgent; + + DigitalIdModule(this._webApp); + + Future _ensureWebAppData({bool forceRefresh = false}) async { + if (!forceRefresh && _webAppData != null) return _webAppData!; + final launch = await _webApp.fetchDigitalId(); + final data = _extractWebAppData(launch.url); + if (data == null) { + throw const DigitalIdException( + 'NO_INIT_DATA', + 'Не удалось получить данные авторизации Цифрового ID', + ); + } + _webAppData = data; + return data; + } + + String? _extractWebAppData(String url) { + final hashIndex = url.indexOf('#'); + if (hashIndex < 0) return null; + final fragment = url.substring(hashIndex + 1); + final match = RegExp( + r'WebAppData=([^&]*(?:&(?!WebApp)[^&]*)*)', + ).firstMatch(fragment); + final raw = match?.group(1); + if (raw == null || raw.isEmpty) return null; + // Фрагмент несёт WebAppData полностью percent-encoded (hash%3D…%26…); + // сервер ждёт канонический initDataRaw (hash=…&…) — как шлёт web-страница + // после decodeURIComponent. Декодируем один раз (фолбэк — сырое значение). + try { + return Uri.decodeComponent(raw); + } catch (_) { + return raw; + } + } + + Future deviceId() async { + // Тот же device_id, что уходит в handshake (опкод 6) — иначе сервер вернёт + // device_mismatch. Сессия обычно уже поднята к моменту открытия Цифрового ID. + final session = _webApp.sessionDeviceId; + if (session != null && session.isNotEmpty) return session; + if (_deviceId != null) return _deviceId!; + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + final stored = await AppDatabase.getSyncValue(accountId, _deviceIdKey); + if (stored != null && stored.isNotEmpty) { + _deviceId = stored; + return stored; + } + } + final generated = await _generateDeviceId(); + if (accountId != null) { + await AppDatabase.setSyncValue(accountId, _deviceIdKey, generated); + } + _deviceId = generated; + return generated; + } + + Future _generateDeviceId() async { + final rnd = Random.secure(); + final bytes = List.generate(16, (_) => rnd.nextInt(256)); + final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + try { + final info = DeviceInfoPlugin(); + if (Platform.isAndroid) { + final android = await info.androidInfo; + return '${android.id}_$hex'; + } + } catch (_) {} + return hex; + } + + Future _resolveUserAgent() async { + final spoofed = await SpoofingService.getWebViewUserAgent(); + if (spoofed != null && spoofed.isNotEmpty) return spoofed; + return _realUserAgent ??= await _buildRealUserAgent(); + } + + Future _buildRealUserAgent() async { + try { + final info = DeviceInfoPlugin(); + if (Platform.isAndroid) { + final android = await info.androidInfo; + return 'Mozilla/5.0 (Linux; Android ${android.version.release}; ' + '${android.model}) AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/124.0.0.0 Mobile Safari/537.36'; + } + if (Platform.isIOS) { + final ios = await info.iosInfo; + final version = ios.systemVersion.replaceAll('.', '_'); + return 'Mozilla/5.0 (iPhone; CPU iPhone OS $version like Mac OS X) ' + 'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 ' + 'Mobile/15E148 Safari/604.1'; + } + } catch (_) {} + return 'Mozilla/5.0 (Linux; Android 14; K) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36'; + } + + Future _send( + String method, + String path, { + Object? body, + bool retry = true, + }) async { + final webAppData = await _ensureWebAppData(); + final uri = Uri.parse('$_baseUrl$path'); + final request = await _http.openUrl(method, uri); + request.headers.set('Authorization', '#WebAppData=$webAppData'); + request.headers.set('Origin', 'https://ikar.kusoft.xyz'); + request.headers.set('Referer', 'https://ikar.kusoft.xyz/'); + request.headers.set('x-requested-with', 'ru.oneme.app'); + request.headers.set('Accept', 'application/json'); + request.headers.set('User-Agent', await _resolveUserAgent()); + if (body != null) { + request.headers.contentType = ContentType.json; + request.add(utf8.encode(jsonEncode(body))); + } + final response = await request.close(); + final text = await response.transform(utf8.decoder).join(); + if (kDebugMode) { + logger.i('[DID-native] $method $path -> ${response.statusCode}'); + } + + if (response.statusCode == 401 && retry) { + await _ensureWebAppData(forceRefresh: true); + return _send(method, path, body: body, retry: false); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw _errorFor(response.statusCode, text); + } + if (text.isEmpty) return null; + return jsonDecode(text); + } + + DigitalIdException _errorFor(int statusCode, String body) { + String code = 'HTTP_$statusCode'; + String message = 'Ошибка Цифрового ID ($statusCode)'; + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + final rawCode = + decoded['code'] ?? decoded['error'] ?? decoded['status']; + if (rawCode is String && rawCode.isNotEmpty) code = rawCode; + final rawMessage = decoded['message'] ?? decoded['error_description']; + if (rawMessage is String && rawMessage.isNotEmpty) message = rawMessage; + } + } catch (_) {} + if (statusCode == 401) code = 'UNAUTHORIZED'; + return DigitalIdException(code, message, statusCode: statusCode); + } + + Map _unwrapData(dynamic decoded) { + if (decoded is Map && decoded['data'] is Map) { + return decoded['data'] as Map; + } + return decoded is Map ? decoded : const {}; + } + + Future biometryStatus() async { + final decoded = await _send('GET', '/v2/digital-id/biometry-status'); + return DigitalIdBiometryStatus.fromMap(_unwrapData(decoded)); + } + + Future createBiometryToken({ + required String deviceId, + String? photoHash, + }) async { + final decoded = await _send( + 'POST', + '/v3/digital-id/create-biometry-token', + body: {'device_id': deviceId, 'photo_hash': ?photoHash}, + ); + return _unwrapData(decoded)['token'] as String? ?? ''; + } + + Future refreshUserDocs(String token) async { + final decoded = await _send( + 'POST', + '/v3/digital-id/refresh-user-docs', + body: {'token': token}, + ); + return _unwrapData(decoded)['state'] as String? ?? ''; + } + + Future getUserDocs(String state) async { + final decoded = await _send( + 'POST', + '/v2/digital-id/get-user-docs', + body: {'state': state}, + ); + if (decoded is Map && decoded['status'] == 'done') { + final data = decoded['data']; + if (data is Map) return DigitalIdUserDocs.fromMap(data); + } + return null; + } + + Future createEsiaLink() async { + final decoded = await _send('GET', '/v2/digital-id/create-esia-link'); + return DigitalIdEsiaLink.fromMap(decoded is Map ? decoded : const {}); + } + + Future verifyPhoto({ + required String deviceId, + String? photoHash, + }) async { + final decoded = await _send( + 'POST', + '/digital-id-verify-photo', + body: {'device_id': deviceId, 'photo_hash': ?photoHash}, + ); + final status = decoded is Map ? decoded['status'] as String? : null; + return DigitalIdVerification.fromValue(status); + } + + Future shadowMode(String deviceId) async { + try { + final decoded = await _send( + 'POST', + '/v3/digital-id/shadow-mode', + body: {'device_id': deviceId}, + ); + return _unwrapData(decoded)['shadow_mode'] == true; + } on DigitalIdException catch (e) { + if (e.statusCode == 404) return false; + rethrow; + } + } + + Future updateGuHashes() async { + await _send('POST', '/v2/digital-id/update-gu-hashes', body: const {}); + } + + Future userQr(String token) async { + final decoded = await _send( + 'POST', + '/v3/digital-id/user-qr', + body: {'token': token}, + ); + return DigitalIdUniversalQr.fromMap(_unwrapData(decoded)); + } + + Future generateQr({ + required String photo, + required String token, + required DigitalIdQrType qrType, + String? kidAct, + }) async { + final decoded = await _send( + 'POST', + '/v3/digital-id/generate-qr', + body: { + 'photo': photo, + 'token': token, + 'qr_type': qrType.code, + 'kid_act': ?kidAct, + }, + ); + return DigitalIdQr.fromMap(_unwrapData(decoded)); + } + + Future> getCardsList({ + String? passStatus, + String? inn, + }) async { + final query = {'pass_status': ?passStatus, 'inn': ?inn}; + final suffix = query.isEmpty ? '' : '?${Uri(queryParameters: query).query}'; + final decoded = await _send('GET', '/v2/digital-id/get-cards-list$suffix'); + final cards = _unwrapData(decoded)['acms_cards']; + if (cards is! List) return const []; + return cards + .whereType() + .map(DigitalIdAcmsCard.fromMap) + .toList(growable: false); + } + + Future activateAcms({required String id, required String inn}) async { + await _send( + 'POST', + '/v2/digital-id/activate-acms', + body: {'id': id, 'inn': inn, 'pass_status': 'active'}, + ); + } + + Future createLiteProfile(String deviceId) async { + await _send( + 'POST', + '/v2/digital-id/create-lite-profile', + body: {'device_id': deviceId}, + ); + } + + Future deleteProfile() async { + await _send('DELETE', '/v3/digital-id/delete-profile'); + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await TokenStorage.deleteSecure('${_tokenKey}_$accountId'); + await AppDatabase.setSyncValue(accountId, _tokenKey, ''); + } + } + + Future _storedToken() async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return null; + final secureKey = '${_tokenKey}_$accountId'; + final secured = await TokenStorage.readSecure(secureKey); + if (secured != null && secured.isNotEmpty) return secured; + + final legacy = await AppDatabase.getSyncValue(accountId, _tokenKey); + if (legacy != null && legacy.isNotEmpty) { + await TokenStorage.writeSecure(secureKey, legacy); + await AppDatabase.setSyncValue(accountId, _tokenKey, ''); + return legacy; + } + return null; + } + + Future _saveToken(String token) async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await TokenStorage.writeSecure('${_tokenKey}_$accountId', token); + } + } + + Future ensureBiometryToken({String? photoHash}) async { + final existing = await _storedToken(); + if (existing != null) return existing; + final id = await deviceId(); + final token = await createBiometryToken(deviceId: id, photoHash: photoHash); + if (token.isNotEmpty) await _saveToken(token); + return token; + } + + Future loadDocuments({ + bool createIfMissing = false, + int attempts = 5, + }) async { + var token = await _storedToken(); + if (token == null) { + if (!createIfMissing) return null; + final id = await deviceId(); + token = await createBiometryToken(deviceId: id); + if (token.isEmpty) return null; + await _saveToken(token); + } + final state = await refreshUserDocs(token); + if (state.isEmpty) return null; + for (var attempt = 0; attempt < attempts; attempt++) { + final docs = await getUserDocs(state); + if (docs != null) return docs; + await Future.delayed(const Duration(seconds: 2)); + } + return null; + } + + Future?> fetchMobileIdVerification(String url) async { + final uri = Uri.tryParse(url); + if (uri == null || uri.scheme != 'https') return null; + try { + final request = await _http.getUrl(uri); + request.followRedirects = true; + final response = await request.close(); + final builder = BytesBuilder(copy: false); + await for (final chunk in response) { + builder.add(chunk); + } + final headers = {}; + response.headers.forEach((name, values) { + headers[name] = values.join(','); + }); + return { + 'statusCode': response.statusCode, + 'headers': headers, + 'data': base64Encode(builder.takeBytes()), + }; + } catch (_) { + return null; + } + } + + void reset() { + _webAppData = null; + _deviceId = null; + _realUserAgent = null; + } +} diff --git a/lib/backend/modules/file_uploader.dart b/lib/backend/modules/file_uploader.dart new file mode 100644 index 0000000..76b2ae2 --- /dev/null +++ b/lib/backend/modules/file_uploader.dart @@ -0,0 +1,426 @@ +import 'dart:async'; +import 'dart:convert' show jsonDecode, utf8; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:kusoft/kusoft.dart' as kb; + +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/utils/logger.dart'; +import 'messages.dart'; + +sealed class UploadEvent { + const UploadEvent(); +} + +class UploadProgress extends UploadEvent { + final int sent; + final int total; + const UploadProgress({required this.sent, required this.total}); +} + +class UploadDone extends UploadEvent { + final int fileId; + final String? token; + final String? url; + final String filename; + final int size; + + /// Server-assigned message id. Without it the optimistic message keeps its + /// local temp id and download URLs cannot be resolved until a restart. + final String? messageId; + + const UploadDone({ + required this.fileId, + required this.filename, + required this.size, + this.token, + this.url, + this.messageId, + }); +} + +class UploadError extends UploadEvent { + final String message; + const UploadError(this.message); +} + +/// Оркестратор медиа-загрузок: control-plane (URL, отправка сообщения) идёт +/// обычными опкодами, data-plane (заливка на CDN) — через Rust-ядро kusoft, +/// которое стримит файл с диска (не держит его целиком в памяти). +class FileUploader { + final Api api; + final MessagesModule messages; + + FileUploader({required this.api, required this.messages}); + + Stream upload({ + required int chatId, + required File file, + required String filename, + required int totalSize, + String? text, + int? scheduledTime, + Duration autoForceAfter = const Duration(seconds: 1), + Duration overallTimeout = const Duration(minutes: 5), + Duration progressThrottle = const Duration(milliseconds: 16), + }) { + final ctrl = StreamController(); + var cancelled = false; + StreamSubscription? sub; + + ctrl.onCancel = () { + cancelled = true; + sub?.cancel(); + }; + + Future run() async { + try { + final info = await messages.requestUploadUrl(); + if (cancelled) return; + if (info == null) { + ctrl.add(const UploadError('no_upload_url')); + return; + } + + final session = api.session; + if (session == null) { + ctrl.add(const UploadError('no_session')); + return; + } + + unawaited(() async { + try { + await api.sendRequest(Opcode.msgTyping, { + 'chatId': chatId, + 'type': 'FILE', + }); + } catch (_) {} + }()); + + var status = 0; + String? error; + final done = Completer(); + sub = session + .uploadFilePath( + url: info.url, + path: file.path, + filename: filename, + connection: 'close', + ) + .listen( + (e) { + switch (e) { + case kb.UploadEvent_Progress(:final sent, :final total): + ctrl.add( + UploadProgress(sent: sent.toInt(), total: total.toInt()), + ); + case kb.UploadEvent_Done(status: final s): + status = s; + case kb.UploadEvent_Error(:final message): + error = message; + } + }, + onError: (Object err) { + error = err.toString(); + if (!done.isCompleted) done.complete(); + }, + onDone: () { + if (!done.isCompleted) done.complete(); + }, + cancelOnError: true, + ); + await done.future; + if (cancelled) return; + if (error != null) { + ctrl.add(UploadError(error!)); + return; + } + if (status != 200 && status != 0) { + ctrl.add(UploadError('http_$status')); + return; + } + + final messageId = await messages.sendFileMessage( + chatId, + info.fileId, + token: info.token, + text: text, + scheduledTime: scheduledTime, + ); + if (cancelled) return; + if (messageId == null) { + ctrl.add(const UploadError('send_failed')); + return; + } + + ctrl.add( + UploadDone( + fileId: info.fileId, + token: info.token, + url: info.url, + filename: filename, + size: totalSize, + messageId: messageId, + ), + ); + } catch (e) { + if (!cancelled) ctrl.add(UploadError(e.toString())); + } finally { + await ctrl.close(); + } + } + + unawaited(run()); + return ctrl.stream; + } + + Future uploadMediaFile( + Uri uri, + File file, { + void Function(int sent, int total)? onProgress, + Duration overallTimeout = const Duration(minutes: 5), + Duration progressThrottle = const Duration(milliseconds: 16), + }) async { + final session = api.session; + if (session == null) return false; + try { + final result = await _consume( + session.uploadFilePath( + url: uri.toString(), + path: file.path, + filename: _syntheticFilename(), + contentType: 'application/octet-stream', + connection: 'close', + ), + onProgress: onProgress, + ); + if (result.error != null) { + logger.w('uploadMediaFile: ${result.error}'); + return false; + } + final respBody = utf8.decode(result.body, allowMalformed: true); + final hasError = + respBody.contains('error_msg') || respBody.contains('error_code'); + if (result.status != 200 || hasError) { + logger.w( + 'uploadMediaFile rejected: status=${result.status} ' + 'body=${respBody.length > 500 ? respBody.substring(0, 500) : respBody}', + ); + } + return result.status == 200 && !hasError; + } catch (e) { + logger.w('uploadMediaFile: $e'); + return false; + } + } + + Future uploadImage( + Uri uri, + Uint8List bytes, { + String filename = 'avatar.jpg', + }) async { + final session = api.session; + if (session == null) return null; + try { + final result = await _consume( + session.uploadPhoto( + url: uri.toString(), + data: bytes, + filename: filename, + ), + ); + if (result.error != null || result.status != 200) { + logger.w('uploadImage: status=${result.status} error=${result.error}'); + return null; + } + return _parsePhotoToken(utf8.decode(result.body, allowMalformed: true)); + } catch (e) { + logger.w('uploadImage: $e'); + return null; + } + } + + Future uploadPhoto( + Uri uri, + File file, { + String filename = 'photo.jpg', + void Function(int sent, int total)? onProgress, + Duration progressThrottle = const Duration(milliseconds: 16), + }) async { + final session = api.session; + if (session == null) return null; + try { + final result = await _consume( + session.uploadPhotoPath( + url: uri.toString(), + path: file.path, + filename: filename, + ), + onProgress: onProgress, + ); + if (result.error != null || result.status != 200) { + logger.w('uploadPhoto: status=${result.status} error=${result.error}'); + return null; + } + return _parsePhotoToken(utf8.decode(result.body, allowMalformed: true)); + } catch (e) { + logger.w('uploadPhoto: $e'); + return null; + } + } + + Future uploadVideoFile( + Uri uri, + File file, { + void Function(int sent, int total)? onProgress, + int chunkSize = 2 * 1024 * 1024, + int concurrency = 4, + Duration overallTimeout = const Duration(minutes: 30), + }) async { + final session = api.session; + if (session == null) return false; + try { + final result = await _consume( + session.uploadVideoPath( + url: uri.toString(), + path: file.path, + chunkSize: chunkSize, + concurrency: concurrency, + ), + onProgress: onProgress, + ); + if (result.error != null || result.status != 200) { + logger.w( + 'uploadVideoFile: status=${result.status} error=${result.error}', + ); + } + return result.error == null && result.status == 200; + } catch (e) { + logger.w('uploadVideoFile: $e'); + return false; + } + } + + /// Загрузка видео для истории. В отличие от чата (чанковый `uploadVideoPath` + /// с GET-handshake), story-эндпоинт `su.oneme.ru/uploadVideo` ждёт **один POST + /// на весь файл** (как у оригинального клиента) и возвращает медиа-токен в теле + /// ответа — `[{"token":"..."}]`. Именно этот токен идёт в `media.token` + /// STORIES_SEND, а не токен из ответа VIDEO_UPLOAD. + Future<({bool ok, String? token})> uploadVideoWithToken( + Uri uri, + File file, { + void Function(int sent, int total)? onProgress, + }) async { + final session = api.session; + if (session == null) return (ok: false, token: null); + try { + final result = await _consume( + session.uploadFilePath( + url: uri.toString(), + path: file.path, + filename: _syntheticFilename(), + contentType: 'application/octet-stream', + connection: 'close', + ), + onProgress: onProgress, + ); + if (result.error != null || result.status != 200) { + logger.w( + 'uploadVideoWithToken: status=${result.status} error=${result.error}', + ); + return (ok: false, token: null); + } + final token = _parseVideoToken( + utf8.decode(result.body, allowMalformed: true), + ); + return (ok: true, token: token); + } catch (e) { + logger.w('uploadVideoWithToken: $e'); + return (ok: false, token: null); + } + } + + String? _parseVideoToken(String body) { + try { + final json = jsonDecode(body); + // Сервер отвечает массивом: [{"token":"..."}] + if (json is List) { + for (final v in json) { + if (v is Map) { + final token = v['token']; + if (token is String && token.isNotEmpty) return token; + } + } + } + if (json is Map) { + for (final key in const ['videos', 'video', 'photos']) { + final node = json[key]; + if (node is Map) { + for (final v in node.values) { + if (v is Map) { + final token = v['token']; + if (token is String && token.isNotEmpty) return token; + } + } + } + } + for (final key in const ['token', 'videoToken', 'photoToken']) { + final t = json[key]; + if (t is String && t.isNotEmpty) return t; + } + } + } catch (e) { + logger.w('parseVideoToken: $e'); + } + return null; + } + + /// Прогоняет стрим ядра до конца, форвардит прогресс, отдаёт итог. + Future<({int status, Uint8List body, String? error})> _consume( + Stream stream, { + void Function(int sent, int total)? onProgress, + }) async { + var status = 0; + var body = Uint8List(0); + String? error; + await for (final event in stream) { + switch (event) { + case kb.UploadEvent_Progress(:final sent, :final total): + onProgress?.call(sent.toInt(), total.toInt()); + case kb.UploadEvent_Done(status: final s, body: final b): + status = s; + body = b; + case kb.UploadEvent_Error(:final message): + error = message; + } + } + return (status: status, body: body, error: error); + } + + String _syntheticFilename() => + (DateTime.now().microsecondsSinceEpoch & 0x7FFFFFFF).toString(); + + String? _parsePhotoToken(String body) { + try { + final json = jsonDecode(body); + if (json is Map) { + final photos = json['photos']; + if (photos is Map) { + for (final v in photos.values) { + if (v is Map) { + final token = v['token']; + if (token is String && token.isNotEmpty) return token; + } + } + } + final pt = json['photoToken']; + if (pt is String && pt.isNotEmpty) return pt; + } + } catch (e) { + logger.w('parsePhotoToken: $e'); + } + return null; + } +} diff --git a/lib/backend/modules/folders.dart b/lib/backend/modules/folders.dart new file mode 100644 index 0000000..0ab98e5 --- /dev/null +++ b/lib/backend/modules/folders.dart @@ -0,0 +1,566 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; + +import '../api.dart'; +import '../models/chat_folder.dart'; +import 'chats.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; +import '../../core/storage/app_database.dart'; +import '../../core/storage/token_storage.dart'; + +class _FoldersSnapshot { + final List folders; + final List order; + final int folderSync; + + const _FoldersSnapshot({ + this.folders = const [], + this.order = const [], + this.folderSync = 0, + }); +} + +class FoldersModule { + static const _syncKey = 'chat_folders_snapshot'; + static const _listReadyKey = 'chat_folders_list_ready'; + + static const String allChatsFolderId = 'all.chat.folder'; + static const int titleMaxLength = 20; + + static final ValueNotifier revision = ValueNotifier(0); + + static StreamSubscription? _pushSub; + static Future _pushQueue = Future.value(); + + static void attachGlobalPushHandlers(Api api) { + _pushSub?.cancel(); + _pushSub = api.pushStream + .where((p) => p.opcode == Opcode.notifFolders) + .listen(_enqueuePush); + } + + static void _enqueuePush(Packet packet) { + _pushQueue = _pushQueue + .then((_) => _handleFoldersPush(packet)) + .catchError((Object _) {}); + } + + static Future _handleFoldersPush(Packet packet) async { + final payload = packet.payload; + if (payload is! Map) return; + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + await applyPayload(accountId, payload.cast()); + await chats.applyFavorites(accountId); + } + + static Future markFoldersListReady(int accountId) async { + await AppDatabase.setSyncValue(accountId, _listReadyKey, '1'); + } + + static Future hasReceivedFoldersList(int accountId) async { + final ready = await AppDatabase.getSyncValue(accountId, _listReadyKey); + if (ready == '1') return true; + final snap = await AppDatabase.getSyncValue(accountId, _syncKey); + return snap != null && snap.isNotEmpty; + } + + static bool isAllChatsFolder(ChatFolder f) { + if (f.id == allChatsFolderId) return true; + final t = f.title.trim().toLowerCase(); + return t == 'все' || t == 'все чаты' || t == 'all' || t == 'all chats'; + } + + static String? preferredInitialFolderId(List folders) { + if (folders.isEmpty) return null; + for (final f in folders) { + if (isAllChatsFolder(f)) return f.id; + } + return folders.first.id; + } + + static String newFolderId() { + final random = Random.secure(); + final bytes = List.generate(16, (_) => random.nextInt(256)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-' + '${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}'; + } + + static void _sortInPlace(List folders, List order) { + if (order.isEmpty) return; + final orderIndex = {}; + for (var i = 0; i < order.length; i++) { + orderIndex.putIfAbsent(order[i], () => i); + } + folders.sort((a, b) { + final aIndex = orderIndex[a.id] ?? -1; + final bIndex = orderIndex[b.id] ?? -1; + if (aIndex == -1 && bIndex == -1) return 0; + if (aIndex == -1) return 1; + if (bIndex == -1) return -1; + return aIndex.compareTo(bIndex); + }); + } + + static bool _matchesType( + int filter, + CachedChat chat, { + required int myId, + required Set contactIds, + }) { + final isDialog = chat.type == 'DIALOG'; + final isBot = isDialog && chat.options.contains('BOT'); + final peerId = isDialog ? chat.id ^ myId : null; + final isSelf = peerId != null && peerId == myId; + final isContact = + isDialog && !isSelf && peerId != null && contactIds.contains(peerId); + + switch (filter) { + case FolderFilter.channel: + return chat.type == 'CHANNEL'; + case FolderFilter.chat: + return chat.type == 'CHAT' || chat.type == 'GROUP'; + case FolderFilter.dialog: + return isDialog; + case FolderFilter.contact: + return isDialog && !isBot && isContact; + case FolderFilter.notContact: + return isDialog && !isBot && !isSelf && !isContact; + case FolderFilter.bot: + return isBot; + } + return false; + } + + static bool _matchesRole(int filter, CachedChat chat, int myId) { + switch (filter) { + case FolderFilter.owner: + return chat.owner == myId; + case FolderFilter.admin: + return chat.owner == myId || chat.admins.contains(myId); + } + return false; + } + + static const Set _knownRestrictions = { + FolderFilter.unread, + FolderFilter.read, + FolderFilter.muted, + FolderFilter.notMuted, + }; + + static bool _narrowsAllChats(ChatFolder folder) => + folder.include.isEmpty && + folder.filters.any( + (f) => _knownRestrictions.contains(f) || FolderFilter.roles.contains(f), + ); + + // #***! фильтр ограничение, непрочитанные беззвучные и прочее + static bool _matchesRestriction(int filter, CachedChat chat) { + switch (filter) { + case FolderFilter.unread: + return chat.unreadCount > 0; + case FolderFilter.read: + return chat.unreadCount == 0; + case FolderFilter.muted: + return chat.isMuted; + case FolderFilter.notMuted: + return !chat.isMuted; + } + return true; + } + + static bool chatMatchesFolder( + CachedChat chat, + ChatFolder folder, { + required int myId, + required Set contactIds, + }) { + if (!folder.include.contains(chat.id)) { + final typeFilters = folder.filters + .where(FolderFilter.chatTypes.contains) + .toList(); + if (typeFilters.isEmpty) { + if (!_narrowsAllChats(folder)) return false; + } else if (!typeFilters.any( + (f) => _matchesType(f, chat, myId: myId, contactIds: contactIds), + )) { + return false; + } + } + + final roleFilters = folder.filters.where(FolderFilter.roles.contains); + if (roleFilters.isNotEmpty && + !roleFilters.any((f) => _matchesRole(f, chat, myId))) { + return false; + } + + for (final f in folder.filters.where(FolderFilter.showOnly.contains)) { + if (!_matchesRestriction(f, chat)) return false; + } + return true; + } + + static List chatsForFolder( + Iterable chats, + ChatFolder folder, { + required int myId, + required Set contactIds, + required Set archivedIds, + }) => chats + .where( + (chat) => + (!archivedIds.contains(chat.id) || + folder.include.contains(chat.id)) && + chatMatchesFolder(chat, folder, myId: myId, contactIds: contactIds), + ) + .toList(); + + static List _parseFolderList(dynamic raw) { + if (raw is! List) return []; + return raw + .map((e) { + try { + final m = e is Map + ? e + : Map.from(e as Map); + return ChatFolder.fromJson(m); + } catch (_) { + return null; + } + }) + .whereType() + .toList(); + } + + static List? _parseOrder(dynamic raw) { + if (raw is! List) return null; + return raw.map((e) => e.toString()).toList(); + } + + static int? _parseSync(dynamic raw) => raw is int ? raw : null; + + static Future<_FoldersSnapshot> _loadSnapshot(int accountId) async { + final raw = await AppDatabase.getSyncValue(accountId, _syncKey); + if (raw == null || raw.isEmpty) return const _FoldersSnapshot(); + try { + final map = jsonDecode(raw) as Map; + final folders = _parseFolderList(map['folders']); + final order = _parseOrder(map['foldersOrder']) ?? const []; + _sortInPlace(folders, order); + return _FoldersSnapshot( + folders: folders, + order: order, + folderSync: _parseSync(map['folderSync']) ?? 0, + ); + } catch (_) { + return const _FoldersSnapshot(); + } + } + + static Future _saveSnapshot( + int accountId, + _FoldersSnapshot snapshot, + ) async { + final known = snapshot.folders.map((f) => f.id).toSet(); + final ordered = snapshot.order.where(known.contains).toList(); + final orderedSet = ordered.toSet(); + final order = [ + ...ordered, + ...known.where((id) => !orderedSet.contains(id)), + ]; + await AppDatabase.setSyncValue( + accountId, + _syncKey, + jsonEncode({ + 'folders': snapshot.folders.map((f) => f.toJson()).toList(), + 'foldersOrder': order, + 'folderSync': snapshot.folderSync, + }), + ); + revision.value++; + } + + static Future> loadFolders(int accountId) async { + return (await _loadSnapshot(accountId)).folders; + } + + static Future> loadFoldersOrder(int accountId) async { + return (await _loadSnapshot(accountId)).order; + } + + static Future loadFolderSync(int accountId) async { + return (await _loadSnapshot(accountId)).folderSync; + } + + static Future applyPayload( + int accountId, + Map payload, { + bool replace = false, + }) async { + final foldersRaw = payload['folders']; + final folderRaw = payload['folder']; + final orderRaw = payload['foldersOrder']; + final syncRaw = payload['folderSync']; + if (foldersRaw == null && + folderRaw == null && + orderRaw == null && + syncRaw == null) { + return; + } + + final incoming = [ + ..._parseFolderList(foldersRaw), + if (folderRaw is Map) + ChatFolder.fromJson(Map.from(folderRaw)), + ]; + + final current = await _loadSnapshot(accountId); + var folders = replace && foldersRaw is List + ? List.from(incoming) + : _merge(current.folders, incoming); + + final order = _parseOrder(orderRaw) ?? current.order; + if (orderRaw is List && order.isNotEmpty) { + final known = order.toSet(); + final fresh = incoming.map((f) => f.id).toSet(); + folders = folders + .where((f) => known.contains(f.id) || fresh.contains(f.id)) + .toList(); + } + + _sortInPlace(folders, order); + await _saveSnapshot( + accountId, + _FoldersSnapshot( + folders: folders, + order: order, + folderSync: _parseSync(syncRaw) ?? current.folderSync, + ), + ); + } + + static List _merge( + List current, + List incoming, + ) { + final merged = List.from(current); + for (final folder in incoming) { + final idx = merged.indexWhere((f) => f.id == folder.id); + if (idx >= 0) { + merged[idx] = folder; + } else { + merged.add(folder); + } + } + return merged; + } + + static Future applyFromLoginConfig( + int accountId, + Map config, + ) async { + final chatFolders = config['chatFolders']; + if (chatFolders is! Map) return; + if (chatFolders['FOLDERS'] == null) return; + await applyPayload(accountId, { + 'folders': chatFolders['FOLDERS'], + 'foldersOrder': chatFolders['foldersOrder'], + 'folderSync': chatFolders['folderSync'], + }, replace: true); + await markFoldersListReady(accountId); + } + + static Future createFolder( + Api api, + int accountId, { + required String title, + List include = const [], + List filters = const [], + List options = const [], + List favorites = const [], + }) { + return _sendUpdate( + api, + accountId, + id: newFolderId(), + title: title, + include: include, + filters: filters, + options: options, + favorites: favorites, + ); + } + + static Future updateFolder( + Api api, + int accountId, + ChatFolder folder, { + String? title, + List? include, + List? filters, + List? options, + List? favorites, + }) { + return _sendUpdate( + api, + accountId, + id: folder.id, + title: title ?? folder.title, + include: include ?? folder.include, + filters: filters ?? folder.filters, + options: options ?? folder.options, + favorites: favorites ?? folder.favorites, + ); + } + + static Future _sendUpdate( + Api api, + int accountId, { + required String id, + required String title, + required List include, + required List filters, + required List options, + required List favorites, + }) async { + final packet = await api.sendRequest(Opcode.foldersUpdate, { + 'id': id, + 'title': title.trim(), + 'include': include, + 'filters': filters, + 'options': options, + 'favorites': favorites, + }); + throwIfPacketError(packet); + + final data = packet.payload; + final folderJson = data is Map ? data['folder'] : null; + if (folderJson is! Map) { + throw StateError('FOLDERS_UPDATE: сервер не вернул папку'); + } + await applyPayload(accountId, data.cast()); + return ChatFolder.fromJson(Map.from(folderJson)); + } + + static Future setFolderFavorites( + Api api, + int accountId, + ChatFolder folder, + List favorites, + ) { + return updateFolder(api, accountId, folder, favorites: favorites); + } + + static Future deleteFolders( + Api api, + int accountId, + List folderIds, + ) async { + if (folderIds.isEmpty) return; + final packet = await api.sendRequest(Opcode.foldersDelete, { + 'folderIds': folderIds, + }); + throwIfPacketError(packet); + + final removed = folderIds.toSet(); + final current = await _loadSnapshot(accountId); + await _saveSnapshot( + accountId, + _FoldersSnapshot( + folders: current.folders.where((f) => !removed.contains(f.id)).toList(), + order: current.order, + folderSync: current.folderSync, + ), + ); + + final data = packet.payload; + if (data is Map) { + await applyPayload(accountId, data.cast()); + } + } + + static Future reorderFolders( + Api api, + int accountId, + List order, + ) async { + if (order.isEmpty) return; + final packet = await api.sendRequest(Opcode.foldersReorder, { + 'foldersOrder': order, + }); + throwIfPacketError(packet); + + final current = await _loadSnapshot(accountId); + final folders = List.from(current.folders); + _sortInPlace(folders, order); + final data = packet.payload; + await _saveSnapshot( + accountId, + _FoldersSnapshot( + folders: folders, + order: order, + folderSync: + (data is Map ? _parseSync(data['folderSync']) : null) ?? + current.folderSync, + ), + ); + } + + static Future> fetchFoldersByIds( + Api api, + int accountId, + List folderIds, + ) async { + if (folderIds.isEmpty) return const []; + final packet = await api.sendRequest(Opcode.foldersGetById, { + 'folderIds': folderIds, + }); + throwIfPacketError(packet); + + final data = packet.payload; + if (data is! Map) return const []; + final folders = _parseFolderList(data['folders']); + + final missing = folderIds.toSet()..removeAll(folders.map((f) => f.id)); + final current = await _loadSnapshot(accountId); + await _saveSnapshot( + accountId, + _FoldersSnapshot( + folders: _merge( + current.folders.where((f) => !missing.contains(f.id)).toList(), + folders, + ), + order: current.order, + folderSync: _parseSync(data['folderSync']) ?? current.folderSync, + ), + ); + return folders; + } + + static Future syncFromServer(Api api, int accountId) async { + try { + final packet = await api.sendRequest(Opcode.foldersGet, { + 'folderSync': 0, + }); + throwIfPacketError(packet); + final data = packet.payload; + if (data is Map) { + await applyPayload( + accountId, + data.cast(), + replace: true, + ); + } + } finally { + await markFoldersListReady(accountId); + } + } +} diff --git a/lib/backend/modules/forward_sender.dart b/lib/backend/modules/forward_sender.dart new file mode 100644 index 0000000..37651c8 --- /dev/null +++ b/lib/backend/modules/forward_sender.dart @@ -0,0 +1,426 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../core/cache/message_session_cache.dart'; +import '../../core/crypto/e2ee_service.dart'; +import '../../core/storage/chat_encryption_store.dart'; +import '../../core/utils/logger.dart'; +import '../../core/storage/token_storage.dart'; +import '../../main.dart'; +import 'chats.dart'; +import 'message_copy.dart'; +import 'messages.dart'; + +// #***! запрос на пересылку — источник сообщений, ждёт цели/подтверждения +class ForwardRequest { + final int sourceChatId; + final String sourceChatName; + final String sourceChatIconUrl; + final String sourceChatType; + final List messages; + final bool hideSender; + + ForwardRequest({ + required this.sourceChatId, + required this.sourceChatName, + required this.sourceChatIconUrl, + required this.sourceChatType, + required List messages, + this.hideSender = false, + }) : messages = List.unmodifiable(messages); + + late final bool canHideSender = messages.every( + (message) => MessageCopy.of(message) != null, + ); + + ForwardRequest withMessages(List value) => + _copyWith(messages: value); + + ForwardRequest withHideSender(bool value) => _copyWith(hideSender: value); + + ForwardRequest _copyWith({List? messages, bool? hideSender}) => + ForwardRequest( + sourceChatId: sourceChatId, + sourceChatName: sourceChatName, + sourceChatIconUrl: sourceChatIconUrl, + sourceChatType: sourceChatType, + messages: messages ?? this.messages, + hideSender: hideSender ?? this.hideSender, + ); + + CachedMessage optimisticForward( + CachedMessage source, { + required int myId, + required int targetChatId, + required String tempId, + required String status, + }) => MessagesModule.buildForwardMessage( + myId: myId, + targetChatId: targetChatId, + sourceChatId: sourceChatId, + source: source, + tempId: tempId, + time: DateTime.now().millisecondsSinceEpoch, + status: status, + sourceChatName: sourceChatName, + sourceChatIconUrl: sourceChatIconUrl, + sourceChatType: sourceChatType, + ); +} + +class ForwardCaption { + final String text; + final List> elements; + + const ForwardCaption(this.text, [this.elements = const []]); + + bool get isEmpty => text.isEmpty; +} + +class ForwardSendResult { + final Set delivered; + final Map unfinished; + + const ForwardSendResult({required this.delivered, required this.unfinished}); +} + +class ForwardSender { + ForwardSender._(); + + static const int _parallelChats = 3; + + static final StreamController> _deliveredController = + StreamController.broadcast(); + + static Stream> get delivered => + _deliveredController.stream; + + static bool isBlocked(int accountId, int chatId) => + ChatEncryptionStore.instance.isEnabled(accountId, chatId) || + E2eeService.instance.isOn(accountId, chatId); + + static Future _prepareDispatch( + int accountId, + int chatId, + MessagesModule sender, + int expectedSessionEpoch, { + int? sourceChatId, + }) async { + await ChatEncryptionStore.instance.load(); + await E2eeService.instance.ensureLoaded(accountId); + final preferences = await SharedPreferences.getInstance(); + void check() { + if (!E2eeService.instance.isLoaded(accountId) || + preferences.getString('active_account_id') != accountId.toString() || + sender.sessionEpoch != expectedSessionEpoch) { + throw StateError('Сессия изменилась до пересылки'); + } + if (isBlocked(accountId, chatId) || + (sourceChatId != null && isBlocked(accountId, sourceChatId))) { + throw StateError('Пересылка в зашифрованный чат недоступна'); + } + } + + check(); + return check; + } + + static Future send({ + required int accountId, + required List chatIds, + required ForwardRequest request, + ForwardCaption caption = const ForwardCaption(''), + Map resumeFrom = const {}, + MessagesModule? sender, + }) async { + final transport = sender ?? messagesModule; + final epoch = transport.sessionEpoch; + final copies = request.hideSender + ? [for (final source in request.messages) MessageCopy.of(source)] + : null; + if (request.messages.any( + (m) => m.accountId != accountId || m.e2ee != CachedMessage.e2eeNone, + ) || + (copies != null && copies.contains(null))) { + return ForwardSendResult( + delivered: const {}, + unfinished: { + for (final chatId in chatIds) chatId: resumeFrom[chatId] ?? 0, + }, + ); + } + final pending = [...chatIds]; + final delivered = {}; + final unfinished = {}; + Future worker() async { + while (pending.isNotEmpty) { + final chatId = pending.removeLast(); + final done = await _sendToChat( + accountId, + chatId, + request, + copies?.cast(), + caption, + resumeFrom[chatId] ?? 0, + transport, + epoch, + ); + if (done == null) { + delivered.add(chatId); + } else { + unfinished[chatId] = done; + } + } + } + + await Future.wait([ + for (var i = 0; i < min(_parallelChats, chatIds.length); i++) worker(), + ]); + return ForwardSendResult(delivered: delivered, unfinished: unfinished); + } + + static Future _sendToChat( + int accountId, + int chatId, + ForwardRequest request, + List? copies, + ForwardCaption caption, + int from, + MessagesModule sender, + int expectedSessionEpoch, + ) async { + final messages = request.messages; + final steps = messages.length + (caption.isEmpty ? 0 : 1); + final sent = []; + var step = from; + try { + for (; step < steps; step++) { + await _guard( + accountId, + chatId, + request.sourceChatId, + expectedSessionEpoch, + sender, + ); + sent.add( + step == messages.length + ? await _sendCaption( + accountId, + chatId, + caption, + sender, + expectedSessionEpoch, + sourceChatId: request.sourceChatId, + ) + : copies == null + ? await sendForward( + accountId, + chatId, + request, + messages[step], + sender: sender, + expectedSessionEpoch: expectedSessionEpoch, + ) + : await _deliverCopy( + accountId, + chatId, + copies[step], + sender, + expectedSessionEpoch, + sourceChatId: request.sourceChatId, + ), + ); + } + return null; + } catch (e) { + logger.w('Пересылка в $chatId остановилась на шаге $step: $e'); + return step; + } finally { + await _persist(sent, expectedSessionEpoch); + } + } + + static Future sendForward( + int accountId, + int chatId, + ForwardRequest request, + CachedMessage source, { + CachedMessage? optimistic, + MessagesModule? sender, + int? expectedSessionEpoch, + }) async { + final transport = sender ?? messagesModule; + if (source.accountId != accountId || + source.e2ee != CachedMessage.e2eeNone) { + throw StateError( + 'Encrypted or foreign-account message cannot be forwarded', + ); + } + final beforeSend = await _prepareDispatch( + accountId, + chatId, + transport, + expectedSessionEpoch ?? transport.sessionEpoch, + sourceChatId: request.sourceChatId, + ); + final local = + optimistic ?? + request.optimisticForward( + source, + myId: accountId, + targetChatId: chatId, + tempId: '', + status: 'sent', + ); + final realId = await transport.forwardMessage( + chatId, + request.sourceChatId, + int.parse(source.id), + beforeSend: beforeSend, + ); + return MessagesModule.reidentifyMessage( + local, + realId.isNotEmpty ? realId : local.id, + status: 'sent', + ); + } + + static Future sendCopy( + int accountId, + int chatId, + CachedMessage source, { + MessagesModule? sender, + int? expectedSessionEpoch, + }) { + if (source.accountId != accountId || + source.e2ee != CachedMessage.e2eeNone) { + throw StateError('Encrypted or foreign-account message cannot be copied'); + } + final copy = MessageCopy.of(source); + if (copy == null) throw StateError('message ${source.id} cannot be copied'); + final transport = sender ?? messagesModule; + return _deliverCopy( + accountId, + chatId, + copy, + transport, + expectedSessionEpoch ?? transport.sessionEpoch, + sourceChatId: source.chatId, + ); + } + + static Future _deliverCopy( + int accountId, + int chatId, + MessageCopy copy, + MessagesModule sender, + int expectedSessionEpoch, { + int? sourceChatId, + }) async { + final beforeSend = await _prepareDispatch( + accountId, + chatId, + sender, + expectedSessionEpoch, + sourceChatId: sourceChatId, + ); + final sent = await sender.sendMessageCopy( + chatId, + copy, + beforeSend: beforeSend, + ); + return CachedMessage.fromPushPayload(accountId, chatId, sent); + } + + static Future recordInChatList(CachedMessage message) async { + final status = message.status ?? 'sending'; + if (message.forwardedAttachment == null) { + await chats.applyOutgoingMessage(message, status: status); + return; + } + await chats.applyOutgoing( + message.accountId, + message.chatId, + messageId: message.id, + time: message.time, + text: MessagesModule.forwardPreviewText(message), + status: status, + ); + } + + static Future _persist(List sent, int epoch) async { + final stored = [ + for (final message in sent) + if (message.id.isNotEmpty) message, + ]; + if (stored.isEmpty) return; + final last = stored.last; + try { + await chats.storeSentMessages(last.accountId, last.chatId, stored); + await recordInChatList(last); + } catch (e) { + logger.w('Пересланное в ${last.chatId} не сохранилось: $e'); + } + MessageSessionCache.append(last.accountId, last.chatId, stored); + if (api.sessionEpoch == epoch && + await TokenStorage.getActiveAccountId() == last.accountId) { + _deliveredController.add(stored); + } + } + + static Future _guard( + int accountId, + int target, + int source, + int epoch, + MessagesModule sender, + ) async { + if (await TokenStorage.getActiveAccountId() != accountId || + sender.sessionEpoch != epoch) { + throw StateError('Forward account changed'); + } + for (final chatId in [source, target]) { + if (ChatEncryptionStore.instance.isEnabled(accountId, chatId) || + E2eeService.instance.isOn(accountId, chatId)) { + throw StateError('Encrypted chats cannot be forwarded'); + } + } + } + + static Future _sendCaption( + int accountId, + int chatId, + ForwardCaption caption, + MessagesModule sender, + int expectedSessionEpoch, { + int? sourceChatId, + }) async { + final beforeSend = await _prepareDispatch( + accountId, + chatId, + sender, + expectedSessionEpoch, + sourceChatId: sourceChatId, + ); + final id = await sender.sendMessage( + accountId, + chatId, + caption.text, + elements: caption.elements, + beforeSend: beforeSend, + ); + return CachedMessage( + id: id, + accountId: accountId, + chatId: chatId, + senderId: accountId, + text: caption.text, + time: DateTime.now().millisecondsSinceEpoch, + status: 'sent', + payload: caption.elements.isEmpty + ? null + : {'text': caption.text, 'elements': caption.elements}, + ); + } +} diff --git a/lib/backend/modules/links.dart b/lib/backend/modules/links.dart new file mode 100644 index 0000000..6269812 --- /dev/null +++ b/lib/backend/modules/links.dart @@ -0,0 +1,80 @@ +import 'dart:async'; + +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; +import '../api.dart'; + +sealed class ResolvedLink { + const ResolvedLink(); +} + +class ResolvedChat extends ResolvedLink { + final Map chat; + final Map? message; + + const ResolvedChat(this.chat, this.message); +} + +class ResolvedUser extends ResolvedLink { + final Map contact; + + const ResolvedUser(this.contact); +} + +class ResolvedLinkError extends ResolvedLink { + final String message; + final String? code; + + const ResolvedLinkError(this.message, {this.code}); + + bool get accessDenied => code == 'chat.denied'; +} + +abstract class LinkModule { + static Future resolve(Api api, String url) async { + final Packet response; + try { + response = await api.sendRequest(Opcode.linkInfo, { + 'link': url, + }, silent: true); + } on TimeoutException { + return const ResolvedLinkError('Превышено время ожидания'); + } on PacketError catch (e) { + return ResolvedLinkError(e.message, code: e.errorKey); + } + + final payload = response.payload; + if (payload is! Map) return null; + if (!response.isOk) { + return ResolvedLinkError( + messageFromErrorPayload(payload), + code: payload['error']?.toString(), + ); + } + + final chat = payload['chat']; + if (chat is Map) { + final message = payload['message']; + return ResolvedChat(chat, message is Map ? message : null); + } + + final user = payload['user']; + if (user is Map && user['contact'] is Map) { + return ResolvedUser(user['contact'] as Map); + } + + return null; + } + + static Future join(Api api, String url) async { + try { + final response = await api.sendRequest(Opcode.chatJoin, {'link': url}); + if (response.isOk) return null; + return messageFromErrorPayload(response.payload); + } on TimeoutException { + return 'Превышено время ожидания'; + } on PacketError catch (e) { + return e.message; + } + } +} diff --git a/lib/backend/modules/message_copy.dart b/lib/backend/modules/message_copy.dart new file mode 100644 index 0000000..f912f0c --- /dev/null +++ b/lib/backend/modules/message_copy.dart @@ -0,0 +1,167 @@ +import 'dart:typed_data'; + +import '../../core/utils/parse.dart'; +import '../../models/attachment.dart'; +import 'messages.dart'; + +class MessageCopy { + final String text; + final List> elements; + final List> attaches; + final List> wireAttaches; + + const MessageCopy._({ + required this.text, + required this.elements, + required this.attaches, + required this.wireAttaches, + }); + + static const _strippedTypes = {'INLINE_KEYBOARD', 'SHARE'}; + + static MessageCopy? of(CachedMessage message) { + if (message.isControl || message.e2ee != CachedMessage.e2eeNone) { + return null; + } + final source = _contentOf(message); + final text = source['text']?.toString() ?? ''; + final attaches = >[]; + final wireAttaches = >[]; + for (final attach in _maps(source['attaches'])) { + final type = attach['_type']?.toString().toUpperCase(); + if (_strippedTypes.contains(type)) continue; + final wire = _wireAttach(type, attach); + if (wire == null) return null; + attaches.add(attach); + wireAttaches.add(wire); + } + if (text.trim().isEmpty && wireAttaches.isEmpty) return null; + return MessageCopy._( + text: text, + elements: _maps(source['elements']), + attaches: attaches, + wireAttaches: wireAttaches, + ); + } + + CachedMessage toOutgoing({ + required int accountId, + required int chatId, + required String tempId, + required int time, + }) { + final payload = { + 'text': text, + 'elements': elements, + 'attaches': attaches, + }; + final (attachments, isControl) = CachedMessage.parseAttachments(payload); + return CachedMessage( + id: tempId, + accountId: accountId, + chatId: chatId, + senderId: accountId, + text: text, + time: time, + status: 'sending', + payload: payload, + attachments: attachments, + isControl: isControl, + ); + } + + static Map _contentOf(CachedMessage message) { + final payload = message.payload; + final link = payload?['link']; + if (link is Map && link['type']?.toString().toUpperCase() == 'FORWARD') { + final original = link['message']; + return original is Map ? Map.from(original) : const {}; + } + return { + 'text': message.text, + 'elements': payload?['elements'], + 'attaches': + payload?['attaches'] ?? + [ + for (final attachment + in message.attachments ?? const []) + attachment.toMap(), + ], + }; + } + + static List> _maps(Object? raw) => raw is List + ? [ + for (final item in raw.whereType()) + Map.from(item), + ] + : const []; + + static Map? _wireAttach( + String? type, + Map attach, + ) { + switch (type) { + case 'PHOTO': + final token = attach['photoToken']; + if (token == null) return null; + return {'_type': 'PHOTO', 'photoToken': token}; + case 'VIDEO': + final token = attach['token'] ?? attach['videoToken']; + if (token == null) return null; + if (parseIntOrNull(attach['videoType']) != 1) { + return {'videoType': 0, '_type': 'VIDEO', 'token': token}; + } + final thumbhash = attach['thumbhash']; + return { + 'duration': ?parseIntOrNull(attach['duration']), + 'videoType': 1, + '_type': 'VIDEO', + 'wave': _wave(attach['wave']), + 'token': token, + 'thumbhash': ?thumbhash, + }; + case 'AUDIO': + final token = attach['token'] ?? attach['audioToken']; + if (token == null) return null; + return { + 'duration': ?parseIntOrNull(attach['duration']), + '_type': 'AUDIO', + 'wave': _wave(attach['wave']), + 'token': token, + }; + case 'FILE': + final fileId = parseIntOrNull(attach['fileId']); + if (fileId != null) return {'_type': 'FILE', 'fileId': fileId}; + final token = attach['token'] ?? attach['fileToken']; + if (token == null) return null; + return {'_type': 'FILE', 'token': token}; + case 'STICKER': + final stickerId = parseIntOrNull(attach['stickerId']); + if (stickerId == null) return null; + return {'_type': 'STICKER', 'stickerId': stickerId}; + case 'CONTACT': + final contactId = parseIntOrNull(attach['contactId']); + if (contactId == null) return null; + return {'_type': 'CONTACT', 'contactId': contactId}; + case 'LOCATION': + final latitude = attach['latitude']; + final longitude = attach['longitude']; + if (latitude is! num || longitude is! num) return null; + final zoom = attach['zoom']; + return { + '_type': 'LOCATION', + 'latitude': latitude.toDouble(), + 'longitude': longitude.toDouble(), + 'zoom': zoom is num ? zoom.toDouble() : 15.0, + }; + default: + return null; + } + } + + static Uint8List _wave(Object? raw) { + final samples = parseIntList(raw); + return samples.isEmpty ? Uint8List(80) : Uint8List.fromList(samples); + } +} diff --git a/lib/backend/modules/message_info.dart b/lib/backend/modules/message_info.dart new file mode 100644 index 0000000..1f91ceb --- /dev/null +++ b/lib/backend/modules/message_info.dart @@ -0,0 +1,151 @@ +import '../../core/utils/format.dart'; +import 'messages.dart'; + +typedef MessageInfoRow = ({String label, String value}); + +// #***! эти поля раскладываем по своим строкам, остальные скаляры payload +// дописываем как есть — новое поле сервера появится в панели само +const Set _ownRows = { + 'id', + 'cid', + 'sender', + 'time', + 'updateTime', + 'type', + 'status', + 'text', + 'attaches', + 'elements', + 'link', + 'reactionInfo', +}; + +const Map _e2eeNames = { + CachedMessage.e2eeText: 'text', + CachedMessage.e2eeFailed: 'failed', + CachedMessage.e2eeFile: 'file', +}; + +// #***! технические поля сообщения ровно теми именами, что приходят с сервера +List buildMessageInfoRows(CachedMessage message) { + final payload = message.payload ?? const {}; + final rows = []; + + void add(String label, Object? value) { + if (value == null) return; + final text = value.toString(); + if (text.isEmpty) return; + rows.add((label: label, value: text)); + } + + add('id', message.id); + add('cid', payload['cid']); + add('chatId', message.chatId); + add('sender', message.senderId == 0 ? null : message.senderId); + add('type', payload['type']); + add('status', message.status); + add('time', _stamp(message.time)); + add('updateTime', _stamp(_asInt(payload['updateTime']))); + add('text.length', message.text?.length); + add('attaches', _attachesSummary(message)); + add('elements', _elementsSummary(payload['elements'])); + add('link', _linkSummary(payload['link'])); + add('reactionInfo', _reactionsSummary(payload['reactionInfo'])); + add('editHistory', message.editHistory?.length); + add('e2ee', _e2eeNames[message.e2ee]); + add('typingMs', message.typingMs); + if (message.deleted) add('deleted', true); + + for (final entry in payload.entries) { + if (_ownRows.contains(entry.key)) continue; + final value = entry.value; + if (value is Map || value is List) continue; + add(entry.key, value); + } + + return rows; +} + +String? _stamp(int? ms) { + if (ms == null || ms <= 0) return null; + final at = DateTime.fromMillisecondsSinceEpoch(ms); + return '$ms · ${formatDateTimeNumeric(at)}'; +} + +int? _asInt(Object? raw) { + if (raw is int) return raw; + return int.tryParse(raw?.toString() ?? ''); +} + +String? _attachesSummary(CachedMessage message) { + final raw = message.previewPayload['attaches']; + if (raw is! List || raw.isEmpty) return null; + return _joinCounted([ + for (final attach in raw.whereType()) + attach['_type']?.toString() ?? '?', + ]); +} + +String? _elementsSummary(Object? raw) { + if (raw is! List || raw.isEmpty) return null; + return _joinCounted([ + for (final element in raw.whereType()) + element['type']?.toString() ?? '?', + ]); +} + +// #***! повторы схлопываем в «LINK ×3», иначе разметка займёт весь экран +String? _joinCounted(List values) { + if (values.isEmpty) return null; + final counts = {}; + for (final value in values) { + counts.update(value, (n) => n + 1, ifAbsent: () => 1); + } + return [ + for (final entry in counts.entries) + entry.value > 1 ? '${entry.key} ×${entry.value}' : entry.key, + ].join(', '); +} + +String? _linkSummary(Object? raw) { + if (raw is! Map) return null; + final parts = []; + final type = raw['type']?.toString(); + if (type != null && type.isNotEmpty) parts.add(type); + final name = raw['chatName']?.toString(); + final chatId = raw['chatId']; + if (name != null && name.isNotEmpty) { + parts.add(chatId == null ? name : '$name ($chatId)'); + } else if (chatId != null) { + parts.add(chatId.toString()); + } + final inner = raw['message']; + if (inner is Map) { + final innerId = inner['id']?.toString(); + if (innerId != null && innerId.isNotEmpty) parts.add(innerId); + } + return parts.isEmpty ? null : parts.join(' · '); +} + +String? _reactionsSummary(Object? raw) { + if (raw is! Map) return null; + final counters = raw['counters']; + if (counters is! List || counters.isEmpty) return null; + final parts = []; + var total = 0; + for (final counter in counters.whereType()) { + final reaction = counter['reaction']?.toString(); + if (reaction == null || reaction.isEmpty) continue; + final count = _asInt(counter['count']) ?? 0; + total += count; + parts.add('$reaction $count'); + } + if (parts.isEmpty) return null; + final your = raw['yourReaction']?.toString(); + final declared = _asInt(raw['totalCount']) ?? total; + return [ + parts.join(', '), + 'total $declared', + if (your != null && your.isNotEmpty) 'your $your', + ].join(' · '); +} diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart new file mode 100644 index 0000000..264b9ed --- /dev/null +++ b/lib/backend/modules/messages.dart @@ -0,0 +1,2282 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../api.dart'; +import '../../core/config/qlyra_settings.dart'; +import '../../core/contacts/device_contacts_service.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; +import '../../core/crypto/e2ee_service.dart'; +import '../../core/storage/app_database.dart'; +import '../../core/storage/message_ranges.dart'; +import '../../core/storage/token_storage.dart'; +import '../../core/utils/logger.dart'; +import '../../core/utils/text_format.dart'; +import '../../models/attachment.dart'; +import 'chats.dart' show chats; +import 'message_copy.dart'; + +class ContactCache { + static final Map _nameCache = {}; + static final Map _avatarCache = {}; + static final Map> _optionsCache = {}; + static final Map _phoneCache = {}; + + static const _prefsKey = 'contact_cache_v1'; + static Timer? _saveTimer; + static bool _loaded = false; + + static Future load() async { + if (_loaded) return; + _loaded = true; + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_prefsKey); + if (raw == null) return; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return; + decoded.forEach((key, value) { + final id = int.tryParse(key.toString()); + if (id == null || value is! Map) return; + final name = value['n']; + final avatar = value['a']; + final opts = value['o']; + if (name is String) _nameCache[id] = name; + if (avatar is String) _avatarCache[id] = avatar; + if (opts is List) _optionsCache[id] = opts.whereType().toSet(); + }); + } catch (_) {} + } + + static void put(int id, String name) { + _nameCache[id] = name; + _scheduleSave(); + } + + static void putAvatar(int id, String? baseUrl) { + if (baseUrl != null) { + _avatarCache[id] = baseUrl; + _scheduleSave(); + } + } + + static void putOptions(int id, Set opts) { + _optionsCache[id] = opts; + _scheduleSave(); + } + + static void putPhone(int id, int phone) { + if (phone > 0) _phoneCache[id] = phone; + } + + static String? get(int id) { + final phone = _phoneCache[id]; + if (phone != null) { + final book = DeviceContactsService.nameForPhone(phone); + if (book != null && book.isNotEmpty) return book; + } + return _nameCache[id]; + } + + static String? getAvatar(int id) => _avatarCache[id]; + static Set? getOptions(int id) => _optionsCache[id]; + static bool isOfficial(int id) => + _optionsCache[id]?.contains('OFFICIAL') ?? false; + + static void remove(int id) { + _nameCache.remove(id); + _avatarCache.remove(id); + _optionsCache.remove(id); + _phoneCache.remove(id); + _scheduleSave(); + } + + static void clear() { + _nameCache.clear(); + _avatarCache.clear(); + _optionsCache.clear(); + _phoneCache.clear(); + _saveTimer?.cancel(); + _saveTimer = null; + unawaited(_wipePersisted()); + } + + static void _scheduleSave() { + _saveTimer?.cancel(); + _saveTimer = Timer(const Duration(seconds: 3), () => unawaited(_save())); + } + + static Future _save() async { + final ids = { + ..._nameCache.keys, + ..._avatarCache.keys, + ..._optionsCache.keys, + }; + final map = {}; + for (final id in ids) { + final entry = {}; + final name = _nameCache[id]; + final avatar = _avatarCache[id]; + final opts = _optionsCache[id]; + if (name != null) entry['n'] = name; + if (avatar != null) entry['a'] = avatar; + if (opts != null && opts.isNotEmpty) entry['o'] = opts.toList(); + if (entry.isNotEmpty) map['$id'] = entry; + } + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKey, jsonEncode(map)); + } + + static Future _wipePersisted() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_prefsKey); + } +} + +class TranscriptionResult { + static const String emptyText = 'Не распознали голос'; + final int status; + final String? text; + final String? messageId; + final int? chatId; + final int? mediaId; + + TranscriptionResult({ + required this.status, + this.text, + this.messageId, + this.chatId, + this.mediaId, + }); +} + +class TranscriptionCache { + static final Map _cache = {}; + static final Map> _listeners = {}; + static final Set _expanded = {}; + + static void put( + String messageId, + TranscriptionResult result, { + bool expanded = false, + }) { + _cache[messageId] = result; + if (expanded) _expanded.add(messageId); + final listeners = _listeners[messageId]; + if (listeners == null) return; + for (final listener in listeners.toList()) { + listener(); + } + } + + static TranscriptionResult? get(String messageId) => _cache[messageId]; + + static bool has(String messageId) => _cache.containsKey(messageId); + + static bool isExpanded(String messageId) => _expanded.contains(messageId); + + static void setExpanded(String messageId, bool value) { + if (isExpanded(messageId) == value) return; + if (value) { + _expanded.add(messageId); + } else { + _expanded.remove(messageId); + } + for (final listener in _listeners[messageId]?.toList() ?? []) { + listener(); + } + } + + static void listen(String messageId, VoidCallback listener) { + _listeners.putIfAbsent(messageId, () => {}).add(listener); + } + + static void unlisten(String messageId, VoidCallback listener) { + final listeners = _listeners[messageId]; + if (listeners == null) return; + listeners.remove(listener); + if (listeners.isEmpty) _listeners.remove(messageId); + } + + static void clear() { + _cache.clear(); + _expanded.clear(); + } +} + +class TranscriptionPushHandler { + static StreamSubscription? _sub; + + static void attach(Api api) { + _sub?.cancel(); + _sub = api.pushStream + .where((p) => p.opcode == Opcode.transcriptionResult) + .listen(_onPush); + } + + static void _onPush(Packet packet) { + final payload = packet.payload; + if (payload is! Map) return; + final source = payload['message'] is Map + ? payload['message'] as Map + : payload; + + final messageId = (source['messageId'] ?? source['msgId'])?.toString(); + if (messageId == null || messageId.isEmpty) return; + + final status = source['transcriptionStatus'] as int? ?? 1; + final rawText = source['transcription'] as String?; + if (status != 1) return; + + TranscriptionCache.put( + messageId, + TranscriptionResult( + status: 1, + text: rawText ?? '', + messageId: messageId, + chatId: source['chatId'] as int?, + mediaId: source['mediaId'] as int?, + ), + expanded: true, + ); + } +} + +class FileHistoryEntry { + final int fileId; + final String? url; + final String? token; + final String? filename; + final int? size; + final DateTime sentAt; + + FileHistoryEntry({ + required this.fileId, + this.url, + this.token, + this.filename, + this.size, + required this.sentAt, + }); + + Map toJson() => { + 'fileId': fileId, + if (url != null) 'url': url, + if (token != null) 'token': token, + if (filename != null) 'filename': filename, + if (size != null) 'size': size, + 'sentAt': sentAt.millisecondsSinceEpoch, + }; + + static FileHistoryEntry? fromJson(Map j) { + final id = j['fileId']; + final ts = j['sentAt']; + if (id is! int || ts is! int) return null; + return FileHistoryEntry( + fileId: id, + url: j['url'] as String?, + token: j['token'] as String?, + filename: j['filename'] as String?, + size: j['size'] as int?, + sentAt: DateTime.fromMillisecondsSinceEpoch(ts), + ); + } +} + +class FileHistoryCache { + static const _prefKey = 'file_history_v1'; + static const _maxEntries = 50; + + static final ValueNotifier> notifier = ValueNotifier( + const [], + ); + + static List get history => notifier.value; + static bool get isEmpty => notifier.value.isEmpty; + + static SharedPreferences? _prefs; + + static Future load(SharedPreferences prefs) async { + _prefs = prefs; + final raw = prefs.getString(_prefKey); + if (raw == null) return; + try { + final list = jsonDecode(raw); + if (list is! List) return; + final entries = []; + for (final e in list) { + if (e is Map) { + final entry = FileHistoryEntry.fromJson(Map.from(e)); + if (entry != null) entries.add(entry); + } + } + notifier.value = entries; + } catch (_) {} + } + + static void add(FileHistoryEntry entry) { + final next = [ + entry, + ...notifier.value.where((e) => e.fileId != entry.fileId), + ]; + if (next.length > _maxEntries) next.removeRange(_maxEntries, next.length); + notifier.value = next; + _persist(); + } + + static void remove(int fileId) { + final next = notifier.value.where((e) => e.fileId != fileId).toList(); + if (next.length == notifier.value.length) return; + notifier.value = next; + _persist(); + } + + static void _persist() { + final prefs = _prefs; + if (prefs == null) return; + final encoded = jsonEncode(notifier.value.map((e) => e.toJson()).toList()); + prefs.setString(_prefKey, encoded); + } +} + +class FileUploadInfo { + final String url; + final int fileId; + final String token; + + FileUploadInfo({ + required this.url, + required this.fileId, + required this.token, + }); +} + +class VideoUploadInfo { + final String url; + final int videoId; + final String token; + + VideoUploadInfo({ + required this.url, + required this.videoId, + required this.token, + }); +} + +class ReplyInfo { + final String? messageId; + final int senderId; + final String? text; + final int? time; + final List? attachments; + + const ReplyInfo({ + this.messageId, + required this.senderId, + this.text, + this.time, + this.attachments, + }); + + bool get missing => previewText().isEmpty; + + static ReplyInfo? fromPayload(Map? payload) { + if (payload == null) return null; + final link = payload['link']; + if (link is! Map) return null; + if ((link['type'] as String?)?.toUpperCase() != 'REPLY') return null; + + final msg = link['message']; + if (msg is! Map) { + final mid = link['messageId']; + if (mid == null) return null; + return ReplyInfo(messageId: mid.toString(), senderId: 0); + } + + List? attaches; + final raw = msg['attaches']; + if (raw is List && raw.isNotEmpty) { + attaches = raw + .whereType() + .map((a) => MessageAttachment.fromMap(Map.from(a))) + .toList(); + } + + final sender = msg['sender']; + return ReplyInfo( + messageId: msg['id']?.toString(), + senderId: sender is int + ? sender + : int.tryParse(sender?.toString() ?? '') ?? 0, + text: msg['text']?.toString(), + time: msg['time'] is int ? msg['time'] as int : null, + attachments: attaches, + ); + } + + String previewText() { + final t = text; + if (t != null && t.trim().isNotEmpty) return t; + final a = attachments; + if (a != null && a.isNotEmpty) { + switch (a.first.type) { + case AttachmentType.photo: + return 'Фото'; + case AttachmentType.video: + return 'Видео'; + case AttachmentType.audio: + return 'Голосовое сообщение'; + case AttachmentType.file: + final file = a.first; + final name = file is FileAttachment ? file.name?.trim() : null; + return name == null || name.isEmpty ? 'Файл' : name; + case AttachmentType.sticker: + return 'Стикер'; + case AttachmentType.contact: + return 'Контакт'; + case AttachmentType.location: + return 'Геолокация'; + case AttachmentType.poll: + return 'Опрос'; + case AttachmentType.call: + return 'Звонок'; + case AttachmentType.share: + return 'Ссылка'; + case AttachmentType.control: + return ''; + case AttachmentType.inlineKeyboard: + return ''; + case AttachmentType.forward: + return 'Переслано'; + case AttachmentType.unknown: + return 'Вложение'; + } + } + return ''; + } +} + +class AudioUploadInfo { + final String url; + final int audioId; + final String token; + + AudioUploadInfo({ + required this.url, + required this.audioId, + required this.token, + }); +} + +final Expando> _formatRangesCache = + Expando>('formatRanges'); + +class CachedMessage { + final String id; + final int accountId; + final int chatId; + final int senderId; + final String? text; + final int time; + final String? status; + final Map? payload; + final List? attachments; + final bool isControl; + final bool deleted; + final List>? editHistory; + // #***! открытый текст лежит запечатанным локальным ключом + final Uint8List? sealedText; + final int e2ee; + final int? typingMs; + + static const int e2eeNone = 0; + static const int e2eeText = 1; + static const int e2eeFailed = 2; + static const int e2eeFile = 3; + + const CachedMessage({ + required this.id, + required this.accountId, + required this.chatId, + required this.senderId, + this.text, + required this.time, + this.status, + this.payload, + this.attachments, + this.isControl = false, + this.deleted = false, + this.editHistory, + this.sealedText, + this.e2ee = e2eeNone, + this.typingMs, + }); + + ControlAttachment? get controlAttachment => + attachments?.whereType().firstOrNull; + + ForwardedMessageAttachment? get forwardedAttachment => + attachments?.whereType().firstOrNull; + + String? get selectableText { + final own = text; + if (own != null && own.isNotEmpty) return own; + final forwarded = forwardedAttachment?.originalText; + if (forwarded != null && forwarded.isNotEmpty) return forwarded; + return null; + } + + String? get botStartPayload { + final control = controlAttachment; + if (control == null || !control.isBotStart) return null; + final payload = (control.startPayload ?? text)?.trim(); + return (payload == null || payload.isEmpty) ? null : payload; + } + + bool get isSilentBotStart => + (controlAttachment?.isBotStart ?? false) && botStartPayload == null; + + CachedMessage copyWith({ + String? status, + bool? deleted, + List? attachments, + List>? editHistory, + Map? payload, + Uint8List? sealedText, + int? e2ee, + int? typingMs, + }) => CachedMessage( + id: id, + accountId: accountId, + chatId: chatId, + senderId: senderId, + text: text, + time: time, + status: status ?? this.status, + payload: payload ?? this.payload, + attachments: attachments ?? this.attachments, + isControl: isControl, + deleted: deleted ?? this.deleted, + editHistory: editHistory ?? this.editHistory, + sealedText: sealedText ?? this.sealedText, + e2ee: e2ee ?? this.e2ee, + typingMs: typingMs ?? this.typingMs, + ); + + CachedMessage withSendFailure(Object error) { + final permanent = isPermanentSendFailure(error); + return copyWith( + status: permanent ? 'error' : 'pending', + payload: {...?payload, if (permanent) '_sendFailure': error.toString()}, + ); + } + + String? get sendFailure { + if (status != 'error') return null; + final reason = payload?['_sendFailure']; + return reason is String && reason.trim().isNotEmpty ? reason : null; + } + + // #***! история правок это список прошлых версий текста + static List>? parseEditHistory(dynamic raw) { + if (raw is! String || raw.isEmpty) return null; + try { + final decoded = jsonDecode(raw); + if (decoded is List) { + final list = decoded + .whereType() + .map((e) => Map.from(e)) + .toList(); + return list.isEmpty ? null : list; + } + } catch (_) {} + return null; + } + + static List> appendEditHistory( + List>? current, + String? oldText, + int time, + ) { + final list = current != null + ? List>.from(current) + : >[]; + if (list.isNotEmpty && (list.last['text'] as String?) == oldText) { + return list; + } + list.add({'text': oldText, 'time': time}); + return list; + } + + static (List?, bool) parseAttachments( + Map map, + ) { + List? attachments; + final link = map['link']; + final linkType = link is Map ? link['type'] as String? : null; + if (linkType == 'FORWARD') { + attachments = [ForwardedMessageAttachment.fromMap(map)]; + } else { + final attaches = map['attaches'] as List?; + if (attaches != null) { + attachments = attaches + .whereType() + .map((a) => MessageAttachment.fromMap(Map.from(a))) + .toList(); + } + } + final isControl = + attachments?.any((a) => a.type == AttachmentType.control) ?? false; + return (attachments, isControl); + } + + factory CachedMessage.fromDbRow(Map row) { + Map? payload; + final payloadRaw = row['payload']; + if (payloadRaw is String && payloadRaw.isNotEmpty) { + try { + payload = jsonDecode(payloadRaw) as Map; + } catch (_) {} + } + + List? attachments; + bool isControl = false; + if (payload != null) { + final parsed = parseAttachments(payload); + attachments = parsed.$1; + isControl = parsed.$2; + } + + return CachedMessage( + id: row['id']?.toString() ?? '', + accountId: row['account_id'] is int + ? row['account_id'] as int + : int.tryParse(row['account_id']?.toString() ?? '') ?? 0, + chatId: row['chat_id'] is int + ? row['chat_id'] as int + : int.tryParse(row['chat_id']?.toString() ?? '') ?? 0, + senderId: row['sender_id'] is int + ? row['sender_id'] as int + : int.tryParse(row['sender_id']?.toString() ?? '') ?? 0, + text: row['text']?.toString(), + time: row['time'] is int + ? row['time'] as int + : int.tryParse(row['time']?.toString() ?? '') ?? 0, + status: row['status']?.toString(), + payload: payload, + attachments: attachments, + isControl: isControl, + deleted: row['deleted'] is int + ? row['deleted'] == 1 + : row['deleted']?.toString() == '1', + editHistory: parseEditHistory(row['edit_history']), + sealedText: row['text_sealed'] is Uint8List + ? row['text_sealed'] as Uint8List + : null, + e2ee: row['e2ee'] is int ? row['e2ee'] as int : 0, + typingMs: row['typing_ms'] is int ? row['typing_ms'] as int : null, + ); + } + + int? get delayedTimeToFire { + final attrs = payload?['delayedAttributes']; + if (attrs is Map) { + final t = attrs['timeToFire']; + if (t is int) return t; + if (t is String) return int.tryParse(t); + } + return null; + } + + bool get isDelayed => delayedTimeToFire != null; + + // #***! у своего сообщения до ответа сервера payload'а нет, собираем его + // из вложений чтобы превью считал тот же код что и для входящих + Map get previewPayload => + payload ?? + { + 'text': text, + 'attaches': [ + for (final attachment in attachments ?? const []) + attachment.toMap(), + ], + }; + + ReplyInfo? get replyInfo => ReplyInfo.fromPayload(payload); + + // #***! геттер зовётся на каждую перестройку пузыря, а разбор payload + // каждый раз рождал новый список объектов + List get formatRanges => + _formatRangesCache[this] ??= parseFormatElements(payload?['elements']); + + static List _decodeRows(List> rows) => + rows.map(CachedMessage.fromDbRow).toList(); + + static Future> fromDbRowsAsync( + List> rows, + ) { + if (rows.length < 20) { + return Future.value(_decodeRows(rows)); + } + return compute(_decodeRows, rows); + } + + Map toDbRow() => { + 'id': id, + 'account_id': accountId, + 'chat_id': chatId, + 'sender_id': senderId, + 'text': text, + 'time': time, + 'status': status, + 'payload': payload != null ? jsonEncode(payload) : null, + 'deleted': deleted ? 1 : 0, + 'edit_history': editHistory != null ? jsonEncode(editHistory) : null, + 'text_sealed': sealedText, + 'e2ee': e2ee, + 'typing_ms': typingMs, + }; + + static CachedMessage fromPushPayload(int accountId, int chatId, Map msg) { + final full = Map.from(msg); + final parsed = parseAttachments(full); + return CachedMessage( + id: msg['id']?.toString() ?? '', + accountId: accountId, + chatId: chatId, + senderId: msg['sender'] as int? ?? 0, + text: msg['text'] as String?, + time: (msg['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch, + status: (msg['status'] as String?) ?? 'sent', + payload: full, + attachments: parsed.$1, + isControl: parsed.$2, + ); + } +} + +class MessagesModule { + int get sessionEpoch => _api.sessionEpoch; + final Api _api; + + MessagesModule(this._api); + + static int _lastCid = 0; + + static int _nextCid() { + final now = DateTime.now().millisecondsSinceEpoch; + _lastCid = now > _lastCid ? now : _lastCid + 1; + return _lastCid; + } + + // #***! история с сервера + Future> fetchHistory( + int accountId, + int chatId, { + int? fromTime, + int count = 50, + int forward = 0, + int? backward, + }) async { + final payload = { + 'chatId': chatId, + 'from': fromTime ?? (DateTime.now().millisecondsSinceEpoch + 86400000), + 'forward': forward, + 'backward': backward ?? count, + 'getMessages': true, + }; + + final response = await _api.sendRequest(Opcode.chatHistory, payload); + + if (!response.isOk) return []; + + final data = response.payload; + if (data is! Map) return []; + + final messagesData = data['messages']; + if (messagesData is! List) return []; + + final List results = []; + + for (var i = 0; i < messagesData.length; i++) { + final m = messagesData[i]; + if (m is! Map) continue; + + final msg = _parseMessage(m.cast(), accountId, chatId); + if (msg != null) { + results.add(msg); + } + + if (i > 0 && i % 20 == 0) { + await Future.delayed(Duration.zero); + } + } + + final merged = results.isNotEmpty + ? await _mergeLocalFields(accountId, chatId, results) + : results; + final toSave = await E2eeService.instance.inspectHistory( + accountId, + chatId, + merged, + ); + + if (toSave.isNotEmpty) { + try { + await AppDatabase.saveMessages(toSave.map((m) => m.toDbRow()).toList()); + } catch (e) { + logger.e('saveMessages error: $e'); + return toSave; + } + } + + await _recordCoverage( + accountId, + chatId, + fromTime: fromTime, + forward: forward, + backward: backward ?? count, + messagesData: messagesData, + ); + return toSave; + } + + Future _recordCoverage( + int accountId, + int chatId, { + required int? fromTime, + required int forward, + required int backward, + required List messagesData, + }) async { + final range = MessageRanges.coverageOfFetch( + fromTime: fromTime, + forward: forward, + backward: backward, + times: [ + for (final m in messagesData) + if (m is Map && m['time'] is int) m['time'] as int, + ], + ); + if (range == null) return; + try { + await AppDatabase.addMessageRange(accountId, chatId, range); + } catch (e) { + logger.w('addMessageRange error: $e'); + } + } + + Future>> searchMessages( + int chatId, + String query, { + int count = 30, + }) async { + final response = await _api.sendRequest(Opcode.msgSearch, { + 'chatId': chatId, + 'query': query, + 'count': count, + }); + + if (!response.isOk) return const []; + + final data = response.payload; + if (data is! Map) return const []; + + final result = data['result']; + if (result is! List) return const []; + + return result + .whereType() + .map((e) => Map.from(e.cast())) + .toList(); + } + + Future> _mergeLocalFields( + int accountId, + int chatId, + List serverMessages, + ) async { + final cachedRows = await AppDatabase.loadMessagesByIds( + accountId, + chatId, + serverMessages.map((m) => m.id).toList(), + ); + final byId = >{}; + for (final row in cachedRows) { + final id = row['id']?.toString(); + if (id != null) byId[id] = row; + } + + final now = DateTime.now().millisecondsSinceEpoch; + final out = []; + for (final msg in serverMessages) { + final existing = byId[msg.id]; + if (existing == null) { + out.add(msg); + continue; + } + var history = CachedMessage.parseEditHistory(existing['edit_history']); + final oldText = existing['text']?.toString(); + if (QlyraSettings.viewRedacted.value && + (oldText ?? '') != (msg.text ?? '') && + oldText != null && + oldText.isNotEmpty) { + history = CachedMessage.appendEditHistory(history, oldText, now); + } + final typingMs = existing['typing_ms']; + out.add( + msg.copyWith( + editHistory: history, + typingMs: typingMs is int ? typingMs : null, + ), + ); + } + return out; + } + + Future> getLocalHistory( + int accountId, + int chatId, { + int limit = 50, + int offset = 0, + }) async { + final rows = await AppDatabase.loadMessages( + accountId, + chatId, + limit: limit, + offset: offset, + ); + return CachedMessage.fromDbRowsAsync(rows); + } + + CachedMessage? _parseMessage( + Map m, + int accountId, + int chatId, + ) { + final id = m['id']?.toString(); + if (id == null) return null; + + final full = Map.from(m.cast()); + final parsed = CachedMessage.parseAttachments(full); + + return CachedMessage( + id: id, + accountId: accountId, + chatId: chatId, + senderId: _parseIntField(m['sender']), + text: m['text']?.toString(), + time: _parseIntField(m['time']), + status: m['status']?.toString(), + payload: full, + attachments: parsed.$1, + isControl: parsed.$2, + ); + } + + int _parseIntField(dynamic value) { + if (value == null) return 0; + if (value is int) return value; + if (value is String) return int.tryParse(value) ?? 0; + return int.tryParse(value.toString()) ?? 0; + } + + Future sendMessage( + int accountId, + int chatId, + String text, { + int? clientId, + bool notify = true, + int? scheduledTime, + int? replyToMessageId, + int? replySourceChatId, + List> elements = const [], + void Function()? beforeSend, + }) async { + final message = { + 'text': text, + 'cid': clientId ?? DateTime.now().millisecondsSinceEpoch * -1, + 'elements': elements, + 'attaches': [], + }; + if (replyToMessageId != null) { + message['link'] = { + 'type': 'REPLY', + 'chatId': replySourceChatId ?? chatId, + 'messageId': replyToMessageId, + }; + } + if (scheduledTime != null) { + message['delayedAttributes'] = { + 'timeToFire': scheduledTime, + 'notifySender': true, + }; + } + final payload = {'chatId': chatId, 'message': message, 'notify': notify}; + + return _sendAndExtractMessageId( + payload, + 'Ошибка отправки', + beforeSend: beforeSend, + ); + } + + Future sendControlMessage( + int chatId, + Map control, { + bool notify = true, + }) { + final payload = { + 'chatId': chatId, + 'message': { + 'cid': -_nextCid(), + 'text': '', + 'attaches': [control], + }, + 'notify': notify, + }; + return _api.sendRequest(Opcode.msgSend, payload); + } + + Future?> sendBotStart( + int chatId, [ + String? startPayload, + ]) async { + final response = await _api.sendRequest(Opcode.msgSend, { + 'chatId': chatId, + 'message': { + 'cid': -_nextCid(), + 'attaches': [ + { + '_type': 'CONTROL', + 'event': ControlAttachment.botStartedEvent, + 'startPayload': ?startPayload, + }, + ], + }, + }); + return _sentMessageMap(response); + } + + Future _sendAndExtractMessageId( + Map payload, + String defaultError, { + void Function()? beforeSend, + }) async { + final response = await _api.sendRequest( + Opcode.msgSend, + payload, + beforeSend: beforeSend, + ); + if (!response.isOk) { + _throwSendError(response.payload, defaultError); + } + final data = response.payload; + if (data is Map) { + final msgMap = data['message']; + if (msgMap is Map) { + final id = msgMap['id']; + if (id != null) return id.toString(); + } + } + return ''; + } + + Never _throwSendError(dynamic payload, String fallback) { + final msg = (payload is Map) + ? (payload['localizedMessage'] ?? payload['message'] ?? fallback) + : fallback; + throw Exception(msg.toString()); + } + + Map? _sentMessageMap(Packet response) { + if (!response.isOk) return null; + final data = response.payload; + if (data is Map) { + final msg = data['message']; + if (msg is Map) return Map.from(msg); + } + return null; + } + + Future _sendWithNotReadyRetry({ + required Map payload, + required int maxAttempts, + required Duration retryDelay, + required T Function(Packet response) onResult, + required T onExhausted, + }) async { + for (var attempt = 0; attempt < maxAttempts; attempt++) { + try { + final response = await _api.sendRequest(Opcode.msgSend, payload); + return onResult(response); + } on PacketError catch (e) { + if (!(e.errorKey?.contains('not.ready') ?? false)) { + logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}'); + rethrow; + } + if (attempt == maxAttempts - 1) return onExhausted; + await Future.delayed(retryDelay); + } + } + return onExhausted; + } + + Future forwardMessage( + int targetChatId, + int sourceChatId, + int messageId, { + bool notify = true, + void Function()? beforeSend, + }) async { + final message = { + 'isLive': false, + 'detectShare': false, + 'elements': [], + 'attaches': [], + 'cid': -_nextCid(), + 'link': { + 'type': 'FORWARD', + 'chatId': sourceChatId, + 'messageId': messageId, + }, + }; + final payload = { + 'chatId': targetChatId, + 'message': message, + 'notify': notify, + }; + + return _sendAndExtractMessageId( + payload, + 'Ошибка пересылки', + beforeSend: beforeSend, + ); + } + + Future> sendMessageCopy( + int chatId, + MessageCopy copy, { + bool notify = true, + void Function()? beforeSend, + }) async { + final response = await _api.sendRequest(Opcode.msgSend, { + 'chatId': chatId, + 'message': { + 'cid': -_nextCid(), + if (copy.text.isNotEmpty) 'text': copy.text, + 'elements': copy.elements, + 'attaches': copy.wireAttaches, + }, + 'notify': notify, + }, beforeSend: beforeSend); + final sent = _sentMessageMap(response); + if (sent == null) _throwSendError(response.payload, 'Ошибка пересылки'); + return sent; + } + + // #***! локальная копия чтоб пересланное появилось сразу + static CachedMessage buildForwardMessage({ + required int myId, + required int targetChatId, + required int sourceChatId, + required CachedMessage source, + required String tempId, + required int time, + required String status, + String? sourceChatName, + String? sourceChatIconUrl, + String? sourceChatType, + }) { + final srcPayload = source.payload; + final srcLink = srcPayload?['link']; + final isForwardedSource = + srcLink is Map && + srcLink['type']?.toString().toUpperCase() == 'FORWARD' && + srcLink['message'] is Map; + Map originalMsg; + if (isForwardedSource) { + originalMsg = Map.from(srcLink['message'] as Map); + } else { + final originalType = srcPayload?['type']?.toString() ?? sourceChatType; + originalMsg = { + 'id': int.tryParse(source.id) ?? source.id, + 'type': ?originalType, + 'sender': source.senderId, + 'time': source.time, + 'text': source.text, + 'attaches': (srcPayload?['attaches'] as List?) ?? const [], + 'elements': (srcPayload?['elements'] as List?) ?? const [], + }; + } + final isChannelSource = + originalMsg['type']?.toString().toUpperCase() == 'CHANNEL'; + final rawChannelName = isForwardedSource + ? srcLink['chatName'] + : sourceChatName; + final rawChannelIconUrl = isForwardedSource + ? srcLink['chatIconUrl'] + : sourceChatIconUrl; + final channelName = rawChannelName?.toString().trim(); + final channelIconUrl = rawChannelIconUrl?.toString().trim(); + final payload = { + 'elements': const [], + 'attaches': const [], + 'link': { + 'type': 'FORWARD', + 'chatId': sourceChatId, + 'messageId': int.tryParse(source.id) ?? source.id, + 'message': originalMsg, + if (isChannelSource && channelName != null && channelName.isNotEmpty) + 'chatName': channelName, + if (isChannelSource && + channelIconUrl != null && + channelIconUrl.isNotEmpty) + 'chatIconUrl': channelIconUrl, + }, + }; + return CachedMessage( + id: tempId, + accountId: myId, + chatId: targetChatId, + senderId: myId, + text: null, + time: time, + status: status, + payload: payload, + attachments: [ForwardedMessageAttachment.fromMap(payload)], + ); + } + + static CachedMessage reidentifyMessage( + CachedMessage message, + String newId, { + String? status, + }) => CachedMessage( + id: newId, + accountId: message.accountId, + chatId: message.chatId, + senderId: message.senderId, + text: message.text, + time: message.time, + status: status ?? message.status, + payload: message.payload, + attachments: message.attachments, + isControl: message.isControl, + deleted: message.deleted, + editHistory: message.editHistory, + typingMs: message.typingMs, + ); + + static String forwardPreviewText(CachedMessage message) { + final link = message.payload?['link']; + if (link is Map && link['message'] is Map) { + final original = link['message'] as Map; + final text = original['text']; + if (text is String && text.trim().isNotEmpty) return text; + } + return 'Пересланное сообщение'; + } + + Future sendLinkMessage(int chatId, String url) async { + final message = { + 'text': url, + 'cid': -_nextCid(), + 'elements': [ + { + 'type': 'LINK', + 'from': 0, + 'length': url.length, + 'attributes': {'url': url}, + }, + ], + 'attaches': [], + }; + return _api.sendRequestOk(Opcode.msgSend, { + 'chatId': chatId, + 'message': message, + 'notify': true, + }); + } + + Future> fetchDelayedMessages( + int accountId, + int chatId, + ) async { + final payload = { + 'chatId': chatId, + 'forward': 0, + 'backwardTime': 0, + 'getChat': false, + 'from': 1, + 'itemType': 'DELAYED', + 'getMessages': true, + 'forwardTime': 0, + 'interactive': true, + 'backward': 150, + }; + + final response = await _api.sendRequest(Opcode.chatHistory, payload); + if (!response.isOk) return []; + + final data = response.payload; + if (data is! Map) return []; + + final messagesData = data['messages']; + if (messagesData is! List) return []; + + final results = []; + for (final m in messagesData) { + if (m is! Map) continue; + final msg = _parseMessage(m.cast(), accountId, chatId); + if (msg != null) results.add(msg); + } + + results.sort( + (a, b) => (a.delayedTimeToFire ?? a.time).compareTo( + b.delayedTimeToFire ?? b.time, + ), + ); + + return results; + } + + Future editMessage( + int chatId, + String messageId, { + required String text, + List> elements = const [], + bool sendAttachments = false, + }) async { + final id = int.tryParse(messageId); + if (id == null) return false; + + final payload = { + 'messageId': id, + 'chatId': chatId, + 'elements': elements, + 'text': text, + }; + if (sendAttachments) payload['attachments'] = const []; + + return _api.sendRequestOk(Opcode.msgEdit, payload); + } + + Future editScheduledMessage( + int chatId, + String messageId, { + required String text, + required int timeToFire, + }) async { + final id = int.tryParse(messageId); + if (id == null) return false; + + final payload = { + 'messageId': id, + 'chatId': chatId, + 'elements': [], + 'text': text, + 'delayedAttributes': {'timeToFire': timeToFire, 'notifySender': true}, + }; + + return _api.sendRequestOk(Opcode.msgEdit, payload); + } + + Future deleteMessages( + int chatId, + List messageIds, { + bool forEveryone = false, + String itemType = 'REGULAR', + }) async { + final ids = messageIds + .map((id) => int.tryParse(id)) + .whereType() + .toList(); + if (ids.isEmpty) return false; + + final payload = { + 'messageIds': ids, + 'chatId': chatId, + 'forMe': !forEveryone, + 'itemType': itemType, + }; + + return _api.sendRequestOk(Opcode.msgDelete, payload); + } + + Future<({bool ok, Map? info})> setReaction( + int chatId, + String messageId, + String emoji, + ) async { + final id = int.tryParse(messageId); + if (id == null) return (ok: false, info: null); + final response = await _api.sendRequest(Opcode.msgReaction, { + 'chatId': chatId, + 'messageId': id, + 'reaction': {'reactionType': 'EMOJI', 'id': emoji}, + }); + return _applyReactionResponse(chatId, messageId, response); + } + + Future<({bool ok, Map? info})> cancelReaction( + int chatId, + String messageId, + ) async { + final id = int.tryParse(messageId); + if (id == null) return (ok: false, info: null); + final response = await _api.sendRequest(Opcode.msgCancelReaction, { + 'chatId': chatId, + 'messageId': id, + }); + return _applyReactionResponse(chatId, messageId, response); + } + + Future> getDetailedReactions( + int chatId, + String messageId, { + int count = 100, + }) async { + final id = int.tryParse(messageId); + if (id == null) return const {}; + if (_api.state != SessionState.online) return const {}; + try { + final response = await _api.sendRequest(Opcode.msgGetDetailedReactions, { + 'chatId': chatId, + 'messageId': id, + 'count': count, + }); + if (!response.isOk) return const {}; + final payload = response.payload; + if (payload is! Map) return const {}; + return _parseDetailedReactions(payload['reactions']); + } catch (e) { + logger.e('getDetailedReactions error: $e'); + return const {}; + } + } + + static Map _parseDetailedReactions(dynamic raw) { + if (raw is! List) return const {}; + final result = {}; + for (final entry in raw.whereType()) { + final userId = entry['userId']; + final reaction = entry['reaction']; + if (userId is! int || reaction is! String || reaction.isEmpty) continue; + result[userId] = reaction; + } + return result; + } + + Future<({bool ok, Map? info})> _applyReactionResponse( + int chatId, + String messageId, + Packet response, + ) async { + if (!response.isOk) return (ok: false, info: null); + final payload = response.payload; + final info = payload is Map + ? _normalizeReactionInfo(payload['reactionInfo']) + : null; + try { + await _persistReaction(chatId, messageId, info); + } catch (e) { + logger.w('Реакция на $messageId не сохранилась: $e'); + } + return (ok: true, info: info); + } + + static Map? _normalizeReactionInfo(dynamic raw) { + if (raw is! Map) return null; + final rawCounters = raw['counters']; + if (rawCounters is! List) return null; + final counters = >[]; + for (final c in rawCounters) { + if (c is! Map) continue; + final reaction = c['reaction']?.toString(); + if (reaction == null || reaction.isEmpty) continue; + final count = c['count']; + counters.add({'reaction': reaction, 'count': count is int ? count : 0}); + } + if (counters.isEmpty) return null; + final your = raw['yourReaction']?.toString(); + final total = raw['totalCount']; + return { + 'counters': counters, + if (your != null && your.isNotEmpty) 'yourReaction': your, + 'totalCount': total is int + ? total + : counters.fold(0, (a, b) => a + (b['count'] as int)), + }; + } + + Future _persistReaction( + int chatId, + String messageId, + Map? info, + ) async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + final existing = await AppDatabase.loadMessage( + accountId, + chatId, + messageId, + ); + if (existing == null) return; + + Map payloadMap; + final raw = existing['payload']; + if (raw is String && raw.isNotEmpty) { + try { + payloadMap = Map.from(jsonDecode(raw) as Map); + } catch (_) { + payloadMap = {}; + } + } else { + payloadMap = {}; + } + + if (info == null) { + payloadMap.remove('reactionInfo'); + } else { + payloadMap['reactionInfo'] = info; + } + + final newRow = Map.from(existing); + newRow['payload'] = jsonEncode(payloadMap); + await AppDatabase.saveMessages([newRow]); + } + + Future?> sendButtonCallback({ + required int chatId, + required String messageId, + required String callbackId, + String? payload, + }) async { + final mid = int.tryParse(messageId); + if (mid == null) return null; + + final request = { + 'chatId': chatId, + 'messageId': mid, + 'callbackId': callbackId, + 'payload': ?payload, + }; + + final response = await _api.sendRequest(Opcode.msgSendCallback, request); + if (!response.isOk) return null; + final data = response.payload; + return data is Map ? Map.from(data) : null; + } + + Future requestTranscription( + int chatId, + int messageId, + int mediaId, + ) async { + final payload = { + 'chatId': chatId, + 'messageId': messageId, + 'mediaId': mediaId, + }; + + final response = await _api.sendRequest(Opcode.audioTranscription, payload); + if (!response.isOk) return TranscriptionResult(status: -1); + + final data = response.payload; + if (data is! Map) return TranscriptionResult(status: -1); + + final transcriptionStatus = data['transcriptionStatus'] as int? ?? -1; + if (transcriptionStatus == 1) { + return TranscriptionResult( + status: 1, + text: data['transcription'] as String? ?? '', + ); + } + + return TranscriptionResult(status: transcriptionStatus); + } + + Future requestUploadUrl({int count = 1}) async { + final payload = {'count': count}; + final response = await _api.sendRequest(Opcode.fileUpload, payload); + if (!response.isOk) return null; + + final data = response.payload; + if (data is! Map) return null; + + final infoList = data['info'] as List?; + if (infoList == null || infoList.isEmpty) return null; + + final info = infoList.first; + if (info is! Map) return null; + + return FileUploadInfo( + url: info['url'] as String? ?? '', + fileId: info['fileId'] as int? ?? 0, + token: info['token'] as String? ?? '', + ); + } + + /// Returns the server-assigned message id, or null on failure. The id is + /// required to later resolve a download URL — a message still carrying its + /// local temp id resolves to messageId 0 and the server rejects it. + Future sendFileMessage( + int chatId, + int fileId, { + String? token, + String? text, + bool notify = true, + int? scheduledTime, + int maxAttempts = 20, + Duration retryDelay = const Duration(seconds: 1), + }) async { + final message = { + 'isLive': false, + 'detectShare': false, + 'elements': [], + 'cid': _nextCid(), + 'attaches': [ + if (token != null) + {'_type': 'FILE', 'token': token} + else + {'_type': 'FILE', 'fileId': fileId}, + ], + }; + if (text != null && text.isNotEmpty) message['text'] = text; + if (scheduledTime != null) { + message['delayedAttributes'] = { + 'timeToFire': scheduledTime, + 'notifySender': true, + }; + } + final payload = {'chatId': chatId, 'message': message, 'notify': notify}; + + return _sendWithNotReadyRetry( + payload: payload, + maxAttempts: maxAttempts, + retryDelay: retryDelay, + onResult: (response) => _sentMessageMap(response)?['id']?.toString(), + onExhausted: null, + ); + } + + Future requestPhotoUploadUrl({int? type}) async { + final response = await _api.sendRequest(Opcode.photoUpload, { + 'type': ?type, + 'count': 1, + }); + if (!response.isOk) return null; + final data = response.payload; + if (data is! Map) return null; + return data['url'] as String?; + } + + Future?> sendPhotoMessage( + int chatId, + List photoTokens, { + String? caption, + bool notify = true, + int? scheduledTime, + int maxAttempts = 20, + Duration retryDelay = const Duration(seconds: 1), + }) async { + final message = { + 'cid': -_nextCid(), + 'attaches': [ + for (final token in photoTokens) + {'_type': 'PHOTO', 'photoToken': token}, + ], + }; + if (caption != null && caption.isNotEmpty) message['text'] = caption; + if (scheduledTime != null) { + message['delayedAttributes'] = { + 'timeToFire': scheduledTime, + 'notifySender': true, + }; + } + final payload = {'chatId': chatId, 'message': message, 'notify': notify}; + + return _sendWithNotReadyRetry?>( + payload: payload, + maxAttempts: maxAttempts, + retryDelay: retryDelay, + onResult: _sentMessageMap, + onExhausted: null, + ); + } + + Future requestVideoUploadUrl({int type = 0}) async { + final response = await _api.sendRequest(Opcode.videoUpload, { + 'uploaderType': 0, + 'type': type, + 'count': 1, + }); + if (!response.isOk) return null; + + final data = response.payload; + if (data is! Map) return null; + + final infoList = data['info'] as List?; + if (infoList == null || infoList.isEmpty) return null; + + final info = infoList.first; + if (info is! Map) return null; + + return VideoUploadInfo( + url: info['url'] as String? ?? '', + videoId: info['videoId'] as int? ?? 0, + token: info['token'] as String? ?? '', + ); + } + + Future?> sendVideoMessage( + int chatId, + String token, { + String? caption, + bool notify = true, + int? scheduledTime, + int maxAttempts = 30, + Duration retryDelay = const Duration(seconds: 1), + }) async { + final message = { + 'isLive': false, + 'detectShare': false, + 'elements': [], + 'cid': -_nextCid(), + 'attaches': [ + {'videoType': 0, '_type': 'VIDEO', 'token': token}, + ], + }; + if (caption != null && caption.isNotEmpty) message['text'] = caption; + if (scheduledTime != null) { + message['delayedAttributes'] = { + 'timeToFire': scheduledTime, + 'notifySender': true, + }; + } + final payload = {'chatId': chatId, 'message': message, 'notify': notify}; + + return _sendWithNotReadyRetry?>( + payload: payload, + maxAttempts: maxAttempts, + retryDelay: retryDelay, + onResult: _sentMessageMap, + onExhausted: null, + ); + } + + Future requestAudioUploadUrl() async { + final response = await _api.sendRequest(Opcode.videoUpload, { + 'uploaderType': 1, + 'type': 2, + 'count': 1, + }); + if (!response.isOk) return null; + + final data = response.payload; + if (data is! Map) return null; + + final infoList = data['info'] as List?; + if (infoList == null || infoList.isEmpty) return null; + + final info = infoList.first; + if (info is! Map) return null; + + return AudioUploadInfo( + url: info['url'] as String? ?? '', + audioId: info['videoId'] as int? ?? 0, + token: info['token'] as String? ?? '', + ); + } + + Future?> sendAudioMessage( + int chatId, + String token, { + required int duration, + Uint8List? wave, + bool notify = true, + int? scheduledTime, + int maxAttempts = 30, + Duration retryDelay = const Duration(seconds: 1), + }) async { + final message = { + 'isLive': false, + 'detectShare': false, + 'elements': [], + 'cid': -_nextCid(), + 'attaches': [ + { + 'duration': duration, + '_type': 'AUDIO', + 'wave': (wave != null && wave.isNotEmpty) ? wave : Uint8List(80), + 'token': token, + }, + ], + }; + if (scheduledTime != null) { + message['delayedAttributes'] = { + 'timeToFire': scheduledTime, + 'notifySender': true, + }; + } + final payload = {'chatId': chatId, 'message': message, 'notify': notify}; + + return _sendWithNotReadyRetry?>( + payload: payload, + maxAttempts: maxAttempts, + retryDelay: retryDelay, + onResult: _sentMessageMap, + onExhausted: null, + ); + } + + Future requestVideoNoteUploadUrl() async { + final response = await _api.sendRequest(Opcode.videoUpload, { + 'uploaderType': 1, + 'type': 1, + 'count': 1, + }); + if (!response.isOk) return null; + + final data = response.payload; + if (data is! Map) return null; + + final infoList = data['info'] as List?; + if (infoList == null || infoList.isEmpty) return null; + + final info = infoList.first; + if (info is! Map) return null; + + return VideoUploadInfo( + url: info['url'] as String? ?? '', + videoId: info['videoId'] as int? ?? 0, + token: info['token'] as String? ?? '', + ); + } + + Future?> sendVideoNoteMessage( + int chatId, + String token, { + required int duration, + Uint8List? wave, + String? thumbhash, + bool notify = true, + int maxAttempts = 30, + Duration retryDelay = const Duration(seconds: 1), + }) async { + final message = { + 'isLive': false, + 'detectShare': false, + 'elements': [], + 'cid': -_nextCid(), + 'attaches': [ + { + 'duration': duration, + 'videoType': 1, + '_type': 'VIDEO', + 'wave': (wave != null && wave.isNotEmpty) ? wave : Uint8List(80), + 'token': token, + if (thumbhash != null && thumbhash.isNotEmpty) 'thumbhash': thumbhash, + }, + ], + }; + final payload = {'chatId': chatId, 'message': message, 'notify': notify}; + + return _sendWithNotReadyRetry?>( + payload: payload, + maxAttempts: maxAttempts, + retryDelay: retryDelay, + onResult: _sentMessageMap, + onExhausted: null, + ); + } + + Future?> sendLocationMessage( + int chatId, + double latitude, + double longitude, { + double zoom = 15, + bool notify = true, + }) async { + final payload = { + 'chatId': chatId, + 'message': { + 'cid': -_nextCid(), + 'attaches': [ + { + '_type': 'LOCATION', + 'latitude': latitude, + 'longitude': longitude, + 'zoom': zoom, + }, + ], + }, + 'notify': notify, + }; + + final response = await _api.sendRequest(Opcode.msgSend, payload); + return _sentMessageMap(response); + } + + Future?> sendContactMessage( + int chatId, + int contactId, { + bool notify = true, + }) async { + final payload = { + 'chatId': chatId, + 'message': { + 'cid': -_nextCid(), + 'attaches': [ + {'_type': 'CONTACT', 'contactId': contactId}, + ], + }, + 'notify': notify, + }; + + final response = await _api.sendRequest(Opcode.msgSend, payload); + return _sentMessageMap(response); + } + + static const int _pollAnonymousFlag = 4; + static const int _pollMultipleFlag = 1; + + Future?> sendPollMessage( + int chatId, + String title, + List answers, { + bool multiple = false, + bool anonymous = true, + bool notify = true, + }) async { + final settings = + (anonymous ? _pollAnonymousFlag : 0) | + (multiple ? _pollMultipleFlag : 0); + final payload = { + 'chatId': chatId, + 'message': { + 'cid': -_nextCid(), + 'attaches': [ + { + '_type': 'POLL', + 'title': title, + 'settings': settings, + 'answers': [ + for (final a in answers) {'text': a}, + ], + }, + ], + }, + 'notify': notify, + }; + + final response = await _api.sendRequest(Opcode.msgSend, payload); + return _sentMessageMap(response); + } + + void sendTyping(int chatId, String type) { + unawaited(() async { + try { + await _api.sendRequest(Opcode.msgTyping, { + 'chatId': chatId, + 'type': type, + }); + } catch (_) {} + }()); + } + + Future?> sendStickerMessage( + int chatId, + int stickerId, { + bool notify = true, + }) async { + final payload = { + 'chatId': chatId, + 'message': { + 'cid': -_nextCid(), + 'attaches': [ + {'_type': 'STICKER', 'stickerId': stickerId}, + ], + }, + 'notify': notify, + }; + + final response = await _api.sendRequest(Opcode.msgSend, payload); + return _sentMessageMap(response); + } + + Future downloadPhoto(String baseUrl, String photoToken) async { + try { + final response = await _api.sendRequest(Opcode.fileDownload, { + 'url': baseUrl, + 'token': photoToken, + }); + + if (!response.isOk) return null; + final data = response.payload; + if (data is! Map) return null; + + final content = data['content']; + if (content is Uint8List) return content; + if (content is List) return Uint8List.fromList(content); + return null; + } catch (e) { + return null; + } + } + + Future getPhotoUrl(String baseUrl, String photoToken) async { + try { + final response = await _api.sendRequest(Opcode.fileDownload, { + 'url': baseUrl, + 'token': photoToken, + }); + + if (!response.isOk) return null; + final data = response.payload; + if (data is! Map) return null; + + return data['content'] as String?; + } catch (e) { + return null; + } + } + + Future> getVideoSources({ + required String messageId, + required int chatId, + required String token, + required int videoId, + }) async { + try { + final response = await _api.sendRequest(Opcode.videoPlay, { + 'messageId': int.tryParse(messageId) ?? 0, + 'chatId': chatId, + 'token': token, + 'videoId': videoId, + }); + if (!response.isOk) return const {}; + final data = response.payload; + if (data is! Map) return const {}; + + const mp4Keys = { + 'MP4_1080': '1080p', + 'MP4_720': '720p', + 'MP4_480': '480p', + 'MP4_360': '360p', + 'MP4_240': '240p', + 'MP4_144': '144p', + }; + final sources = {}; + for (final entry in mp4Keys.entries) { + final url = data[entry.key]; + if (url is String && url.isNotEmpty) sources[entry.value] = url; + } + if (sources.isEmpty) { + final hls = data['HLS']; + if (hls is String && hls.isNotEmpty) sources['Авто'] = hls; + final external = data['EXTERNAL']; + if (external is String && external.isNotEmpty) { + sources['Источник'] = external; + } + } + return sources; + } catch (_) { + return const {}; + } + } + + Future getVideoUrl({ + required String messageId, + required int chatId, + required String token, + required int videoId, + }) async { + final sources = await getVideoSources( + messageId: messageId, + chatId: chatId, + token: token, + videoId: videoId, + ); + return sources.values.isEmpty ? null : sources.values.first; + } + + Future downloadVideo(String baseUrl, String videoToken) async { + try { + final response = await _api.sendRequest(Opcode.fileDownload, { + 'url': baseUrl, + 'token': videoToken, + }); + + if (!response.isOk) return null; + final data = response.payload; + if (data is! Map) return null; + + final content = data['content']; + if (content is Uint8List) return content; + if (content is List) return Uint8List.fromList(content); + return null; + } catch (e) { + return null; + } + } + + Future downloadFile(String baseUrl, String fileToken) async { + try { + final response = await _api.sendRequest(Opcode.fileDownload, { + 'url': baseUrl, + 'token': fileToken, + }); + + if (!response.isOk) return null; + final data = response.payload; + if (data is! Map) return null; + + final content = data['content']; + if (content is Uint8List) return content; + if (content is List) return Uint8List.fromList(content); + return null; + } catch (e) { + return null; + } + } + + Future getFileUrl({ + required String messageId, + required int chatId, + required int fileId, + }) async { + try { + final response = await _api.sendRequest(Opcode.fileDownload, { + 'fileId': fileId, + 'chatId': chatId, + 'messageId': int.tryParse(messageId) ?? 0, + 'itemType': 'REGULAR', + }); + + if (!response.isOk) return null; + final data = response.payload; + if (data is! Map) return null; + + return data['url'] as String?; + } catch (e) { + return null; + } + } + + Future searchContactById(int contactId) async { + final cached = ContactCache.get(contactId); + if (cached != null) return cached; + + if (_api.state != SessionState.online) return null; + + try { + final response = await _api.sendRequest(Opcode.contactInfo, { + 'contactIds': [contactId], + }); + + if (!response.isOk) return null; + final data = response.payload; + if (data is! Map) return null; + + final contacts = data['contacts'] as List?; + if (contacts != null && contacts.isNotEmpty) { + final contact = contacts.first; + if (contact is Map) { + final names = contact['names'] as List?; + if (names != null && names.isNotEmpty) { + final name = names.first; + if (name is Map) { + final firstName = name['firstName'] as String? ?? ''; + final lastName = name['lastName'] as String?; + final fullName = lastName != null + ? '$firstName $lastName' + : firstName; + ContactCache.put(contactId, fullName); + + final baseUrl = contact['baseUrl'] as String?; + ContactCache.putAvatar(contactId, baseUrl); + + final rawOpts = contact['options']; + if (rawOpts is List) { + ContactCache.putOptions( + contactId, + rawOpts.whereType().toSet(), + ); + } + + chats.applyContactUpdate(contactId); + return fullName; + } + } + } + } + } catch (e) { + logger.e('searchContactById error: $e'); + } + return null; + } + + Future ensureContactNames(Iterable ids) async { + final missing = ids + .where((id) => id != 0 && ContactCache.get(id) == null) + .toSet(); + if (missing.isEmpty) return false; + if (_api.state != SessionState.online) return false; + + try { + final response = await _api.sendRequest(Opcode.contactInfo, { + 'contactIds': missing.toList(), + }); + + if (!response.isOk) return false; + final data = response.payload; + if (data is! Map) return false; + + final contacts = data['contacts']; + if (contacts is! List) return false; + + var resolvedAny = false; + for (final raw in contacts.whereType()) { + final id = raw['id']; + if (id is! int) continue; + + final names = raw['names']; + if (names is List && names.isNotEmpty) { + final nameRaw = names.firstWhere( + (n) => n is Map && n['type'] == 'ONEME', + orElse: () => names.firstWhere((n) => n is Map, orElse: () => null), + ); + if (nameRaw is Map) { + final firstName = (nameRaw['firstName'] as String?) ?? ''; + final lastName = nameRaw['lastName'] as String?; + final fullName = (lastName != null && lastName.isNotEmpty) + ? '$firstName $lastName' + : firstName; + if (fullName.isNotEmpty) ContactCache.put(id, fullName); + } + } + + final baseUrl = raw['baseUrl'] as String?; + if (baseUrl != null && baseUrl.isNotEmpty) { + ContactCache.putAvatar(id, baseUrl); + } + + final rawOpts = raw['options']; + if (rawOpts is List) { + ContactCache.putOptions(id, rawOpts.whereType().toSet()); + } + + chats.applyContactUpdate(id); + resolvedAny = true; + } + + return resolvedAny; + } catch (e) { + logger.e('ensureContactNames error: $e'); + return false; + } + } +} diff --git a/lib/backend/modules/outbox.dart b/lib/backend/modules/outbox.dart new file mode 100644 index 0000000..2ab6541 --- /dev/null +++ b/lib/backend/modules/outbox.dart @@ -0,0 +1,149 @@ +import 'dart:async'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../core/crypto/message_decryption_cache.dart'; +import '../../core/storage/app_database.dart'; +import '../../core/storage/token_storage.dart'; +import '../../core/utils/logger.dart'; +import '../api.dart'; +import 'chats.dart'; +import 'messages.dart'; +import 'outbox_queue.dart'; +import 'outgoing_text.dart'; + +class OutboxService { + OutboxService._(); + + static final OutboxService instance = OutboxService._(); + OutboxQueue? _queue; + StreamSubscription? _subscription; + final Map _latest = {}; + + void init( + Api api, + MessagesModule messages, { + required bool Function() isAuthenticated, + }) { + if (_queue != null) { + unawaited(flush()); + return; + } + _queue = OutboxQueue( + activeAccount: TokenStorage.getActiveAccountId, + isOnline: () => api.state == SessionState.online && isAuthenticated(), + sessionEpoch: () => api.sessionEpoch, + load: (accountId) async => (await AppDatabase.loadPendingMessages( + accountId, + )).map(CachedMessage.fromDbRow).toList(), + save: (message) => AppDatabase.saveMessages([message.toDbRow()]), + updatePending: (message) => AppDatabase.replacePendingMessage( + message.toDbRow(), + pendingId: message.id, + ), + replace: (before, after) async { + if (!await AppDatabase.replacePendingMessage( + after.toDbRow(), + pendingId: before.id, + )) { + throw const OutboxCancelled(); + } + }, + send: (message, cid) async { + final epoch = api.sessionEpoch; + final preferences = await SharedPreferences.getInstance(); + final stored = await AppDatabase.loadMessage( + message.accountId, + message.chatId, + message.id, + ); + if (stored == null || + stored['deleted'] != 0 || + stored['status'] != 'pending') { + throw const OutboxCancelled(); + } + void checkSession() { + if (api.sessionEpoch != epoch || + !isAuthenticated() || + preferences.getString('active_account_id') != + message.accountId.toString()) { + throw StateError('Outbox account changed before dispatch'); + } + } + + checkSession(); + return messages.sendMessage( + message.accountId, + message.chatId, + message.text!, + clientId: cid, + replyToMessageId: OutgoingText.replyId(message), + replySourceChatId: OutgoingText.replyChatId(message), + elements: OutgoingText.elements(message), + beforeSend: checkSession, + ); + }, + onChanged: (before, after) { + _latest[before.id] = after; + if (_latest.length > 256) _latest.remove(_latest.keys.first); + MessageDecryptionCache.instance.adopt(before.id, after.id); + chats.emitMessageSent(after.chatId, before.id, after); + unawaited(_updatePreview(after)); + }, + onError: (error, stack) => logger.w('Outbox: $error'), + ); + _subscription = api.stateStream.listen((state) { + if (state == SessionState.online) unawaited(flush()); + }); + unawaited(flush()); + } + + Future enqueue( + CachedMessage message, { + bool createDialog = false, + String? dialogTitle, + String? dialogIconUrl, + }) async { + final queue = _queue; + if (queue == null) throw StateError('Outbox is not initialized'); + await queue.enqueue( + message, + persist: createDialog + ? (message) => AppDatabase.saveOutgoingMessage( + message.toDbRow(), + createDialog: true, + dialogTitle: dialogTitle, + dialogIconUrl: dialogIconUrl, + ) + : null, + ); + await _updatePreview(message); + return _latest.remove(message.id) ?? message; + } + + Future _updatePreview(CachedMessage message) async { + try { + await chats.applyOutgoing( + message.accountId, + message.chatId, + messageId: message.id, + time: message.time, + text: message.text ?? '', + status: message.status!, + elements: OutgoingText.elements(message), + ); + } catch (error) { + logger.w('Outbox preview: $error'); + } + } + + Future flush() => _queue?.flush() ?? Future.value(); + + String resolveMessageId(String id) => _latest[id]?.id ?? id; + + Future dispose() async { + _queue?.dispose(); + _queue = null; + await _subscription?.cancel(); + _subscription = null; + } +} diff --git a/lib/backend/modules/outbox_queue.dart b/lib/backend/modules/outbox_queue.dart new file mode 100644 index 0000000..d4cc4b0 --- /dev/null +++ b/lib/backend/modules/outbox_queue.dart @@ -0,0 +1,199 @@ +import 'dart:async'; + +import '../../core/protocol/packet.dart'; +import 'messages.dart'; +import 'outgoing_text.dart'; + +/// The user removed or changed an item after a flush loaded its snapshot. +class OutboxCancelled implements Exception { + const OutboxCancelled(); +} + +class OutboxQueue { + OutboxQueue({ + required this.activeAccount, + required this.isOnline, + required this.sessionEpoch, + required this.load, + required this.save, + required this.updatePending, + required this.replace, + required this.send, + required this.onChanged, + required this.onError, + DateTime Function()? now, + this.retryBase = const Duration(seconds: 2), + }) : now = now ?? DateTime.now; + + final Future Function() activeAccount; + final bool Function() isOnline; + final int Function() sessionEpoch; + final Future> Function(int) load; + final Future Function(CachedMessage) save; + final Future Function(CachedMessage) updatePending; + final Future Function(CachedMessage, CachedMessage) replace; + final Future Function(CachedMessage, int) send; + final void Function(CachedMessage, CachedMessage) onChanged; + final void Function(Object, StackTrace) onError; + final DateTime Function() now; + final Duration retryBase; + final Map _receipts = {}; + Timer? _timer; + Future? _running; + bool _wakeAgain = false; + bool _disposed = false; + + Future enqueue( + CachedMessage message, { + Future Function(CachedMessage)? persist, + }) async { + if (OutgoingText.metadata(message) == null) { + throw ArgumentError('Only prepared outgoing text may be queued'); + } + await (persist ?? save)(message); + } + + Future flush() { + if (_disposed) return Future.value(); + final running = _running; + if (running != null) { + _wakeAgain = true; + return running; + } + _timer?.cancel(); + return _running = _flush().whenComplete(() { + _running = null; + if (_wakeAgain && !_disposed) { + _wakeAgain = false; + unawaited(flush()); + } + }); + } + + Future _validSession(int accountId, int epoch) async { + final active = await activeAccount(); + return !_disposed && + active == accountId && + isOnline() && + sessionEpoch() == epoch; + } + + Future _flush() async { + if (!isOnline()) return; + final epoch = sessionEpoch(); + try { + final accountId = await activeAccount(); + if (accountId == null || !await _validSession(accountId, epoch)) return; + final rows = await load(accountId); + final blockedChats = {}; + for (final pending in rows) { + if (!await _validSession(accountId, epoch)) break; + if (pending.accountId != accountId || + pending.deleted || + blockedChats.contains(pending.chatId)) { + continue; + } + final meta = OutgoingText.metadata(pending); + if (meta == null || pending.text == null || pending.text!.isEmpty) { + final failed = pending.copyWith(status: 'error'); + if (!await updatePending(failed)) continue; + if (await _validSession(accountId, epoch)) _notify(pending, failed); + continue; + } + final retryAt = meta['retryAt'] as int? ?? 0; + final remaining = retryAt - now().millisecondsSinceEpoch; + if (remaining > 0) { + blockedChats.add(pending.chatId); + _schedule(Duration(milliseconds: remaining)); + continue; + } + final receiptKey = + '${pending.accountId}/${pending.chatId}/${pending.id}'; + try { + var sent = _receipts[receiptKey]; + if (sent == null) { + final id = await send(pending, meta['cid'] as int); + if (id.isEmpty) throw StateError('Missing server message ID'); + sent = CachedMessage( + id: id, + accountId: pending.accountId, + chatId: pending.chatId, + senderId: pending.senderId, + text: pending.text, + time: pending.time, + status: 'sent', + payload: pending.payload, + sealedText: pending.sealedText, + e2ee: pending.e2ee, + ); + _receipts[receiptKey] = sent; + } + await replace(pending, sent); + _receipts.remove(receiptKey); + if (await _validSession(accountId, epoch)) _notify(pending, sent); + } catch (error, stack) { + if (error is OutboxCancelled) { + _receipts.remove(receiptKey); + continue; + } + onError(error, stack); + if (!await _validSession(accountId, epoch)) return; + if (isPermanentSendFailure(error)) { + final failed = pending.withSendFailure(error); + if (!await updatePending(failed)) continue; + if (await _validSession(accountId, epoch)) _notify(pending, failed); + continue; + } + final attempts = ((meta['attempts'] as int? ?? 0) + 1).clamp(1, 30); + final delay = Duration( + milliseconds: + (retryBase.inMilliseconds * (1 << (attempts - 1).clamp(0, 5))) + .clamp(1, 60000), + ); + final queued = pending.copyWith( + status: 'pending', + payload: { + ...?pending.payload, + '_outbox': { + ...meta, + 'attempts': attempts, + 'retryAt': now().add(delay).millisecondsSinceEpoch, + }, + }, + ); + if (!await updatePending(queued)) continue; + if (await _validSession(accountId, epoch)) _notify(pending, queued); + blockedChats.add(pending.chatId); + _schedule(delay); + } + } + } catch (error, stack) { + onError(error, stack); + _schedule(retryBase); + } + } + + void _notify(CachedMessage before, CachedMessage after) { + try { + onChanged(before, after); + } catch (error, stack) { + onError(error, stack); + } + } + + DateTime? _scheduledAt; + + void _schedule(Duration delay) { + if (_disposed) return; + final at = now().add(delay); + if (_timer?.isActive == true && _scheduledAt!.isBefore(at)) return; + _timer?.cancel(); + _scheduledAt = at; + _timer = Timer(delay, () => unawaited(flush())); + } + + void dispose() { + _disposed = true; + _timer?.cancel(); + } +} diff --git a/lib/backend/modules/outgoing_text.dart b/lib/backend/modules/outgoing_text.dart new file mode 100644 index 0000000..b268322 --- /dev/null +++ b/lib/backend/modules/outgoing_text.dart @@ -0,0 +1,82 @@ +import 'dart:typed_data'; + +import 'messages.dart'; + +class OutgoingText { + static int _lastCid = 0; + + static int nextCid() { + final now = DateTime.now().microsecondsSinceEpoch; + _lastCid = now > _lastCid ? now : _lastCid + 1; + return -_lastCid; + } + + static CachedMessage prepare({ + required int accountId, + required int chatId, + required String wireText, + required bool encrypted, + Map? payload, + Uint8List? sealedText, + int e2ee = CachedMessage.e2eeNone, + }) { + if (accountId == 0 || wireText.isEmpty) { + throw ArgumentError('An account and prepared text are required'); + } + final cid = nextCid(); + return CachedMessage( + id: 'temp_outbox_${-cid}', + accountId: accountId, + chatId: chatId, + senderId: accountId, + text: wireText, + time: DateTime.now().millisecondsSinceEpoch, + status: 'pending', + sealedText: sealedText, + e2ee: e2ee, + payload: { + ...?payload, + '_outbox': { + 'version': 1, + 'cid': cid, + 'encrypted': encrypted, + 'attempts': 0, + 'retryAt': 0, + }, + }, + ); + } + + static Map? metadata(CachedMessage message) { + final raw = message.payload?['_outbox']; + if (raw is! Map || + raw['version'] != 1 || + raw['cid'] is! int || + (raw['cid'] as int) >= 0 || + raw['encrypted'] is! bool) { + return null; + } + return Map.from(raw); + } + + static List> elements(CachedMessage message) => + (message.payload?['elements'] as List? ?? const []) + .whereType() + .map((e) => Map.from(e)) + .toList(); + + static int? replyId(CachedMessage message) { + final link = message.payload?['link']; + if (link is! Map || link['type'] != 'REPLY') return null; + final original = link['message']; + return _int(original is Map ? original['id'] : link['messageId']); + } + + static int? replyChatId(CachedMessage message) { + final link = message.payload?['link']; + return link is Map && link['type'] == 'REPLY' ? _int(link['chatId']) : null; + } + + static int? _int(Object? value) => + value is int ? value : int.tryParse(value?.toString() ?? ''); +} diff --git a/lib/backend/modules/polls.dart b/lib/backend/modules/polls.dart new file mode 100644 index 0000000..3abc8cb --- /dev/null +++ b/lib/backend/modules/polls.dart @@ -0,0 +1,93 @@ +import 'package:flutter/foundation.dart'; + +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/utils/logger.dart'; +import '../../models/poll.dart'; + +class PollsModule extends ChangeNotifier { + final Api _api; + + PollsModule(this._api); + + final Map _cache = {}; + final Set _inFlight = {}; + + Poll? get(int pollId) => _cache[pollId]; + + Future fetch( + int chatId, + String messageId, + int pollId, { + bool force = false, + }) async { + if (pollId == 0) return; + if (!force && (_cache.containsKey(pollId) || _inFlight.contains(pollId))) { + return; + } + _inFlight.add(pollId); + try { + final mid = int.tryParse(messageId) ?? 0; + final response = await _api.sendRequest(Opcode.getPollUpdates, { + 'chatId': chatId, + 'polls': [ + {'messageId': mid, 'pollId': pollId}, + ], + }); + if (!response.isOk) return; + + final data = response.payload; + if (data is! Map) return; + + final polls = data['polls']; + if (polls is! List) return; + + var changed = false; + for (final p in polls) { + if (p is Map) { + final poll = Poll.fromServerMap(p); + if (poll.pollId != 0) { + _cache[poll.pollId] = poll; + changed = true; + } + } + } + if (changed) notifyListeners(); + } catch (e) { + logger.w('PollsModule.fetch: pollId=$pollId chatId=$chatId $e'); + } finally { + _inFlight.remove(pollId); + } + } + + Future vote( + int chatId, + String messageId, + int pollId, + List answersIds, + ) async { + try { + final response = await _api.sendRequest(Opcode.sendVote, { + 'messageId': int.tryParse(messageId) ?? 0, + 'chatId': chatId, + 'pollId': pollId, + 'answersIds': answersIds, + }); + if (!response.isOk) return false; + + final data = response.payload; + final state = data is Map ? data['state'] : null; + final cached = _cache[pollId]; + if (state is Map && cached != null) { + _cache[pollId] = cached.withStateMap(state); + notifyListeners(); + } else { + await fetch(chatId, messageId, pollId, force: true); + } + return true; + } catch (e) { + logger.w('PollsModule.vote: pollId=$pollId chatId=$chatId $e'); + return false; + } + } +} diff --git a/lib/backend/modules/self_check.dart b/lib/backend/modules/self_check.dart new file mode 100644 index 0000000..074a494 --- /dev/null +++ b/lib/backend/modules/self_check.dart @@ -0,0 +1,54 @@ +import 'dart:async'; + +import '../../core/cache/info_cache.dart'; +import '../../core/cache/self_presence.dart'; +import '../../core/config/qlyra_settings.dart'; +import '../../core/storage/token_storage.dart'; +import '../../core/utils/logger.dart'; +import '../api.dart'; + +class SelfCheckService { + SelfCheckService._(); + + static final SelfCheckService instance = SelfCheckService._(); + + static const Duration interval = Duration(seconds: 10); + + Api? _api; + Timer? _timer; + + void init(Api api) { + if (_api != null) return; + _api = api; + _timer?.cancel(); + _timer = Timer.periodic(interval, (_) => unawaited(_check())); + } + + void checkNow() => unawaited(_check()); + + void pause() { + _timer?.cancel(); + _timer = null; + } + + void resume() { + if (_api == null || _timer != null) return; + _timer = Timer.periodic(interval, (_) => unawaited(_check())); + checkNow(); + } + + Future _check() async { + final api = _api; + if (api == null || api.state != SessionState.online) return; + if (!QlyraSettings.selfOnlineCheck.value) return; + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + final presence = await PresenceFetch.get(accountId, forceRefresh: true); + logger.i('[SELF CHECK] id=$accountId presence=$presence'); + if (presence == null) return; + SelfPresence.applySelfCheck( + online: presence['status'] == 1, + seenSeconds: presence['seen'] is int ? presence['seen'] as int : null, + ); + } +} diff --git a/lib/backend/modules/share_sender.dart b/lib/backend/modules/share_sender.dart new file mode 100644 index 0000000..62bb477 --- /dev/null +++ b/lib/backend/modules/share_sender.dart @@ -0,0 +1,568 @@ +import 'dart:async'; +import 'dart:io'; + +import '../../core/media/gallery_source.dart'; +import '../../core/media/share_thumbnail.dart'; +import '../../core/media/video_transcoder.dart'; +import '../../core/share/shared_file_uploads.dart'; +import '../../core/storage/app_database.dart'; +import '../../core/utils/logger.dart'; +import '../../models/attachment.dart'; +import '../../models/chat_preview_media.dart'; +import '../../models/shared_payload.dart'; +import '../../main.dart'; +import 'chats.dart'; +import 'messages.dart'; +import 'upload_service.dart'; + +class PreparedShareFile { + final SharedFile source; + final String? thumbDataUri; + final int? width; + final int? height; + final int? durationMs; + + const PreparedShareFile({ + required this.source, + this.thumbDataUri, + this.width, + this.height, + this.durationMs, + }); + + SharedFileKind get kind => source.kind; + File get file => source.file; +} + +class PreparedShare { + final List files; + final String? text; + + const PreparedShare({required this.files, this.text}); + + List get photos => + files.where((f) => f.kind == SharedFileKind.photo).toList(); + + List get videos => + files.where((f) => f.kind == SharedFileKind.video).toList(); + + List get documents => + files.where((f) => f.kind == SharedFileKind.file).toList(); + + bool get isTextOnly => files.isEmpty; + + static Future prepare(SharedPayload payload) async { + final prepared = []; + for (final source in payload.files) { + prepared.add(await _prepareOne(source)); + } + return PreparedShare(files: prepared, text: payload.text); + } + + static Future _prepareOne(SharedFile source) async { + final thumb = await sharedThumbnailDataUri(source); + switch (source.kind) { + case SharedFileKind.photo: + final dim = await imageFileDimensions(source.file); + return PreparedShareFile( + source: source, + thumbDataUri: thumb, + width: dim?.$1, + height: dim?.$2, + ); + case SharedFileKind.video: + VideoInfo? info; + try { + info = await VideoTranscoder.probe(source.path); + } catch (e) { + logger.w('Поделиться: probe ${source.path}: $e'); + } + return PreparedShareFile( + source: source, + thumbDataUri: thumb, + width: (info?.width ?? 0) > 0 ? info!.width : null, + height: (info?.height ?? 0) > 0 ? info!.height : null, + durationMs: (info?.durationMs ?? 0) > 0 ? info!.durationMs : null, + ); + case SharedFileKind.file: + return PreparedShareFile(source: source); + } + } +} + +class ShareSendResult { + final int chatCount; + final int messageCount; + + const ShareSendResult({required this.chatCount, required this.messageCount}); +} + +class ShareSender { + ShareSender._(); + + static final Map< + String, + ({int accountId, int chatId, String text, String? preview, int time}) + > + _tracked = {}; + + static StreamSubscription? _sub; + + static void _track( + String tempId, { + required int accountId, + required int chatId, + required String text, + required String? preview, + required int time, + }) { + _sub ??= UploadService.instance.events.listen(_onUploadEvent); + _tracked[tempId] = ( + accountId: accountId, + chatId: chatId, + text: text, + preview: preview, + time: time, + ); + } + + static void _onUploadEvent(UploadJobEvent event) { + final entry = _tracked.remove(event.tempId); + if (entry == null) return; + final status = event is UploadJobDone ? 'sent' : 'error'; + final messageId = event is UploadJobDone + ? (event.message?.id ?? event.tempId) + : event.tempId; + unawaited( + chats + .applyOutgoing( + entry.accountId, + entry.chatId, + messageId: messageId, + time: entry.time, + text: entry.text, + status: status, + preview: entry.preview, + ) + .catchError((Object e) { + logger.w('Поделиться: не обновить превью чата ${entry.chatId}: $e'); + }), + ); + } + + static Future send({ + required int accountId, + required List chatIds, + required PreparedShare share, + required String caption, + }) async { + return SharedFileUploads.instance.use( + share.files.map((file) => file.file.path), + () async { + var messages = 0; + for (final chatId in chatIds) { + messages += await _sendToChat( + accountId: accountId, + chatId: chatId, + share: share, + caption: caption, + ); + } + logger.i( + 'Поделиться: подготовлено $messages сообщений в ${chatIds.length} чатов', + ); + return ShareSendResult( + chatCount: chatIds.length, + messageCount: messages, + ); + }, + ); + } + + static Future _sendToChat({ + required int accountId, + required int chatId, + required PreparedShare share, + required String caption, + }) async { + if (share.isTextOnly) { + final text = caption.trim().isNotEmpty + ? caption + : (share.text ?? '').trim(); + if (text.isEmpty) return 0; + await _sendText(accountId: accountId, chatId: chatId, text: text); + return 1; + } + + final photos = share.photos; + final videos = share.videos; + final documents = share.documents; + + var used = false; + String take() { + if (used || caption.isEmpty) return ''; + used = true; + return caption; + } + + var count = 0; + if (photos.isNotEmpty) { + await _sendPhotos( + accountId: accountId, + chatId: chatId, + photos: photos, + caption: take(), + ); + count++; + } + for (final video in videos) { + await _sendVideo( + accountId: accountId, + chatId: chatId, + video: video, + caption: take(), + ); + count++; + } + for (final document in documents) { + await _sendDocument( + accountId: accountId, + chatId: chatId, + document: document, + caption: take(), + ); + count++; + } + return count; + } + + static Future _sendText({ + required int accountId, + required int chatId, + required String text, + }) async { + final now = DateTime.now().millisecondsSinceEpoch; + final tempId = UploadService.instance.newTempId(); + final placeholder = CachedMessage( + id: tempId, + accountId: accountId, + chatId: chatId, + senderId: accountId, + text: text, + time: now, + status: 'sending', + ); + await _persist(placeholder); + await _bumpChat( + accountId: accountId, + chatId: chatId, + messageId: tempId, + time: now, + text: text, + preview: null, + status: 'sending', + ); + + String realId = tempId; + var status = 'sent'; + String? failure; + try { + final sent = await messagesModule.sendMessage(accountId, chatId, text); + if (sent.isNotEmpty) realId = sent; + } catch (e) { + logger.w('Поделиться: текст в $chatId не ушёл: $e'); + status = 'error'; + failure = e.toString(); + } + final settled = CachedMessage( + id: realId, + accountId: accountId, + chatId: chatId, + senderId: accountId, + text: text, + time: now, + status: status, + payload: failure == null ? null : {'_sendFailure': failure}, + ); + await _persist(settled, removeId: realId == tempId ? null : tempId); + await _bumpChat( + accountId: accountId, + chatId: chatId, + messageId: realId, + time: now, + text: text, + preview: null, + status: status, + ); + } + + static Future _sendPhotos({ + required int accountId, + required int chatId, + required List photos, + required String caption, + }) async { + final now = DateTime.now().millisecondsSinceEpoch; + final tempId = UploadService.instance.newTempId(); + + final jobs = <({File file, GalleryItem? item})>[]; + final attachments = []; + for (final photo in photos) { + jobs.add((file: photo.file, item: null)); + attachments.add( + PhotoAttachment( + localPath: photo.file.path, + previewData: photo.thumbDataUri, + width: photo.width, + height: photo.height, + ), + ); + } + if (jobs.isEmpty) return; + + final label = photos.length > 1 ? 'Изображения' : 'Изображение'; + final preview = _preview( + kind: ChatPreviewKind.photo, + files: photos, + label: caption.isEmpty ? label : null, + ); + final placeholder = CachedMessage( + id: tempId, + accountId: accountId, + chatId: chatId, + senderId: accountId, + text: caption.isEmpty ? null : caption, + time: now, + status: 'sending', + attachments: attachments, + ); + + await _persist(placeholder); + final text = caption.isEmpty ? label : caption; + await _bumpChat( + accountId: accountId, + chatId: chatId, + messageId: tempId, + time: now, + text: text, + preview: preview, + status: 'sending', + ); + _track( + tempId, + accountId: accountId, + chatId: chatId, + text: text, + preview: preview, + time: now, + ); + + unawaited( + SharedFileUploads.instance.use( + jobs.map((job) => job.file.path), + () => UploadService.instance.sendPhotos( + accountId: accountId, + chatId: chatId, + tempId: tempId, + jobs: jobs, + caption: caption, + placeholder: placeholder, + ), + ), + ); + } + + static Future _sendVideo({ + required int accountId, + required int chatId, + required PreparedShareFile video, + required String caption, + }) async { + final now = DateTime.now().millisecondsSinceEpoch; + final tempId = UploadService.instance.newTempId(); + + final preview = _preview( + kind: ChatPreviewKind.video, + files: [video], + label: caption.isEmpty ? 'Видео' : null, + ); + final placeholder = CachedMessage( + id: tempId, + accountId: accountId, + chatId: chatId, + senderId: accountId, + text: caption.isEmpty ? null : caption, + time: now, + status: 'sending', + attachments: [ + VideoAttachment( + localPath: video.file.path, + previewData: video.thumbDataUri, + width: video.width, + height: video.height, + duration: video.durationMs, + ), + ], + ); + + await _persist(placeholder); + final text = caption.isEmpty ? 'Видео' : caption; + await _bumpChat( + accountId: accountId, + chatId: chatId, + messageId: tempId, + time: now, + text: text, + preview: preview, + status: 'sending', + ); + _track( + tempId, + accountId: accountId, + chatId: chatId, + text: text, + preview: preview, + time: now, + ); + + unawaited( + SharedFileUploads.instance.use( + [video.file.path], + () => UploadService.instance.sendVideo( + accountId: accountId, + chatId: chatId, + tempId: tempId, + file: video.file, + caption: caption, + placeholder: placeholder, + ), + ), + ); + } + + static Future _sendDocument({ + required int accountId, + required int chatId, + required PreparedShareFile document, + required String caption, + }) async { + final now = DateTime.now().millisecondsSinceEpoch; + final tempId = UploadService.instance.newTempId(); + final name = document.source.name; + final size = document.source.size; + + final preview = ChatPreviewMedia( + kind: ChatPreviewKind.file, + label: caption.isEmpty ? 'Файл' : null, + detail: caption.isEmpty ? name : null, + ).encode(); + final placeholder = CachedMessage( + id: tempId, + accountId: accountId, + chatId: chatId, + senderId: accountId, + text: caption.isEmpty ? null : caption, + time: now, + status: 'sending', + attachments: [FileAttachment(name: name, size: size)], + ); + + await _persist(placeholder); + final text = caption.isEmpty ? 'Файл: $name' : caption; + await _bumpChat( + accountId: accountId, + chatId: chatId, + messageId: tempId, + time: now, + text: text, + preview: preview, + status: 'sending', + ); + _track( + tempId, + accountId: accountId, + chatId: chatId, + text: text, + preview: preview, + time: now, + ); + + unawaited( + SharedFileUploads.instance.use( + [document.file.path], + () => UploadService.instance.sendFile( + accountId: accountId, + chatId: chatId, + tempId: tempId, + source: document.file, + filename: name, + size: size, + placeholder: placeholder, + ), + ), + ); + } + + static String? _preview({ + required ChatPreviewKind kind, + required List files, + String? label, + }) { + final thumbs = []; + for (final file in files) { + final data = file.thumbDataUri; + if (data == null) continue; + thumbs.add( + ChatPreviewThumb( + source: data, + video: file.kind == SharedFileKind.video, + ), + ); + if (thumbs.length >= 3) break; + } + if (thumbs.isEmpty && label == null) return null; + return ChatPreviewMedia(kind: kind, thumbs: thumbs, label: label).encode(); + } + + static Future _persist( + CachedMessage message, { + String? removeId, + }) async { + try { + if (removeId != null && removeId != message.id) { + await AppDatabase.deleteMessage( + message.accountId, + message.chatId, + removeId, + ); + } + await AppDatabase.saveMessages([message.toDbRow()]); + } catch (e) { + logger.w('Поделиться: не сохранить плейсхолдер: $e'); + } + } + + static Future _bumpChat({ + required int accountId, + required int chatId, + required String messageId, + required int time, + required String text, + required String? preview, + required String status, + }) async { + try { + await chats.applyOutgoing( + accountId, + chatId, + messageId: messageId, + time: time, + text: text, + status: status, + preview: preview, + ); + } catch (e) { + logger.w('Поделиться: не обновить строку чата $chatId: $e'); + } + } +} diff --git a/lib/backend/modules/shared_content.dart b/lib/backend/modules/shared_content.dart new file mode 100644 index 0000000..b2e962a --- /dev/null +++ b/lib/backend/modules/shared_content.dart @@ -0,0 +1,340 @@ +import '../../core/protocol/opcode_map.dart'; +import '../../core/utils/logger.dart'; +import '../../models/attachment.dart'; +import '../api.dart'; + +const Map _attachTypeByName = { + 'PHOTO': AttachmentType.photo, + 'VIDEO': AttachmentType.video, + 'AUDIO': AttachmentType.audio, + 'FILE': AttachmentType.file, + 'SHARE': AttachmentType.share, +}; + +class SharedMediaItem { + final String messageId; + final int chatId; + final int senderId; + final int time; + final MessageAttachment attachment; + final String? text; + + const SharedMediaItem({ + required this.messageId, + required this.chatId, + required this.senderId, + required this.time, + required this.attachment, + this.text, + }); + + String get dedupKey { + final a = attachment; + final String tail; + if (a is PhotoAttachment) { + tail = 'p${a.photoId ?? a.baseUrl}'; + } else if (a is VideoAttachment) { + tail = 'v${a.videoId ?? a.baseUrl}'; + } else if (a is FileAttachment) { + tail = 'f${a.fileId ?? a.name}'; + } else if (a is AudioAttachment) { + tail = 'a${a.audioId ?? a.fileUrl}'; + } else if (a is ShareAttachment) { + tail = 's${a.shareId ?? a.url}'; + } else { + tail = a.hashCode.toString(); + } + return '$messageId:$tail'; + } +} + +class SharedMediaPage { + final List items; + final int total; + + const SharedMediaPage({required this.items, required this.total}); + + static const empty = SharedMediaPage(items: [], total: 0); +} + +class CommonChatEntry { + final int id; + final String type; + final String title; + final String? iconUrl; + final int participantsCount; + final List participantIds; + + const CommonChatEntry({ + required this.id, + required this.type, + required this.title, + required this.iconUrl, + required this.participantsCount, + required this.participantIds, + }); + + factory CommonChatEntry.fromMap(Map map) { + final participants = map['participants']; + final ids = []; + if (participants is Map) { + for (final key in participants.keys) { + final id = key is int ? key : int.tryParse(key.toString()); + if (id != null) ids.add(id); + } + } + return CommonChatEntry( + id: (map['id'] as num?)?.toInt() ?? 0, + type: map['type']?.toString() ?? 'CHAT', + title: map['title']?.toString() ?? '', + iconUrl: map['baseIconUrl'] as String?, + participantsCount: + (map['participantsCount'] as num?)?.toInt() ?? ids.length, + participantIds: ids, + ); + } +} + +class ChatMediaFeed { + final List items; + final int total; + final bool reachedEnd; + + const ChatMediaFeed({ + required this.items, + required this.total, + required this.reachedEnd, + }); +} + +class _ChatMediaIndex { + final List items = []; + final Set seen = {}; + int total = 0; + bool reachedEnd = false; + bool started = false; + Future? inFlight; +} + +String mediaDedupKey(String messageId, MessageAttachment attachment) { + if (attachment is PhotoAttachment) { + return '$messageId:p${attachment.photoId ?? attachment.baseUrl}'; + } + if (attachment is VideoAttachment) { + return '$messageId:v${attachment.videoId ?? attachment.baseUrl}'; + } + return '$messageId:${attachment.hashCode}'; +} + +class SharedContentModule { + static const int _mediaIndexPageSize = 60; + static const int _mediaIndexMaxPages = 40; + + static final Map _mediaIndexes = {}; + + final Api _api; + + SharedContentModule(this._api); + + static void clearMediaIndex() => _mediaIndexes.clear(); + + Future mediaFeedFor({ + required int chatId, + required String mediaKey, + required Future Function() resolveAnchor, + }) async { + final index = _mediaIndexes.putIfAbsent(chatId, _ChatMediaIndex.new); + + for (var page = 0; page < _mediaIndexMaxPages; page++) { + if (index.seen.contains(mediaKey)) return _snapshot(index); + if (index.reachedEnd) return null; + await _nextMediaPage(chatId, index, resolveAnchor); + } + return null; + } + + Future loadMoreMedia({ + required int chatId, + required Future Function() resolveAnchor, + }) async { + final index = _mediaIndexes.putIfAbsent(chatId, _ChatMediaIndex.new); + if (!index.reachedEnd) { + await _nextMediaPage(chatId, index, resolveAnchor); + } + return _snapshot(index); + } + + ChatMediaFeed _snapshot(_ChatMediaIndex index) { + final counted = index.items.length; + final total = index.reachedEnd + ? counted + : (index.total > counted ? index.total : counted); + return ChatMediaFeed( + items: List.unmodifiable(index.items), + total: total, + reachedEnd: index.reachedEnd, + ); + } + + Future _nextMediaPage( + int chatId, + _ChatMediaIndex index, + Future Function() resolveAnchor, + ) async { + final pending = index.inFlight; + if (pending != null) { + await pending; + return; + } + final task = _loadMediaPage(chatId, index, resolveAnchor); + index.inFlight = task; + try { + await task; + } finally { + index.inFlight = null; + } + } + + Future _loadMediaPage( + int chatId, + _ChatMediaIndex index, + Future Function() resolveAnchor, + ) async { + final initial = !index.started; + final anchor = initial ? await resolveAnchor() : index.items.last.messageId; + if (anchor == null || anchor.isEmpty) { + index.reachedEnd = true; + return; + } + + final page = await fetchMedia( + chatId: chatId, + anchorMessageId: anchor, + attachTypes: const ['PHOTO', 'VIDEO'], + forward: initial ? _mediaIndexPageSize : 0, + backward: _mediaIndexPageSize, + ); + index.started = true; + if (page.total > index.total) index.total = page.total; + + final fresh = []; + for (final item in page.items) { + if (index.seen.add(item.dedupKey)) fresh.add(item); + } + if (fresh.isEmpty) { + index.reachedEnd = true; + return; + } + + final oldest = index.items.isEmpty ? null : index.items.last; + index.items.addAll(fresh); + if (oldest != null && fresh.first.time > oldest.time) { + sortMediaNewestFirst(index.items); + } + } + + Future fetchMedia({ + required int chatId, + required String anchorMessageId, + required List attachTypes, + int forward = 0, + int backward = 60, + }) async { + try { + final response = await _api.sendRequest(Opcode.chatMedia, { + 'chatId': chatId, + 'messageId': int.tryParse(anchorMessageId) ?? 0, + 'attachTypes': attachTypes, + 'forward': forward, + 'backward': backward, + }); + if (!response.isOk) return SharedMediaPage.empty; + + final data = response.payload; + if (data is! Map) return SharedMediaPage.empty; + + final messages = data['messages']; + if (messages is! List) return SharedMediaPage.empty; + + final wanted = attachTypes + .map((t) => _attachTypeByName[t]) + .whereType() + .toSet(); + + final out = []; + for (final m in messages) { + if (m is! Map) continue; + final map = Map.from(m); + final id = map['id']?.toString(); + if (id == null) continue; + final sender = (map['sender'] as num?)?.toInt() ?? 0; + final time = (map['time'] as num?)?.toInt() ?? 0; + final text = map['text'] as String?; + final attaches = map['attaches']; + if (attaches is! List) continue; + for (final a in attaches) { + if (a is! Map) continue; + final att = MessageAttachment.fromMap(Map.from(a)); + if (!wanted.contains(att.type)) continue; + out.add( + SharedMediaItem( + messageId: id, + chatId: chatId, + senderId: sender, + time: time, + attachment: att, + text: text, + ), + ); + } + } + + sortMediaNewestFirst(out); + final total = (data['total'] as num?)?.toInt() ?? out.length; + return SharedMediaPage(items: out, total: total); + } catch (e) { + logger.w('SharedContent.fetchMedia failed: $e'); + return SharedMediaPage.empty; + } + } + + Future> fetchCommonChats(int userId) async { + try { + final response = await _api.sendRequest( + Opcode.chatSearchCommonParticipants, + { + 'userIds': [userId], + }, + ); + if (!response.isOk) return const []; + + final data = response.payload; + if (data is! Map) return const []; + + final chats = data['commonChats']; + if (chats is! List) return const []; + + final out = []; + for (final c in chats) { + if (c is Map) { + out.add(CommonChatEntry.fromMap(Map.from(c))); + } + } + return out; + } catch (e) { + logger.w('SharedContent.fetchCommonChats failed: $e'); + return const []; + } + } +} + +void sortMediaNewestFirst(List items) { + final ordered = [for (var i = 0; i < items.length; i++) (i, items[i])]; + ordered.sort((a, b) { + final byTime = b.$2.time.compareTo(a.$2.time); + return byTime != 0 ? byTime : a.$1.compareTo(b.$1); + }); + for (var i = 0; i < ordered.length; i++) { + items[i] = ordered[i].$2; + } +} diff --git a/lib/backend/modules/stickers.dart b/lib/backend/modules/stickers.dart new file mode 100644 index 0000000..1634786 --- /dev/null +++ b/lib/backend/modules/stickers.dart @@ -0,0 +1,316 @@ +import 'dart:math'; + +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; +import '../../core/utils/logger.dart'; +import '../../models/sticker.dart'; + +class StickersModule { + final Api _api; + + StickersModule(this._api) { + _api.pushStream + .where((p) => p.opcode == Opcode.notifAssetsUpdate) + .listen(_handleAssetsPush); + } + + final Map _sets = {}; + final Map _stickers = {}; + List _orderedSetIds = []; + List _favoriteSetIds = []; + List _recentStickerIds = []; + + Future? _loading; + Future? _favoritesLoading; + final Random _random = Random(); + + List get sets => + _orderedSetIds.map((id) => _sets[id]).whereType().toList(); + + List get favoriteSetIds => _favoriteSetIds; + List get recentStickerIds => _recentStickerIds; + StickerItem? cachedSticker(int id) => _stickers[id]; + StickerSet? cachedSet(int id) => _sets[id]; + bool isFavorite(int setId) => _favoriteSetIds.contains(setId); + + Future ensureLoaded() { + return _loading ??= _loadSections().catchError((Object e) { + _loading = null; + throw e; + }); + } + + Future ensureFavoritesLoaded() { + return _favoritesLoading ??= _loadFavorites().catchError((Object e) { + _favoritesLoading = null; + throw e; + }); + } + + Future _loadFavorites() async { + final favIds = []; + final fav = await _api.sendRequestMap(Opcode.assetsUpdate, { + 'type': 'FAVORITE_STICKER', + 'sync': 0, + }); + if (fav != null) { + final sections = fav['sections']; + if (sections is List) { + for (final s in sections) { + if (s is Map && s['id'] == 'FAVORITE_STICKER_SETS') { + _appendIntList(favIds, s['stickerSets']); + } + } + } + } + _favoriteSetIds = favIds; + } + + Future _loadSections() async { + final newSetIds = []; + int marker = 0; + + final stickerData = await _api.sendRequestMap(Opcode.assetsUpdate, { + 'type': 'STICKER', + 'sync': 0, + }); + if (stickerData != null) { + final sections = stickerData['sections']; + if (sections is List) { + for (final s in sections) { + if (s is! Map) continue; + if (s['id'] == 'NEW_STICKER_SETS') { + _appendIntList(newSetIds, s['stickerSets']); + final m = s['marker']; + if (m is int) marker = m; + } else if (s['type'] == 'RECENTS') { + _parseRecents(s['recentsList']); + } + } + } + } + + var guard = 0; + while (marker != 0 && guard < 50) { + guard++; + final page = await _api.sendRequestMap(Opcode.assetsGet, { + 'sectionId': 'NEW_STICKER_SETS', + 'from': marker, + 'count': 100, + }); + if (page == null) break; + final before = newSetIds.length; + _appendIntList(newSetIds, page['stickerSets']); + if (newSetIds.length == before) break; + final m = page['marker']; + marker = m is int ? m : 0; + } + + await ensureFavoritesLoaded(); + + final ordered = []; + final seen = {}; + for (final id in [..._favoriteSetIds, ...newSetIds]) { + if (seen.add(id)) ordered.add(id); + } + _orderedSetIds = ordered; + logger.i( + 'Стикеры: ${ordered.length} паков, ${_recentStickerIds.length} недавних', + ); + + await _ensureSetMetas(ordered); + } + + Future _fetchAndCache({ + required String type, + required List ids, + required String listKey, + required T Function(Map) fromMap, + required Map cache, + }) async { + final missing = ids.where((id) => !cache.containsKey(id)).toList(); + for (final batch in _chunk(missing, 100)) { + final map = await _api.sendRequestMap(Opcode.assetsGetByIds, { + 'type': type, + 'ids': batch, + }); + if (map == null) continue; + final list = map[listKey]; + if (list is! List) continue; + for (final e in list) { + if (e is Map && e['id'] is int) { + cache[e['id'] as int] = fromMap(e); + } + } + } + } + + Future _ensureSetMetas(List ids) => _fetchAndCache( + type: 'STICKER_SET', + ids: ids, + listKey: 'stickerSets', + fromMap: StickerSet.fromMap, + cache: _sets, + ); + + Future> ensureStickers(List stickerIds) async { + await _fetchAndCache( + type: 'STICKER', + ids: stickerIds, + listKey: 'stickers', + fromMap: StickerItem.fromMap, + cache: _stickers, + ); + return stickerIds + .map((id) => _stickers[id]) + .whereType() + .toList(); + } + + Future ensureSet(int setId) async { + await _ensureSetMetas([setId]); + return _sets[setId]; + } + + Future ensureAllStickersLoaded() { + final ids = {..._recentStickerIds}; + for (final set in _sets.values) { + ids.addAll(set.stickerIds); + } + return ensureStickers(ids.toList()); + } + + List searchByTags(Set emojiTargets) { + if (emojiTargets.isEmpty) return const []; + final result = []; + for (final sticker in _stickers.values) { + for (final tag in sticker.tags) { + if (emojiTargets.contains(_stripVariation(tag))) { + result.add(sticker); + break; + } + } + } + return result; + } + + static String _stripVariation(String s) => s.replaceAll('️', ''); + + static const String greetingEmoji = '👋'; + static const int greetingSetScanLimit = 5; + + Future randomGreetingSticker(List welcomeIds) async { + if (welcomeIds.isNotEmpty) { + final welcome = await ensureStickers(welcomeIds); + final animated = welcome.where((s) => s.isAnimated).toList(); + final pool = animated.isNotEmpty ? animated : welcome; + if (pool.isNotEmpty) return pool[_random.nextInt(pool.length)]; + } + final cached = _animatedGreetings(); + if (cached.isNotEmpty) return cached[_random.nextInt(cached.length)]; + await ensureLoaded(); + final shuffled = List.of(sets)..shuffle(_random); + for (final set in shuffled.take(greetingSetScanLimit)) { + await ensureStickers(set.stickerIds); + final found = _animatedGreetings(); + if (found.isNotEmpty) return found[_random.nextInt(found.length)]; + } + return null; + } + + List _animatedGreetings() => [ + for (final sticker in _stickers.values) + if (sticker.isAnimated && + sticker.tags.any((tag) => tag.contains(greetingEmoji))) + sticker, + ]; + + void cacheSet(StickerSet set) => _sets[set.id] = set; + + Future resolveSetByLink(String link) async { + final map = await _api.sendRequestMap(Opcode.linkInfo, {'link': link}); + if (map == null) return null; + final raw = map['stickerSet']; + if (raw is! Map || raw['id'] is! int) return null; + final set = StickerSet.fromMap(raw); + _sets[set.id] = set; + return set; + } + + Future resolveSetId(int stickerId) async { + await ensureStickers([stickerId]); + return _stickers[stickerId]?.setId; + } + + Future favoriteSet(int setId) async { + final map = await _api.sendRequestMap(Opcode.assetsAdd, { + 'type': 'FAVORITE_STICKER_SET', + 'id': setId, + }); + final ok = map != null && map['success'] == true; + if (ok) _markFavorite(setId, true); + return ok; + } + + Future unfavoriteSet(int setId) async { + final map = await _api.sendRequestMap(Opcode.assetsRemove, { + 'type': 'FAVORITE_STICKER_SET', + 'ids': [setId], + }); + final ok = map != null && map['success'] == true; + if (ok) _markFavorite(setId, false); + return ok; + } + + void _handleAssetsPush(Packet push) { + final payload = push.payload; + if (payload is! Map) return; + if (payload['type'] != 'FAVORITE_STICKER_SET') return; + final id = payload['id']; + if (id is! int) return; + switch (payload['updateType']) { + case 'ADDED': + _markFavorite(id, true); + case 'REMOVED': + _markFavorite(id, false); + } + } + + void _markFavorite(int setId, bool favorite) { + if (favorite) { + if (!_favoriteSetIds.contains(setId)) { + _favoriteSetIds = [setId, ..._favoriteSetIds]; + } + } else { + if (_favoriteSetIds.contains(setId)) { + _favoriteSetIds = _favoriteSetIds.where((id) => id != setId).toList(); + } + } + } + + void _parseRecents(dynamic list) { + if (list is! List) return; + final ids = []; + for (final e in list) { + if (e is Map && e['type'] == 'STICKER') { + final sid = e['stickerId'] ?? e['id']; + if (sid is int) ids.add(sid); + } + } + _recentStickerIds = ids; + } + + void _appendIntList(List target, dynamic raw) { + if (raw is! List) return; + for (final e in raw) { + if (e is int) target.add(e); + } + } + + Iterable> _chunk(List list, int size) sync* { + for (var i = 0; i < list.length; i += size) { + yield list.sublist(i, i + size > list.length ? list.length : i + size); + } + } +} diff --git a/lib/backend/modules/stories.dart b/lib/backend/modules/stories.dart new file mode 100644 index 0000000..daa6cc0 --- /dev/null +++ b/lib/backend/modules/stories.dart @@ -0,0 +1,553 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; + +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; +import '../../core/storage/app_database.dart'; +import '../../core/storage/token_storage.dart'; +import '../../core/utils/logger.dart'; +import '../../models/story.dart'; +import '../api.dart'; + +/// Работа с «Историями»: лента-кольца, полные истории владельца, отметка +/// просмотра и реакции. Кэшируется в SQLite (превью, полные истории и позиция +/// просмотра) — переживает перезапуск; истёкшие кольца отсеиваются при загрузке. +class StoriesModule { + StoriesModule(this._api); + + static const _previewsKey = 'stories_previews'; + static const _peersKey = 'stories_peers'; + static const _progressKey = 'stories_progress'; + + final Api _api; + + final Map _previews = {}; + final Map> _peerStories = {}; + + /// ownerId → storyId, на котором пользователь остановил просмотр. + final Map _lastViewed = {}; + + int? _accountId; + + StreamSubscription? _pushSub; + + /// Бампается при любом изменении лент/историй — UI слушает и перечитывает. + final ValueNotifier storiesChanged = ValueNotifier(0); + + void _bump() => storiesChanged.value++; + + Future _acc() async { + _accountId ??= await TokenStorage.getActiveAccountId(); + return _accountId; + } + + int _nowMs() => DateTime.now().millisecondsSinceEpoch; + + int _normMs(int t) => t <= 0 ? 0 : (t < 1000000000000 ? t * 1000 : t); + + // ── Кэш (SQLite) ─────────────────────────────────────────────────────── + + /// Загружает кэш из БД (превью/истории/позиции) и показывает мгновенно, + /// до сетевого ответа. Истёкшие кольца отбрасываются. + Future loadCache() async { + final acc = await _acc(); + if (acc == null) return; + try { + final rawPreviews = await AppDatabase.getSyncValue(acc, _previewsKey); + if (rawPreviews != null && rawPreviews.isNotEmpty) { + final list = jsonDecode(rawPreviews); + final now = _nowMs(); + if (list is List) { + for (final raw in list) { + final preview = StoryPreview.fromMap(raw); + if (preview == null || preview.isEmpty) continue; + final exp = _normMs(preview.lastStoryExpirationTime); + if (exp != 0 && exp < now) continue; + // Не затираем уже загруженные из сети (более свежие) кольца. + _previews.putIfAbsent(preview.owner.ownerId, () => preview); + } + } + } + + final rawPeers = await AppDatabase.getSyncValue(acc, _peersKey); + if (rawPeers != null && rawPeers.isNotEmpty) { + final map = jsonDecode(rawPeers); + if (map is Map) { + map.forEach((key, value) { + final ownerId = int.tryParse(key.toString()); + if (ownerId == null || value is! List) return; + if (!_previews.containsKey(ownerId)) return; + if (_peerStories.containsKey(ownerId)) return; + final stories = []; + for (final s in value) { + final story = Story.fromMap(s); + if (story != null) stories.add(story); + } + if (stories.isNotEmpty) _peerStories[ownerId] = stories; + }); + } + } + + final rawProgress = await AppDatabase.getSyncValue(acc, _progressKey); + if (rawProgress != null && rawProgress.isNotEmpty) { + final map = jsonDecode(rawProgress); + if (map is Map) { + map.forEach((key, value) { + final ownerId = int.tryParse(key.toString()); + final storyId = value is int ? value : int.tryParse('$value'); + if (ownerId != null && storyId != null) { + _lastViewed.putIfAbsent(ownerId, () => storyId); + } + }); + } + } + _bump(); + } catch (e) { + logger.w('StoriesModule.loadCache: $e'); + } + } + + Future _persistPreviews() async { + final acc = await _acc(); + if (acc == null) return; + final list = _previews.values.map((p) => p.toJson()).toList(); + await AppDatabase.setSyncValue(acc, _previewsKey, jsonEncode(list)); + } + + Future _persistPeers() async { + final acc = await _acc(); + if (acc == null) return; + final map = {}; + _peerStories.forEach((ownerId, stories) { + if (!_previews.containsKey(ownerId)) return; + map['$ownerId'] = stories.map((s) => s.toJson()).toList(); + }); + await AppDatabase.setSyncValue(acc, _peersKey, jsonEncode(map)); + } + + Future _persistProgress() async { + final acc = await _acc(); + if (acc == null) return; + final map = {}; + _lastViewed.forEach((ownerId, storyId) => map['$ownerId'] = storyId); + await AppDatabase.setSyncValue(acc, _progressKey, jsonEncode(map)); + } + + // ── Позиция просмотра ────────────────────────────────────────────────── + + /// Запоминает, что у [ownerId] пользователь остановился на [storyId]. + void setLastViewed(int ownerId, int storyId) { + if (storyId == 0 || _lastViewed[ownerId] == storyId) return; + _lastViewed[ownerId] = storyId; + unawaited(_persistProgress()); + } + + int? lastViewedStoryId(int ownerId) => _lastViewed[ownerId]; + + void clearLastViewed(int ownerId) { + if (_lastViewed.remove(ownerId) == null) return; + unawaited(_persistProgress()); + } + + /// Кольца-превью, отсортированные: сначала непрочитанные, затем по времени. + List get previews { + final list = _previews.values.where((p) => !p.isEmpty).toList(); + list.sort((a, b) { + if (a.hasUnread != b.hasUnread) return a.hasUnread ? -1 : 1; + return b.updateTime.compareTo(a.updateTime); + }); + return list; + } + + bool get hasAny => previews.isNotEmpty; + + StoryPreview? previewFor(int ownerId) => _previews[ownerId]; + + final Map _peerPreviews = {}; + final Set _requestedOwners = {}; + + static const int _ownersChunk = 20; + + StoryPreview? previewOf(int ownerId) { + final feed = _previews[ownerId]; + if (feed != null) return feed.isEmpty ? null : feed; + final peer = _peerPreviews[ownerId]; + return (peer == null || peer.isEmpty) ? null : peer; + } + + Future loadOwnersPreviews(List ownerIds) async { + if (_api.state != SessionState.online) return; + final missing = ownerIds + .where((id) => id > 0 && !_requestedOwners.contains(id)) + .toSet() + .toList(); + if (missing.isEmpty) return; + _requestedOwners.addAll(missing); + + var changed = false; + for (var i = 0; i < missing.length; i += _ownersChunk) { + final end = i + _ownersChunk > missing.length + ? missing.length + : i + _ownersChunk; + final chunk = missing.sublist(i, end); + try { + final packet = await _api.sendRequest(Opcode.storiesGetByOwner, { + 'owners': [for (final id in chunk) StoryOwner(ownerId: id).toMap()], + }, silent: true); + if (packet.isError) continue; + if (_applyOwnerPayload(packet.payload, chunk)) changed = true; + } catch (e) { + logger.w('StoriesModule.loadOwnersPreviews: $e'); + } + } + if (changed) _bump(); + } + + bool _applyOwnerPayload(Object? data, List requested) { + if (data is! Map) return false; + var changed = false; + final seen = {}; + final rawPreviews = data['storiesPreviews']; + if (rawPreviews is List) { + for (final raw in rawPreviews) { + final preview = StoryPreview.fromMap(raw); + if (preview == null) continue; + final id = preview.owner.ownerId; + seen.add(id); + if (preview.isEmpty) { + _peerPreviews.remove(id); + } else { + _peerPreviews[id] = preview; + } + _refreshPreview(preview); + changed = true; + } + } + for (final id in requested) { + if (!seen.contains(id) && _peerPreviews.remove(id) != null) { + changed = true; + } + } + final rawPeers = data['peerStories']; + if (rawPeers is List) { + for (final raw in rawPeers) { + final peer = PeerStories.fromMap(raw); + if (peer == null) continue; + _peerStories[peer.owner.ownerId] = peer.stories; + changed = true; + } + } + return changed; + } + + List? cachedStories(int ownerId) => _peerStories[ownerId]; + + /// Подписка на серверные пуши обновления колец (NOTIF_STORIES_UPDATE). + void attach() { + _pushSub ??= _api.pushStream + .where((p) => p.opcode == Opcode.notifStoriesUpdate) + .listen(_onPush); + } + + void _onPush(Packet packet) { + final payload = packet.payload; + if (payload is! Map) return; + final preview = StoryPreview.fromMap(payload['storiesPreview']); + if (preview == null) return; + _applyPreview(preview); + _bump(); + unawaited(_persistPreviews()); + } + + void _refreshPreview(StoryPreview preview) { + if (!_previews.containsKey(preview.owner.ownerId)) return; + _applyPreview(preview); + } + + void _applyPreview(StoryPreview preview) { + if (preview.isEmpty) { + _previews.remove(preview.owner.ownerId); + _peerStories.remove(preview.owner.ownerId); + } else { + _previews[preview.owner.ownerId] = preview; + } + } + + /// Первая страница ленты историй. Возвращает false при ошибке/оффлайне. + Future loadFeed({int count = 20}) async { + if (_api.state != SessionState.online) return false; + try { + final packet = await _api.sendRequest(Opcode.storiesList, { + 'cursor': '', + 'count': count, + }); + throwIfPacketError(packet); + final data = packet.payload; + if (data is! Map) return false; + final rawPreviews = data['storiesPreviews']; + if (rawPreviews is List) { + _previews.clear(); + for (final raw in rawPreviews) { + final preview = StoryPreview.fromMap(raw); + if (preview != null) _applyPreview(preview); + } + } + _bump(); + unawaited(_persistPreviews()); + return true; + } catch (e) { + logger.w('StoriesModule.loadFeed: $e'); + return false; + } + } + + /// Полные истории владельца. Обновляет кэш и кольцо, возвращает список. + Future> getByOwner(StoryOwner owner) async { + if (_api.state != SessionState.online) { + return _peerStories[owner.ownerId] ?? const []; + } + try { + final packet = await _api.sendRequest(Opcode.storiesGetByOwner, { + 'owners': [owner.toMap()], + }); + throwIfPacketError(packet); + final data = packet.payload; + if (data is! Map) return _peerStories[owner.ownerId] ?? const []; + + final rawPreviews = data['storiesPreviews']; + if (rawPreviews is List) { + for (final raw in rawPreviews) { + final preview = StoryPreview.fromMap(raw); + if (preview != null) _refreshPreview(preview); + } + } + + final rawPeers = data['peerStories']; + List result = const []; + if (rawPeers is List) { + for (final raw in rawPeers) { + final peer = PeerStories.fromMap(raw); + if (peer == null) continue; + _peerStories[peer.owner.ownerId] = peer.stories; + if (peer.owner.ownerId == owner.ownerId) result = peer.stories; + } + } + _bump(); + unawaited(_persistPreviews()); + unawaited(_persistPeers()); + return result; + } catch (e) { + logger.w('StoriesModule.getByOwner: $e'); + return _peerStories[owner.ownerId] ?? const []; + } + } + + Future loadOwnerPreview(StoryOwner owner) async { + final cached = _previews[owner.ownerId]; + if (_api.state != SessionState.online) return cached; + try { + final packet = await _api.sendRequest(Opcode.storiesGetByOwner, { + 'owners': [owner.toMap()], + }); + throwIfPacketError(packet); + final data = packet.payload; + if (data is! Map) return cached; + + _applyOwnerPayload(data, [owner.ownerId]); + _requestedOwners.add(owner.ownerId); + final own = previewOf(owner.ownerId); + + _bump(); + unawaited(_persistPreviews()); + unawaited(_persistPeers()); + return own; + } catch (e) { + logger.w('StoriesModule.loadOwnerPreview: $e'); + return cached; + } + } + + /// Отметить историю просмотренной. Оптимистично поднимает readCount кольца. + Future mark(StoryOwner owner, int storyId) async { + if (_api.state != SessionState.online) return false; + try { + final ok = await _api.sendRequestOk(Opcode.storiesMark, { + 'owner': owner.toMap(), + 'storyId': storyId, + }); + if (ok) _markReadLocally(owner.ownerId); + return ok; + } catch (e) { + logger.w('StoriesModule.mark: $e'); + return false; + } + } + + void _markReadLocally(int ownerId) { + final preview = _previews[ownerId]; + if (preview == null) return; + if (preview.readCount >= preview.totalCount) return; + _previews[ownerId] = preview.copyWith(readCount: preview.readCount + 1); + _bump(); + unawaited(_persistPreviews()); + } + + /// Публикация фото-истории. [photoToken] — токен уже загруженного фото. + /// [settings]: 1 = видно всем, 2 = только контактам. [expiration] — TTL, мс. + /// Бросает [PacketError]/[TimeoutException] при ошибке сервера — чтобы UI + /// показал реальную причину, а не общее «не удалось». + Future publishPhoto({ + required String photoToken, + int settings = 1, + int expiration = 86400000, + }) { + return _publishMedia( + media: {'_type': 'PHOTO', 'photoToken': photoToken}, + settings: settings, + expiration: expiration, + ); + } + + /// Публикация видео-истории. [videoToken] — токен уже загруженного видео + /// (`VideoUploadInfo.token`), [durationMs] — длительность ролика в мс. + Future publishVideo({ + required String videoToken, + int? durationMs, + int settings = 1, + int expiration = 86400000, + }) { + return _publishMedia( + media: { + '_type': 'VIDEO', + 'videoType': 2, + 'token': videoToken, + 'duration': ?durationMs, + }, + settings: settings, + expiration: expiration, + ); + } + + Future _publishMedia({ + required Map media, + required int settings, + required int expiration, + }) async { + if (_api.state != SessionState.online) { + throw const PacketError('Нет соединения с сервером'); + } + final cid = DateTime.now().millisecondsSinceEpoch; + final packet = await _api.sendRequest(Opcode.storiesSend, { + 'stories': [ + { + 'cid': cid, + 'settings': settings, + 'media': media, + 'expiration': expiration, + }, + ], + }); + throwIfPacketError(packet); + final data = packet.payload; + if (data is Map) { + final preview = StoryPreview.fromMap(data['storiesPreview']); + if (preview != null) _applyPreview(preview); + final rawStories = data['stories']; + if (rawStories is List) { + for (final raw in rawStories) { + final story = Story.fromMap(raw); + if (story == null) continue; + final list = _peerStories.putIfAbsent( + story.owner.ownerId, + () => [], + ); + list.add(story); + } + } + _bump(); + unawaited(_persistPreviews()); + unawaited(_persistPeers()); + } + } + + // #***! удаление своих историй: storyIds списком, как шлёт веб + static Map deletePayload(List storyIds) => { + 'storyIds': storyIds, + }; + + /// Deletes the user's own stories and drops them from the cache. + /// Throws [PacketError]/[TimeoutException] so the UI can show the reason. + Future deleteStories(StoryOwner owner, List storyIds) async { + if (storyIds.isEmpty) return; + if (_api.state != SessionState.online) { + throw const PacketError('Нет соединения с сервером'); + } + final packet = await _api.sendRequest( + Opcode.storiesDelete, + deletePayload(storyIds), + ); + throwIfPacketError(packet); + removeStoriesLocally(owner.ownerId, storyIds); + } + + // #***! убираем истории из кэша, кольцо пересчитываем, пустое выкидываем + void removeStoriesLocally(int ownerId, List storyIds) { + final ids = storyIds.toSet(); + final stories = _peerStories[ownerId]; + var removed = ids.length; + if (stories != null) { + final before = stories.length; + final left = stories.where((s) => !ids.contains(s.id)).toList(); + removed = before - left.length; + if (left.isEmpty) { + _peerStories.remove(ownerId); + } else { + _peerStories[ownerId] = left; + } + } + StoryPreview? shrink(StoryPreview? preview) { + if (preview == null) return null; + final total = math.max(0, preview.totalCount - removed); + return preview.copyWith( + totalCount: total, + readCount: math.min(preview.readCount, total), + ); + } + + final feed = shrink(_previews[ownerId]); + if (feed != null) _applyPreview(feed); + final peer = shrink(_peerPreviews[ownerId]); + if (peer != null) { + if (peer.isEmpty) { + _peerPreviews.remove(ownerId); + } else { + _peerPreviews[ownerId] = peer; + } + } + final lastViewed = _lastViewed[ownerId]; + if (lastViewed != null && ids.contains(lastViewed)) { + clearLastViewed(ownerId); + } + _bump(); + unawaited(_persistPreviews()); + unawaited(_persistPeers()); + } + + // #***! сброс при смене аккаунта + void clear() { + _previews.clear(); + _peerStories.clear(); + _lastViewed.clear(); + _accountId = null; + _bump(); + } + + void dispose() { + _pushSub?.cancel(); + _pushSub = null; + storiesChanged.dispose(); + } +} diff --git a/lib/backend/modules/upload_notification_service.dart b/lib/backend/modules/upload_notification_service.dart new file mode 100644 index 0000000..cc36df6 --- /dev/null +++ b/lib/backend/modules/upload_notification_service.dart @@ -0,0 +1,209 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../main.dart' show QlyraApp; + +enum UploadKind { photo, video, videoNote, voice, file } + +class _NotificationJob { + _NotificationJob({required this.kind, required this.count, this.filename}); + + final UploadKind kind; + final int count; + final String? filename; + + int sent = 0; + int total = 0; + double fraction = 0; + int speedBps = 0; + + int _windowSent = 0; + int _windowAt = DateTime.now().millisecondsSinceEpoch; + + void report(int sentBytes, int totalBytes, double jobFraction) { + sent = sentBytes; + total = totalBytes; + fraction = jobFraction.clamp(0.0, 1.0); + + final now = DateTime.now().millisecondsSinceEpoch; + final elapsed = now - _windowAt; + if (elapsed < 500) return; + final delta = sent - _windowSent; + speedBps = delta <= 0 ? 0 : (delta * 1000 / elapsed).round(); + _windowSent = sent; + _windowAt = now; + } + + String label(AppLocalizations l10n) { + final name = filename; + return switch (kind) { + UploadKind.photo => l10n.uploadNotificationPhotos(count), + UploadKind.video => l10n.uploadNotificationVideo, + UploadKind.videoNote => l10n.uploadNotificationVideoNote, + UploadKind.voice => l10n.uploadNotificationVoice, + UploadKind.file => + name == null || name.isEmpty ? l10n.uploadNotificationFile : name, + }; + } +} + +class UploadNotificationService { + static const MethodChannel _channel = MethodChannel( + 'ru.ikar.app/upload_service', + ); + static const int _minIntervalMs = 350; + static const Duration _startDelay = Duration(milliseconds: 700); + + static final Map _jobs = {}; + static Timer? _startTimer; + static bool _running = false; + static String? _lastTitle; + static String? _lastBody; + static int _lastPercent = -1; + static int _lastPushAt = 0; + + static bool get _enabled => + !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + + static void begin( + String id, { + required UploadKind kind, + int count = 1, + String? filename, + }) { + if (!_enabled) return; + _jobs[id] = _NotificationJob( + kind: kind, + count: count < 1 ? 1 : count, + filename: filename, + ); + if (_running) { + _push(force: true); + return; + } + _startTimer ??= Timer(_startDelay, () { + _startTimer = null; + _push(force: true); + }); + } + + static void report( + String id, { + required int sent, + required int total, + required double fraction, + }) { + if (!_enabled) return; + final job = _jobs[id]; + if (job == null) return; + job.report(sent, total, fraction); + _push(); + } + + static void end(String id) { + if (!_enabled) return; + if (_jobs.remove(id) == null) return; + if (_jobs.isEmpty) { + _stop(); + return; + } + _push(force: true); + } + + static void _stop() { + _startTimer?.cancel(); + _startTimer = null; + final wasRunning = _running; + _running = false; + _lastTitle = null; + _lastBody = null; + _lastPercent = -1; + _lastPushAt = 0; + if (wasRunning) _invoke('stop', const {}); + } + + static void _push({bool force = false}) { + if (_jobs.isEmpty) return; + if (!_running && _startTimer != null) return; + + var sumSent = 0; + var sumTotal = 0; + var sumSpeed = 0; + var fractionSum = 0.0; + var sizesKnown = true; + for (final job in _jobs.values) { + sumSent += job.sent; + sumTotal += job.total; + sumSpeed += job.speedBps; + fractionSum += job.fraction; + if (job.total <= 0) sizesKnown = false; + } + + final fraction = sizesKnown && sumTotal > 0 + ? sumSent / sumTotal + : fractionSum / _jobs.length; + final percent = (fraction * 100).round().clamp(0, 100); + + final l10n = _localizations(); + final title = _jobs.length == 1 + ? _jobs.values.first.label(l10n) + : l10n.uploadNotificationMultiple(_jobs.length); + final body = percent <= 0 && sumSpeed <= 0 + ? l10n.uploadNotificationPreparing + : sumSpeed > 0 + ? '$percent% · ${_formatSpeed(l10n, sumSpeed)}' + : '$percent%'; + + final now = DateTime.now().millisecondsSinceEpoch; + final changed = + title != _lastTitle || body != _lastBody || percent != _lastPercent; + if (!force && (!changed || now - _lastPushAt < _minIntervalMs)) return; + + _lastTitle = title; + _lastBody = body; + _lastPercent = percent; + _lastPushAt = now; + + final args = { + 'title': title, + 'body': body, + 'progress': percent, + 'indeterminate': percent <= 0 && sumSpeed <= 0, + }; + if (_running) { + _invoke('update', args); + return; + } + _running = true; + _invoke('start', args); + } + + static String _formatSpeed(AppLocalizations l10n, int bps) { + if (bps < 1024) return l10n.uploadSpeedBytes('$bps'); + if (bps < 1024 * 1024) return l10n.uploadSpeedKb('${(bps / 1024).round()}'); + return l10n.uploadSpeedMb((bps / (1024 * 1024)).toStringAsFixed(1)); + } + + static AppLocalizations _localizations() { + final context = QlyraApp.navigatorKey.currentContext; + if (context != null) { + final scoped = Localizations.of( + context, + AppLocalizations, + ); + if (scoped != null) return scoped; + } + final code = WidgetsBinding.instance.platformDispatcher.locale.languageCode; + return lookupAppLocalizations(Locale(code == 'ru' ? 'ru' : 'en')); + } + + static Future _invoke(String method, Map args) async { + try { + await _channel.invokeMethod(method, args); + } catch (_) {} + } +} diff --git a/lib/backend/modules/upload_service.dart b/lib/backend/modules/upload_service.dart new file mode 100644 index 0000000..6145ddc --- /dev/null +++ b/lib/backend/modules/upload_service.dart @@ -0,0 +1,714 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import '../../core/cache/message_session_cache.dart'; +import '../../core/media/gallery_source.dart'; +import '../../core/media/image_optimizer.dart'; +import '../../core/storage/app_database.dart'; +import '../../core/utils/logger.dart'; +import '../../main.dart' show fileUploader, messagesModule; +import '../../models/attachment.dart'; +import 'chats.dart' show chats; +import 'file_uploader.dart'; +import 'messages.dart'; +import 'upload_notification_service.dart'; + +export 'upload_notification_service.dart' show UploadKind; + +sealed class UploadJobEvent { + const UploadJobEvent({ + required this.chatId, + required this.tempId, + required this.kind, + required this.scheduled, + }); + + final int chatId; + final String tempId; + final UploadKind kind; + final bool scheduled; +} + +class UploadJobDone extends UploadJobEvent { + const UploadJobDone({ + required super.chatId, + required super.tempId, + required super.kind, + required super.scheduled, + this.message, + this.scheduledTime, + this.fileId, + this.fileToken, + }); + + final CachedMessage? message; + final int? scheduledTime; + final int? fileId; + final String? fileToken; +} + +class UploadJobFailed extends UploadJobEvent { + const UploadJobFailed({ + required super.chatId, + required super.tempId, + required super.kind, + required super.scheduled, + required this.reason, + }); + + final String reason; +} + +class UploadFailure implements Exception { + const UploadFailure(this.message); + + final String message; + + @override + String toString() => 'UploadFailure($message)'; +} + +class UploadBytes { + const UploadBytes(this.sent, this.total); + + final int sent; + final int total; +} + +class UploadJob { + UploadJob._({ + required this.id, + required this.accountId, + required this.chatId, + required this.kind, + required int slots, + this.filename, + this.totalBytes = 0, + this.placeholder, + this.scheduledTime, + }) : _slots = slots < 1 ? 1 : slots, + _sent = List.filled(slots < 1 ? 1 : slots, 0), + _total = List.filled(slots < 1 ? 1 : slots, 0), + progress = ValueNotifier>( + List.filled(slots < 1 ? 1 : slots, 0), + ), + bytes = ValueNotifier(UploadBytes(0, totalBytes)) { + if (_slots == 1 && totalBytes > 0) _total[0] = totalBytes; + } + + final String id; + final int accountId; + final int chatId; + final UploadKind kind; + final String? filename; + final int totalBytes; + final CachedMessage? placeholder; + final int? scheduledTime; + + final ValueNotifier> progress; + final ValueNotifier bytes; + + int? resultFileId; + String? resultFileToken; + + final int _slots; + final List _sent; + final List _total; + + bool get scheduled => scheduledTime != null; + int get slots => _slots; + + void report(int slot, int sent, int total) { + if (slot < 0 || slot >= _slots) return; + _sent[slot] = sent; + if (total > 0) _total[slot] = total; + _publish(); + } + + void resetSlot(int slot) { + if (slot < 0 || slot >= _slots) return; + _sent[slot] = 0; + _publish(); + } + + void markUploaded() { + for (var i = 0; i < _slots; i++) { + if (_total[i] <= 0) _total[i] = _sent[i] > 0 ? _sent[i] : 1; + _sent[i] = _total[i]; + } + _publish(); + } + + void _publish() { + final fractions = List.generate(_slots, (i) { + if (_total[i] <= 0) return 0.0; + return (_sent[i] / _total[i]).clamp(0.0, 1.0); + }); + var sumSent = 0; + var sumTotal = 0; + var fractionSum = 0.0; + for (var i = 0; i < _slots; i++) { + sumSent += _sent[i]; + sumTotal += _total[i]; + fractionSum += fractions[i]; + } + progress.value = fractions; + bytes.value = UploadBytes(sumSent, sumTotal); + UploadNotificationService.report( + id, + sent: sumSent, + total: sumTotal, + fraction: fractionSum / _slots, + ); + } +} + +class UploadService { + UploadService._(); + + static final UploadService instance = UploadService._(); + + static const int _historyLimit = 60; + static const int _photoConcurrency = 3; + static const int _photoAttempts = 3; + + final StreamController _events = + StreamController.broadcast(); + + final Map _jobs = {}; + final Map _completed = {}; + final Set _failed = {}; + + int _tempIdCounter = 0; + + Stream get events => _events.stream; + + String newTempId() => + 'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}'; + + UploadJob? job(String tempId) => _jobs[tempId]; + + ValueListenable>? progressFor(String tempId) => + _jobs[tempId]?.progress; + + UploadJob? activeFileJob(int chatId) { + for (final job in _jobs.values) { + if (job.chatId == chatId && job.kind == UploadKind.file) return job; + } + return null; + } + + List pendingFor(int chatId) { + final pending = []; + for (final job in _jobs.values) { + final placeholder = job.placeholder; + if (job.chatId == chatId && placeholder != null) pending.add(placeholder); + } + return List.unmodifiable(pending); + } + + CachedMessage? completedFor(String tempId) => _completed[tempId]; + + bool didFail(String tempId) => _failed.contains(tempId); + + Future sendPhotos({ + required int accountId, + required int chatId, + required String tempId, + required List<({File file, GalleryItem? item})> jobs, + required String caption, + CachedMessage? placeholder, + int? scheduledTime, + }) { + return _run( + UploadJob._( + id: tempId, + accountId: accountId, + chatId: chatId, + kind: UploadKind.photo, + slots: jobs.length, + placeholder: placeholder, + scheduledTime: scheduledTime, + ), + (job) async { + final tokens = await _uploadPhotos(jobs, job); + if (tokens.any((token) => token == null)) { + throw const UploadFailure('upload_failed'); + } + final sent = await messagesModule.sendPhotoMessage( + chatId, + tokens.cast(), + caption: caption.isEmpty ? null : caption, + scheduledTime: scheduledTime, + ); + if (sent == null) return null; + return CachedMessage.fromPushPayload(accountId, chatId, sent); + }, + ); + } + + Future sendVideo({ + required int accountId, + required int chatId, + required String tempId, + required File file, + required String caption, + CachedMessage? placeholder, + int? scheduledTime, + }) { + return _run( + UploadJob._( + id: tempId, + accountId: accountId, + chatId: chatId, + kind: UploadKind.video, + slots: 1, + placeholder: placeholder, + scheduledTime: scheduledTime, + ), + (job) async { + final info = await messagesModule.requestVideoUploadUrl(); + if (info == null || info.url.isEmpty) { + throw const UploadFailure('no_upload_url'); + } + final ok = await fileUploader.uploadVideoFile( + Uri.parse(info.url), + file, + onProgress: (sent, total) => job.report(0, sent, total), + ); + if (!ok) throw const UploadFailure('upload_failed'); + job.markUploaded(); + final sent = await messagesModule.sendVideoMessage( + chatId, + info.token, + caption: caption.isEmpty ? null : caption, + scheduledTime: scheduledTime, + ); + if (sent == null) return null; + return CachedMessage.fromPushPayload(accountId, chatId, sent); + }, + ); + } + + Future sendVoice({ + required int accountId, + required int chatId, + required String tempId, + required File file, + required int durationMs, + required Uint8List wave, + CachedMessage? placeholder, + }) { + return _sendRecording( + accountId: accountId, + chatId: chatId, + tempId: tempId, + file: file, + kind: UploadKind.voice, + placeholder: placeholder, + requestUpload: () async { + final info = await messagesModule.requestAudioUploadUrl(); + return info == null ? null : (url: info.url, token: info.token); + }, + send: (token) => messagesModule.sendAudioMessage( + chatId, + token, + duration: durationMs, + wave: wave, + ), + ); + } + + Future sendVideoNote({ + required int accountId, + required int chatId, + required String tempId, + required File file, + required int durationMs, + CachedMessage? placeholder, + }) { + return _sendRecording( + accountId: accountId, + chatId: chatId, + tempId: tempId, + file: file, + kind: UploadKind.videoNote, + placeholder: placeholder, + requestUpload: () async { + final info = await messagesModule.requestVideoNoteUploadUrl(); + return info == null ? null : (url: info.url, token: info.token); + }, + send: (token) => messagesModule.sendVideoNoteMessage( + chatId, + token, + duration: durationMs, + ), + ); + } + + Future _sendRecording({ + required int accountId, + required int chatId, + required String tempId, + required File file, + required UploadKind kind, + required Future<({String url, String token})?> Function() requestUpload, + required Future?> Function(String token) send, + CachedMessage? placeholder, + }) { + return _run( + UploadJob._( + id: tempId, + accountId: accountId, + chatId: chatId, + kind: kind, + slots: 1, + placeholder: placeholder, + ), + (job) async { + try { + final info = await requestUpload(); + if (info == null || info.url.isEmpty) { + throw const UploadFailure('no_upload_url'); + } + final ok = await fileUploader.uploadMediaFile( + Uri.parse(info.url), + file, + onProgress: (sent, total) => job.report(0, sent, total), + ); + if (!ok) throw const UploadFailure('upload_failed'); + job.markUploaded(); + final sent = await send(info.token); + if (sent == null) return null; + return CachedMessage.fromPushPayload(accountId, chatId, sent); + } finally { + try { + await file.delete(); + } catch (_) {} + } + }, + ); + } + + Future sendFile({ + required int accountId, + required int chatId, + required String tempId, + required File source, + required String filename, + required int size, + CachedMessage? placeholder, + String? text, + int? scheduledTime, + }) { + return _run( + UploadJob._( + id: tempId, + accountId: accountId, + chatId: chatId, + kind: UploadKind.file, + slots: 1, + filename: filename, + totalBytes: size, + placeholder: placeholder, + scheduledTime: scheduledTime, + ), + (job) async { + final done = await _uploadFile( + chatId: chatId, + job: job, + source: source, + filename: filename, + size: size, + text: text, + scheduledTime: scheduledTime, + ); + FileHistoryCache.add( + FileHistoryEntry( + fileId: done.fileId, + url: done.url, + token: done.token, + filename: done.filename, + size: done.size, + sentAt: DateTime.now(), + ), + ); + job.resultFileId = done.fileId; + job.resultFileToken = done.token; + if (scheduledTime != null) return null; + + final base = placeholder; + return CachedMessage( + id: done.messageId == null || done.messageId!.isEmpty + ? tempId + : done.messageId!, + accountId: accountId, + chatId: chatId, + senderId: accountId, + text: base?.text, + time: base?.time ?? DateTime.now().millisecondsSinceEpoch, + status: 'sent', + attachments: [ + FileAttachment( + fileId: done.fileId, + fileToken: done.token, + name: filename, + size: size, + ), + ], + sealedText: base?.sealedText, + e2ee: base?.e2ee ?? CachedMessage.e2eeNone, + ); + }, + ); + } + + Future _uploadFile({ + required int chatId, + required UploadJob job, + required File source, + required String filename, + required int size, + String? text, + int? scheduledTime, + }) async { + final result = Completer(); + late final StreamSubscription sub; + sub = fileUploader + .upload( + chatId: chatId, + file: source, + filename: filename, + totalSize: size, + text: text, + scheduledTime: scheduledTime, + ) + .listen( + (event) { + switch (event) { + case UploadProgress(:final sent, :final total): + job.report(0, sent, total); + case UploadDone(): + if (!result.isCompleted) result.complete(event); + case UploadError(:final message): + if (!result.isCompleted) { + result.completeError(UploadFailure(message)); + } + } + }, + onError: (Object e) { + if (!result.isCompleted) result.completeError(e); + }, + onDone: () { + if (!result.isCompleted) { + result.completeError(const UploadFailure('upload_failed')); + } + }, + ); + try { + return await result.future; + } finally { + await sub.cancel(); + } + } + + Future _run( + UploadJob job, + Future Function(UploadJob job) upload, + ) async { + _jobs[job.id] = job; + UploadNotificationService.begin( + job.id, + kind: job.kind, + count: job.slots, + filename: job.filename, + ); + + CachedMessage? real; + String? failure; + try { + real = await upload(job); + if (real == null && !job.scheduled) failure = 'send_failed'; + } catch (e) { + failure = e is UploadFailure ? e.message : e.toString(); + } + + UploadNotificationService.end(job.id); + _jobs.remove(job.id); + + if (failure != null) { + logger.w('UploadService: ${job.id} — $failure'); + if (!job.scheduled) { + _replaceInSessionCache(job.accountId, job.chatId, job.id, null); + _remember(job.id, null); + _syncChatPreview(job.placeholder, 'error'); + } + _events.add( + UploadJobFailed( + chatId: job.chatId, + tempId: job.id, + kind: job.kind, + scheduled: job.scheduled, + reason: failure, + ), + ); + return; + } + + if (job.scheduled) { + _events.add( + UploadJobDone( + chatId: job.chatId, + tempId: job.id, + kind: job.kind, + scheduled: true, + scheduledTime: job.scheduledTime, + fileId: job.resultFileId, + fileToken: job.resultFileToken, + ), + ); + return; + } + + final message = real!; + try { + await AppDatabase.saveMessages([message.toDbRow()]); + if (message.id != job.id) { + await AppDatabase.deleteMessage(job.accountId, job.chatId, job.id); + } + } catch (e) { + logger.w('UploadService: не удалось сохранить сообщение: $e'); + } + _replaceInSessionCache(job.accountId, job.chatId, job.id, message); + _remember(job.id, message); + _syncChatPreview(message, 'sent', replacesTime: job.placeholder?.time); + _events.add( + UploadJobDone( + chatId: job.chatId, + tempId: job.id, + kind: job.kind, + scheduled: false, + message: message, + fileId: job.resultFileId, + fileToken: job.resultFileToken, + ), + ); + } + + void _syncChatPreview( + CachedMessage? message, + String status, { + int? replacesTime, + }) { + if (message == null) return; + unawaited( + chats.applyOutgoingMessage( + message, + status: status, + replacesTime: replacesTime, + ), + ); + } + + void _remember(String tempId, CachedMessage? real) { + if (_completed.length + _failed.length > _historyLimit) { + _completed.clear(); + _failed.clear(); + } + if (real == null) { + _failed.add(tempId); + } else { + _completed[tempId] = real; + } + } + + Future> _uploadPhotos( + List<({File file, GalleryItem? item})> jobs, + UploadJob job, + ) async { + final tokens = List.filled(jobs.length, null); + var nextIndex = 0; + var failed = false; + + Future worker() async { + while (!failed) { + final i = nextIndex++; + if (i >= jobs.length) return; + final token = await _uploadOnePhoto(jobs[i], i, job); + if (token == null) { + failed = true; + return; + } + tokens[i] = token; + } + } + + final workerCount = jobs.length < _photoConcurrency + ? jobs.length + : _photoConcurrency; + await Future.wait(List.generate(workerCount, (_) => worker())); + return tokens; + } + + Future _uploadOnePhoto( + ({File file, GalleryItem? item}) photo, + int index, + UploadJob job, + ) async { + File file; + try { + file = await optimizePhotoForUpload(photo.file, item: photo.item); + } catch (e) { + logger.w('optimize photo: $e'); + file = photo.file; + } + for (var attempt = 0; attempt < _photoAttempts; attempt++) { + if (attempt > 0) { + await Future.delayed(Duration(seconds: attempt)); + job.resetSlot(index); + } + try { + final url = await messagesModule.requestPhotoUploadUrl(); + if (url == null || url.isEmpty) continue; + final token = await fileUploader.uploadPhoto( + Uri.parse(url), + file, + filename: _photoFilename(file), + onProgress: (sent, total) => job.report(index, sent, total), + ); + if (token != null) return token; + } catch (e) { + logger.w('uploadOnePhoto attempt ${attempt + 1}: $e'); + } + } + return null; + } + + String _photoFilename(File file) { + final segments = file.uri.pathSegments; + final name = segments.isNotEmpty ? segments.last : ''; + return name.isNotEmpty ? name : 'photo.jpg'; + } + + void _replaceInSessionCache( + int accountId, + int chatId, + String tempId, + CachedMessage? real, + ) { + final cached = MessageSessionCache.get(accountId, chatId); + if (cached == null) return; + final list = List.of(cached.messages); + final idx = list.indexWhere((m) => m.id == tempId); + if (idx == -1) return; + list[idx] = real ?? list[idx].copyWith(status: 'error'); + MessageSessionCache.save( + accountId, + chatId, + list, + reachedStart: cached.reachedStart, + ); + } +} diff --git a/lib/backend/modules/webapp.dart b/lib/backend/modules/webapp.dart new file mode 100644 index 0000000..75d5320 --- /dev/null +++ b/lib/backend/modules/webapp.dart @@ -0,0 +1,236 @@ +import '../../core/utils/logger.dart'; +import 'account/account_models.dart'; +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/storage/app_database.dart'; +import '../../core/storage/token_storage.dart'; + +abstract class EntryBannerApps { + static const String sferumKey = 'entry_banner_app_sferum'; + static const String digitalIdKey = 'entry_banner_app_digital_id'; + + static const Map> iconMatchers = { + sferumKey: ['sferum'], + digitalIdKey: ['digital'], + }; + + static const Map> titleMatchers = { + sferumKey: ['ферум'], + digitalIdKey: ['цифровой'], + }; + + static const Map knownAppIds = { + sferumKey: 2340831, + digitalIdKey: 8250447, + }; + + static Map appIdsFrom(Map serverConfig) { + final banners = serverConfig['settings-entry-banners']; + if (banners is! List) return const {}; + final resolved = {}; + const passes = [('icon', iconMatchers), ('title', titleMatchers)]; + for (final (field, matchers) in passes) { + for (final banner in banners) { + final items = (banner is Map) ? banner['items'] : null; + if (items is! List) continue; + for (final item in items) { + if (item is! Map) continue; + final appId = item['appid']; + if (appId is! int) continue; + final value = item[field]?.toString().toLowerCase() ?? ''; + if (value.isEmpty) continue; + for (final entry in matchers.entries) { + if (resolved.containsKey(entry.key)) continue; + if (entry.value.any(value.contains)) resolved[entry.key] = appId; + } + } + } + } + return resolved; + } +} + +const Set kMiniAppOptions = {'HAS_WEBAPP', 'HAS_WEB_APP', 'WEBAPP'}; + +bool hasMiniAppOption(Set? options) => + options != null && options.any(kMiniAppOptions.contains); + +class WebAppLaunch { + final String url; + final String? queryId; + final int botId; + + const WebAppLaunch({required this.url, required this.botId, this.queryId}); +} + +class WebAppPhone { + final String phone; + final String hash; + final int authDate; + + const WebAppPhone({ + required this.phone, + required this.hash, + required this.authDate, + }); +} + +class ExternalCallbackResult { + final int botId; + final String? startParam; + + const ExternalCallbackResult({required this.botId, this.startParam}); + + static ExternalCallbackResult? fromPayload(dynamic payload) { + final data = _findResponseMap(payload); + if (data == null) return null; + final botId = _asInt(data['botId'] ?? data['bot_id']); + if (botId == null) return null; + final startParam = data['startParam'] ?? data['start_param']; + return ExternalCallbackResult( + botId: botId, + startParam: startParam?.toString(), + ); + } + + static Map? _findResponseMap(dynamic value) { + if (value is! Map) return null; + if (value.containsKey('botId') || value.containsKey('bot_id')) return value; + for (final key in const ['data', 'result', 'response']) { + final nested = _findResponseMap(value[key]); + if (nested != null) return nested; + } + return null; + } +} + +int? _asInt(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? ''); +} + +class WebAppModule { + final Api _api; + + WebAppModule(this._api); + + /// device_id сессии (опкод 6 sessionInit, с учётом спуфинга) — тот же id, + /// к которому сервер привязывает Цифровой ID. + String? get sessionDeviceId => _api.deviceId; + + Future fetchLaunch( + int botId, { + String? startParam, + int? chatId, + }) async { + if (_api.state != SessionState.online) { + throw const WebAppUnavailable('Нет соединения с сервером'); + } + final normalizedStartParam = + (startParam != null && startParam.trim().isNotEmpty) + ? startParam + : null; + final packet = await _api.sendRequest(Opcode.webAppInitData, { + 'botId': botId, + 'startParam': ?normalizedStartParam, + 'chatId': ?chatId, + }); + if (!packet.isOk) { + throw const WebAppUnavailable('Не удалось открыть мини-приложение'); + } + final data = packet.payload; + final url = (data is Map) ? data['url'] as String? : null; + if (url == null || url.isEmpty) { + throw const WebAppUnavailable('Сервер не вернул адрес приложения'); + } + final queryId = (data is Map) ? data['query_id']?.toString() : null; + return WebAppLaunch(url: url, botId: botId, queryId: queryId); + } + + Future requestPhone(int botId) async { + if (_api.state != SessionState.online) { + throw const WebAppUnavailable('Нет соединения с сервером'); + } + final packet = await _api.sendRequest(Opcode.phoneWebappShare, { + 'botId': botId, + }); + final data = packet.payload; + if (!packet.isOk || data is! Map) { + throw const WebAppUnavailable('Не удалось передать номер телефона'); + } + final phone = data['phone']?.toString(); + final hash = data['hash']?.toString(); + final authDate = _asInt(data['authDate'] ?? data['auth_date']); + if (phone == null || + phone.isEmpty || + hash == null || + hash.isEmpty || + authDate == null || + authDate == 0) { + throw const WebAppUnavailable('Сервер не вернул номер телефона'); + } + return WebAppPhone(phone: phone, hash: hash, authDate: authDate); + } + + Future fetchSferum() async { + final botId = await _resolveEntryApp(EntryBannerApps.sferumKey); + if (botId == null) { + throw const WebAppUnavailable( + 'Сферум сейчас недоступен. Переподключитесь и попробуйте снова.', + ); + } + return fetchLaunch(botId); + } + + Future fetchDigitalId() async { + final botId = await _resolveEntryApp(EntryBannerApps.digitalIdKey); + if (botId == null) { + throw const WebAppUnavailable( + 'Цифровой ID сейчас недоступен. Переподключитесь и попробуйте снова.', + ); + } + return fetchLaunch(botId); + } + + Future handleExternalCallback(String url) async { + final uri = Uri.tryParse(url); + if (uri == null || uri.queryParameters['externalCallback'] != '1') { + throw const WebAppUnavailable('Некорректный callback Цифрового ID'); + } + if (_api.state != SessionState.online) { + throw const WebAppUnavailable('Нет соединения с сервером'); + } + final packet = await _api.sendRequest(Opcode.externalCallback, { + 'url': url, + }); + final result = ExternalCallbackResult.fromPayload(packet.payload); + if (result == null) { + throw const WebAppUnavailable( + 'Сервер не вернул данные для завершения Цифрового ID', + ); + } + return fetchLaunch(result.botId, startParam: result.startParam); + } + + Future _resolveEntryApp(String key) async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return null; + final raw = await AppDatabase.getSyncValue(accountId, key); + final stored = int.tryParse(raw ?? ''); + if (stored != null) return stored; + await LoginSyncParams.forgetServerConfig(accountId); + final known = EntryBannerApps.knownAppIds[key]; + logger.w('Мини-аппа $key: id нет в конфиге, берём известный $known'); + return known; + } +} + +class WebAppUnavailable implements Exception { + final String message; + + const WebAppUnavailable(this.message); + + @override + String toString() => message; +} diff --git a/lib/core/cache/info_cache.dart b/lib/core/cache/info_cache.dart new file mode 100644 index 0000000..b95c408 --- /dev/null +++ b/lib/core/cache/info_cache.dart @@ -0,0 +1,379 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../../backend/api.dart'; +import '../../models/bot_info.dart'; +import '../../models/chat_info.dart'; +import '../../models/contact_info.dart'; +import '../protocol/opcode_map.dart'; +import '../storage/chat_members_store.dart'; + +Api? _api; + +void attachInfoCacheApi(Api api) { + _api = api; +} + +class _Entry { + T? value; + DateTime? fetchedAt; + DateTime? failedAt; + Future? inFlight; +} + +class InfoCache { + final Duration ttl; + final Duration failureBackoff; + final Future Function(int id) fetcher; + final Map> _entries = {}; + + InfoCache({ + required this.ttl, + required this.fetcher, + this.failureBackoff = const Duration(seconds: 10), + }); + + bool _isFresh(_Entry e) { + if (e.fetchedAt == null) return false; + return DateTime.now().difference(e.fetchedAt!) < ttl; + } + + bool _isInFailureBackoff(_Entry e) { + if (e.failedAt == null) return false; + return DateTime.now().difference(e.failedAt!) < failureBackoff; + } + + Future get(int id, {bool forceRefresh = false}) { + final entry = _entries.putIfAbsent(id, () => _Entry()); + + if (!forceRefresh && _isFresh(entry)) { + return Future.value(entry.value); + } + if (!forceRefresh && _isInFailureBackoff(entry)) { + return Future.value(null); + } + if (entry.inFlight != null) return entry.inFlight!; + + final future = _runFetch(entry, id); + entry.inFlight = future; + return future; + } + + Future _runFetch(_Entry entry, int id) async { + try { + final result = await fetcher(id); + if (result == null) { + entry.failedAt = DateTime.now(); + return null; + } + entry.value = result; + entry.fetchedAt = DateTime.now(); + entry.failedAt = null; + return result; + } catch (_) { + entry.failedAt = DateTime.now(); + return null; + } finally { + entry.inFlight = null; + } + } + + T? peek(int id) { + final entry = _entries[id]; + if (entry == null || !_isFresh(entry)) return null; + return entry.value; + } + + void invalidate(int id) => _entries.remove(id); + void clear() => _entries.clear(); + + void putValue(int id, T value, {DateTime? at}) { + final entry = _entries.putIfAbsent(id, () => _Entry()); + entry.value = value; + entry.fetchedAt = at ?? DateTime.now(); + entry.failedAt = null; + } + + void markFailed(int id, {DateTime? at}) { + final entry = _entries.putIfAbsent(id, () => _Entry()); + entry.failedAt = at ?? DateTime.now(); + } +} + +class ContactInfoFetch { + static final _cache = InfoCache( + ttl: const Duration(minutes: 5), + fetcher: _fetch, + ); + + static Future get(int id, {bool forceRefresh = false}) => + _cache.get(id, forceRefresh: forceRefresh); + + static ContactInfo? peek(int id) => _cache.peek(id); + + static void invalidate(int id) => _cache.invalidate(id); + static void clear() => _cache.clear(); + + static void putContact(int id, Map contact) { + _cache.putValue( + id, + ContactInfo.fromMap(Map.from(contact)), + ); + } + + static Future> getMany( + List ids, { + bool forceRefresh = false, + }) async { + final result = {}; + final missing = []; + for (final id in ids) { + if (!forceRefresh) { + final cached = _cache.peek(id); + if (cached != null) { + result[id] = cached; + continue; + } + } + missing.add(id); + } + if (missing.isEmpty) return result; + + final api = _api; + if (api == null || api.state != SessionState.online) return result; + try { + final resp = await api.sendRequest(Opcode.contactInfo, { + 'contactIds': missing, + }); + final data = resp.payload; + final contacts = data is Map ? data['contacts'] : null; + if (contacts is List) { + final now = DateTime.now(); + for (final c in contacts.whereType()) { + final id = c['id']; + if (id is! int) continue; + final info = ContactInfo.fromMap(Map.from(c)); + _cache.putValue(id, info, at: now); + result[id] = info; + } + } + } catch (_) {} + return result; + } + + static Future _fetch(int id) async { + final api = _api; + if (api == null || api.state != SessionState.online) return null; + final resp = await api.sendRequest(Opcode.contactInfo, { + 'contactIds': [id], + }); + final data = resp.payload; + if (data is! Map) return null; + final contacts = data['contacts']; + if (contacts is! List || contacts.isEmpty) return null; + final first = contacts.first; + if (first is! Map) return null; + return ContactInfo.fromMap(Map.from(first)); + } +} + +class PresenceFetch { + static final _cache = InfoCache>( + ttl: const Duration(seconds: 60), + fetcher: _fetch, + ); + + static Future?> get( + int id, { + bool forceRefresh = false, + }) => _cache.get(id, forceRefresh: forceRefresh); + + static Map? peek(int id) => _cache.peek(id); + + static final Map> _live = {}; + static final ValueNotifier revision = ValueNotifier(0); + + static Map? live(int id) => _live[id] ?? _cache.peek(id); + + static bool isOnline(int id) => (live(id)?['status'] as int?) == 1; + + static void apply(int id, Map presence) { + if (id <= 0) return; + _live[id] = presence; + _cache.putValue(id, presence); + revision.value++; + } + + static void invalidate(int id) => _cache.invalidate(id); + + static void clear() { + _cache.clear(); + _live.clear(); + revision.value++; + } + + static void primeAll(Map presence) { + final now = DateTime.now(); + presence.forEach((key, value) { + if (value is! Map) return; + final id = key is int ? key : int.tryParse(key.toString()); + if (id == null) return; + final map = Map.from(value); + _cache.putValue(id, map, at: now); + _live[id] = map; + }); + revision.value++; + } + + static Future?> _fetch(int id) async { + final results = await _fetchBatch([id]); + return results[id]; + } + + static Future>> getMany( + List ids, { + bool forceRefresh = false, + }) async { + final result = >{}; + final missing = []; + for (final id in ids) { + if (!forceRefresh) { + final cached = _cache.peek(id); + if (cached != null) { + result[id] = cached; + continue; + } + } + missing.add(id); + } + if (missing.isNotEmpty) { + final fetched = await _fetchBatch(missing); + final now = DateTime.now(); + var changed = false; + for (final id in missing) { + final value = fetched[id]; + if (value != null) { + _cache.putValue(id, value, at: now); + _live[id] = value; + result[id] = value; + changed = true; + } else { + _cache.markFailed(id, at: now); + } + } + if (changed) revision.value++; + } + return result; + } + + static const _batchSize = 100; + + static Future ensureFor(Iterable ids) async { + final wanted = {}; + for (final id in ids) { + if (id <= 0) continue; + if (_cache.peek(id) != null) continue; + wanted.add(id); + } + if (wanted.isEmpty) return; + final list = wanted.toList(); + for (var i = 0; i < list.length; i += _batchSize) { + final chunk = list.sublist( + i, + i + _batchSize > list.length ? list.length : i + _batchSize, + ); + await getMany(chunk); + } + } + + static Future>> _fetchBatch( + List ids, + ) async { + final api = _api; + if (api == null || api.state != SessionState.online || ids.isEmpty) { + return const {}; + } + final resp = await api.sendRequest(Opcode.contactPresence, { + 'contactIds': ids, + }); + final data = resp.payload; + if (data is! Map) return const {}; + final presence = data['presence']; + if (presence is! Map) return const {}; + final out = >{}; + for (final id in ids) { + final entry = presence[id.toString()] ?? presence[id]; + if (entry is Map) { + out[id] = Map.from(entry); + } + } + return out; + } +} + +class BotInfoFetch { + static final _cache = InfoCache( + ttl: const Duration(minutes: 30), + fetcher: _fetch, + ); + + static Future get(int botId, {bool forceRefresh = false}) => + _cache.get(botId, forceRefresh: forceRefresh); + + static BotInfo? peek(int botId) => _cache.peek(botId); + + static List commandsOf(int botId) => + _cache.peek(botId)?.commands ?? const []; + + static void invalidate(int botId) => _cache.invalidate(botId); + static void clear() => _cache.clear(); + + static Future _fetch(int botId) async { + final api = _api; + if (api == null || api.state != SessionState.online) return null; + final resp = await api.sendRequest(Opcode.botInfo, {'botId': botId}); + final data = resp.payload; + if (data is! Map) return null; + final info = BotInfo.fromPayload(botId, Map.from(data)); + final contact = info.contact; + if (contact != null) ContactInfoFetch.putContact(botId, contact.raw); + return info; + } +} + +class ChatInfoFetch { + static final _cache = InfoCache( + ttl: const Duration(minutes: 5), + fetcher: _fetch, + ); + + static Future get(int id, {bool forceRefresh = false}) => + _cache.get(id, forceRefresh: forceRefresh); + + static ChatInfo? peek(int id) => _cache.peek(id); + + static void put(int id, ChatInfo info) { + ChatMembersStore.instance.applyChatPayload(info.raw); + _cache.putValue(id, info); + } + + static void invalidate(int id) => _cache.invalidate(id); + static void clear() => _cache.clear(); + + static Future _fetch(int id) async { + final api = _api; + if (api == null || api.state != SessionState.online) return null; + final resp = await api.sendRequest(Opcode.chatInfo, { + 'chatIds': [id], + }); + final data = resp.payload; + if (data is! Map) return null; + final chats = data['chats']; + if (chats is! List || chats.isEmpty) return null; + final first = chats.first; + if (first is! Map) return null; + ChatMembersStore.instance.applyChatPayload(first); + return ChatInfo.fromMap(Map.from(first)); + } +} diff --git a/lib/core/cache/message_session_cache.dart b/lib/core/cache/message_session_cache.dart new file mode 100644 index 0000000..e9078fd --- /dev/null +++ b/lib/core/cache/message_session_cache.dart @@ -0,0 +1,79 @@ +import 'dart:collection'; + +import '../../backend/modules/messages.dart'; + +class CachedChatMessages { + final List messages; + final bool reachedStart; + + const CachedChatMessages(this.messages, this.reachedStart); +} + +class MessageSessionCache { + static const int _capacity = 24; + + static final LinkedHashMap _store = + LinkedHashMap(); + + static String _key(int accountId, int chatId) => '$accountId:$chatId'; + + static CachedChatMessages? get(int accountId, int chatId) { + final key = _key(accountId, chatId); + final entry = _store.remove(key); + if (entry == null) return null; + _store[key] = entry; + return entry; + } + + static void save( + int accountId, + int chatId, + List messages, { + required bool reachedStart, + }) { + if (messages.isEmpty) return; + final key = _key(accountId, chatId); + _store.remove(key); + _store[key] = CachedChatMessages( + List.of(messages), + reachedStart, + ); + while (_store.length > _capacity) { + _store.remove(_store.keys.first); + } + } + + static void replace( + int accountId, + int chatId, + String messageId, + CachedMessage Function(CachedMessage current) change, + ) { + final cached = get(accountId, chatId); + if (cached == null) return; + final list = List.of(cached.messages); + final idx = list.indexWhere((m) => m.id == messageId); + if (idx == -1) return; + list[idx] = change(list[idx]); + save(accountId, chatId, list, reachedStart: cached.reachedStart); + } + + static void append(int accountId, int chatId, List added) { + final cached = _store[_key(accountId, chatId)]; + if (cached == null) return; + final known = {for (final message in cached.messages) message.id}; + final fresh = [ + for (final message in added) + if (!known.contains(message.id)) message, + ]; + if (fresh.isEmpty) return; + final merged = [...cached.messages, ...fresh] + ..sort((a, b) => a.time.compareTo(b.time)); + save(accountId, chatId, merged, reachedStart: cached.reachedStart); + } + + static void remove(int accountId, int chatId) => + _store.remove(_key(accountId, chatId)); + + static void clearAll() => _store.clear(); +} diff --git a/lib/core/cache/self_presence.dart b/lib/core/cache/self_presence.dart new file mode 100644 index 0000000..84166a9 --- /dev/null +++ b/lib/core/cache/self_presence.dart @@ -0,0 +1,32 @@ +import 'package:flutter/foundation.dart'; + +class SelfPresence { + static final ValueNotifier isOnline = ValueNotifier(true); + static final ValueNotifier lastSeenSeconds = ValueNotifier(null); + + static int get _nowSeconds => DateTime.now().millisecondsSinceEpoch ~/ 1000; + + static void markOnline() { + isOnline.value = true; + } + + static void markOffline() { + isOnline.value = false; + } + + static void markOfflineFromPing() { + if (isOnline.value || lastSeenSeconds.value == null) { + lastSeenSeconds.value = _nowSeconds; + } + isOnline.value = false; + } + + static void applySelfCheck({required bool online, int? seenSeconds}) { + if (online) { + isOnline.value = true; + } else { + isOnline.value = false; + if (seenSeconds != null) lastSeenSeconds.value = seenSeconds; + } + } +} diff --git a/lib/core/calls/active_call.dart b/lib/core/calls/active_call.dart new file mode 100644 index 0000000..1705aeb --- /dev/null +++ b/lib/core/calls/active_call.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; + +import 'call_session.dart'; + +class ActiveCallPresentation { + const ActiveCallPresentation({ + required this.session, + required this.name, + required this.avatarUrl, + required this.isGroup, + }); + + final CallSession session; + final String name; + final String? avatarUrl; + final bool isGroup; + + bool sameAs(ActiveCallPresentation other) => + identical(session, other.session) && + name == other.name && + avatarUrl == other.avatarUrl && + isGroup == other.isGroup; +} + +class ActiveCall { + ActiveCall._(); + + static final ActiveCall instance = ActiveCall._(); + + final ValueNotifier current = ValueNotifier(null); + + final ValueNotifier screenVisible = ValueNotifier(false); + + int _openScreens = 0; + StreamSubscription? _stateSub; + + void attach({ + required CallSession session, + required String name, + String? avatarUrl, + bool isGroup = false, + }) { + if (session.currentState == CallSessionState.ended) return; + final next = ActiveCallPresentation( + session: session, + name: name, + avatarUrl: avatarUrl, + isGroup: isGroup, + ); + final previous = current.value; + if (previous != null && previous.sameAs(next)) return; + if (previous == null || !identical(previous.session, session)) { + _stateSub?.cancel(); + _stateSub = session.stateStream.listen((state) { + if (state == CallSessionState.ended) detach(session); + }); + } + _publish(current, next); + } + + void detach([CallSession? session]) { + final active = current.value; + if (active == null) return; + if (session != null && !identical(active.session, session)) return; + _stateSub?.cancel(); + _stateSub = null; + _publish(current, null); + } + + void enterScreen() { + _openScreens++; + _publish(screenVisible, true); + } + + void leaveScreen() { + if (_openScreens > 0) _openScreens--; + _publish(screenVisible, _openScreens > 0); + } + + void _publish(ValueNotifier notifier, T value) { + if (SchedulerBinding.instance.schedulerPhase == + SchedulerPhase.persistentCallbacks) { + SchedulerBinding.instance.addPostFrameCallback((_) { + notifier.value = value; + }); + return; + } + notifier.value = value; + } +} diff --git a/lib/core/calls/audio_devices.dart b/lib/core/calls/audio_devices.dart new file mode 100644 index 0000000..35b0451 --- /dev/null +++ b/lib/core/calls/audio_devices.dart @@ -0,0 +1,89 @@ +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform, kIsWeb; +import 'package:flutter_webrtc/flutter_webrtc.dart'; + +import '../utils/logger.dart'; + +class AudioInputDevice { + const AudioInputDevice({required this.id, required this.label}); + + final String id; + final String label; +} + +class AudioDevices { + AudioDevices._(); + + static Future> microphones() async { + try { + final devices = await navigator.mediaDevices.enumerateDevices(); + final mics = []; + final seen = {}; + for (final device in devices) { + if (device.kind != 'audioinput') continue; + if (device.deviceId.isEmpty || !seen.add(device.deviceId)) continue; + mics.add( + AudioInputDevice(id: device.deviceId, label: device.label.trim()), + ); + } + return mics; + } catch (e) { + logger.w('[call] enumerateDevices: $e'); + return const []; + } + } + + static bool get switchesInsideEngine => + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS); + + static Future selectInput(String deviceId) async { + try { + await Helper.selectAudioInput(deviceId); + } catch (e) { + logger.w('[call] selectAudioInput($deviceId): $e'); + } + } + + static Object micConstraints( + String? deviceId, { + bool monitorCapture = false, + }) { + final constraints = {}; + final hasDevice = deviceId != null && deviceId.isNotEmpty; + if (hasDevice && !switchesInsideEngine) { + if (kIsWeb) { + constraints['deviceId'] = deviceId; + } else { + constraints['optional'] = [ + {'sourceId': deviceId}, + ]; + } + } + if (monitorCapture) { + constraints['echoCancellation'] = true; + constraints['noiseSuppression'] = false; + constraints['autoGainControl'] = false; + constraints['highpassFilter'] = false; + } + return constraints.isEmpty ? true : constraints; + } + + static Future findDevice(String token, {int attempts = 1}) async { + for (var attempt = 0; attempt < attempts; attempt++) { + for (final device in await microphones()) { + if (device.id == token || + device.id.contains(token) || + device.label.contains(token)) { + return device.id; + } + } + if (attempt + 1 < attempts) { + await Future.delayed(const Duration(milliseconds: 250)); + } + } + return null; + } +} diff --git a/lib/core/calls/call_admin.dart b/lib/core/calls/call_admin.dart new file mode 100644 index 0000000..da9a51a --- /dev/null +++ b/lib/core/calls/call_admin.dart @@ -0,0 +1,298 @@ +import 'ws2_signaling.dart'; + +enum CallMedia { + audio('AUDIO'), + video('VIDEO'), + screenShare('SCREEN_SHARING'), + movieShare('MOVIE_SHARING'); + + const CallMedia(this.wire); + final String wire; +} + +enum CallMuteState { + unmute('UNMUTE'), + mute('MUTE'), + mutePermanent('MUTE_PERMANENT'); + + const CallMuteState(this.wire); + final String wire; +} + +enum CallRoleName { + creator('CREATOR'), + admin('ADMIN'), + speaker('SPEAKER'); + + const CallRoleName(this.wire); + final String wire; +} + +enum CallOption { + requireAuthToJoin('REQUIRE_AUTH_TO_JOIN'), + waitingHall('WAITING_HALL'), + recurring('RECURRING'), + feedback('FEEDBACK'), + audienceMode('AUDIENCE_MODE'), + asr('ASR'), + waitForAdmin('WAIT_FOR_ADMIN'), + adminIsHere('ADMIN_IS_HERE'); + + const CallOption(this.wire); + final String wire; +} + +enum CallFeature { + addParticipant('ADD_PARTICIPANT'), + admin('ADMIN'), + asr('ASR'), + movieShare('MOVIE_SHARE'), + record('RECORD'), + speaker('SPEAKER'); + + const CallFeature(this.wire); + final String wire; +} + +enum CallListType { + grid('GRID'), + side('SIDE'); + + const CallListType(this.wire); + final String wire; +} + +class CallParticipantRef { + final int id; + final int deviceIdx; + final bool isGroup; + + const CallParticipantRef(this.id, {this.deviceIdx = 0, this.isGroup = false}); + + String get wire => '${isGroup ? 'g' : 'u'}$id:d$deviceIdx'; +} + +class CallAdmin { + final Ws2Signaling _signaling; + + const CallAdmin(this._signaling); + + Future requestMedia( + Set media, { + CallParticipantRef? participant, + String? roomId, + }) { + return _signaling.sendCommand( + 'mute-participant', + extra: { + 'participantId': ?participant?.wire, + 'requestedMedia': media.map((m) => m.wire).toList(), + 'roomId': ?roomId, + }, + ); + } + + Future setMuteStates( + Map states, { + CallParticipantRef? participant, + String? roomId, + }) { + return _signaling.sendCommand( + 'mute-participant', + extra: { + 'participantId': ?participant?.wire, + 'muteStates': { + for (final media in CallMedia.values) media.wire: states[media]?.wire, + }, + 'roomId': ?roomId, + }, + ); + } + + Future muteMicrophone( + CallParticipantRef participant, { + bool muted = true, + }) { + return _signaling.sendCommand( + 'switch-micro', + extra: {'eId': participant.wire, 'muteTarget': muted}, + ); + } + + Future muteEveryone() { + return _signaling.sendCommand( + 'switch-micro', + extra: const {'all': true, 'muteTarget': true}, + ); + } + + Future setPromoted(CallParticipantRef participant, bool promoted) { + return _signaling.sendCommand( + 'promote-participant', + extra: {'participantId': participant.wire, 'demote': !promoted}, + ); + } + + Future setRoles( + CallParticipantRef participant, + List roles, { + bool revoke = false, + }) { + return _signaling.sendCommand( + 'grant-roles', + extra: { + 'participantId': participant.wire, + 'roles': roles.map((r) => r.wire).toList(), + 'revoke': revoke, + }, + ); + } + + Future removeParticipant(CallParticipantRef participant) { + return _signaling.sendCommand( + 'remove-participant', + extra: {'participantId': participant.wire}, + ); + } + + Future setPinned( + CallParticipantRef participant, + bool pinned, { + String? roomId, + }) { + return _signaling.sendCommand( + 'pin-participant', + extra: { + 'participantId': participant.wire, + 'unpin': !pinned, + 'roomId': ?roomId, + }, + ); + } + + Future setOptions(Map options) { + return _signaling.sendCommand( + 'change-options', + extra: { + 'options': { + for (final entry in options.entries) entry.key.wire: entry.value, + }, + }, + ); + } + + Future enableFeatureForRoles( + CallFeature feature, + List roles, + ) { + return _signaling.sendCommand( + 'enable-feature-for-roles', + extra: { + 'feature': feature.wire, + 'roles': roles.map((r) => r.wire).toList(), + }, + ); + } + + Future lowerAllHands() => _signaling.sendCommand('put-hands-down'); + + Future setHandRaised(bool raised, {CallParticipantRef? participant}) { + return _setState({'hand': raised ? '1' : '0'}, participant: participant); + } + + Future setAssistanceRequested( + bool requested, { + CallParticipantRef? participant, + }) { + return _setState({'drat': requested ? '1' : '0'}, participant: participant); + } + + Future _setState( + Map state, { + CallParticipantRef? participant, + }) { + return _signaling.sendCommand( + 'change-participant-state', + extra: { + 'participantState': {'state': state}, + 'participantId': ?participant?.wire, + }, + ); + } + + Future addParticipants( + List externalIds, { + bool? unban, + bool showChatHistory = false, + }) { + return _signaling.sendCommand( + 'add-participant', + extra: { + 'externalIds': externalIds, + if (unban == true) 'unban': true, + if (showChatHistory) 'payload': '{"show_chat_history":true}', + }, + ); + } + + Future addParticipantByLink(String link) { + return _signaling.sendCommand( + 'add-participant', + extra: {'participantIdAsQRCodeLink': link}, + ); + } + + Future> startRecord({ + int? movieId, + String? name, + String? description, + String? privacy, + int? groupId, + String? albumId, + bool streamMovie = false, + String? roomId, + }) { + return _signaling.sendCommand( + 'record-start', + extra: { + 'movieId': movieId, + 'name': name, + 'description': description, + 'privacy': privacy, + 'groupId': groupId, + 'albumId': albumId, + 'streamMovie': streamMovie, + 'roomId': ?roomId, + }, + ); + } + + Future stopRecord({bool remove = false, String? roomId}) { + return _signaling.sendCommand( + 'record-stop', + extra: {if (remove) 'remove': true, 'roomId': ?roomId}, + ); + } + + Future> participantChunk({ + int count = 50, + CallListType listType = CallListType.grid, + String? roomId, + }) { + return _signaling.sendCommand( + 'get-participant-list-chunk', + extra: {'count': count, 'listType': listType.wire, 'roomId': ?roomId}, + ); + } + + Future> waitingHall({ + int count = 50, + String? fromId, + bool backward = false, + }) { + return _signaling.sendCommand( + 'get-waiting-hall', + extra: {'count': count, 'fromId': ?fromId, 'backward': backward}, + ); + } +} diff --git a/lib/core/calls/call_bridge.dart b/lib/core/calls/call_bridge.dart new file mode 100644 index 0000000..9afac18 --- /dev/null +++ b/lib/core/calls/call_bridge.dart @@ -0,0 +1,147 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io' show Platform; + +import 'package:flutter/services.dart'; + +import '../utils/logger.dart'; +import 'call_controller.dart'; + +class CallBridge { + CallBridge._(); + static final CallBridge instance = CallBridge._(); + + static const _method = MethodChannel('ru.ikar.app/calls'); + static const _events = EventChannel('ru.ikar.app/calls_events'); + + bool _started = false; + + bool get _android { + try { + return Platform.isAndroid; + } catch (_) { + return false; + } + } + + void init() { + if (_started || !_android) return; + _started = true; + _events.receiveBroadcastStream().listen( + _handle, + onError: (e) => logger.w('CallBridge.init: events stream error: $e'), + ); + } + + Future checkInitialCall() async { + if (!_android) return; + try { + _handle(await _method.invokeMethod('consumeInitialCall')); + } catch (e) { + logger.w('CallBridge.checkInitialCall: $e'); + } + } + + void _handle(Object? event) { + if (event is! Map) return; + final action = event['action']?.toString(); + if (action == 'hangup') { + unawaited(CallController.instance.endActive()); + return; + } + if (action == 'ended') { + CallController.instance.dismissIncoming(); + return; + } + final dataStr = event['data']; + if (dataStr is! String) return; + Object? decoded; + try { + decoded = jsonDecode(dataStr); + } catch (e) { + logger.w('CallBridge._handle: action=$action jsonDecode failed: $e'); + return; + } + if (decoded is! Map) return; + CallController.instance.injectFromNative( + decoded, + autoAccept: action == 'answer', + ); + } + + Future notifyAccepted({String? caller}) async { + if (!_android) return; + try { + await _method.invokeMethod('notifyAccepted', {'caller': caller}); + } catch (e) { + logger.w('CallBridge.notifyAccepted: caller=$caller $e'); + } + } + + Future ensureOngoing({String? caller}) async { + if (!_android) return; + try { + await _method.invokeMethod('ensureOngoing', {'caller': caller}); + } catch (e) { + logger.w('CallBridge.ensureOngoing: $e'); + } + } + + Future setScreenShare(bool enabled, {String? caller}) async { + if (!_android) return; + try { + await _method.invokeMethod('setScreenShare', { + 'enabled': enabled, + 'caller': caller, + }); + } catch (e) { + logger.w('CallBridge.setScreenShare: enabled=$enabled $e'); + } + } + + Future dropOngoing() async { + if (!_android) return; + try { + await _method.invokeMethod('dropOngoing'); + } catch (e) { + logger.w('CallBridge.dropOngoing: $e'); + } + } + + Future notifyEnded() async { + if (!_android) return; + try { + await _method.invokeMethod('notifyEnded'); + } catch (e) { + logger.w('CallBridge.notifyEnded: $e'); + } + } + + Future cancelIncoming() async { + if (!_android) return; + try { + await _method.invokeMethod('cancelIncoming'); + } catch (e) { + logger.w('CallBridge.cancelIncoming: $e'); + } + } + + Future canUseFullScreenIntent() async { + if (!_android) return true; + try { + return await _method.invokeMethod('canUseFullScreenIntent') ?? true; + } catch (e) { + logger.w('CallBridge.canUseFullScreenIntent: $e'); + return true; + } + } + + Future openFullScreenIntentSettings() async { + if (!_android) return; + try { + await _method.invokeMethod('openFullScreenIntentSettings'); + } catch (e) { + logger.w('CallBridge.openFullScreenIntentSettings: $e'); + } + } +} diff --git a/lib/core/calls/call_controller.dart b/lib/core/calls/call_controller.dart new file mode 100644 index 0000000..5ce9563 --- /dev/null +++ b/lib/core/calls/call_controller.dart @@ -0,0 +1,301 @@ +import 'dart:async'; + +import '../../backend/api.dart'; +import '../../backend/modules/calls.dart'; +import '../protocol/opcode_map.dart'; +import '../push/fkm_controller.dart'; +import '../protocol/packet.dart'; +import '../utils/parse.dart'; +import 'call_bridge.dart'; +import 'call_session.dart'; +import 'conversation_params.dart'; +import 'ws2_signaling.dart'; + +class IncomingCall { + final String conversationId; + + final int callerId; + final bool isVideo; + final ConversationParams params; + + final String? country; + final bool? isContact; + final String? callerName; + + final bool autoAccept; + + const IncomingCall({ + required this.conversationId, + required this.callerId, + required this.isVideo, + required this.params, + this.country, + this.isContact, + this.callerName, + this.autoAccept = false, + }); +} + +class CallController { + CallController._(); + static final CallController instance = CallController._(); + + Api? _api; + CallsModule? _calls; + StreamSubscription? _pushSub; + + final _incoming = StreamController.broadcast(); + final _ended = StreamController.broadcast(); + final _canceled = StreamController.broadcast(); + + bool appResumed = false; + + Stream get incomingCalls => _incoming.stream; + + Stream get callEnded => _ended.stream; + + Stream get incomingCanceled => _canceled.stream; + + CallSession? _active; + StreamSubscription? _activeSub; + CallSession? get activeSession => _active; + + String? _activeJoinLink; + String? get activeJoinLink => _active == null ? null : _activeJoinLink; + bool _starting = false; + + IncomingCall? _pending; + IncomingCall? get pendingIncoming => _pending; + + bool get isBusy => _active != null || _starting; + + void init(Api api) { + if (_api != null) return; + _api = api; + _calls = CallsModule(api); + _pushSub = api.pushStream.listen(_onPush); + } + + void _onPush(Packet packet) { + if (packet.opcode != Opcode.notifCallStart) return; + final payload = packet.payload; + if (payload is! Map) return; + + final vcp = payload['vcp'] as String?; + final conversationId = payload['conversationId'] as String?; + final callerId = payload['callerId'] as int?; + if (vcp == null || conversationId == null || callerId == null) return; + + // Приложение свёрнуто — звонок показывает FKM отдельным уведомлением, + // приём оттуда вернётся через injectFromNative. + if (!appResumed) { + unawaited(FkmController.instance.showIncomingCall(payload)); + return; + } + + final params = ConversationParams.decode(vcp); + if (params == null) return; + + _emitIncoming( + IncomingCall( + conversationId: conversationId, + callerId: callerId, + isVideo: payload['type'] == 'VIDEO' || params.isVideo, + params: params, + country: payload['country'] as String?, + isContact: payload['isContact'] as bool?, + ), + ); + } + + void injectFromNative(Map data, {bool autoAccept = false}) { + final vcp = data['vcp']?.toString(); + if (vcp == null || vcp.isEmpty) return; + + final params = ConversationParams.decode(vcp); + if (params == null) return; + + final conversationId = (data['conversationId'] ?? data['vcId'])?.toString(); + if (conversationId == null || conversationId.isEmpty) return; + + final callerId = parseIntOrNull(data['callerId'] ?? data['suid']); + if (callerId == null) return; + + final type = (data['type'] ?? data['callType'])?.toString(); + final iv = data['iv']; + final isVideo = + params.isVideo || type == 'VIDEO' || iv == true || iv == 'true'; + + _emitIncoming( + IncomingCall( + conversationId: conversationId, + callerId: callerId, + isVideo: isVideo, + params: params, + country: data['country']?.toString(), + isContact: data['isContact'] is bool ? data['isContact'] as bool : null, + callerName: data['userName']?.toString(), + autoAccept: autoAccept, + ), + ); + } + + void _emitIncoming(IncomingCall incoming) { + if (isBusy) return; + if (_pending?.conversationId == incoming.conversationId) return; + _pending = incoming; + _incoming.add(incoming); + } + + void dismissIncoming() { + if (_pending == null) return; + _pending = null; + _canceled.add(null); + } + + Future startOutgoing(int calleeId, {bool isVideo = false}) => + _startExclusive(() async { + final out = await _calls!.initiateCall(calleeId, isVideo: isVideo); + final config = Ws2Config.fromEndpoint( + out.endpoint, + userId: out.callsUserId, + device: _api?.callsDevice, + osVersion: _api?.callsOsVersion, + ); + final session = CallSession(ws2Config: config, role: CallRole.caller); + return _launch(session, session.start); + }); + + // #***! конференция со ссылкой + Future createConference() async { + if (isBusy) throw StateError('уже идёт звонок'); + return _calls!.createConference(); + } + + Future previewCallLink(String url) => + _calls!.resolveCallLink(url); + + Future joinByLink(String token, {bool isVideo = false}) => + _startExclusive(() async { + final params = await _calls!.joinByLink(token, isVideo: isVideo); + final config = Ws2Config.fromEndpoint( + params.endpoint, + userId: params.callsUserId, + device: _api?.callsDevice, + osVersion: _api?.callsOsVersion, + ); + final session = CallSession( + ws2Config: config, + role: CallRole.joiner, + isGroup: true, + ); + _activeJoinLink = token; + return _launch(session, session.start); + }); + + Future _startExclusive( + Future Function() start, + ) async { + if (isBusy) throw StateError('уже идёт звонок'); + _starting = true; + try { + return await start(); + } finally { + _starting = false; + } + } + + Future acceptIncoming(IncomingCall call) async { + _pending = null; + CallBridge.instance.cancelIncoming(); + final config = Ws2Config.fromVcp( + call.params, + conversationId: call.conversationId, + device: _api?.callsDevice, + osVersion: _api?.callsOsVersion, + ); + final session = CallSession( + ws2Config: config, + params: call.params, + role: CallRole.callee, + ); + return _launch(session, () async { + await session.start(); + await session.accept(); + }, caller: call.callerName); + } + + Future rejectIncoming(IncomingCall call) async { + _pending = null; + CallBridge.instance.notifyEnded(); + final config = Ws2Config.fromVcp( + call.params, + conversationId: call.conversationId, + device: _api?.callsDevice, + osVersion: _api?.callsOsVersion, + ); + final signaling = Ws2Signaling(config); + try { + await signaling.connect(); + await signaling.hangup(reason: 'REJECTED'); + } catch (_) { + } finally { + await signaling.close(); + } + } + + Future endActive() => _active?.hangup() ?? Future.value(); + + Future sendMicSignal(bool enabled) async { + final session = _active; + if (session == null) return false; + await session.sendAudioEnabledSignal(enabled); + return true; + } + + Future _launch( + CallSession session, + Future Function() open, { + String? caller, + }) async { + _bind(session); + try { + await open(); + } catch (_) { + await _release(session); + try { + await session.hangup(); + } catch (_) {} + rethrow; + } + CallBridge.instance.notifyAccepted(caller: caller); + return session; + } + + void _bind(CallSession session) { + unawaited(_activeSub?.cancel()); + _active = session; + _activeSub = session.stateStream.listen((state) { + if (state != CallSessionState.ended) return; + unawaited(_release(session)); + }); + } + + Future _release(CallSession session) async { + if (!identical(_active, session)) return; + _active = null; + _activeJoinLink = null; + await _activeSub?.cancel(); + _activeSub = null; + CallBridge.instance.notifyEnded(); + _ended.add(null); + } + + void dispose() { + _activeSub?.cancel(); + _pushSub?.cancel(); + _incoming.close(); + _ended.close(); + _canceled.close(); + } +} diff --git a/lib/core/calls/call_info.dart b/lib/core/calls/call_info.dart new file mode 100644 index 0000000..bd7ea3e --- /dev/null +++ b/lib/core/calls/call_info.dart @@ -0,0 +1,101 @@ +import 'dart:convert'; + +class CallInfo { + String? conversationId; + String? topology; + + String? peerPlatform; + String? peerEngine; + + String? peerIp; + String? peerNetwork; + String? path; + + String? audioCodec; + bool record = false; + bool denoise = false; + bool animoji = false; + + String? region; + String? dtlsFingerprint; + final List stun = []; + final List turn = []; +} + +class CallParse { + static Map candidate(String c) { + final parts = c.trim().split(RegExp(r'\s+')); + String? at(int i) => (i >= 0 && i < parts.length) ? parts[i] : null; + int idx(String k) => parts.indexOf(k); + final r = {}; + final tr = at(2); + if (tr != null) r['transport'] = tr.toUpperCase(); + final ip = at(4); + if (ip != null) r['ip'] = ip; + final port = at(5); + if (port != null) r['port'] = port; + final typ = idx('typ'); + if (typ != -1) r['type'] = at(typ + 1) ?? ''; + final nc = idx('network-cost'); + if (nc != -1) r['cost'] = at(nc + 1) ?? ''; + return r; + } + + static String networkLabel(String? cost) { + switch (cost) { + case '0': + return 'VPN'; + case '10': + return 'Wi-Fi / Ethernet'; + case '50': + return 'неизвестно'; + case '900': + case '999': + return 'сотовая'; + default: + return (cost == null || cost.isEmpty) ? '—' : 'cost=$cost'; + } + } + + static String engine(String sdp) { + for (final line in const LineSplitter().convert(sdp)) { + if (!line.startsWith('o=')) continue; + final l = line.toLowerCase(); + if (l.contains('mozilla') || l.contains('sdparta')) + return 'Firefox (web)'; + if (l.contains('gstreamer')) return 'GStreamer'; + return 'нативный libwebrtc'; + } + return 'неизвестно'; + } + + static String? audioCodec(String sdp) { + for (final line in const LineSplitter().convert(sdp)) { + if (line.startsWith('a=rtpmap:') && line.toLowerCase().contains('opus')) { + final i = line.indexOf(' '); + if (i != -1) return line.substring(i + 1).trim(); + } + } + return null; + } + + static String? fingerprint(String sdp) { + for (final line in const LineSplitter().convert(sdp)) { + if (line.startsWith('a=fingerprint:')) { + return line.substring('a=fingerprint:'.length).trim(); + } + } + return null; + } + + static bool hasAnimoji(String sdp) => sdp.contains('animoji'); + + static bool isServerIp(String ip) => ip.startsWith('155.212.'); + + static String pathLabel(String? localType, String? remoteType) { + final relay = localType == 'relay' || remoteType == 'relay'; + final via = relay ? 'через сервер (TURN)' : 'прямое (P2P)'; + String t(String? v) => v ?? '?'; + return '$via · ${t(localType)} ↔ ${t(remoteType)}'; + } +} diff --git a/lib/core/calls/call_link.dart b/lib/core/calls/call_link.dart new file mode 100644 index 0000000..41dd81f --- /dev/null +++ b/lib/core/calls/call_link.dart @@ -0,0 +1,26 @@ +class CallLink { + static const String base = 'https://ikar.kusoft.xyz/joincall/'; + + static final RegExp _pattern = RegExp( + r'^https?://(?:[^/\s]+\.)?max\.ru/joincall/([A-Za-z0-9_-]+)', + caseSensitive: false, + ); + + static final RegExp _rawPattern = RegExp( + r'^(?:joincall/)?([A-Za-z0-9_-]+)$', + caseSensitive: false, + ); + + static bool isCallLink(String url) => token(url) != null; + + static String? token(String url) => _pattern.firstMatch(url.trim())?.group(1); + + static String? normalizeToken(String raw) { + final value = raw.trim(); + return token(value) ?? _rawPattern.firstMatch(value)?.group(1); + } + + static String url(String token) => '$base$token'; + + static String path(String token) => 'joincall/$token'; +} diff --git a/lib/core/calls/call_session.dart b/lib/core/calls/call_session.dart new file mode 100644 index 0000000..208ad3a --- /dev/null +++ b/lib/core/calls/call_session.dart @@ -0,0 +1,2516 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform; +import 'package:flutter_webrtc/flutter_webrtc.dart'; + +import '../config/app_camera.dart'; +import '../config/app_microphone.dart'; +import '../config/app_pulse_source.dart'; +import '../config/call_no_mute.dart'; +import '../utils/logger.dart'; +import '../utils/parse.dart'; +import 'audio_devices.dart'; +import 'camera_devices.dart'; +import 'call_admin.dart'; +import 'call_bridge.dart'; +import 'call_info.dart'; +import 'conversation_params.dart'; +import 'pulse_audio.dart'; +import 'sfu_data_channel.dart'; +import 'ws2_signaling.dart'; + +enum CallRole { caller, callee, joiner } + +enum CallSessionState { connecting, ringing, active, ended } + +class CallParticipant { + final int id; + final bool isSelf; + int? externalId; + String state; + bool audioEnabled; + bool videoEnabled; + bool screenSharing; + bool handRaised; + List roles; + + CallParticipant({ + required this.id, + this.isSelf = false, + this.externalId, + this.state = '', + this.audioEnabled = true, + this.videoEnabled = false, + this.screenSharing = false, + this.handRaised = false, + this.roles = const [], + }); + + bool get isAdmin => roles.contains('ADMIN') || roles.contains('CREATOR'); + bool get isCreator => roles.contains('CREATOR'); + bool get isSpeaker => roles.contains('SPEAKER'); +} + +class CallChatMessage { + final String text; + final bool mine; + final DateTime time; + + CallChatMessage({required this.text, required this.mine, required this.time}); +} + +class CallSession { + final Ws2Config ws2Config; + + final ConversationParams? params; + final CallRole role; + final bool isGroup; + + CallSession({ + required this.ws2Config, + required this.role, + this.params, + this.isGroup = false, + }); + + Ws2Signaling? _signaling; + RTCPeerConnection? _pc; + MediaStream? _localStream; + MediaStream? _micStream; + MediaStream? _remoteStreamRef; + + int? _peerId; + String _peerType = 'USER'; + int _peerDeviceIdx = 0; + + bool _muted = false; + bool _speakerOn = false; + bool _accepted = false; + bool _peerMuted = false; + bool _peerVideo = false; + bool _mediaConnected = false; + bool _remoteDescSet = false; + bool _ownRemoteStream = false; + final List _pendingCandidates = []; + Future _tail = Future.value(); + + final Map _participants = {}; + final Map _participantStreams = {}; + final _participantStreamUpdates = StreamController.broadcast(); + + String? _topology; + List _iceServers = const []; + Object? _sfuSessionId; + Set _speaking = const {}; + + bool _localVideo = false; + bool _localScreen = false; + MediaStream? _cameraStream; + MediaStream? _screenStream; + RTCRtpSender? _audioSender; + RTCRtpSender? _videoSender; + RTCRtpSender? _screenSender; + String? _micDeviceId = AppMicrophone.deviceId; + String? _pulseSource = AppPulseSource.name; + bool _monitorCapture = false; + + Completer? _gatherDone; + bool _gotConnection = false; + + bool _reconnecting = false; + bool _iceRestarting = false; + int _iceRestarts = 0; + static const int _maxIceRestarts = 6; + static const int _maxReconnectAttempts = 12; + static const Duration _maxReconnectDelay = Duration(seconds: 20); + + bool get isReconnecting => _reconnecting; + + Timer? _levelTimer; + final Map _speakHold = {}; + + static const double _speakLevelOn = 0.05; + static const int _speakHoldTicks = 3; + + RTCDataChannel? _probeChannel; + bool _peerIsQlyra = false; + + final List _sfuChannels = []; + SfuCommandChannel? _sfuCommands; + StreamSubscription>? _sfuSlotSub; + StreamSubscription>? _sfuLevelSub; + final Map _slotParticipant = {}; + Timer? _layoutDebounce; + Timer? _videoStatsTimer; + List _lastLayout = const []; + bool _layoutSent = false; + + static const int _maxVideoSlots = 10; + static const int _sfuSpeakLevel = 50; + static const Duration _levelTtl = Duration(seconds: 6); + final Map _levelState = {}; + + static const List _sfuChannelLabels = [ + 'producerCommand', + 'producerNotification', + ]; + + static const bool _qlyraProbeEnabled = false; + static const String _probeQuestion = 'AreYouQlyra?'; + static const String _probeAnswer = 'YesImQlyra😎'; + + final List _chat = []; + final _chatController = StreamController.broadcast(); + final _gameController = StreamController>.broadcast(); + + List get chatLog => List.unmodifiable(_chat); + Stream get chatMessages => _chatController.stream; + Stream> get gameMessages => _gameController.stream; + + int get selfUserId => ws2Config.userId; + int? get peerUserId => _peerId; + + bool get localVideo => _localVideo; + bool get localScreen => _localScreen; + MediaStream? get localVideoStream => + _localScreen ? _screenStream : _cameraStream; + MediaStream? get localCameraStream => _cameraStream; + MediaStream? get localScreenStream => _screenStream; + + CallAdmin? get admin { + final signaling = _signaling; + return signaling == null ? null : CallAdmin(signaling); + } + + List get participants => + _participants.values.toList(growable: false); + + Map get participantStreams => + Map.unmodifiable(_participantStreams); + + Stream get participantStreamUpdates => _participantStreamUpdates.stream; + + MediaStream? streamOf(int participantId) => + _participantStreams[participantId]; + + int get participantCount => _participants.length; + + bool isSpeaking(int id) => _speaking.contains(id); + + String? get topology => _topology; + + bool get _wantVideo => params?.isVideo == true; + + final CallInfo info = CallInfo(); + + final _state = StreamController.broadcast(); + final _remoteStream = StreamController.broadcast(); + final _info = StreamController.broadcast(); + final _qlyraDetected = StreamController.broadcast(); + + Stream get stateStream => _state.stream; + Stream get remoteStreamStream => _remoteStream.stream; + MediaStream? get remoteStream => _remoteStreamRef; + + Stream get infoUpdates => _info.stream; + + Stream get peerQlyraDetected => _qlyraDetected.stream; + bool get peerIsQlyra => _peerIsQlyra; + + bool get isMuted => _muted; + bool get audioTransmitting => !_muted || CallNoMute.enabled; + String? get micDeviceId => _micDeviceId; + String? get pulseSource => _pulseSource; + bool get isSpeaker => _speakerOn; + bool get peerMuted => _peerMuted; + bool get peerVideo => _peerVideo; + bool get mediaConnected => _mediaConnected; + + CallSessionState _current = CallSessionState.connecting; + DateTime? _activeSince; + + CallSessionState get currentState => _current; + + int get elapsedSeconds => _activeSince == null + ? 0 + : DateTime.now().difference(_activeSince!).inSeconds; + + void _setState(CallSessionState s) { + if (_current == s || _current == CallSessionState.ended) return; + if (s == CallSessionState.active) _activeSince ??= DateTime.now(); + _current = s; + _state.add(s); + } + + void _notifyInfo() { + if (!_info.isClosed) _info.add(null); + if (_topology == 'SERVER') _scheduleDisplayLayout(); + } + + Future start() async { + _setState(CallSessionState.connecting); + info.region = ws2Config.uri.host; + await _openSignaling(); + _levelTimer = Timer.periodic( + const Duration(milliseconds: 300), + (_) => unawaited(_sampleLevels()), + ); + } + + Future _openSignaling() async { + final signaling = Ws2Signaling(ws2Config); + _signaling = signaling; + signaling.notifications.listen( + _enqueue, + onError: (_) => _onSignalingLost(), + ); + signaling.done.then((_) => _onSignalingLost()); + await signaling.connect(); + logger.i('[call] signaling connected to ${ws2Config.uri.host}'); + logger.i('[call] ws2 url ${_maskedUrl()}'); + unawaited(_wakeSignalingIfSilent(signaling)); + Timer(const Duration(seconds: 10), () { + if (_ended || _gotConnection) return; + logger.w( + '[call] ws2 молчит 10 с: нотификация "connection" не пришла — ' + 'конференция закрыта или токен протух', + ); + }); + } + + String _maskedUrl() { + final token = ws2Config.uri.queryParameters['token']; + if (token == null || token.length < 12) return ws2Config.uri.toString(); + final masked = + '${token.substring(0, 4)}…${token.substring(token.length - 6)}'; + return ws2Config.uri.toString().replaceAll( + Uri.encodeQueryComponent(token), + masked, + ); + } + + /// Кадры ws2 приходят в broadcast-канал Rust-ядра, а подписка на него + /// создаётся уже после того, как сокет открыт: нотификацию `connection`, + /// присланную сразу после хэндшейка, ядро выбрасывает. Если её нет — толкаем + /// сервер командой (ответы идут по sequence и гонке не подвержены). + Future _wakeSignalingIfSilent(Ws2Signaling signaling) async { + await Future.delayed(const Duration(milliseconds: 1200)); + if (_ended || _gotConnection || _signaling != signaling) return; + + logger.w('[call] "connection" не пришла за 1.2 с — бужу ws2'); + try { + final response = await signaling.sendCommand( + 'change-media-settings', + extra: { + 'mediaSettings': { + 'isVideoEnabled': _localVideo, + 'isAudioEnabled': !_muted, + 'isScreenSharingEnabled': _localScreen, + 'isAnimojiEnabled': false, + }, + }, + ); + logger.i('[call] ws2 wake ok: $response'); + } catch (e) { + logger.w('[call] ws2 wake failed: $e'); + return; + } + + await Future.delayed(const Duration(milliseconds: 1200)); + if (_ended || _gotConnection || _signaling != signaling) return; + + logger.w('[call] всё ещё тихо — шлю accept-call вслепую'); + try { + await accept(activate: false); + } catch (e) { + logger.w('[call] accept-call failed: $e'); + } + } + + void _onSignalingLost() { + if (_ended || _reconnecting) return; + logger.w('[call] signaling lost, reconnecting'); + unawaited(_reconnect()); + } + + Future _reconnect() async { + _reconnecting = true; + _setState(CallSessionState.connecting); + _notifyInfo(); + + for (var attempt = 1; attempt <= _maxReconnectAttempts; attempt++) { + final backoff = Duration(seconds: 1 << (attempt - 1)); + final delay = backoff > _maxReconnectDelay ? _maxReconnectDelay : backoff; + await Future.delayed(delay); + if (_ended) break; + + logger.i('[call] reconnect attempt $attempt/$_maxReconnectAttempts'); + try { + await _resetForReconnect(); + await _openSignaling(); + _reconnecting = false; + return; + } catch (e) { + logger.w('[call] reconnect attempt $attempt failed: $e'); + } + } + + _reconnecting = false; + if (!_ended) { + logger.w('[call] reconnect gave up'); + _end(); + } + } + + Future _restartIce() async { + if (_ended || _iceRestarting || _topology == 'SERVER') return; + if (_iceRestarts >= _maxIceRestarts) { + logger.w('[call] ice restart budget exhausted, ending call'); + _end(); + return; + } + _iceRestarting = true; + _iceRestarts++; + _setState(CallSessionState.connecting); + _notifyInfo(); + logger.i('[call] ice restart $_iceRestarts/$_maxIceRestarts'); + try { + _pendingCandidates.clear(); + await _createAndSendOffer(iceRestart: true); + } catch (e) { + logger.w('[call] ice restart failed: $e'); + } finally { + _iceRestarting = false; + } + } + + Future _resetForReconnect() async { + try { + await _signaling?.close(); + } catch (_) {} + _signaling = null; + + try { + await _probeChannel?.close(); + } catch (_) {} + _probeChannel = null; + await _closeSfuChannels(); + + try { + await _pc?.close(); + } catch (_) {} + _pc = null; + + _audioSender = null; + _videoSender = null; + _screenSender = null; + _remoteDescSet = false; + _pendingCandidates.clear(); + _accepted = false; + _mediaConnected = false; + _sfuSessionId = null; + await _clearParticipantStreams(); + + for (final track in _localStream?.getTracks() ?? []) { + try { + await track.stop(); + } catch (_) {} + } + try { + await _localStream?.dispose(); + } catch (_) {} + _localStream = null; + await _disposeMicStream(); + + await _disposeStream(_cameraStream); + await _disposeStream(_screenStream); + _cameraStream = null; + _screenStream = null; + _localVideo = false; + _localScreen = false; + } + + Future _sampleLevels() async { + final pc = _pc; + if (pc == null || _ended || _topology == 'SERVER') return; + if (!_mediaConnected || _current != CallSessionState.active) return; + + var local = 0.0; + var remote = 0.0; + try { + for (final r in await pc.getStats()) { + final lvl = r.values['audioLevel']; + if (lvl is! num) continue; + final kind = r.values['kind'] ?? r.values['mediaType']; + if (kind != 'audio') continue; + if (r.type == 'media-source') { + local = lvl.toDouble(); + } else if (r.type == 'inbound-rtp') { + final v = lvl.toDouble(); + if (v > remote) remote = v; + } + } + } catch (_) { + return; + } + + final loud = {}; + if (audioTransmitting && local > _speakLevelOn) loud.add(ws2Config.userId); + final others = _participants.values.where((p) => !p.isSelf).toList(); + if (others.length == 1 && remote > _speakLevelOn) loud.add(others.first.id); + + for (final id in loud) { + _speakHold[id] = _speakHoldTicks; + } + _speakHold.updateAll((id, ticks) => loud.contains(id) ? ticks : ticks - 1); + _speakHold.removeWhere((_, ticks) => ticks <= 0); + + final next = _speakHold.keys.toSet(); + if (next.length != _speaking.length || !next.containsAll(_speaking)) { + _speaking = next; + _notifyInfo(); + } + } + + void _enqueue(Map msg) { + _tail = _tail.then((_) => _onNotification(msg)).catchError(( + Object e, + StackTrace st, + ) { + logger.w('[call] handler failed for ${msg['notification']}: $e\n$st'); + }); + } + + Future _onNotification(Map msg) async { + final name = msg['notification'] ?? msg['response'] ?? msg['type']; + logger.i('[call] ws2 <- $name'); + if (msg['type'] == 'error') { + _onWs2Error(msg); + return; + } + _applyPeerMedia(msg); + switch (msg['notification']) { + case 'connection': + await _onConnection(msg); + break; + case 'transmitted-data': + await _onTransmittedData(msg); + break; + case 'accepted-call': + _setState(CallSessionState.active); + break; + case 'registered-peer': + _applyRegisteredPeer(msg); + break; + case 'participant-joined': + case 'participant-added': + _onParticipantJoined(msg); + break; + case 'media-settings-changed': + _onParticipantMedia(msg); + break; + case 'participant-state-changed': + _onParticipantStateChanged(msg); + break; + case 'roles-changed': + _onRolesChanged(msg); + break; + case 'participants-state-changed': + _onParticipantsStateChanged(msg); + break; + case 'participant-left': + case 'participant-removed': + _onParticipantLeft(msg); + break; + case 'force-media-settings-change': + case 'switch-micro': + _onForcedMedia(msg); + break; + case 'mute-participant': + _onMuteParticipant(msg); + break; + case 'hungup': + _onHungup(msg); + break; + case 'topology-changed': + await _onTopologyChanged(msg); + break; + case 'producer-updated': + await _onProducerUpdated(msg); + break; + case 'session-state': + _onSessionState(msg); + break; + case 'closed-conversation': + _end(); + break; + } + } + + void _onWs2Error(Map msg) { + final err = msg['error']; + logger.w('[call] ws2 error: $err raw=$msg'); + if (err == 'conversation-ended') _end(); + } + + int? _participantIdFrom(Object? raw) { + if (raw is int) return raw; + if (raw is! String) return null; + for (final seg in raw.split(':')) { + if (seg.isEmpty) continue; + final c = seg[0]; + if (c == 'u' || c == 'g') { + final v = int.tryParse(seg.substring(1)); + if (v != null) return v; + } else if (c != 'd') { + final v = int.tryParse(seg); + if (v != null) return v; + } + } + return null; + } + + void _onForcedMedia(Map msg) { + bool? audioOn; + final ms = msg['mediaSettings']; + if (ms is Map && ms['isAudioEnabled'] is bool) { + audioOn = ms['isAudioEnabled'] as bool; + } + final muteStates = msg['muteStates']; + if (muteStates is Map && muteStates['AUDIO'] is String) { + audioOn = muteStates['AUDIO'] == 'UNMUTE'; + } + final mute = msg['mute']; + if (mute is bool) audioOn = !mute; + if (audioOn == null) return; + logger.t('[call] forced media audioEnabled=$audioOn raw=$msg'); + _applyMuted(!audioOn); + } + + void _onMuteParticipant(Map msg) { + final muteStates = msg['muteStates']; + if (muteStates is! Map || muteStates['AUDIO'] is! String) return; + final audioOn = muteStates['AUDIO'] == 'UNMUTE'; + final target = _participantIdFrom(msg['participantId']); + final muteAll = msg['muteAll'] == true; + + if (target != null) { + final p = _participants[target]; + if (p != null) { + p.audioEnabled = audioOn; + _notifyInfo(); + } + } + if (muteAll || target == null || target == ws2Config.userId) { + _applyMuted(!audioOn); + } + } + + void _onHungup(Map msg) { + final raw = + msg['participantId'] ?? + (msg['participant'] is Map ? (msg['participant'] as Map)['id'] : null); + if (raw is! int) return; + if (raw == ws2Config.userId) { + _end(); + return; + } + if (_participants.remove(raw) != null) _notifyInfo(); + } + + void _onSessionState(Map msg) { + logger.t( + '[call][sfu] session-state id=${msg['participantId']} connected=${msg['connected']}', + ); + } + + void _resolveParticipants(Object? conversation) { + if (conversation is! Map) return; + final list = conversation['participants']; + if (list is! List) return; + final seen = {}; + for (final p in list.whereType()) { + final id = p['id']; + if (id is! int) continue; + seen.add(id); + _upsertParticipant( + id, + externalId: _externalId(p['externalId']), + state: p['state'] as String?, + mediaSettings: p['mediaSettings'], + muteStates: p['muteStates'], + roles: p['roles'], + ); + } + _participants.removeWhere((key, _) => !seen.contains(key)); + _notifyInfo(); + } + + CallParticipant _upsertParticipant( + int id, { + int? externalId, + String? state, + Object? mediaSettings, + Object? muteStates, + bool? handRaised, + Object? roles, + }) { + final p = _participants.putIfAbsent( + id, + () => CallParticipant(id: id, isSelf: id == ws2Config.userId), + ); + if (externalId != null) p.externalId = externalId; + if (state != null) p.state = state; + if (mediaSettings is Map) { + p.audioEnabled = mediaSettings['isAudioEnabled'] == true; + p.videoEnabled = mediaSettings['isVideoEnabled'] == true; + p.screenSharing = mediaSettings['isScreenSharingEnabled'] == true; + } + if (muteStates is Map) { + final a = muteStates['AUDIO']; + final v = muteStates['VIDEO']; + final s = muteStates['SCREEN_SHARING']; + if (a is String && a != 'UNMUTE') p.audioEnabled = false; + if (v is String && v != 'UNMUTE') p.videoEnabled = false; + if (s is String && s != 'UNMUTE') p.screenSharing = false; + } + if (handRaised != null) p.handRaised = handRaised; + if (roles is List) { + p.roles = roles.whereType().toList(growable: false); + } + return p; + } + + int? _externalId(Object? ext) { + if (ext is! Map) return null; + return parseIntOrNull(ext['id']); + } + + bool? _handFrom(Object? participantState) { + if (participantState is! Map) return null; + final state = participantState['state']; + if (state is! Map || !state.containsKey('hand')) return null; + return state['hand'] == '1' || state['hand'] == true; + } + + void _onParticipantMedia(Map msg) { + final id = _participantIdFrom(msg['participantId']); + if (id == null) return; + final p = _upsertParticipant( + id, + externalId: _externalId(msg['externalId']), + mediaSettings: msg['mediaSettings'], + muteStates: msg['muteStates'], + ); + logger.i( + '[call] media $id video=${p.videoEnabled} audio=${p.audioEnabled} ' + 'screen=${p.screenSharing} raw=${msg['mediaSettings']}', + ); + _maybeAdoptPeer(id, msg); + _notifyInfo(); + } + + void _onParticipantJoined(Map msg) { + final nested = msg['participant']; + final p = nested is Map ? nested : msg; + final id = _participantIdFrom( + p['id'] ?? p['participantId'] ?? msg['participantId'], + ); + if (id == null) return; + _upsertParticipant( + id, + externalId: _externalId(p['externalId']), + state: p['state'] as String?, + mediaSettings: p['mediaSettings'], + muteStates: p['muteStates'], + handRaised: _handFrom(p['participantState']), + roles: p['roles'], + ); + _maybeAdoptPeer(id, p); + _notifyInfo(); + } + + void _maybeAdoptPeer(int id, Map source) { + if (role != CallRole.joiner || _peerId != null || _pc == null) return; + if (_topology == 'SERVER') return; + if (id == ws2Config.userId) return; + _peerId = id; + final type = source['participantType'] ?? source['idType']; + if (type is String && type.isNotEmpty) _peerType = type; + final deviceIdx = source['deviceIdx']; + if (deviceIdx is int) _peerDeviceIdx = deviceIdx; + logger.t('[call] adopting peer $_peerId on join'); + unawaited(_createAndSendOffer()); + } + + void _onRolesChanged(Map msg) { + final id = _participantIdFrom(msg['participantId']); + if (id == null) return; + _upsertParticipant(id, roles: msg['roles']); + _notifyInfo(); + } + + void _onParticipantStateChanged(Map msg) { + final id = msg['participantId']; + if (id is! int) return; + _upsertParticipant(id, handRaised: _handFrom(msg['participantState'])); + _notifyInfo(); + } + + void _onParticipantsStateChanged(Map msg) { + final list = msg['participants']; + if (list is! List) return; + for (final p in list.whereType()) { + final id = _participantIdFrom(p['participantId'] ?? p['id']); + if (id == null) continue; + _upsertParticipant( + id, + externalId: _externalId(p['externalId']), + state: p['state'] as String?, + mediaSettings: p['mediaSettings'], + muteStates: p['muteStates'], + handRaised: _handFrom(p['participantState']), + roles: p['roles'], + ); + } + _notifyInfo(); + } + + void _onParticipantLeft(Map msg) { + final id = msg['participantId']; + if (id is! int) return; + if (_participants.remove(id) != null) _notifyInfo(); + } + + Future _onConnection(Map msg) async { + _gotConnection = true; + logger.i('[call] connection notification received'); + final convParams = msg['conversationParams']; + final conversation = msg['conversation']; + + final ice = _iceServersFrom(convParams) ?? params?.iceServers ?? const []; + _iceServers = ice; + _resolvePeer(conversation); + _resolveParticipants(conversation); + _applyConnectionInfo(msg, ice); + + _topology = + (conversation is Map ? conversation['topology']?.toString() : null) ?? + _topology; + logger.i('[call] connection role=$role peer=$_peerId topology=$_topology'); + + if (_topology == 'SERVER') { + await accept(activate: role != CallRole.caller); + await _setupSfu(); + return; + } + + final pc = await _createPc(ice); + _pc = pc; + await _addLocalMedia(pc); + + await pc.addTransceiver( + kind: RTCRtpMediaType.RTCRtpMediaTypeVideo, + init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly), + ); + + await _setupQlyraProbe(pc); + + if (_isDesktop) await _preferVp8Codecs(pc); + + if (role == CallRole.caller) { + _setState(CallSessionState.ringing); + await _createAndSendOffer(); + } else if (role == CallRole.joiner) { + await _createAndSendOffer(); + } + await accept(activate: role != CallRole.caller); + } + + Future _createPc(List ice) async { + final pc = await createPeerConnection({ + 'iceServers': ice, + 'sdpSemantics': 'unified-plan', + 'bundlePolicy': 'max-bundle', + 'rtcpMuxPolicy': 'require', + 'tcpCandidatePolicy': 'enabled', + 'continualGatheringPolicy': 'gather_continually', + 'audioJitterBufferMaxPackets': 200, + }); + pc.onIceCandidate = _onLocalCandidate; + pc.onIceGatheringState = (s) { + logger.i('[call] ice gathering $s'); + if (s != RTCIceGatheringState.RTCIceGatheringStateComplete) return; + final done = _gatherDone; + if (done != null && !done.isCompleted) done.complete(); + }; + pc.onTrack = (event) => unawaited(_onRemoteTrack(event)); + pc.onDataChannel = (channel) { + if (!_qlyraProbeEnabled) return; + _bindProbeChannel(channel, ask: false); + }; + pc.onIceConnectionState = (s) { + logger.i('[call] ice $s'); + if (s != RTCIceConnectionState.RTCIceConnectionStateFailed) return; + if (_topology != 'SERVER' || _ended) return; + unawaited(_dumpIceStats(pc)); + logger.w('[call][sfu] ice failed, request-realloc'); + unawaited( + _signaling?.requestRealloc().catchError( + (e) => logger.w('[call] request-realloc failed: $e'), + ) ?? + Future.value(), + ); + }; + pc.onConnectionState = (s) { + logger.i('[call] pc $s'); + final connected = + s == RTCPeerConnectionState.RTCPeerConnectionStateConnected; + if (connected != _mediaConnected) { + _mediaConnected = connected; + _notifyInfo(); + if (connected) { + _iceRestarts = 0; + if (role == CallRole.joiner || _topology == 'SERVER') { + _setState(CallSessionState.active); + } + unawaited(applyAudioRoute()); + unawaited(_resolvePath()); + unawaited(_collectReceivers()); + } + } + if (_topology == 'SERVER') return; + if (s == RTCPeerConnectionState.RTCPeerConnectionStateClosed) { + _end(); + return; + } + if (s == RTCPeerConnectionState.RTCPeerConnectionStateFailed) { + unawaited(_restartIce()); + } + }; + return pc; + } + + Future _addLocalMedia(RTCPeerConnection pc) async { + await _prepareAudioSession(); + await _disposeMicStream(); + try { + await _prepareMicRoute(); + } catch (e) { + logger.w( + '[call][pulse] маршрут недоступен, беру устройство по умолчанию: $e', + ); + await _resetMicRoute(); + } + await _selectMicInsideEngine(); + _localStream = await navigator.mediaDevices.getUserMedia({ + 'audio': AudioDevices.micConstraints( + _micDeviceId, + monitorCapture: _monitorCapture, + ), + 'video': _wantVideo, + }); + for (final track in _localStream!.getTracks()) { + final sender = await pc.addTrack(track, _localStream!); + if (track.kind == 'audio') _audioSender = sender; + } + _applyAudioTracks(); + await applyAudioRoute(); + } + + Future _selectMicInsideEngine() async { + final deviceId = _micDeviceId; + if (deviceId == null || !AudioDevices.switchesInsideEngine) return; + await AudioDevices.selectInput(deviceId); + } + + Future _disposeMicStream() async { + final stream = _micStream; + _micStream = null; + await _disposeStream(stream); + } + + List get _audioTracks => + _micStream?.getAudioTracks() ?? + _localStream?.getAudioTracks() ?? + const []; + + void _applyAudioTracks() { + for (final track in _audioTracks) { + track.enabled = audioTransmitting; + } + } + + Future setPulseSource(String? sourceName) async { + final previous = _pulseSource; + final next = (sourceName == null || sourceName.isEmpty) ? null : sourceName; + _pulseSource = next; + if (next == null) await _resetMicRoute(); + try { + await _replaceMicTrack(); + } catch (e) { + _pulseSource = previous; + await _resetMicRoute(); + rethrow; + } + await AppPulseSource.save(next ?? ''); + _notifyInfo(); + } + + Future _resetMicRoute() async { + _pulseSource = null; + _monitorCapture = false; + _micDeviceId = AppMicrophone.deviceId; + await PulseAudio.closeBridge(); + } + + Future _prepareMicRoute() async { + final wanted = _pulseSource; + if (!PulseAudio.supported || wanted == null) { + _monitorCapture = false; + await PulseAudio.closeBridge(); + return; + } + final source = await PulseAudio.find(wanted); + if (source == null) { + logger.w('[call][pulse] источник $wanted пропал'); + await _resetMicRoute(); + return; + } + _monitorCapture = source.isMonitor; + if (!source.isMonitor) { + final direct = await AudioDevices.findDevice(source.name); + if (direct != null) { + _micDeviceId = direct; + await PulseAudio.closeBridge(); + return; + } + } + final bridge = await PulseAudio.openBridge(source.name); + final device = bridge == null + ? null + : await AudioDevices.findDevice(bridge, attempts: 8); + if (device == null) { + await PulseAudio.closeBridge(); + throw PulseRouteException(source.label); + } + _micDeviceId = device; + } + + Future setMicrophone(String? deviceId) async { + final next = (deviceId == null || deviceId.isEmpty) ? null : deviceId; + _micDeviceId = next; + _pulseSource = null; + _monitorCapture = false; + await AppMicrophone.save(next ?? ''); + await AppPulseSource.save(''); + await PulseAudio.closeBridge(); + if (AudioDevices.switchesInsideEngine) { + await _selectMicInsideEngine(); + } else { + await _replaceMicTrack(); + } + _notifyInfo(); + } + + Future _replaceMicTrack() async { + await _prepareMicRoute(); + final sender = _audioSender; + if (sender == null) return; + final stream = await navigator.mediaDevices.getUserMedia({ + 'audio': AudioDevices.micConstraints( + _micDeviceId, + monitorCapture: _monitorCapture, + ), + 'video': false, + }); + final tracks = stream.getAudioTracks(); + if (tracks.isEmpty) { + await _disposeStream(stream); + return; + } + final track = tracks.first; + track.enabled = audioTransmitting; + await sender.replaceTrack(track); + final previous = _micStream; + _micStream = stream; + if (previous != null) { + await _disposeStream(previous); + } else { + for (final old + in _localStream?.getAudioTracks() ?? const []) { + try { + await old.stop(); + } catch (_) {} + } + } + } + + Future setSpeaker(bool on) async { + if (_speakerOn == on) return; + _speakerOn = on; + await applyAudioRoute(); + _notifyInfo(); + } + + Future _prepareAudioSession() async { + if (!_canRouteAudio) return; + if (defaultTargetPlatform == TargetPlatform.android) { + try { + await Helper.setAndroidAudioConfiguration( + AndroidAudioConfiguration.communication, + ); + } catch (e) { + logger.w('[call] setAndroidAudioConfiguration: $e'); + } + } + await applyAudioRoute(); + } + + Future applyAudioRoute() async { + if (!_canRouteAudio) return; + try { + await Helper.setSpeakerphoneOn(_speakerOn); + } catch (e) { + logger.w('[call] setSpeakerphoneOn($_speakerOn) недоступен: $e'); + } + } + + static bool get _canRouteAudio => + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS; + + Future _openSfuChannels(RTCPeerConnection pc) async { + await _closeSfuChannels(); + final commands = SfuCommandChannel(); + _sfuCommands = commands; + _sfuSlotSub = commands.slotUpdates.listen(_onSfuSlots); + _sfuLevelSub = commands.audioLevels.listen(_onSfuLevels); + for (final label in _sfuChannelLabels) { + try { + final channel = await pc.createDataChannel( + label, + RTCDataChannelInit() + ..ordered = true + ..maxRetransmitTime = 10000000, + ); + channel.onDataChannelState = (state) { + logger.i('[call][sfu] data channel $label $state'); + if (state == RTCDataChannelState.RTCDataChannelOpen) { + _scheduleDisplayLayout(); + } + }; + commands.bind(channel); + _sfuChannels.add(channel); + } catch (e) { + logger.w('[call][sfu] data channel $label failed: $e'); + } + } + } + + void _onSfuLevels(Map levels) { + final now = DateTime.now(); + levels.forEach((key, level) { + final id = _participantIdFrom(key.split(':').first); + if (id != null) _levelState[id] = (level: level, at: now); + }); + _levelState.removeWhere((_, v) => now.difference(v.at) > _levelTtl); + + final loud = _levelState.entries + .where((e) => e.value.level >= _sfuSpeakLevel) + .map((e) => e.key) + .toSet(); + logger.i('[call][sfu] levels: $levels speaking=$loud'); + if (loud.length == _speaking.length && loud.containsAll(_speaking)) return; + _speaking = loud; + _notifyInfo(); + } + + void _onSfuSlots(Map slots) { + if (slots.isEmpty) return; + _slotParticipant.clear(); + slots.forEach((key, slot) { + if (slot < 0) return; + final id = _participantIdFrom(key.split(':').first); + if (id != null) _slotParticipant[slot] = id; + }); + unawaited(_rebindSlotTracks()); + } + + Future _rebindSlotTracks() async { + await _clearParticipantStreams(); + await _collectReceivers(); + _notifyInfo(); + } + + void _scheduleDisplayLayout() { + _layoutDebounce?.cancel(); + _layoutDebounce = Timer( + const Duration(milliseconds: 300), + () => unawaited(_publishDisplayLayout()), + ); + } + + Future _publishDisplayLayout({bool force = false}) async { + final commands = _sfuCommands; + if (commands == null || _topology != 'SERVER' || _ended) return; + final items = []; + for (final p in _participants.values) { + if (p.isSelf || items.length >= _maxVideoSlots) continue; + if (!p.videoEnabled && !p.screenSharing) continue; + items.add( + SfuLayoutItem( + trackKey: 'u${p.id}:${p.screenSharing ? 'sSCREEN' : 'sCAMERA'}', + ), + ); + } + final keys = items.map((i) => i.trackKey).toList(growable: false); + if (!force && + _layoutSent && + keys.length == _lastLayout.length && + keys.every(_lastLayout.contains)) { + return; + } + if (!await commands.sendDisplayLayout(items)) return; + _lastLayout = keys; + _layoutSent = true; + } + + Set _videoSlotMids(String sdp) { + final mids = {}; + String? kind; + String? mid; + var recvOnly = false; + + void flush() { + final id = mid; + if (kind == 'video' && recvOnly && id != null) mids.add(id); + } + + for (var line in sdp.split('\n')) { + line = line.trim(); + if (line.startsWith('m=')) { + flush(); + kind = line.substring(2).split(' ').first; + mid = null; + recvOnly = false; + } else if (line.startsWith('a=mid:')) { + mid = line.substring(6); + } else if (line == 'a=recvonly') { + recvOnly = true; + } + } + flush(); + return mids; + } + + Future _prepareVideoSlot(RTCPeerConnection pc, String offerSdp) async { + final mids = _videoSlotMids(offerSdp); + if (mids.isEmpty) return; + for (final transceiver in await pc.getTransceivers()) { + final mid = transceiver.mid; + if (!mids.contains(mid)) continue; + final tracks = + _cameraStream?.getVideoTracks() ?? const []; + if (tracks.isNotEmpty) { + try { + await transceiver.sender.replaceTrack(tracks.first); + } catch (e) { + logger.w('[call][sfu] video slot $mid replaceTrack failed: $e'); + } + } + try { + await transceiver.setDirection(TransceiverDirection.SendOnly); + } catch (e) { + logger.w('[call][sfu] video slot $mid setDirection failed: $e'); + continue; + } + _videoSender = transceiver.sender; + logger.i('[call][sfu] video slot mid=$mid -> sendonly'); + return; + } + } + + Future _closeSfuChannels() async { + _layoutDebounce?.cancel(); + _layoutDebounce = null; + await _sfuSlotSub?.cancel(); + _sfuSlotSub = null; + await _sfuLevelSub?.cancel(); + _sfuLevelSub = null; + await _sfuCommands?.dispose(); + _sfuCommands = null; + _slotParticipant.clear(); + _lastLayout = const []; + _layoutSent = false; + final channels = List.from(_sfuChannels); + _sfuChannels.clear(); + for (final channel in channels) { + try { + await channel.close(); + } catch (_) {} + } + } + + Future _setupQlyraProbe(RTCPeerConnection pc) async { + if (!_qlyraProbeEnabled || _topology == 'SERVER') return; + try { + final channel = await pc.createDataChannel( + 'qlyra', + RTCDataChannelInit()..ordered = true, + ); + _probeChannel = channel; + _bindProbeChannel(channel, ask: true); + } catch (_) {} + } + + void _bindProbeChannel(RTCDataChannel channel, {required bool ask}) { + channel.onMessage = (message) => _onProbeMessage(channel, message); + channel.onDataChannelState = (state) { + if (ask && state == RTCDataChannelState.RTCDataChannelOpen) { + _sendProbe(channel, _probeQuestion); + } + }; + } + + void _onProbeMessage(RTCDataChannel channel, RTCDataChannelMessage message) { + if (message.isBinary) return; + final text = message.text; + + final frame = _decodeFrame(text); + if (frame != null && frame['t'] == 'chat') { + final body = frame['text']; + if (body is String && body.isNotEmpty) { + _addChat( + CallChatMessage(text: body, mine: false, time: DateTime.now()), + ); + } + return; + } + if (frame != null && frame['t'] == 'game') { + final data = Map.of(frame)..remove('t'); + if (!_gameController.isClosed) _gameController.add(data); + return; + } + + if (text == _probeQuestion) { + _sendProbe(channel, _probeAnswer); + } else if (text == _probeAnswer) { + _markPeerQlyra(); + } + } + + Map? _decodeFrame(String text) { + try { + final v = jsonDecode(text); + return v is Map ? v : null; + } catch (_) { + return null; + } + } + + void _sendProbe(RTCDataChannel channel, String text) { + try { + channel.send(RTCDataChannelMessage(text)); + } catch (_) {} + } + + void sendChatMessage(String text) { + final body = text.trim(); + final channel = _probeChannel; + if (body.isEmpty || channel == null) return; + try { + channel.send( + RTCDataChannelMessage(jsonEncode({'t': 'chat', 'text': body})), + ); + } catch (_) { + return; + } + _addChat(CallChatMessage(text: body, mine: true, time: DateTime.now())); + } + + void sendGame(Map data) { + final channel = _probeChannel; + if (channel == null) return; + try { + channel.send(RTCDataChannelMessage(jsonEncode({'t': 'game', ...data}))); + } catch (_) {} + } + + void _addChat(CallChatMessage message) { + _chat.add(message); + if (!_chatController.isClosed) _chatController.add(message); + } + + void _markPeerQlyra() { + if (_peerIsQlyra) return; + _peerIsQlyra = true; + logger.t('[call] peer is ИКАР'); + if (!_qlyraDetected.isClosed) _qlyraDetected.add(null); + _notifyInfo(); + } + + Future _setupSfu() async { + if (_pc != null) { + await _closeSfuChannels(); + await _pc!.close(); + _pc = null; + _probeChannel = null; + _remoteDescSet = false; + _pendingCandidates.clear(); + for (final track in _localStream?.getTracks() ?? []) { + await track.stop(); + } + await _localStream?.dispose(); + _localStream = null; + await _disposeMicStream(); + _audioSender = null; + _videoSender = null; + _screenSender = null; + } + _setState(CallSessionState.connecting); + final pc = await _createPc(_iceServers); + _pc = pc; + await _addLocalMedia(pc); + await _republishVideo(pc); + await _openSfuChannels(pc); + logger.i( + '[call][sfu] allocate-consumer camera=$_localVideo screen=$_localScreen', + ); + try { + final reply = await _signaling?.allocateConsumer(); + logger.i('[call][sfu] allocate-consumer reply: $reply'); + } catch (e) { + logger.w('[call][sfu] allocate-consumer failed: $e'); + } + } + + Future _rebuildSfuPc() async { + await _closeSfuChannels(); + try { + await _pc?.close(); + } catch (_) {} + _pc = null; + _audioSender = null; + _videoSender = null; + _screenSender = null; + _remoteDescSet = false; + _pendingCandidates.clear(); + await _clearParticipantStreams(); + + for (final track in _localStream?.getTracks() ?? []) { + try { + await track.stop(); + } catch (_) {} + } + try { + await _localStream?.dispose(); + } catch (_) {} + _localStream = null; + + final pc = await _createPc(_iceServers); + _pc = pc; + await _addLocalMedia(pc); + await _republishVideo(pc); + await _openSfuChannels(pc); + } + + Future _republishVideo(RTCPeerConnection pc) async { + final camera = _cameraStream; + if (camera != null) { + final tracks = camera.getVideoTracks(); + if (tracks.isNotEmpty) { + _videoSender = await pc.addTrack(tracks.first, camera); + } + } + final screen = _screenStream; + if (screen != null) { + final tracks = screen.getVideoTracks(); + if (tracks.isNotEmpty) { + _screenSender = await pc.addTrack(tracks.first, screen); + } + } + } + + Future _onTopologyChanged(Map msg) async { + final topo = msg['topology']?.toString(); + if (topo == null) return; + logger.i('[call] topology-changed -> $topo'); + info.topology = topo; + final switchingToSfu = topo == 'SERVER' && _topology != 'SERVER'; + _topology = topo; + _notifyInfo(); + if (switchingToSfu) await _setupSfu(); + } + + Future _onProducerUpdated(Map msg) async { + logger.i( + '[call][sfu] producer-updated fields=${msg.keys.toList()} ' + 'sessionId=${msg['sessionId']}', + ); + if (_pc == null) return; + + final session = msg['sessionId']; + final previous = _sfuSessionId; + if (session != null) _sfuSessionId = session; + if (previous != null && session != null && session != previous) { + logger.i('[call][sfu] session changed, recreating peer connection'); + await _rebuildSfuPc(); + } + + final pc = _pc; + if (pc == null) return; + + final description = msg['description']; + String? sdp; + var type = 'offer'; + if (description is Map) { + sdp = (description['sdp'] ?? description['description']) as String?; + type = (description['type'] as String?) ?? 'offer'; + } else if (description is String) { + sdp = description; + } + if (sdp == null) { + logger.w('[call][sfu] producer-updated without sdp: $msg'); + return; + } + + final ssrcs = _extractSsrcs(sdp); + logger.i( + '[call][sfu] producer offer: ${_mLines(sdp)} m-lines, ' + 'ssrcs=${ssrcs.length}, candidates=${_countCandidates(sdp)} ' + '(${_candidateTypes(sdp)}), ${_sdpSummary(sdp)}, ice=${_iceServerUrls()}', + ); + logger.i('[call][sfu] producer m-lines: ${_mLineDetails(sdp)}'); + logger.i('[call][sfu] producer video codecs: ${_videoCodecs(sdp)}'); + await pc.setRemoteDescription(RTCSessionDescription(sdp, type)); + _remoteDescSet = true; + await _flushCandidates(); + await _addRemoteCandidatesFromSdp(pc, sdp); + await _prepareVideoSlot(pc, sdp); + + final answer = await pc.createAnswer({}); + if (_pc != pc) return; + await pc.setLocalDescription(answer); + if (_pc != pc) { + logger.w('[call][sfu] peer connection replaced, dropping answer'); + return; + } + + await _awaitIceGathering(pc); + if (_pc != pc) { + logger.w('[call][sfu] peer connection replaced while gathering'); + return; + } + + RTCSessionDescription? local; + try { + local = await pc.getLocalDescription(); + } catch (e) { + logger.w('[call][sfu] getLocalDescription failed: $e'); + } + final answerSdp = local?.sdp ?? answer.sdp ?? ''; + if (answerSdp.isEmpty) return; + logger.i( + '[call][sfu] answer: ${_mLines(answerSdp)} m-lines, ' + 'candidates=${_countCandidates(answerSdp)} ' + '(${_candidateTypes(answerSdp)}), ${_sdpSummary(answerSdp)}, ' + 'gathering=${pc.iceGatheringState}', + ); + logger.i('[call][sfu] answer m-lines: ${_mLineDetails(answerSdp)}'); + logger.i('[call][sfu] answer video codecs: ${_videoCodecs(answerSdp)}'); + logger.i( + '[call][sfu] video feedback: offer=[${_videoFeedback(sdp)}] ' + 'answer=[${_videoFeedback(answerSdp)}]', + ); + await _logSenders(); + + try { + logger.i('[call][sfu] accept-producer ssrcs=$ssrcs'); + final reply = await _signaling?.acceptProducer( + description: _labelLocalTracks(answerSdp), + ssrcs: ssrcs, + sessionId: _sfuSessionId, + ); + logger.i('[call][sfu] accept-producer reply: $reply'); + } catch (e) { + logger.w('[call][sfu] accept-producer failed: $e'); + } + + Timer(const Duration(seconds: 5), () { + if (_pc == pc && !_ended) unawaited(_dumpIceStats(pc)); + }); + _videoStatsTimer?.cancel(); + _videoStatsTimer = Timer.periodic(const Duration(seconds: 5), (t) { + if (_pc != pc || _ended) { + t.cancel(); + return; + } + unawaited(_dumpVideoStats(pc)); + }); + + if (_accepted) await _sendMediaSettings(); + unawaited(_collectReceivers()); + unawaited(_publishDisplayLayout(force: true)); + } + + int _countCandidates(String sdp) => + RegExp(r'^a=candidate:', multiLine: true).allMatches(sdp).length; + + String _candidateTypes(String sdp) { + final counts = {}; + for (final m in RegExp( + r'^a=candidate:.* typ (\w+)', + multiLine: true, + ).allMatches(sdp)) { + final type = m.group(1) ?? '?'; + counts[type] = (counts[type] ?? 0) + 1; + } + return counts.isEmpty + ? 'none' + : counts.entries.map((e) => '${e.key}=${e.value}').join(' '); + } + + Future _addRemoteCandidatesFromSdp( + RTCPeerConnection pc, + String sdp, + ) async { + final mid = RegExp(r'^a=mid:(\S+)', multiLine: true).firstMatch(sdp); + if (mid == null) return; + final seen = {}; + var added = 0; + for (final m in RegExp( + r'^a=(candidate:\S.*)$', + multiLine: true, + ).allMatches(sdp)) { + final line = m.group(1)!.trim(); + if (!seen.add(line)) continue; + try { + await pc.addCandidate(RTCIceCandidate(line, mid.group(1), 0)); + added++; + } catch (_) {} + } + logger.i( + '[call][sfu] remote candidates added=$added: ' + '${seen.map((c) => c.split(' ').take(6).join(' ')).join(' | ')}', + ); + } + + Future _dumpVideoStats(RTCPeerConnection pc) async { + try { + final rows = []; + for (final r in await pc.getStats()) { + if (r.type != 'inbound-rtp') continue; + final v = r.values; + if (v['kind'] != 'video' && v['mediaType'] != 'video') continue; + rows.add( + '[ssrc=${v['ssrc']} bytes=${v['bytesReceived']} ' + 'packets=${v['packetsReceived']} decoded=${v['framesDecoded']} ' + '${v['frameWidth']}x${v['frameHeight']}]', + ); + } + var transportBytes = 0; + var audioBytes = 0; + for (final r in await pc.getStats()) { + final v = r.values; + if (r.type == 'transport') { + final b = v['bytesReceived']; + if (b is num) transportBytes += b.toInt(); + } else if (r.type == 'inbound-rtp' && + (v['kind'] == 'audio' || v['mediaType'] == 'audio')) { + final b = v['bytesReceived']; + if (b is num) audioBytes += b.toInt(); + } + } + logger.i( + '[call][sfu] inbound video: ${rows.join(' ')} ' + '| transport=$transportBytes audio=$audioBytes', + ); + } catch (e) { + logger.w('[call][sfu] video stats failed: $e'); + } + } + + Future _dumpIceStats(RTCPeerConnection pc) async { + try { + final reports = await pc.getStats(); + final candidates = {}; + for (final r in reports) { + if (r.type != 'local-candidate' && r.type != 'remote-candidate') { + continue; + } + final v = r.values; + candidates[r.id] = + '${v['candidateType']}/${v['protocol']} ' + '${v['ip'] ?? v['address']}:${v['port']}'; + } + + for (final r in reports) { + if (r.type != 'candidate-pair' && r.type != 'googCandidatePair') { + continue; + } + final v = r.values; + final from = candidates[v['localCandidateId']] ?? '?'; + final to = candidates[v['remoteCandidateId']] ?? '?'; + logger.w( + '[call][sfu] pair ${v['state'] ?? v['googState']}: $from -> $to ' + 'sent=${v['requestsSent']} recv=${v['responsesReceived']} ' + 'inRecv=${v['requestsReceived']} nominated=${v['nominated']}', + ); + } + } catch (e) { + logger.w('[call][sfu] ice stats failed: $e'); + } + } + + String _iceServerUrls() => + _iceServers.whereType().map((s) => '${s['urls']}').join(' | '); + + String _sdpSummary(String sdp) { + final bundle = RegExp( + r'^a=group:BUNDLE (.*)$', + multiLine: true, + ).firstMatch(sdp); + final mids = bundle == null + ? 'none' + : '${bundle.group(1)!.trim().split(RegExp(r'\s+')).length}'; + final ufrags = RegExp( + r'^a=ice-ufrag:(\S+)', + multiLine: true, + ).allMatches(sdp).map((m) => m.group(1)).toSet().length; + var active = 0; + var total = 0; + for (final m in RegExp(r'^m=\S+ (\d+)', multiLine: true).allMatches(sdp)) { + total++; + if (m.group(1) != '0') active++; + } + final setup = RegExp( + r'^a=setup:(\S+)', + multiLine: true, + ).allMatches(sdp).map((m) => m.group(1)).toSet().join(','); + final lite = sdp.contains('a=ice-lite') ? ' ice-lite' : ''; + return 'bundle=$mids ufrags=$ufrags active=$active/$total ' + 'setup=$setup$lite'; + } + + String _videoFeedback(String sdp) { + final fb = {}; + var inVideo = false; + for (var line in sdp.split('\n')) { + line = line.trim(); + if (line.startsWith('m=')) { + inVideo = line.startsWith('m=video'); + } else if (inVideo && line.startsWith('a=rtcp-fb:')) { + final idx = line.indexOf(' '); + if (idx > 0) fb.add(line.substring(idx + 1)); + } else if (inVideo && line.startsWith('a=extmap:')) { + if (line.contains('transport-wide-cc')) fb.add('extmap:transport-cc'); + } + } + return fb.isEmpty ? 'нет' : fb.join(', '); + } + + String _videoCodecs(String sdp) { + final codecs = {}; + var inVideo = false; + for (var line in sdp.split('\n')) { + line = line.trim(); + if (line.startsWith('m=')) { + inVideo = line.startsWith('m=video'); + } else if (inVideo && line.startsWith('a=rtpmap:')) { + final m = RegExp(r'^a=rtpmap:\d+ ([^/]+)/').firstMatch(line); + if (m != null) codecs.add(m.group(1)!); + } + } + return codecs.isEmpty ? 'нет' : codecs.join(','); + } + + int _mLines(String sdp) => + RegExp(r'^m=', multiLine: true).allMatches(sdp).length; + + String _mLineDetails(String sdp) { + final rows = []; + String? kind; + String? port; + String? mid; + String? dir; + String? msid; + + void flush() { + if (kind == null) return; + rows.add( + '[$kind:$port mid=${mid ?? '?'} ${dir ?? '?'} msid=${msid ?? '-'}]', + ); + } + + for (var line in sdp.split('\n')) { + line = line.trim(); + if (line.startsWith('m=')) { + flush(); + final parts = line.substring(2).split(' '); + kind = parts.isEmpty ? '?' : parts.first; + port = parts.length > 1 ? parts[1] : '?'; + mid = null; + dir = null; + msid = null; + } else if (line.startsWith('a=mid:')) { + mid = line.substring(6); + } else if (line == 'a=sendrecv' || + line == 'a=recvonly' || + line == 'a=sendonly' || + line == 'a=inactive') { + dir = line.substring(2); + } else if (line.startsWith('a=msid:')) { + msid = line.substring(7); + } + } + flush(); + return rows.join(' '); + } + + List _extractSsrcs(String sdp) { + final set = {}; + for (final m in RegExp(r'a=ssrc:(\d+)', multiLine: true).allMatches(sdp)) { + final v = m.group(1); + if (v != null) set.add(v); + } + return set.toList(); + } + + String _labelLocalTracks(String sdp) { + final self = 'u${ws2Config.userId}'; + final names = {}; + final camera = _videoSender?.track?.id; + final screen = _screenSender?.track?.id; + if (camera != null && camera.isNotEmpty) names[camera] = '$self:sCAMERA'; + if (screen != null && screen.isNotEmpty) names[screen] = '$self:sSCREEN'; + if (names.isEmpty) return sdp; + + var out = sdp; + for (final entry in names.entries) { + final id = RegExp.escape(entry.key); + final name = entry.value; + out = out.replaceAllMapped( + RegExp('^a=msid:(\\S+) $id\\s*\$', multiLine: true), + (m) => 'a=msid:${m[1]} $name', + ); + out = out.replaceAllMapped( + RegExp('^(a=ssrc:\\d+ msid:\\S+) $id\\s*\$', multiLine: true), + (m) => '${m[1]} $name', + ); + out = out.replaceAllMapped( + RegExp('^(a=ssrc:\\d+ label:)$id\\s*\$', multiLine: true), + (m) => '${m[1]}$name', + ); + } + return out; + } + + String _videoDir(String sdp) { + var inVideo = false; + String? mline; + var dir = '?'; + for (var line in sdp.split('\n')) { + line = line.trim(); + if (line.startsWith('m=')) { + inVideo = line.startsWith('m=video'); + if (inVideo) mline = line; + } else if (inVideo && + (line == 'a=sendrecv' || + line == 'a=recvonly' || + line == 'a=sendonly' || + line == 'a=inactive')) { + dir = line.substring(2); + } + } + return mline == null ? 'НЕТ m=video' : '$mline -> $dir'; + } + + Future _onRemoteTrack(RTCTrackEvent event) async { + logger.t( + '[call] remote track: ${event.track.kind} id=${event.track.id} ' + 'streams=${event.streams.length}', + ); + await _bindParticipantTrack(event.track); + if (event.streams.isNotEmpty) { + _remoteStreamRef = event.streams.first; + _remoteStream.add(event.streams.first); + } else { + await _collectReceivers(); + } + } + + int? _participantFromTrackId(String? trackId) { + if (trackId == null) return null; + final slot = RegExp(r'^video-pat-(\d+)$').firstMatch(trackId); + if (slot != null) { + return _slotParticipant[int.parse(slot.group(1)!)]; + } + for (final prefix in const ['video-', 'audio-']) { + if (trackId.length > prefix.length && trackId.startsWith(prefix)) { + final parsed = _participantIdFrom(trackId.substring(prefix.length)); + if (parsed != null) return parsed; + } + } + return null; + } + + Future _bindParticipantTrack(MediaStreamTrack track) async { + final id = _participantFromTrackId(track.id); + if (id == null || id == ws2Config.userId) return; + var stream = _participantStreams[id]; + if (stream == null) { + stream = await createLocalMediaStream('qlyra_p$id'); + _participantStreams[id] = stream; + } + if (stream.getTracks().any((t) => t.id == track.id)) return; + try { + await stream.addTrack(track); + } catch (_) { + return; + } + logger.t('[call] track ${track.id} -> participant $id'); + if (!_participantStreamUpdates.isClosed) _participantStreamUpdates.add(id); + } + + Future _pushRemoteTrack(MediaStreamTrack track) async { + var stream = _remoteStreamRef; + if (stream == null) { + stream = await createLocalMediaStream('qlyra_remote'); + _ownRemoteStream = true; + } + _remoteStreamRef = stream; + if (!stream.getTracks().any((t) => t.id == track.id)) { + try { + await stream.addTrack(track); + } catch (_) {} + } + _remoteStream.add(stream); + } + + Future _logSenders() async { + final pc = _pc; + if (pc == null) return; + try { + final rows = []; + for (final tr in await pc.getTransceivers()) { + final sent = tr.sender.track; + final received = tr.receiver.track; + rows.add( + '[mid=${tr.mid} dir=${await tr.getCurrentDirection()} ' + 'send=${sent == null ? '-' : '${sent.kind}:${sent.id}'} ' + 'recv=${received == null ? '-' : '${received.kind}:${received.id}'}]', + ); + } + logger.i('[call][sfu] transceivers: ${rows.join(' ')}'); + } catch (e) { + logger.w('[call][sfu] transceiver dump failed: $e'); + } + } + + Future _collectReceivers() async { + final pc = _pc; + if (pc == null) return; + try { + for (final tr in await pc.getTransceivers()) { + final track = tr.receiver.track; + if (track != null) { + logger.t('[call] receiver track: ${track.kind} id=${track.id}'); + await _bindParticipantTrack(track); + await _pushRemoteTrack(track); + } + } + } catch (_) {} + } + + Future _createAndSendOffer({bool iceRestart = false}) async { + final pc = _pc; + final peerId = _peerId; + if (pc == null || peerId == null) return; + + final offer = await pc.createOffer(iceRestart ? {'iceRestart': true} : {}); + final sdp = offer.sdp ?? ''; + await pc.setLocalDescription(RTCSessionDescription(sdp, offer.type)); + logger.t('[call] our offer video: ${_videoDir(sdp)}'); + await _signaling?.transmitSdp( + participantId: peerId, + participantType: _peerType, + deviceIdx: _peerDeviceIdx, + type: offer.type!, + sdp: _labelLocalTracks(sdp), + ); + } + + bool get _isDesktop => + defaultTargetPlatform == TargetPlatform.linux || + defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.macOS; + + Future _preferVp8Codecs(RTCPeerConnection pc) async { + try { + final caps = await getRtpSenderCapabilities('video'); + final all = caps.codecs ?? const []; + final hasVp8 = all.any((c) => c.mimeType.toLowerCase() == 'video/vp8'); + if (!hasVp8) return; + final preferred = all.where((c) { + final m = c.mimeType.toLowerCase(); + return m == 'video/vp8' || m == 'video/rtx'; + }).toList(); + if (preferred.isEmpty) return; + for (final t in await pc.getTransceivers()) { + try { + await t.setCodecPreferences(preferred); + } catch (_) {} + } + } catch (e) { + logger.t('[call] setCodecPreferences недоступен: $e'); + } + } + + Future _onTransmittedData(Map msg) async { + final pc = _pc; + if (pc == null) return; + + final data = msg['data']; + if (data is! Map) return; + + final sdp = data['sdp']; + if (sdp is Map) { + final type = sdp['type'] as String?; + final desc = sdp['sdp'] as String?; + if (type == null || desc == null) return; + + _applyRemoteSdp(desc); + logger.t('[call] remote $type video: ${_videoDir(desc)}'); + + if (type == 'answer' && + pc.signalingState != + RTCSignalingState.RTCSignalingStateHaveLocalOffer) { + logger.t('[call] extra answer ignored (state=${pc.signalingState})'); + return; + } + + if (type == 'offer' && + pc.signalingState == + RTCSignalingState.RTCSignalingStateHaveLocalOffer) { + logger.w('[call] offer glare, rolling back local offer'); + await pc.setLocalDescription(RTCSessionDescription(null, 'rollback')); + } + + await pc.setRemoteDescription(RTCSessionDescription(desc, type)); + _remoteDescSet = true; + await _flushCandidates(); + + if (type == 'offer') { + final answer = await pc.createAnswer({}); + await pc.setLocalDescription(answer); + logger.t('[call] our answer video: ${_videoDir(answer.sdp ?? '')}'); + final peerId = _peerId; + if (peerId != null) { + await _signaling?.transmitSdp( + participantId: peerId, + participantType: _peerType, + deviceIdx: _peerDeviceIdx, + type: answer.type!, + sdp: _labelLocalTracks(answer.sdp!), + ); + } + if (_current == CallSessionState.connecting) { + _setState(CallSessionState.ringing); + } + } + unawaited(_collectReceivers()); + return; + } + + final candidate = data['candidate']; + if (candidate is Map) { + _applyRemoteCandidate(candidate['candidate']); + final ice = RTCIceCandidate( + candidate['candidate'] as String?, + candidate['sdpMid'] as String?, + candidate['sdpMLineIndex'] as int?, + ); + if (_remoteDescSet) { + try { + await pc.addCandidate(ice); + } catch (_) {} + } else { + _pendingCandidates.add(ice); + } + } + } + + Future _flushCandidates() async { + final pc = _pc; + if (pc == null || _pendingCandidates.isEmpty) return; + final pending = List.from(_pendingCandidates); + _pendingCandidates.clear(); + for (final c in pending) { + try { + await pc.addCandidate(c); + } catch (_) {} + } + } + + Future _awaitIceGathering( + RTCPeerConnection pc, { + Duration timeout = const Duration(seconds: 5), + }) async { + if (pc.iceGatheringState == + RTCIceGatheringState.RTCIceGatheringStateComplete) { + return; + } + final done = Completer(); + _gatherDone = done; + try { + await done.future.timeout(timeout); + logger.i('[call][sfu] relay candidate gathered'); + } catch (_) { + logger.w('[call][sfu] no relay candidate within $timeout'); + } finally { + _gatherDone = null; + } + } + + void _onLocalCandidate(RTCIceCandidate candidate) { + final line = candidate.candidate; + if (line != null && line.contains(' typ relay')) { + final done = _gatherDone; + if (done != null && !done.isCompleted) done.complete(); + } + if (_topology == 'SERVER') return; + final peerId = _peerId; + if (peerId == null || candidate.candidate == null) return; + _signaling?.transmitCandidate( + participantId: peerId, + participantType: _peerType, + deviceIdx: _peerDeviceIdx, + candidate: candidate.candidate!, + sdpMid: candidate.sdpMid ?? '0', + sdpMLineIndex: candidate.sdpMLineIndex ?? 0, + ); + } + + Future accept({bool activate = true}) async { + if (_accepted) return; + _accepted = true; + logger.i('[call] accept-call sent (activate=$activate)'); + await _signaling?.acceptCall( + isAudioEnabled: !_muted, + isVideoEnabled: _localVideo, + isScreenSharingEnabled: _localScreen, + ); + if (activate) _setState(CallSessionState.active); + } + + Future sendAudioEnabledSignal(bool enabled) async { + await _signaling?.changeMediaSettings(isAudioEnabled: enabled); + } + + Future setMuted(bool muted) async { + await _applyMuted(muted, announce: true); + } + + Future _applyMuted(bool muted, {bool announce = false}) async { + _muted = muted; + _applyAudioTracks(); + _notifyInfo(); + if (announce) await _sendMediaSettings(); + } + + Future _sendMediaSettings() async { + await _signaling?.changeMediaSettings( + isAudioEnabled: !_muted, + isVideoEnabled: _localVideo, + isScreenSharingEnabled: _localScreen, + ); + } + + Future setVideoEnabled(bool on) => on ? _startCamera() : _stopCamera(); + + Future setScreenSharing(bool on) => + on ? _startScreenShare() : _stopScreenShare(); + + Future switchToServerTopology({bool force = false}) async { + if (_topology == 'SERVER') return; + try { + await _signaling?.switchTopology(force: force); + } catch (e) { + logger.w('[call] switch-topology failed: $e'); + } + } + + Future _startCamera() async { + final pc = _pc; + if (pc == null) return; + + final stream = await navigator.mediaDevices.getUserMedia({ + 'video': CameraDevices.constraints(AppCamera.deviceId), + 'audio': false, + }); + + await _disposeStream(_cameraStream); + _cameraStream = stream; + + final tracks = stream.getVideoTracks(); + final track = tracks.isEmpty ? null : tracks.first; + if (track != null) { + if (_videoSender == null) { + _videoSender = await pc.addTrack(track, stream); + } else { + await _videoSender!.replaceTrack(track); + } + } + + _localVideo = true; + await _renegotiate(); + await _sendMediaSettings(); + _notifyInfo(); + } + + Future _stopCamera() async { + try { + await _videoSender?.replaceTrack(null); + } catch (_) {} + await _disposeStream(_cameraStream); + _cameraStream = null; + _localVideo = false; + await _sendMediaSettings(); + _notifyInfo(); + } + + Future _startScreenShare() async { + if (_pc == null) return; + + await CallBridge.instance.setScreenShare(true); + + _localScreen = true; + await _sendMediaSettings(); + _notifyInfo(); + + final MediaStream stream; + try { + stream = await _captureScreen(); + } catch (e) { + _localScreen = false; + await CallBridge.instance.setScreenShare(false); + await _sendMediaSettings(); + _notifyInfo(); + rethrow; + } + logger.i('[call] screen captured, topology=$_topology'); + + await _disposeStream(_screenStream); + _screenStream = stream; + + final pc = _pc; + if (pc == null) return; + + final tracks = stream.getVideoTracks(); + final track = tracks.isEmpty ? null : tracks.first; + if (track != null) { + if (_screenSender == null) { + _screenSender = await pc.addTrack(track, stream); + } else { + await _screenSender!.replaceTrack(track); + } + } + + logger.i('[call] screen share published, topology=$_topology'); + await _renegotiate(); + await _sendMediaSettings(); + _notifyInfo(); + } + + Future _captureScreen() async { + if (!_isDesktop) { + return navigator.mediaDevices.getDisplayMedia({ + 'video': true, + 'audio': false, + }); + } + final sources = await desktopCapturer.getSources( + types: [SourceType.Screen], + ); + if (sources.isEmpty) { + throw StateError('нет доступных экранов для захвата'); + } + return navigator.mediaDevices.getDisplayMedia({ + 'video': { + 'deviceId': {'exact': sources.first.id}, + 'mandatory': {'frameRate': 30.0}, + }, + 'audio': false, + }); + } + + Future _stopScreenShare() async { + try { + await _screenSender?.replaceTrack(null); + } catch (_) {} + await _disposeStream(_screenStream); + _screenStream = null; + _localScreen = false; + await CallBridge.instance.setScreenShare(false); + await _sendMediaSettings(); + _notifyInfo(); + } + + Future _renegotiate() async { + if (_topology == 'SERVER') return; + try { + await _createAndSendOffer(); + } catch (e) { + logger.w('[call] renegotiation offer failed: $e'); + } + } + + Future _clearParticipantStreams() async { + final entries = Map.from(_participantStreams); + _participantStreams.clear(); + for (final id in entries.keys) { + if (!_participantStreamUpdates.isClosed) { + _participantStreamUpdates.add(id); + } + } + for (final stream in entries.values) { + try { + await stream.dispose(); + } catch (_) {} + } + } + + Future _disposeStream(MediaStream? stream) async { + if (stream == null) return; + for (final track in stream.getTracks()) { + try { + await track.stop(); + } catch (_) {} + } + try { + await stream.dispose(); + } catch (_) {} + } + + Future hangup({String? reason}) async { + final r = reason ?? _autoHangupReason(); + try { + await _signaling?.hangup(reason: r); + } catch (_) {} + _end(); + } + + String _autoHangupReason() { + if (_current != CallSessionState.active) { + if (role == CallRole.caller) return 'CANCELED'; + if (role == CallRole.callee && !_accepted) return 'REJECTED'; + } + return 'HUNGUP'; + } + + bool _ended = false; + void _end() { + if (_ended) return; + _ended = true; + _setState(CallSessionState.ended); + _dispose(); + } + + Future _dispose() async { + _levelTimer?.cancel(); + _videoStatsTimer?.cancel(); + try { + await _probeChannel?.close(); + } catch (_) {} + _probeChannel = null; + await _closeSfuChannels(); + for (final track in _localStream?.getTracks() ?? []) { + await track.stop(); + } + await _localStream?.dispose(); + await _disposeMicStream(); + await PulseAudio.closeBridge(); + await _disposeStream(_cameraStream); + await _disposeStream(_screenStream); + _cameraStream = null; + _screenStream = null; + await _pc?.close(); + if (_ownRemoteStream) { + try { + await _remoteStreamRef?.dispose(); + } catch (_) {} + } + await _clearParticipantStreams(); + await _signaling?.close(); + if (!_participantStreamUpdates.isClosed) { + await _participantStreamUpdates.close(); + } + if (!_state.isClosed) await _state.close(); + if (!_remoteStream.isClosed) await _remoteStream.close(); + if (!_info.isClosed) await _info.close(); + if (!_qlyraDetected.isClosed) await _qlyraDetected.close(); + if (!_chatController.isClosed) await _chatController.close(); + if (!_gameController.isClosed) await _gameController.close(); + } + + void _applyConnectionInfo(Map msg, List iceServers) { + final conv = msg['conversation']; + if (conv is Map) { + info.conversationId = conv['id']?.toString(); + info.topology = conv['topology']?.toString(); + final features = conv['features']; + if (features is List) info.record = features.contains('RECORD'); + final parts = conv['participants']; + if (parts is List) { + for (final p in parts.whereType()) { + if (p['id'] != ws2Config.userId) { + final ms = p['mediaSettings']; + if (ms is Map) { + _peerMuted = ms['isAudioEnabled'] != true; + _peerVideo = ms['isVideoEnabled'] == true; + } + } + } + } + } + final mm = msg['mediaModifiers']; + if (mm is Map) { + info.denoise = mm['denoise'] == true || mm['denoiseAnn'] == true; + } + info.stun.clear(); + info.turn.clear(); + for (final s in iceServers.whereType()) { + final urls = s['urls']; + final list = urls is List ? urls : [urls]; + for (final u in list) { + final str = u.toString(); + if (str.startsWith('stun')) { + info.stun.add(str); + } else if (str.startsWith('turn')) { + info.turn.add(str); + } + } + } + _notifyInfo(); + } + + void _applyPeerMedia(Map msg) { + final ms = msg['mediaSettings']; + if (ms is! Map) return; + final pid = msg['participantId']; + if (_peerId != null && pid != null && pid != _peerId) return; + + final muted = ms['isAudioEnabled'] != true; + final video = ms['isVideoEnabled'] == true; + if (muted != _peerMuted || video != _peerVideo) { + _peerMuted = muted; + _peerVideo = video; + _notifyInfo(); + if (video) unawaited(_collectReceivers()); + } + } + + void _applyRegisteredPeer(Map msg) { + final peer = msg['peerId']; + if (peer is Map && peer['type'] == 'WEB_TRANSPORT') return; + final platform = msg['platform']; + if (platform is String && platform.isNotEmpty) { + info.peerPlatform = platform; + _notifyInfo(); + } + } + + void _applyRemoteSdp(String sdp) { + info.peerEngine = CallParse.engine(sdp); + info.audioCodec ??= CallParse.audioCodec(sdp); + info.dtlsFingerprint ??= CallParse.fingerprint(sdp); + if (CallParse.hasAnimoji(sdp)) info.animoji = true; + _notifyInfo(); + } + + void _applyRemoteCandidate(Object? raw) { + if (raw is! String || raw.isEmpty) return; + final c = CallParse.candidate(raw); + final type = c['type']; + final ip = c['ip']; + if (ip == null) return; + if ((type == 'srflx' || type == 'host') && !CallParse.isServerIp(ip)) { + info.peerIp = ip; + info.peerNetwork = CallParse.networkLabel(c['cost']); + _notifyInfo(); + } + } + + Future _resolvePath() async { + final pc = _pc; + if (pc == null) return; + try { + final stats = await pc.getStats(); + final byId = {for (final r in stats) r.id: r}; + StatsReport? pair; + StatsReport? anySucceeded; + for (final r in stats) { + if (r.type != 'candidate-pair') continue; + if (r.values['state'] != 'succeeded') continue; + anySucceeded ??= r; + if (r.values['nominated'] == true || r.values['selected'] == true) { + pair = r; + break; + } + } + pair ??= anySucceeded; + if (pair == null) return; + final local = byId[pair.values['localCandidateId']]; + final remote = byId[pair.values['remoteCandidateId']]; + info.path = CallParse.pathLabel( + local?.values['candidateType']?.toString(), + remote?.values['candidateType']?.toString(), + ); + _notifyInfo(); + } catch (_) {} + } + + void _resolvePeer(Object? conversation) { + if (conversation is! Map) return; + final participants = conversation['participants']; + if (participants is! List) return; + for (final p in participants.whereType()) { + final id = p['id']; + if (id is int && id != ws2Config.userId) { + _peerId = id; + final responderTypes = p['responderTypes']; + if (responderTypes is List && responderTypes.isNotEmpty) { + _peerType = responderTypes.first.toString(); + } + final deviceIdxs = p['responderDeviceIdxs']; + if (deviceIdxs is List && + deviceIdxs.isNotEmpty && + deviceIdxs.first is int) { + _peerDeviceIdx = deviceIdxs.first as int; + } + break; + } + } + } + + List>? _iceServersFrom(Object? convParams) { + if (convParams is! Map) return null; + final servers = >[]; + final stun = convParams['stun']; + if (stun is Map && stun['urls'] != null) { + servers.add({'urls': stun['urls']}); + } + final turn = convParams['turn']; + if (turn is Map && turn['urls'] != null) { + servers.add({ + 'urls': turn['urls'], + if (turn['username'] != null) 'username': turn['username'], + if (turn['credential'] != null) 'credential': turn['credential'], + }); + } + return servers.isEmpty ? null : servers; + } +} diff --git a/lib/core/calls/camera_devices.dart b/lib/core/calls/camera_devices.dart new file mode 100644 index 0000000..d4e117d --- /dev/null +++ b/lib/core/calls/camera_devices.dart @@ -0,0 +1,69 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter_webrtc/flutter_webrtc.dart'; + +import '../utils/logger.dart'; + +enum CameraFacing { front, back, external } + +class VideoInputDevice { + const VideoInputDevice({ + required this.id, + required this.label, + required this.facing, + }); + + final String id; + final String label; + final CameraFacing facing; +} + +class CameraDevices { + CameraDevices._(); + + static Future> cameras() async { + try { + final devices = await navigator.mediaDevices.enumerateDevices(); + final cameras = []; + final seen = {}; + for (final device in devices) { + if (device.kind != 'videoinput') continue; + if (device.deviceId.isEmpty || !seen.add(device.deviceId)) continue; + final label = device.label.trim(); + cameras.add( + VideoInputDevice( + id: device.deviceId, + label: label, + facing: facingOf(label), + ), + ); + } + return cameras; + } catch (e) { + logger.w('[media] enumerate cameras: $e'); + return const []; + } + } + + static CameraFacing facingOf(String label) { + final lower = label.toLowerCase(); + if (lower.contains('front') || lower.contains('user')) { + return CameraFacing.front; + } + if (lower.contains('back') || + lower.contains('rear') || + lower.contains('environment')) { + return CameraFacing.back; + } + return CameraFacing.external; + } + + static Object constraints(String? deviceId) { + if (deviceId == null || deviceId.isEmpty) return true; + if (kIsWeb) return {'deviceId': deviceId}; + return { + 'optional': [ + {'sourceId': deviceId}, + ], + }; + } +} diff --git a/lib/core/calls/conversation_params.dart b/lib/core/calls/conversation_params.dart new file mode 100644 index 0000000..cb88218 --- /dev/null +++ b/lib/core/calls/conversation_params.dart @@ -0,0 +1,94 @@ +import 'package:kusoft/kusoft.dart' as kb; + +/// Параметры подключения к звонку (`vcp`), которые сервер присылает в пуше +/// входящего звонка (opcode 137) и в ответе на инициацию исходящего. +/// +/// Формат строки: `:`. После распаковки — +/// компактный JSON с короткими ключами. Расшифровка повторяет +/// `ru.ok.android.externcalls.sdk.api.ConversationParams.decode`. +class ConversationParams { + /// Токен авторизации в сигналинге звонка. + final String token; + + /// WebSocket сигналинга, напр. `wss://videowebrtc.okcdn.ru/ws2`. + final String wsEndpoint; + final List wsIps; + + /// HTTP/3 web-transport fallback, напр. `https://videowebrtc.okcdn.ru:23456/wt`. + final String? wtEndpoint; + final List wtIps; + + /// API звонков, напр. `https://calls.okcdn.ru`. + final String? callsApiEndpoint; + final List callsApiIps; + + /// Тип клиента, напр. `one_me`. + final String? clientType; + + /// Время истечения параметров (unix-секунды). + final int? expiresAt; + + final String? stun; + final List turn; + final String? turnUser; + final String? turnPassword; + + final bool isVideo; + + const ConversationParams({ + required this.token, + required this.wsEndpoint, + this.wsIps = const [], + this.wtEndpoint, + this.wtIps = const [], + this.callsApiEndpoint, + this.callsApiIps = const [], + this.clientType, + this.expiresAt, + this.stun, + this.turn = const [], + this.turnUser, + this.turnPassword, + this.isVideo = false, + }); + + /// ICE-серверы в формате, который ожидает `flutter_webrtc` + /// (`RTCPeerConnection`). + List> get iceServers { + final servers = >[]; + if (stun != null && stun!.isNotEmpty) { + servers.add({'urls': stun}); + } + if (turn.isNotEmpty) { + servers.add({ + 'urls': turn, + if (turnUser != null) 'username': turnUser, + if (turnPassword != null) 'credential': turnPassword, + }); + } + return servers; + } + + /// `true`, если параметры ещё действительны (с запасом в 5 секунд). + bool get isExpired { + if (expiresAt == null) return false; + final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return nowSec >= expiresAt! - 5; + } + + /// Распаковывает и парсит строку `vcp` через Rust-ядро (kusoft). Возвращает + /// `null`, если формат не распознан. Требует инициализации `initKusoft()`. + static ConversationParams? decode(String vcp) { + final kb.CallParams? p = kb.decodeVcp(vcp: vcp, conversationId: ''); + if (p == null) return null; + return ConversationParams( + token: p.token, + wsEndpoint: p.wsEndpoint, + stun: p.stun, + turn: p.turn, + turnUser: p.turnUser, + turnPassword: p.turnPassword, + isVideo: p.isVideo, + ); + } +} diff --git a/lib/core/calls/pulse_audio.dart b/lib/core/calls/pulse_audio.dart new file mode 100644 index 0000000..fd11b9e --- /dev/null +++ b/lib/core/calls/pulse_audio.dart @@ -0,0 +1,168 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart' show kIsWeb; + +import '../utils/logger.dart'; + +class PulseSource { + const PulseSource({ + required this.name, + required this.label, + required this.isMonitor, + }); + + final String name; + final String label; + final bool isMonitor; +} + +class PulseRouteException implements Exception { + const PulseRouteException(this.source); + + final String source; + + @override + String toString() => source; +} + +class PulseAudio { + PulseAudio._(); + + static const String bridgePrefix = 'qlyra_capture_'; + + static String? _bridgeModule; + static String? _bridgeMaster; + static String? _bridgeSource; + + static bool get supported => !kIsWeb && Platform.isLinux; + + static String get _bridgeName => '$bridgePrefix$pid'; + + static Future _pactl(List args) async { + if (!supported) return null; + try { + return await Process.run( + 'pactl', + args, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); + } on ProcessException catch (e) { + logger.w('[call][pulse] pactl ${args.first}: ${e.message}'); + return null; + } + } + + static Future isAvailable() async => + (await _pactl(const ['info']))?.exitCode == 0; + + static Future> sources() async { + final result = await _pactl(const ['-f', 'json', 'list', 'sources']); + if (result == null || result.exitCode != 0) return const []; + return parseSources(result.stdout as String); + } + + static List parseSources(String json) { + final List entries; + try { + entries = jsonDecode(json) as List; + } catch (e) { + logger.w('[call][pulse] список источников: $e'); + return const []; + } + final sources = []; + for (final entry in entries.whereType>()) { + final name = entry['name']; + if (name is! String || name.isEmpty) continue; + if (name.startsWith(bridgePrefix)) continue; + final monitorOf = entry['monitor_source']; + final isMonitor = monitorOf is String && monitorOf.isNotEmpty; + sources.add( + PulseSource( + name: name, + label: _labelOf(entry, name, isMonitor), + isMonitor: isMonitor, + ), + ); + } + return sources; + } + + static Future find(String name) async { + for (final source in await sources()) { + if (source.name == name) return source; + } + return null; + } + + static Future openBridge(String master) async { + if (_bridgeMaster == master && _bridgeSource != null) return _bridgeSource; + await closeBridge(); + await _dropStaleBridges(); + final name = _bridgeName; + final result = await _pactl([ + 'load-module', + 'module-remap-source', + 'master=$master', + 'source_name=$name', + 'source_properties=device.description=$name', + ]); + if (result == null || result.exitCode != 0) { + logger.w('[call][pulse] remap-source($master): ${result?.stderr}'); + return null; + } + final module = (result.stdout as String).trim(); + if (module.isEmpty) return null; + _bridgeModule = module; + _bridgeMaster = master; + _bridgeSource = name; + logger.i('[call][pulse] мост $name ← $master (модуль $module)'); + return name; + } + + static Future closeBridge() async { + final module = _bridgeModule; + _bridgeModule = null; + _bridgeMaster = null; + _bridgeSource = null; + if (module == null) return; + await _pactl(['unload-module', module]); + } + + static Future _dropStaleBridges() async { + final result = await _pactl(const ['list', 'modules', 'short']); + if (result == null || result.exitCode != 0) return; + for (final line in const LineSplitter().convert(result.stdout as String)) { + final columns = line.split('\t'); + if (columns.length < 3) continue; + final owner = _bridgeOwnerPid(columns[2]); + if (owner == null || Directory('/proc/$owner').existsSync()) continue; + logger.i('[call][pulse] снимаю зависший мост процесса $owner'); + await _pactl(['unload-module', columns[0]]); + } + } + + static int? _bridgeOwnerPid(String argument) { + final match = RegExp('$bridgePrefix([0-9]+)').firstMatch(argument); + return match == null ? null : int.tryParse(match.group(1)!); + } + + static String _labelOf( + Map entry, + String name, + bool isMonitor, + ) { + final description = entry['description']; + if (_usable(description)) return description as String; + final properties = entry['properties']; + final device = properties is Map ? properties['device.description'] : null; + if (_usable(device)) { + return isMonitor ? 'Monitor of $device' : device as String; + } + return name; + } + + static bool _usable(Object? value) => + value is String && value.isNotEmpty && value != '(null)'; +} diff --git a/lib/core/calls/sfu_data_channel.dart b/lib/core/calls/sfu_data_channel.dart new file mode 100644 index 0000000..48ccb5f --- /dev/null +++ b/lib/core/calls/sfu_data_channel.dart @@ -0,0 +1,346 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_webrtc/flutter_webrtc.dart'; + +import '../utils/logger.dart'; + +class MsgpackWriter { + final BytesBuilder _out = BytesBuilder(); + + Uint8List takeBytes() => _out.takeBytes(); + + void raw(int byte) => _out.addByte(byte); + + void nil() => raw(0xC0); + + void boolean(bool value) => raw(value ? 0xC3 : 0xC2); + + void integer(int value) { + if (value >= 0) { + if (value < 0x80) return raw(value); + if (value <= 0xFF) { + raw(0xCC); + return raw(value); + } + if (value <= 0xFFFF) { + raw(0xCD); + return _uint(value, 2); + } + if (value <= 0xFFFFFFFF) { + raw(0xCE); + return _uint(value, 4); + } + raw(0xCF); + return _uint(value, 8); + } + if (value >= -32) return raw(0xE0 | (value + 32)); + if (value >= -128) { + raw(0xD0); + return _uint(value & 0xFF, 1); + } + if (value >= -32768) { + raw(0xD1); + return _uint(value & 0xFFFF, 2); + } + if (value >= -2147483648) { + raw(0xD2); + return _uint(value & 0xFFFFFFFF, 4); + } + raw(0xD3); + _uint(value, 8); + } + + void string(String value) { + final bytes = utf8.encode(value); + final length = bytes.length; + if (length < 32) { + raw(0xA0 | length); + } else if (length <= 0xFF) { + raw(0xD9); + raw(length); + } else if (length <= 0xFFFF) { + raw(0xDA); + _uint(length, 2); + } else { + raw(0xDB); + _uint(length, 4); + } + _out.add(bytes); + } + + void arrayHeader(int length) { + if (length < 16) return raw(0x90 | length); + if (length <= 0xFFFF) { + raw(0xDC); + return _uint(length, 2); + } + raw(0xDD); + _uint(length, 4); + } + + void _uint(int value, int bytes) { + for (var shift = (bytes - 1) * 8; shift >= 0; shift -= 8) { + raw((value >> shift) & 0xFF); + } + } +} + +class MsgpackReader { + final Uint8List _data; + int _pos = 0; + + MsgpackReader(this._data); + + bool get exhausted => _pos >= _data.length; + + bool get nextIsString { + final b = _data[_pos]; + return (b & 0xE0) == 0xA0 || b == 0xD9 || b == 0xDA || b == 0xDB; + } + + int readInt() { + final b = _data[_pos++]; + if (b < 0x80) return b; + if (b >= 0xE0) return b - 256; + switch (b) { + case 0xCC: + return _uint(1); + case 0xCD: + return _uint(2); + case 0xCE: + return _uint(4); + case 0xCF: + return _uint(8); + case 0xD0: + final v = _uint(1); + return v >= 0x80 ? v - 0x100 : v; + case 0xD1: + final v = _uint(2); + return v >= 0x8000 ? v - 0x10000 : v; + case 0xD2: + final v = _uint(4); + return v >= 0x80000000 ? v - 0x100000000 : v; + case 0xD3: + return _uint(8); + } + throw FormatException('не целое: 0x${b.toRadixString(16)}'); + } + + String readString() { + final b = _data[_pos++]; + int length; + if ((b & 0xE0) == 0xA0) { + length = b & 0x1F; + } else if (b == 0xD9) { + length = _uint(1); + } else if (b == 0xDA) { + length = _uint(2); + } else if (b == 0xDB) { + length = _uint(4); + } else { + throw FormatException('не строка: 0x${b.toRadixString(16)}'); + } + final value = utf8.decode(_data.sublist(_pos, _pos + length)); + _pos += length; + return value; + } + + int readMapHeader() { + final b = _data[_pos++]; + if ((b & 0xF0) == 0x80) return b & 0x0F; + if (b == 0xDE) return _uint(2); + if (b == 0xDF) return _uint(4); + throw FormatException('не map: 0x${b.toRadixString(16)}'); + } + + int readArrayHeader() { + final b = _data[_pos++]; + if ((b & 0xF0) == 0x90) return b & 0x0F; + if (b == 0xDC) return _uint(2); + if (b == 0xDD) return _uint(4); + throw FormatException('не array: 0x${b.toRadixString(16)}'); + } + + int _uint(int bytes) { + var value = 0; + for (var i = 0; i < bytes; i++) { + value = (value << 8) | _data[_pos++]; + } + return value; + } +} + +class SfuLayoutItem { + final String trackKey; + final int width; + final int height; + + const SfuLayoutItem({ + required this.trackKey, + this.width = 640, + this.height = 360, + }); +} + +class SfuCommandChannel { + static const int _commandDisplayLayout = 0; + static const int _fitMode = 0; + + static const int _notifyAliases = 1; + static const int _notifySlots = 2; + static const int _notifyAudioLevels = 6; + + RTCDataChannel? _command; + int _sequence = 1; + + final Map _aliases = {}; + final _slots = StreamController>.broadcast(); + final _levels = StreamController>.broadcast(); + + Stream> get slotUpdates => _slots.stream; + Stream> get audioLevels => _levels.stream; + + void bind(RTCDataChannel channel) { + if (channel.label == 'producerCommand') { + _command = channel; + channel.onMessage = _onCommandReply; + return; + } + if (channel.label != 'producerNotification') return; + channel.onMessage = _onNotification; + } + + bool get ready => _command?.state == RTCDataChannelState.RTCDataChannelOpen; + + Future sendDisplayLayout( + List items, { + bool snapshot = true, + }) async { + final channel = _command; + if (channel == null) return false; + if (channel.state != RTCDataChannelState.RTCDataChannelOpen) { + logger.w('[call][sfu] producerCommand не открыт, слои не отправлены'); + return false; + } + + final writer = MsgpackWriter() + ..integer(_commandDisplayLayout) + ..integer(0) + ..integer(_sequence++) + ..boolean(snapshot); + + if (items.isEmpty) { + writer.nil(); + } else { + writer.arrayHeader(items.length * 2); + for (final item in items) { + writer + ..string(item.trackKey) + ..integer(0) + ..nil() + ..integer(item.width) + ..integer(item.height) + ..integer(_fitMode); + } + } + writer.nil(); + + final payload = writer.takeBytes(); + try { + await channel.send(RTCDataChannelMessage.fromBinary(payload)); + logger.i( + '[call][sfu] update-display-layout: ' + '${items.map((i) => i.trackKey).join(', ')} ' + 'raw=${payload.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}', + ); + return true; + } catch (e) { + logger.w('[call][sfu] update-display-layout failed: $e'); + return false; + } + } + + void _onCommandReply(RTCDataChannelMessage message) { + if (!message.isBinary) return; + final bytes = message.binary; + final head = bytes.length > 32 ? bytes.sublist(0, 32) : bytes; + final hex = head.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' '); + try { + final reader = MsgpackReader(bytes); + final type = reader.readInt(); + final version = reader.readInt(); + final error = reader.readInt(); + if (error != 0) { + logger.w( + '[call][sfu] command reply type=$type version=$version ' + 'ERROR=$error raw=$hex', + ); + return; + } + logger.i('[call][sfu] command reply type=$type ok raw=$hex'); + } catch (e) { + logger.w('[call][sfu] command reply parse failed: $e raw=$hex'); + } + } + + int _dumped = 0; + + void _onNotification(RTCDataChannelMessage message) { + if (!message.isBinary) return; + if (_dumped < 12) { + _dumped++; + final bytes = message.binary; + final head = bytes.length > 64 ? bytes.sublist(0, 64) : bytes; + logger.i( + '[call][sfu] notify raw len=${bytes.length} ' + '${head.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}', + ); + } + final bytes = message.binary; + if (bytes.isEmpty) return; + final type = bytes[0]; + final reader = MsgpackReader(Uint8List.sublistView(bytes, 1)); + try { + switch (type) { + case _notifyAliases: + final count = reader.readMapHeader(); + for (var i = 0; i < count; i++) { + final key = reader.readString(); + _aliases[reader.readInt()] = key; + } + break; + case _notifySlots: + final count = reader.readArrayHeader(); + final slots = {}; + for (var i = 0; i < count; i++) { + final key = _aliases[reader.readInt()]; + if (key != null) slots[key] = i; + } + logger.i('[call][sfu] slots: $slots'); + if (!_slots.isClosed) _slots.add(slots); + break; + case _notifyAudioLevels: + final count = reader.readMapHeader(); + final levels = {}; + for (var i = 0; i < count; i++) { + final key = _aliases[reader.readInt()]; + final level = reader.readInt(); + if (key != null) levels[key] = level; + } + if (!_levels.isClosed) _levels.add(levels); + break; + } + } catch (e) { + logger.w('[call][sfu] notify type=$type parse failed: $e'); + } + } + + Future dispose() async { + _command = null; + _aliases.clear(); + if (!_slots.isClosed) await _slots.close(); + if (!_levels.isClosed) await _levels.close(); + } +} diff --git a/lib/core/calls/ws2_signaling.dart b/lib/core/calls/ws2_signaling.dart new file mode 100644 index 0000000..e9d9ec3 --- /dev/null +++ b/lib/core/calls/ws2_signaling.dart @@ -0,0 +1,360 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import '../transport/ikar_network.dart'; + +import 'conversation_params.dart'; + +/// Параметры подключения к сигналинг-сокету ws2. +/// +/// Строится из двух источников: +/// - входящий звонок: [Ws2Config.fromVcp] (параметры из `vcp` пуша opcode 137); +/// - исходящий звонок: [Ws2Config.fromEndpoint] (`endpoint` из ответа opcode 78, +/// в нём уже вшит токен — дописываем только клиентские параметры). +class Ws2Config { + /// Готовый URL подключения к ws2. + final Uri uri; + + /// Внутренний id пользователя в системе звонков. + final int userId; + + const Ws2Config({required this.uri, required this.userId}); + + static const defaultCapabilities = '3c02f'; + static const _appVersion = 'sdk-0.2.1.3'; + static const defaultDevice = 'Android/Unknown'; + static const defaultOsVersion = '34'; + + /// Входящий звонок: из распакованных параметров [ConversationParams]. + /// `userId` — часть после `:` в [ConversationParams.turnUser]. + factory Ws2Config.fromVcp( + ConversationParams params, { + required String conversationId, + String capabilities = defaultCapabilities, + String? device, + String? osVersion, + }) { + final userId = int.tryParse((params.turnUser ?? '').split(':').last) ?? 0; + final uri = Uri.parse(params.wsEndpoint).replace( + queryParameters: { + 'userId': '$userId', + 'token': params.token, + 'conversationId': conversationId, + 'version': '5', + 'capabilities': capabilities, + 'device': device ?? defaultDevice, + 'platform': 'ANDROID', + 'clientType': 'ONE_ME', + 'appVersion': _appVersion, + 'osVersion': osVersion ?? defaultOsVersion, + }, + ); + return Ws2Config(uri: uri, userId: userId); + } + + /// Исходящий звонок или вход в конференцию: `endpoint` из ответа opcode 78 / + /// 166 уже содержит токен и conversationId/userId в query — дописываем + /// клиентские параметры. Без `tgt=start` медиасервер принимает сокет, но не + /// поднимает разговор и не шлёт нотификацию `connection`. + factory Ws2Config.fromEndpoint( + String endpoint, { + required int userId, + String capabilities = defaultCapabilities, + String? device, + String? osVersion, + }) { + final base = Uri.parse(endpoint); + final uri = base.replace( + queryParameters: { + ...base.queryParameters, + 'version': '5', + 'capabilities': capabilities, + 'device': device ?? defaultDevice, + 'platform': 'ANDROID', + 'clientType': 'ONE_ME', + 'appVersion': _appVersion, + 'osVersion': osVersion ?? defaultOsVersion, + 'tgt': 'start', + }, + ); + return Ws2Config(uri: uri, userId: userId); + } +} + +/// Ошибка, которую вернул сервер в ответе на команду. +class Ws2CommandException implements Exception { + final String command; + final Object? error; + Ws2CommandException(this.command, this.error); + @override + String toString() => 'Ws2CommandException($command): $error'; +} + +/// Клиент сигналинга звонка поверх WebSocket `ws2`. +/// +/// Тонкий адаптер над Rust-ядром (kusoft [kb.CallSignaling]): ядро держит +/// WebSocket, корреляцию `sequence`/`response`, keepalive `ping`→`pong` и +/// разбор кадров; здесь — прежний Dart-интерфейс для [call_session]. +/// +/// Конверт сообщений: +/// - запрос: `{"command": ..., ..., "sequence": N}` +/// - ответ: `{"sequence": N, "response": "", "type": "response"}` +/// - пуш: `{..., "notification": "", "type": "notification"}` +class Ws2Signaling { + final Ws2Config config; + + WebSocket? _call; + StreamSubscription? _notifSub; + int _sequence = 0; + final _requests = >>{}; + final _earlyNotifications = >[]; + + final _notifications = StreamController>.broadcast(); + final _closed = Completer(); + + Ws2Signaling(this.config) { + _notifications.onListen = () { + for (final item in _earlyNotifications) { + _notifications.add(item); + } + _earlyNotifications.clear(); + }; + } + + /// Пуши сервера (`type == "notification"`). Фильтруй по полю `notification`. + Stream> get notifications => _notifications.stream; + + /// Завершается, когда сокет закрыт. + Future get done => _closed.future; + + bool get isConnected => _call?.readyState == WebSocket.open; + + Future connect() async { + final call = await IkarNetwork.connectWebSocket( + config.uri.toString(), + headers: {'User-Agent': 'IKAR/1.0.0'}, + ); + _call = call; + _notifSub = call.listen( + (frame) { + Object? decoded; + try { + decoded = jsonDecode( + frame is String ? frame : utf8.decode(frame as List), + ); + } catch (_) { + return; + } + if (decoded is! Map) return; + if (decoded['type'] == 'response') { + final waiter = _requests.remove( + (decoded['sequence'] as num?)?.toInt(), + ); + if (waiter != null && !waiter.isCompleted) waiter.complete(decoded); + } else if (decoded['type'] == 'notification') { + if (_notifications.hasListener) { + _notifications.add(decoded); + } else if (_earlyNotifications.length < 256) { + _earlyNotifications.add(decoded); + } + } + }, + onError: (_) => _onDone(null), + onDone: () => _onDone(null), + cancelOnError: false, + ); + } + + void _onDone(Object? error) { + for (final waiter in _requests.values) { + if (!waiter.isCompleted) + waiter.completeError(error ?? StateError('Звонок завершён')); + } + _requests.clear(); + if (!_closed.isCompleted) _closed.complete(error); + if (!_notifications.isClosed) _notifications.close(); + } + + /// Отправляет команду и ждёт ответ сервера. Бросает [Ws2CommandException], + /// если сервер вернул ошибку. + Future> sendCommand( + String command, { + Map extra = const {}, + Duration timeout = const Duration(seconds: 15), + }) async { + final call = _call; + if (call == null) { + return Future.error(StateError('ws2 не подключён')); + } + try { + final sequence = ++_sequence; + final waiter = Completer>(); + _requests[sequence] = waiter; + call.add( + jsonEncode({ + 'type': 'request', + 'command': command, + 'sequence': sequence, + ...extra, + }), + ); + try { + return await waiter.future.timeout(timeout); + } finally { + _requests.remove(sequence); + } + } catch (e) { + throw Ws2CommandException(command, e); + } + } + + /// Передаёт SDP (offer/answer) другому участнику. + Future transmitSdp({ + required int participantId, + required String type, + required String sdp, + String participantType = 'USER', + int deviceIdx = 0, + String capabilities = Ws2Config.defaultCapabilities, + }) { + return sendCommand( + 'transmit-data', + extra: { + 'participantId': participantId, + 'participantType': participantType, + 'deviceIdx': deviceIdx, + 'data': { + 'sdp': {'type': type, 'sdp': sdp}, + }, + 'capabilities': capabilities, + }, + ); + } + + /// Передаёт ICE-кандидата другому участнику (trickle). + Future transmitCandidate({ + required int participantId, + required String candidate, + required String sdpMid, + required int sdpMLineIndex, + String participantType = 'USER', + int deviceIdx = 0, + }) { + return sendCommand( + 'transmit-data', + extra: { + 'participantId': participantId, + 'participantType': participantType, + 'deviceIdx': deviceIdx, + 'data': { + 'candidate': { + 'candidate': candidate, + 'sdpMid': sdpMid, + 'sdpMLineIndex': sdpMLineIndex, + }, + }, + }, + ); + } + + Future changeMediaSettings({ + bool isAudioEnabled = true, + bool isVideoEnabled = false, + bool isScreenSharingEnabled = false, + bool isAnimojiEnabled = false, + bool? isFastScreenSharingEnabled, + bool? isAudioSharingEnabled, + }) { + return sendCommand( + 'change-media-settings', + extra: { + 'mediaSettings': { + 'isVideoEnabled': isVideoEnabled, + 'isAudioEnabled': isAudioEnabled, + 'isScreenSharingEnabled': isScreenSharingEnabled, + 'isAnimojiEnabled': isAnimojiEnabled, + 'isFastScreenSharingEnabled': ?isFastScreenSharingEnabled, + 'isAudioSharingEnabled': ?isAudioSharingEnabled, + }, + }, + ); + } + + Future switchTopology({ + String topology = 'SERVER', + bool force = false, + }) { + return sendCommand( + 'switch-topology', + extra: {'topology': topology, 'force': force}, + ); + } + + Future requestRealloc() => sendCommand('request-realloc'); + + /// Принять входящий звонок (сторона вызываемого). + Future acceptCall({ + bool isAudioEnabled = true, + bool isVideoEnabled = false, + bool isScreenSharingEnabled = false, + bool isAnimojiEnabled = false, + }) { + return sendCommand( + 'accept-call', + extra: { + 'mediaSettings': { + 'isVideoEnabled': isVideoEnabled, + 'isAudioEnabled': isAudioEnabled, + 'isScreenSharingEnabled': isScreenSharingEnabled, + 'isAnimojiEnabled': isAnimojiEnabled, + }, + }, + ); + } + + Future hangup({String reason = 'HUNGUP'}) => + sendCommand('hangup', extra: {'reason': reason}); + + Future> allocateConsumer() => sendCommand( + 'allocate-consumer', + extra: const { + 'capabilities': { + 'maxH264Decoders': 10, + 'producerNotificationDataChannelVersion': 7, + 'producerCommandDataChannelVersion': 2, + 'audioMix': true, + 'consumerUpdate': true, + 'onDemandTracks': true, + 'singleSession': true, + 'unifiedPlan': true, + 'fastScreenShare': true, + 'consumerFastScreenShareQualityOnDemand': true, + 'red': true, + 'videoTracksCount': 10, + 'csrcAccessible': true, + }, + }, + ); + + Future> acceptProducer({ + required String description, + required List ssrcs, + Object? sessionId, + }) => sendCommand( + 'accept-producer', + extra: { + 'description': description, + if (ssrcs.isNotEmpty) 'ssrcs': ssrcs, + 'sessionId': ?sessionId, + }, + ); + + Future close() async { + await _notifSub?.cancel(); + _notifSub = null; + await _call?.close(); + _call = null; + _onDone(null); + } +} diff --git a/lib/core/config/app_accent.dart b/lib/core/config/app_accent.dart new file mode 100644 index 0000000..a8bd16f --- /dev/null +++ b/lib/core/config/app_accent.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppAccent { + static const prefKey = 'app_accent_seed'; + + static const List<({String label, Color? seed})> presets = [ + (label: 'Системный', seed: null), + (label: 'Сиреневый', seed: Color(0xFFC1C4FF)), + (label: 'Синий', seed: Color(0xFF4F8EFF)), + (label: 'Бирюзовый', seed: Color(0xFF00BFA5)), + (label: 'Зелёный', seed: Color(0xFF43A047)), + (label: 'Янтарный', seed: Color(0xFFFFB300)), + (label: 'Розовый', seed: Color(0xFFE91E63)), + (label: 'Красный', seed: Color(0xFFE53935)), + (label: 'Фиолетовый', seed: Color(0xFF7E57C2)), + ]; + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + final val = prefs.getInt(prefKey); + if (val == null) return null; + return Color(val); + } + + static Future save(Color? color) async { + final prefs = await SharedPreferences.getInstance(); + if (color == null) { + await prefs.remove(prefKey); + } else { + await prefs.setInt(prefKey, color.toARGB32()); + } + } +} diff --git a/lib/core/config/app_amoled.dart b/lib/core/config/app_amoled.dart new file mode 100644 index 0000000..e66b830 --- /dev/null +++ b/lib/core/config/app_amoled.dart @@ -0,0 +1,25 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppAmoled { + static const prefKey = 'app_amoled'; + + static bool get systemIsDark => + PlatformDispatcher.instance.platformBrightness == Brightness.dark; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: systemIsDark, + read: (prefs, key) => prefs.getBool(key) ?? systemIsDark, + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); +} diff --git a/lib/core/config/app_animations.dart b/lib/core/config/app_animations.dart new file mode 100644 index 0000000..6d4abab --- /dev/null +++ b/lib/core/config/app_animations.dart @@ -0,0 +1,12 @@ +class AppAnimations { + const AppAnimations._(); + + static const String _dir = 'assets/lottie'; + + static const String clock = '$_dir/ic_clock.json'; + static const String search = '$_dir/ic_search.json'; + static const String settings = '$_dir/ic_settings.json'; + static const String chat = '$_dir/ic_chat.json'; + static const String call = '$_dir/ic_call.json'; + static const String contacts = '$_dir/ic_contacts.json'; +} diff --git a/lib/core/config/app_badge.dart b/lib/core/config/app_badge.dart new file mode 100644 index 0000000..37b6dd4 --- /dev/null +++ b/lib/core/config/app_badge.dart @@ -0,0 +1,20 @@ +import 'persisted_setting.dart'; + +class AppBadge { + static final enabled = _flag('badge_enabled', true); + static final includeMuted = _flag('badge_include_muted', false); + static final countMessages = _flag('badge_count_messages', true); + + static Future load() => + Future.wait([enabled.load(), includeMuted.load(), countMessages.load()]); + + static PersistedSetting _flag(String key, bool fallback) => + PersistedSetting( + prefKey: key, + defaultValue: fallback, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); +} diff --git a/lib/core/config/app_bubble_behavior.dart b/lib/core/config/app_bubble_behavior.dart new file mode 100644 index 0000000..e062e55 --- /dev/null +++ b/lib/core/config/app_bubble_behavior.dart @@ -0,0 +1,34 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +enum BubbleBehavior { mutable, immutable } + +class AppBubbleBehavior { + static const prefKey = 'app_bubble_behavior'; + + static final _setting = PersistedEnum( + prefKey: prefKey, + defaultValue: BubbleBehavior.mutable, + encode: (value) => value.name, + decode: _parse, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(BubbleBehavior behavior) => _setting.save(behavior); + + static BubbleBehavior _parse(String? val) => + enumFromName(BubbleBehavior.values, val, BubbleBehavior.mutable); + + static String label(BubbleBehavior behavior) { + switch (behavior) { + case BubbleBehavior.mutable: + return 'Изменяемая'; + case BubbleBehavior.immutable: + return 'Неизменяемая'; + } + } +} diff --git a/lib/core/config/app_bubble_shape.dart b/lib/core/config/app_bubble_shape.dart new file mode 100644 index 0000000..df78a76 --- /dev/null +++ b/lib/core/config/app_bubble_shape.dart @@ -0,0 +1,34 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +enum BubbleStyle { mobile, desktop } + +class AppBubbleShape { + static const prefKey = 'app_bubble_shape'; + + static final _setting = PersistedEnum( + prefKey: prefKey, + defaultValue: BubbleStyle.mobile, + encode: (value) => value.name, + decode: _parse, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(BubbleStyle style) => _setting.save(style); + + static BubbleStyle _parse(String? val) => + enumFromName(BubbleStyle.values, val, BubbleStyle.mobile); + + static String label(BubbleStyle style) { + switch (style) { + case BubbleStyle.mobile: + return 'TG Mobile'; + case BubbleStyle.desktop: + return 'TG Desktop'; + } + } +} diff --git a/lib/core/config/app_cache_extent.dart b/lib/core/config/app_cache_extent.dart new file mode 100644 index 0000000..398e361 --- /dev/null +++ b/lib/core/config/app_cache_extent.dart @@ -0,0 +1,34 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppCacheExtent { + static const prefKey = 'app_cache_extent'; + static const double defaultValue = 5000; + static const double min = 1000; + static const double max = 10000; + static const double lowWarnThreshold = 2500; + static const double highWarnThreshold = 7000; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getDouble(key), + write: (prefs, key, value) async { + await prefs.setDouble(key, value); + }, + sanitize: clamp, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(double value) => _setting.save(value); + + static double clamp(double v) { + if (v < min) return min; + if (v > max) return max; + return v; + } +} diff --git a/lib/core/config/app_camera.dart b/lib/core/config/app_camera.dart new file mode 100644 index 0000000..8354e1d --- /dev/null +++ b/lib/core/config/app_camera.dart @@ -0,0 +1,45 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppCamera { + static const prefKey = 'media_camera_id'; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: '', + read: (prefs, key) => prefs.getString(key), + write: (prefs, key, value) async { + await prefs.setString(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static String? get deviceId => + _setting.current.value.isEmpty ? null : _setting.current.value; + + static Future load() => _setting.load(); + + static Future save(String value) => _setting.save(value); +} + +class AppVideoNoteCamera { + static final _custom = PersistedSetting( + prefKey: 'video_note_custom_camera', + defaultValue: false, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); + + static ValueNotifier get useCustom => _custom.current; + + static String? get customCameraId => + _custom.current.value ? AppCamera.deviceId : null; + + static Future load() => _custom.load(); + + static Future setUseCustom(bool value) => _custom.save(value); +} diff --git a/lib/core/config/app_chat_chrome.dart b/lib/core/config/app_chat_chrome.dart new file mode 100644 index 0000000..677380c --- /dev/null +++ b/lib/core/config/app_chat_chrome.dart @@ -0,0 +1,33 @@ +import 'package:flutter/foundation.dart'; + +import '../../frontend/widgets/liquid_glass.dart'; +import 'persisted_setting.dart'; + +enum ChatChromeStyle { color, blur, none, transparent, liquidGlass } + +class ChatChromeMaterial { + static bool isLiquid(ChatChromeStyle style) => + style == ChatChromeStyle.liquidGlass && LiquidGlass.isSupported; +} + +class AppChatChrome { + static const prefKey = 'app_chat_chrome'; + + static final _setting = PersistedEnum( + prefKey: prefKey, + defaultValue: ChatChromeStyle.none, + encode: _encode, + decode: _parse, + ); + + static ValueNotifier get current => _setting.current; + + static ChatChromeStyle _parse(String? value) => + enumFromName(ChatChromeStyle.values, value, ChatChromeStyle.none); + + static String _encode(ChatChromeStyle value) => value.name; + + static Future load() => _setting.load(); + + static Future save(ChatChromeStyle value) => _setting.save(value); +} diff --git a/lib/core/config/app_colors.dart b/lib/core/config/app_colors.dart new file mode 100644 index 0000000..35ffbed --- /dev/null +++ b/lib/core/config/app_colors.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import '../utils/seed_scheme.dart'; + +extension AppColorTokens on ColorScheme { + Color get mutedText => onSurfaceVariant.withValues(alpha: 0.6); +} + +const int kAvatarThumbSize = 144; + +const Color kSuccessGreen = Color(0xFF2EC36B); +const Color kDangerRed = Color(0xFFE5484D); +const Color kReadReceiptBlue = Color(0xFF4FC3F7); + +class MediaAccent { + static Color? _seed; + static ColorScheme? _scheme; + + static ColorScheme schemeOf(BuildContext context) { + final seed = Theme.of(context).colorScheme.primary; + if (_seed != seed || _scheme == null) { + _seed = seed; + _scheme = schemeFromSeed(seed, Brightness.dark); + } + return _scheme!; + } + + static Color of(BuildContext context) => schemeOf(context).primary; +} diff --git a/lib/core/config/app_commands.dart b/lib/core/config/app_commands.dart new file mode 100644 index 0000000..42e8c37 --- /dev/null +++ b/lib/core/config/app_commands.dart @@ -0,0 +1,23 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppCommands { + static const prefKey = 'dev_commands'; + static const bool defaultValue = false; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); +} diff --git a/lib/core/config/app_composer_background.dart b/lib/core/config/app_composer_background.dart new file mode 100644 index 0000000..e37b115 --- /dev/null +++ b/lib/core/config/app_composer_background.dart @@ -0,0 +1,35 @@ +import 'package:flutter/foundation.dart'; + +import '../../frontend/widgets/liquid_glass.dart'; +import 'persisted_setting.dart'; + +enum ComposerBackground { standard, frostBlur, liquidGlass } + +class ComposerMaterial { + static bool isLiquid(ComposerBackground value) => + value == ComposerBackground.liquidGlass && LiquidGlass.isSupported; + + static bool isFrost(ComposerBackground value) => + value == ComposerBackground.frostBlur || + (value == ComposerBackground.liquidGlass && !LiquidGlass.isSupported); +} + +class AppComposerBackground { + static const prefKey = 'app_composer_background'; + + static final _setting = PersistedEnum( + prefKey: prefKey, + defaultValue: ComposerBackground.standard, + encode: (value) => value.name, + decode: _parse, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(ComposerBackground value) => _setting.save(value); + + static ComposerBackground _parse(String? val) => + enumFromName(ComposerBackground.values, val, ComposerBackground.standard); +} diff --git a/lib/core/config/app_composer_style.dart b/lib/core/config/app_composer_style.dart new file mode 100644 index 0000000..39ed03c --- /dev/null +++ b/lib/core/config/app_composer_style.dart @@ -0,0 +1,34 @@ +import 'package:flutter/foundation.dart'; + +import 'app_visual_style.dart'; +import 'persisted_setting.dart'; + +enum ComposerStyle { auto, glossy, materialYou } + +class ComposerChrome { + static bool isGlossy(ComposerStyle style) => switch (style) { + ComposerStyle.auto => AppVisualStyle.current.value.glossyChrome, + ComposerStyle.glossy => true, + ComposerStyle.materialYou => false, + }; +} + +class AppComposerStyle { + static const prefKey = 'app_composer_style'; + + static final _setting = PersistedEnum( + prefKey: prefKey, + defaultValue: ComposerStyle.glossy, + encode: (value) => value.name, + decode: _parse, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(ComposerStyle value) => _setting.save(value); + + static ComposerStyle _parse(String? val) => + enumFromName(ComposerStyle.values, val, ComposerStyle.glossy); +} diff --git a/lib/core/config/app_digital_id_mode.dart b/lib/core/config/app_digital_id_mode.dart new file mode 100644 index 0000000..5766809 --- /dev/null +++ b/lib/core/config/app_digital_id_mode.dart @@ -0,0 +1,22 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppDigitalIdNative { + static const prefKey = 'app_digital_id_native'; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: false, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); +} diff --git a/lib/core/config/app_fonts.dart b/lib/core/config/app_fonts.dart new file mode 100644 index 0000000..624468f --- /dev/null +++ b/lib/core/config/app_fonts.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; + +import 'custom_font_service.dart'; + +const String kDisplayFontFamily = 'Outfit'; + +@immutable +class AppDisplayFont extends ThemeExtension { + final String? family; + + const AppDisplayFont(this.family); + + @override + AppDisplayFont copyWith({String? family}) => + AppDisplayFont(family ?? this.family); + + @override + AppDisplayFont lerp(ThemeExtension? other, double t) => + t < 0.5 ? this : (other as AppDisplayFont? ?? this); +} + +String? displayFontOf(BuildContext context) => + Theme.of(context).extension()?.family ?? kDisplayFontFamily; + +class AppFont { + final String id; + final String label; + final String? fontFamily; + + final double metricScale; + + const AppFont({ + required this.id, + required this.label, + this.fontFamily, + this.metricScale = 1.0, + }); + + bool get isSystem => fontFamily == null; + bool get isCustom => id.startsWith(AppFonts.customPrefix); +} + +class AppFonts { + static const String prefKey = 'app_font'; + static const String scalePrefKey = 'app_font_scale'; + static const String customPrefix = 'g:'; + + static const double minScale = 0.60; + static const double maxScale = 1.35; + static const double defaultScale = 1.0; + + static const List builtIn = [ + AppFont(id: 'system', label: 'Системный'), + AppFont( + id: 'inter', + label: 'Inter', + fontFamily: 'Inter', + metricScale: 0.967, + ), + AppFont( + id: 'unbounded', + label: 'Unbounded', + fontFamily: 'Unbounded', + metricScale: 0.933, + ), + ]; + + static AppFont get fallback => builtIn.first; + + static String customId(String family) => '$customPrefix$family'; + + static AppFont resolve(String id) { + if (id.startsWith(customPrefix)) { + final family = id.substring(customPrefix.length); + return AppFont( + id: id, + label: family, + fontFamily: family, + metricScale: CustomFontService.metricScaleFor(family), + ); + } + return builtIn.firstWhere((f) => f.id == id, orElse: () => fallback); + } + + static double effectiveScale(String id, double userScale) => + userScale * resolve(id).metricScale; + + static String? displayFamily(String id) { + final font = resolve(id); + return font.isSystem ? kDisplayFontFamily : font.fontFamily; + } + + static TextTheme textTheme(String id, TextTheme base) { + final family = resolve(id).fontFamily; + if (family == null) return base; + return base.apply(fontFamily: family); + } + + static TextStyle sample(String id, {required double fontSize}) { + final family = resolve(id).fontFamily; + return TextStyle(fontFamily: family, fontSize: fontSize); + } + + static double clampScale(double scale) => + scale.clamp(minScale, maxScale).toDouble(); + + static String? familyFromInput(String input) { + var value = input.trim(); + if (value.isEmpty) return null; + + final uri = Uri.tryParse(value); + if (uri != null && uri.host.contains('fonts.google.com')) { + final idx = uri.pathSegments.indexOf('specimen'); + if (idx != -1 && idx + 1 < uri.pathSegments.length) { + value = uri.pathSegments[idx + 1]; + } + } + + try { + value = Uri.decodeComponent(value); + } catch (_) {} + value = value.replaceAll('+', ' ').trim(); + return value.isEmpty ? null : value; + } +} diff --git a/lib/core/config/app_frost.dart b/lib/core/config/app_frost.dart new file mode 100644 index 0000000..91b7de2 --- /dev/null +++ b/lib/core/config/app_frost.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +class AppFrost { + static const double sigma = 34; + static const double panelSigma = 24; + static const double overlaySigma = 18; + static const double mediaBackdropSigma = 30; + static const double glassAlpha = 0.28; + static const double blurPanelAlpha = 0.55; + static const double scrimAlpha = 0.4; + + static Color glassTint(ColorScheme cs, [double alpha = glassAlpha]) => + cs.surfaceContainerHigh.withValues(alpha: alpha); + + static Color blurPanelTint(ColorScheme cs) => glassTint(cs, blurPanelAlpha); + + static Color scrim([double alpha = scrimAlpha]) => + Colors.black.withValues(alpha: alpha); + + static BorderSide hairline(ColorScheme cs) => + BorderSide(color: cs.outlineVariant.withValues(alpha: 0.4), width: 0.5); +} diff --git a/lib/core/config/app_icon.dart b/lib/core/config/app_icon.dart new file mode 100644 index 0000000..42fe265 --- /dev/null +++ b/lib/core/config/app_icon.dart @@ -0,0 +1,88 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum AppIcon { + defaultIcon( + 'default', + 'Default', + 'assets/ikar_icon.png', + 'MainActivity', + null, + ), + minimal( + 'minimal', + 'Minimal', + 'assets/meteor_icon.png', + 'MinimalIcon', + 'MinimalIcon', + ); + + final String id; + final String title; + final String previewAsset; + final String androidAlias; + final String? iosAlternateName; + + const AppIcon( + this.id, + this.title, + this.previewAsset, + this.androidAlias, + this.iosAlternateName, + ); +} + +class AppIconConfig { + static const prefKey = 'app_icon'; + static const _channel = MethodChannel('ru.ikar.app/app_icon'); + + static final ValueNotifier current = ValueNotifier( + AppIcon.defaultIcon, + ); + + static bool get isSupported => Platform.isAndroid || Platform.isIOS; + + static Future load() async { + if (!isSupported) return; + final prefs = await SharedPreferences.getInstance(); + var icon = _parse(prefs.getString(prefKey)); + final applied = await _appliedIcon(); + if (applied != null && applied != icon) { + icon = applied; + await prefs.setString(prefKey, icon.id); + } + current.value = icon; + } + + static Future apply(AppIcon icon) async { + if (!isSupported) return; + if (current.value == icon) return; + await _channel.invokeMethod('setAppIcon', { + 'name': Platform.isIOS ? icon.iosAlternateName : icon.androidAlias, + }); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(prefKey, icon.id); + current.value = icon; + } + + static Future _appliedIcon() async { + if (!Platform.isIOS) return null; + try { + final name = await _channel.invokeMethod('getAppIcon'); + for (final icon in AppIcon.values) { + if (icon.iosAlternateName == name) return icon; + } + } catch (_) {} + return null; + } + + static AppIcon _parse(String? val) { + for (final icon in AppIcon.values) { + if (icon.id == val) return icon; + } + return AppIcon.defaultIcon; + } +} diff --git a/lib/core/config/app_link_preview.dart b/lib/core/config/app_link_preview.dart new file mode 100644 index 0000000..b2d104f --- /dev/null +++ b/lib/core/config/app_link_preview.dart @@ -0,0 +1,23 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppLinkPreview { + static const prefKey = 'dev_link_preview'; + static const bool defaultValue = true; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); +} diff --git a/lib/core/config/app_liquid_glass.dart b/lib/core/config/app_liquid_glass.dart new file mode 100644 index 0000000..ad8fcd6 --- /dev/null +++ b/lib/core/config/app_liquid_glass.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +class AppLiquidGlass { + static const bool enabled = false; + + static const double blurSigma = 0; + static const double spread = 1; + static const double refraction = 34; + static const double chroma = 0; + static const double specular = 0.45; + static const double rimWidth = 8; + static const Offset light = Offset(-0.4, -1); + static const double tintFeather = 44; + + static Color navTint(ColorScheme cs) => + cs.surfaceContainerHigh.withValues(alpha: 0); + + static Color panelTint(ColorScheme cs) => cs.surface.withValues(alpha: 0.24); +} diff --git a/lib/core/config/app_media_cache.dart b/lib/core/config/app_media_cache.dart new file mode 100644 index 0000000..66c8450 --- /dev/null +++ b/lib/core/config/app_media_cache.dart @@ -0,0 +1,36 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppMediaCacheLimit { + static const prefKey = 'media_cache_limit_bytes'; + static const int defaultValue = 500 * 1024 * 1024; // 500 МБ + + /// Значение «без лимита» — вытеснение из кэша отключено. + static const int unlimited = 0; + + /// Доступные пресеты лимита, байты (0 — без лимита). + static const List presets = [ + 100 * 1024 * 1024, + 250 * 1024 * 1024, + 500 * 1024 * 1024, + 1024 * 1024 * 1024, + 2 * 1024 * 1024 * 1024, + unlimited, + ]; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getInt(key), + write: (prefs, key, value) async { + await prefs.setInt(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(int value) => _setting.save(value); +} diff --git a/lib/core/config/app_message_actions_style.dart b/lib/core/config/app_message_actions_style.dart new file mode 100644 index 0000000..ffb69a8 --- /dev/null +++ b/lib/core/config/app_message_actions_style.dart @@ -0,0 +1,42 @@ +import 'package:flutter/foundation.dart'; + +import 'ios_release.dart'; +import 'persisted_setting.dart'; + +enum MessageActionsStyle { radial, list } + +class AppMessageActionsStyle { + static const prefKey = 'app_message_actions_style'; + + static final _setting = PersistedEnum( + prefKey: prefKey, + defaultValue: MessageActionsStyle.list, + encode: (value) => value.name, + decode: _parse, + ); + + static ValueNotifier get current => _setting.current; + + /// Style the menu opens with. iOS ignores a stored radial choice and + /// always uses the list. + static MessageActionsStyle get effective => + IosRelease.messageActionsStyleChoice + ? current.value + : MessageActionsStyle.list; + + static Future load() => _setting.load(); + + static Future save(MessageActionsStyle style) => _setting.save(style); + + static MessageActionsStyle _parse(String? val) => + enumFromName(MessageActionsStyle.values, val, MessageActionsStyle.list); + + static String label(MessageActionsStyle style) { + switch (style) { + case MessageActionsStyle.radial: + return 'Радиальное'; + case MessageActionsStyle.list: + return 'Список'; + } + } +} diff --git a/lib/core/config/app_message_status_size.dart b/lib/core/config/app_message_status_size.dart new file mode 100644 index 0000000..8aa8727 --- /dev/null +++ b/lib/core/config/app_message_status_size.dart @@ -0,0 +1,30 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppMessageStatusSize { + static const prefKey = 'app_message_status_scale'; + static const double defaultScale = 1; + static const double minScale = 1; + static const double maxScale = 1.5; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultScale, + read: (prefs, key) => prefs.getDouble(key), + write: (prefs, key, value) async { + await prefs.setDouble(key, value); + }, + sanitize: clampScale, + ); + + static ValueNotifier get current => _setting.current; + + static double clampScale(double value) => value.isFinite + ? value.clamp(minScale, maxScale).toDouble() + : defaultScale; + + static Future load() => _setting.load(); + + static Future save(double value) => _setting.save(value); +} diff --git a/lib/core/config/app_microphone.dart b/lib/core/config/app_microphone.dart new file mode 100644 index 0000000..ad696da --- /dev/null +++ b/lib/core/config/app_microphone.dart @@ -0,0 +1,26 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppMicrophone { + static const prefKey = 'call_microphone_id'; + static const String defaultValue = ''; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getString(key), + write: (prefs, key, value) async { + await prefs.setString(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static String? get deviceId => + _setting.current.value.isEmpty ? null : _setting.current.value; + + static Future load() => _setting.load(); + + static Future save(String value) => _setting.save(value); +} diff --git a/lib/core/config/app_nav_pill_style.dart b/lib/core/config/app_nav_pill_style.dart new file mode 100644 index 0000000..4423e13 --- /dev/null +++ b/lib/core/config/app_nav_pill_style.dart @@ -0,0 +1,45 @@ +import 'package:flutter/foundation.dart'; + +import '../../frontend/widgets/liquid_glass.dart'; +import 'app_visual_style.dart'; +import 'persisted_setting.dart'; + +enum NavPillStyle { auto, glossy, frostBlur, liquidGlass } + +class NavPillMaterial { + static NavPillStyle resolve(NavPillStyle style) { + if (style != NavPillStyle.auto) return style; + return AppVisualStyle.current.value == VisualStyle.liquidGlass + ? NavPillStyle.liquidGlass + : NavPillStyle.glossy; + } + + static bool isLiquid(NavPillStyle style) => + resolve(style) == NavPillStyle.liquidGlass && LiquidGlass.isSupported; + + static bool isFrost(NavPillStyle style) { + final resolved = resolve(style); + return resolved == NavPillStyle.frostBlur || + (resolved == NavPillStyle.liquidGlass && !LiquidGlass.isSupported); + } +} + +class AppNavPillStyle { + static const prefKey = 'app_nav_pill_style'; + + static final _setting = PersistedEnum( + prefKey: prefKey, + defaultValue: NavPillStyle.glossy, + encode: (value) => value.name, + decode: _parse, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(NavPillStyle value) => _setting.save(value); + + static NavPillStyle _parse(String? val) => + enumFromName(NavPillStyle.values, val, NavPillStyle.glossy); +} diff --git a/lib/core/config/app_phonebook_names.dart b/lib/core/config/app_phonebook_names.dart new file mode 100644 index 0000000..f36d954 --- /dev/null +++ b/lib/core/config/app_phonebook_names.dart @@ -0,0 +1,23 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppPhonebookNames { + static const prefKey = 'dev_phonebook_names'; + static const bool defaultValue = true; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); +} diff --git a/lib/core/config/app_pill_gradient.dart b/lib/core/config/app_pill_gradient.dart new file mode 100644 index 0000000..d0b2967 --- /dev/null +++ b/lib/core/config/app_pill_gradient.dart @@ -0,0 +1,22 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppPillGradient { + static const prefKey = 'app_pill_gradient'; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: false, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); +} diff --git a/lib/core/config/app_pranks.dart b/lib/core/config/app_pranks.dart new file mode 100644 index 0000000..36b6219 --- /dev/null +++ b/lib/core/config/app_pranks.dart @@ -0,0 +1,23 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppPranks { + static const prefKey = 'dev_pranks'; + static const bool defaultValue = false; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); +} diff --git a/lib/core/config/app_pulse_source.dart b/lib/core/config/app_pulse_source.dart new file mode 100644 index 0000000..e4a349c --- /dev/null +++ b/lib/core/config/app_pulse_source.dart @@ -0,0 +1,26 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppPulseSource { + static const prefKey = 'call_pulse_source'; + static const String defaultValue = ''; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getString(key), + write: (prefs, key, value) async { + await prefs.setString(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static String? get name => + _setting.current.value.isEmpty ? null : _setting.current.value; + + static Future load() => _setting.load(); + + static Future save(String value) => _setting.save(value); +} diff --git a/lib/core/config/app_shape.dart b/lib/core/config/app_shape.dart new file mode 100644 index 0000000..dfce8fc --- /dev/null +++ b/lib/core/config/app_shape.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class AppShape { + static const double card = 20; + static const double button = 14; + static const double sheet = 24; + static const double dialog = 24; + static const double pill = 100; + + static const BorderRadius cardRadius = BorderRadius.all( + Radius.circular(card), + ); + static const BorderRadius buttonRadius = BorderRadius.all( + Radius.circular(button), + ); + static const BorderRadius pillRadius = BorderRadius.all( + Radius.circular(pill), + ); + + static const RoundedRectangleBorder buttonBorder = RoundedRectangleBorder( + borderRadius: buttonRadius, + ); + static const RoundedRectangleBorder dialogBorder = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(dialog)), + ); + static const RoundedRectangleBorder sheetBorder = RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(sheet)), + ); +} diff --git a/lib/core/config/app_show_extra_info.dart b/lib/core/config/app_show_extra_info.dart new file mode 100644 index 0000000..e1580d4 --- /dev/null +++ b/lib/core/config/app_show_extra_info.dart @@ -0,0 +1,23 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppShowExtraInfo { + static const prefKey = 'dev_show_extra_info'; + static const bool defaultValue = false; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); +} diff --git a/lib/core/config/app_spectrum_background.dart b/lib/core/config/app_spectrum_background.dart new file mode 100644 index 0000000..b1b47be --- /dev/null +++ b/lib/core/config/app_spectrum_background.dart @@ -0,0 +1,25 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppSpectrumBackground { + static const prefKey = 'app_spectrum_background'; + static const bool defaultValue = false; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static bool get isEnabled => _setting.current.value; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); +} diff --git a/lib/core/config/app_stories.dart b/lib/core/config/app_stories.dart new file mode 100644 index 0000000..169d317 --- /dev/null +++ b/lib/core/config/app_stories.dart @@ -0,0 +1,23 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppStories { + static const prefKey = 'dev_stories'; + static const bool defaultValue = true; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); +} diff --git a/lib/core/config/app_swipe_back_desktop.dart b/lib/core/config/app_swipe_back_desktop.dart new file mode 100644 index 0000000..9dd338a --- /dev/null +++ b/lib/core/config/app_swipe_back_desktop.dart @@ -0,0 +1,23 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppSwipeBackDesktop { + static const prefKey = 'dev_swipe_back_desktop'; + static const bool defaultValue = false; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); +} diff --git a/lib/core/config/app_theme_mode.dart b/lib/core/config/app_theme_mode.dart new file mode 100644 index 0000000..3426a8a --- /dev/null +++ b/lib/core/config/app_theme_mode.dart @@ -0,0 +1,38 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +enum AppThemeMode { system, light, dark, schedule } + +class AppThemeModeConfig { + static const prefKey = 'app_theme_mode'; + + static final _setting = PersistedEnum( + prefKey: prefKey, + defaultValue: AppThemeMode.system, + encode: (mode) => mode.name, + decode: _parse, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(AppThemeMode mode) => _setting.save(mode); + + static AppThemeMode _parse(String? val) => + enumFromName(AppThemeMode.values, val, AppThemeMode.system); + + static String label(AppThemeMode mode) { + switch (mode) { + case AppThemeMode.system: + return 'Системная'; + case AppThemeMode.light: + return 'Светлая'; + case AppThemeMode.dark: + return 'Тёмная'; + case AppThemeMode.schedule: + return 'По расписанию'; + } + } +} diff --git a/lib/core/config/app_theme_schedule.dart b/lib/core/config/app_theme_schedule.dart new file mode 100644 index 0000000..bcbff0d --- /dev/null +++ b/lib/core/config/app_theme_schedule.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../utils/format.dart'; + +class ThemeSchedule { + final TimeOfDay darkStart; + final TimeOfDay darkEnd; + + const ThemeSchedule({required this.darkStart, required this.darkEnd}); + + bool isDarkAt(DateTime now) { + final nowMin = now.hour * 60 + now.minute; + final startMin = darkStart.hour * 60 + darkStart.minute; + final endMin = darkEnd.hour * 60 + darkEnd.minute; + if (startMin == endMin) return false; + if (startMin < endMin) { + return nowMin >= startMin && nowMin < endMin; + } + return nowMin >= startMin || nowMin < endMin; + } + + Duration durationUntilNextSwitch(DateTime now) { + final nowMin = now.hour * 60 + now.minute; + final startMin = darkStart.hour * 60 + darkStart.minute; + final endMin = darkEnd.hour * 60 + darkEnd.minute; + int? nextMin; + for (final m in [startMin, endMin]) { + final delta = (m - nowMin + 1440) % 1440; + final candidate = delta == 0 ? 1440 : delta; + if (nextMin == null || candidate < nextMin) nextMin = candidate; + } + final secondsLeft = (nextMin ?? 1) * 60 - now.second; + return Duration(seconds: secondsLeft.clamp(1, 24 * 60 * 60)); + } +} + +class AppThemeSchedule { + static const prefKey = 'app_theme_schedule'; + static const _defaultStart = TimeOfDay(hour: 22, minute: 0); + static const _defaultEnd = TimeOfDay(hour: 7, minute: 0); + + static final ValueNotifier current = ValueNotifier( + const ThemeSchedule(darkStart: _defaultStart, darkEnd: _defaultEnd), + ); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + final value = _parse(prefs.getString(prefKey)); + current.value = value; + return value; + } + + static Future save(ThemeSchedule schedule) async { + current.value = schedule; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + prefKey, + '${_fmt(schedule.darkStart)}-${_fmt(schedule.darkEnd)}', + ); + } + + static String _fmt(TimeOfDay t) => '${pad2(t.hour)}:${pad2(t.minute)}'; + + static ThemeSchedule _parse(String? val) { + if (val == null) { + return const ThemeSchedule( + darkStart: _defaultStart, + darkEnd: _defaultEnd, + ); + } + final parts = val.split('-'); + if (parts.length != 2) { + return const ThemeSchedule( + darkStart: _defaultStart, + darkEnd: _defaultEnd, + ); + } + final start = _parseTime(parts[0]) ?? _defaultStart; + final end = _parseTime(parts[1]) ?? _defaultEnd; + return ThemeSchedule(darkStart: start, darkEnd: end); + } + + static TimeOfDay? _parseTime(String val) { + final parts = val.split(':'); + if (parts.length != 2) return null; + final h = int.tryParse(parts[0]); + final m = int.tryParse(parts[1]); + if (h == null || m == null) return null; + if (h < 0 || h > 23 || m < 0 || m > 59) return null; + return TimeOfDay(hour: h, minute: m); + } + + static String format(TimeOfDay t) => _fmt(t); +} diff --git a/lib/core/config/app_video_note_quality.dart b/lib/core/config/app_video_note_quality.dart new file mode 100644 index 0000000..b582607 --- /dev/null +++ b/lib/core/config/app_video_note_quality.dart @@ -0,0 +1,67 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppVideoNoteResolution { + static const prefKey = 'dev_video_note_resolution'; + static const int defaultValue = 480; + static const List presets = [480, 720, 1080]; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getInt(key), + write: (prefs, key, value) async { + await prefs.setInt(key, value); + }, + sanitize: (value) => presets.contains(value) ? value : defaultValue, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(int value) => _setting.save(value); +} + +class AppVideoNoteRearCamera { + static const prefKey = 'dev_video_note_rear_camera'; + static const bool defaultValue = false; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); +} + +class AppVideoNoteFps { + static const prefKey = 'dev_video_note_fps'; + static const int defaultValue = 30; + static const List presets = [30, 60]; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getInt(key), + write: (prefs, key, value) async { + await prefs.setInt(key, value); + }, + sanitize: (value) => presets.contains(value) ? value : defaultValue, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(int value) => _setting.save(value); +} diff --git a/lib/core/config/app_visual_style.dart b/lib/core/config/app_visual_style.dart new file mode 100644 index 0000000..8c8c786 --- /dev/null +++ b/lib/core/config/app_visual_style.dart @@ -0,0 +1,31 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +enum VisualStyle { materialYou, glossy, liquidGlass } + +extension VisualStyleChrome on VisualStyle { + bool get glossyChrome => this != VisualStyle.materialYou; +} + +class AppVisualStyle { + static const prefKey = 'app_visual_style'; + + static final _setting = PersistedEnum( + prefKey: prefKey, + defaultValue: VisualStyle.materialYou, + encode: _encode, + decode: _parse, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(VisualStyle value) => _setting.save(value); + + static String _encode(VisualStyle value) => value.name; + + static VisualStyle _parse(String? val) => + enumFromName(VisualStyle.values, val, VisualStyle.materialYou); +} diff --git a/lib/core/config/app_wallpaper_tint.dart b/lib/core/config/app_wallpaper_tint.dart new file mode 100644 index 0000000..23c5ed5 --- /dev/null +++ b/lib/core/config/app_wallpaper_tint.dart @@ -0,0 +1,22 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppWallpaperTint { + static const prefKey = 'app_wallpaper_tint'; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: false, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); +} diff --git a/lib/core/config/build_profile.dart b/lib/core/config/build_profile.dart new file mode 100644 index 0000000..9bb88bd --- /dev/null +++ b/lib/core/config/build_profile.dart @@ -0,0 +1,24 @@ +import 'package:flutter/services.dart' show appFlavor; + +// #***! что включено в сборке, всё считается на компиляции из flavor +abstract final class BuildProfile { + static const String storeFlavor = 'store'; + + // #***! store сборка урезана, без самообновления дев инструментов и спуфа + static const bool isStore = appFlavor == storeFlavor; + + static const bool plugins = true; + + static const bool selfUpdate = !isStore; + static const bool firebasePush = appFlavor == 'ikar'; + static const bool spoofUi = !isStore; + static const bool tokenLogin = !isStore; + static const bool devTools = !isStore; + static const bool insecureTransport = !isStore; + static const bool trafficCapture = !isStore; + static const bool pranks = !isStore; + static const bool hiddenContentViewers = !isStore; + static const bool digitalId = !isStore; + static const bool ipGeoLookup = !isStore; + static const bool sessionCityLookup = !isStore; +} diff --git a/lib/core/config/call_no_mute.dart b/lib/core/config/call_no_mute.dart new file mode 100644 index 0000000..4a5f414 --- /dev/null +++ b/lib/core/config/call_no_mute.dart @@ -0,0 +1,11 @@ +class CallNoMute { + CallNoMute._(); + + static const String flag = '--no-mute'; + + static bool enabled = const bool.fromEnvironment('NO_MUTE'); + + static void parse(List args) { + if (args.contains(flag)) enabled = true; + } +} diff --git a/lib/core/config/chat_wallpaper_themes.dart b/lib/core/config/chat_wallpaper_themes.dart new file mode 100644 index 0000000..281c84c --- /dev/null +++ b/lib/core/config/chat_wallpaper_themes.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; + +import '../utils/tiled_svg.dart'; + +@immutable +class ChatWallpaperTheme { + final String id; + final List colors; + final AlignmentGeometry begin; + final AlignmentGeometry end; + final String? pattern; + final Color patternColor; + final double patternOpacity; + final double tileSize; + final bool dark; + + const ChatWallpaperTheme({ + required this.id, + required this.colors, + this.begin = Alignment.topLeft, + this.end = Alignment.bottomRight, + this.pattern, + this.patternColor = Colors.white, + this.patternOpacity = 0.1, + this.tileSize = 130, + this.dark = true, + }); + + Gradient get gradient => + LinearGradient(colors: colors, begin: begin, end: end); + + Color get bubbleTint => colors.first; + + Widget buildBackground() => _ChatWallpaperThemeView(theme: this); + + Widget buildPreview() => + _ChatWallpaperThemeView(theme: this, tileScale: 0.42); +} + +class _ChatWallpaperThemeView extends StatelessWidget { + final ChatWallpaperTheme theme; + final double tileScale; + + const _ChatWallpaperThemeView({required this.theme, this.tileScale = 1}); + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + DecoratedBox(decoration: BoxDecoration(gradient: theme.gradient)), + if (theme.pattern != null) + TiledSvgPattern( + asset: theme.pattern!, + color: theme.patternColor, + opacity: theme.patternOpacity, + tileSize: theme.tileSize * tileScale, + ), + ], + ); + } +} + +const String _kPatternDir = 'assets/wallpapers/patterns'; + +const List kChatWallpaperThemes = [ + ChatWallpaperTheme( + id: 'ocean', + colors: [Color(0xFF2A7B9B), Color(0xFF57C1EB), Color(0xFF246FA8)], + pattern: '$_kPatternDir/bubbles.svg', + patternOpacity: 0.1, + ), + ChatWallpaperTheme( + id: 'sunset', + colors: [Color(0xFFFF7E5F), Color(0xFFFEB47B)], + pattern: '$_kPatternDir/hearts.svg', + patternOpacity: 0.12, + ), + ChatWallpaperTheme( + id: 'lavender', + colors: [Color(0xFF9D50BB), Color(0xFF6E48AA)], + pattern: '$_kPatternDir/stars.svg', + patternOpacity: 0.11, + ), + ChatWallpaperTheme( + id: 'mint', + colors: [Color(0xFF43E97B), Color(0xFF38F9D7)], + pattern: '$_kPatternDir/plus.svg', + patternColor: Colors.black, + patternOpacity: 0.06, + tileSize: 66, + dark: false, + ), + ChatWallpaperTheme( + id: 'graphite', + colors: [Color(0xFF232526), Color(0xFF414345)], + pattern: '$_kPatternDir/plus.svg', + patternOpacity: 0.06, + tileSize: 66, + ), + ChatWallpaperTheme( + id: 'sky', + colors: [Color(0xFF2193B0), Color(0xFF6DD5ED)], + pattern: '$_kPatternDir/planes.svg', + patternOpacity: 0.11, + ), + ChatWallpaperTheme( + id: 'peach', + colors: [Color(0xFFFFD3A5), Color(0xFFFD6585)], + pattern: '$_kPatternDir/rings.svg', + patternColor: Colors.black, + patternOpacity: 0.05, + dark: false, + ), + ChatWallpaperTheme( + id: 'forest', + colors: [Color(0xFF134E5E), Color(0xFF71B280)], + pattern: '$_kPatternDir/rings.svg', + patternOpacity: 0.09, + ), + ChatWallpaperTheme( + id: 'grape', + colors: [Color(0xFF4776E6), Color(0xFF8E54E9)], + pattern: '$_kPatternDir/stars.svg', + patternOpacity: 0.11, + ), + ChatWallpaperTheme( + id: 'night', + colors: [Color(0xFF0F2027), Color(0xFF203A43), Color(0xFF2C5364)], + pattern: '$_kPatternDir/stars.svg', + patternOpacity: 0.08, + ), + ChatWallpaperTheme( + id: 'rose', + colors: [Color(0xFFF4C4F3), Color(0xFFFC67FA)], + pattern: '$_kPatternDir/hearts.svg', + patternOpacity: 0.14, + ), + ChatWallpaperTheme( + id: 'amber', + colors: [Color(0xFFF7971E), Color(0xFFFFD200)], + pattern: '$_kPatternDir/bubbles.svg', + patternColor: Colors.black, + patternOpacity: 0.05, + dark: false, + ), +]; + +ChatWallpaperTheme? chatWallpaperThemeById(String? id) { + if (id == null) return null; + for (final theme in kChatWallpaperThemes) { + if (theme.id == id) return theme; + } + return null; +} diff --git a/lib/core/config/config.dart b/lib/core/config/config.dart new file mode 100644 index 0000000..354067a --- /dev/null +++ b/lib/core/config/config.dart @@ -0,0 +1,55 @@ +import 'package:shared_preferences/shared_preferences.dart'; + + +// #***! адрес сервера и тайминги +abstract class ServerConfig { + static const String defaultHost = 'ikar.kusoft.xyz'; + static const List presetHosts = [defaultHost]; + static const int defaultPort = 443; + static const bool defaultTrustMincifryCa = false; + static const String prefHostKey = 'server_host_override'; + static const String prefPortKey = 'server_port_override'; + static const String prefTrustMincifryKey = 'server_trust_mincifry_ca'; + static const Duration pingInterval = Duration(seconds: 10); + static const Duration requestTimeout = Duration(seconds: 30); + static const int maxReconnectAttempts = 50; + + // #***! в dev адрес можно переопределить, в релизе всегда дефолтный + static Future<({String host, int port, bool trustMincifryCa})> + loadEndpoint() async { + final prefs = await SharedPreferences.getInstance(); + final rawHost = prefs.getString(prefHostKey); + final rawPort = prefs.getInt(prefPortKey); + final host = (rawHost != null && rawHost.trim().isNotEmpty) + ? rawHost.trim() + : defaultHost; + var port = defaultPort; + if (isValidPort(rawPort)) port = rawPort!; + return ( + host: host, + port: port, + trustMincifryCa: false, + ); + } + + static bool isValidPort(int? port) => + port != null && port >= 1 && port <= 65535; + + static Future saveEndpoint({ + required String host, + required int port, + required bool trustMincifryCa, + }) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(prefHostKey, host); + await prefs.setInt(prefPortKey, port); + await prefs.setBool(prefTrustMincifryKey, trustMincifryCa); + } + + static Future resetEndpoint() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(prefHostKey); + await prefs.remove(prefPortKey); + await prefs.remove(prefTrustMincifryKey); + } +} diff --git a/lib/core/config/countries.dart b/lib/core/config/countries.dart new file mode 100644 index 0000000..666f58d --- /dev/null +++ b/lib/core/config/countries.dart @@ -0,0 +1,512 @@ +import 'dart:convert'; + +class CountryName { + final String code; // ISO 3166-1 alpha-2, например "RU" + final String en; + final String ru; + final String phoneCode; // Код страны для звонков, например "+7" + final int phoneDigits; // Количество цифр номера после кода страны + final String + phoneMask; // Маска абонентского номера, например "(###) ###-##-##" + final List phoneGroupSizes; // Размеры групп цифр, например [3, 3, 2, 2] + final List + phoneGroupSeparators; // Разделители вокруг групп, например ["(", ") ", "-", "-", ""] + + const CountryName({ + required this.code, + required this.en, + required this.ru, + required this.phoneCode, + required this.phoneDigits, + required this.phoneMask, + required this.phoneGroupSizes, + required this.phoneGroupSeparators, + }); + + String displayName(String languageCode) { + return languageCode == 'ru' ? ru : en; + } +} + +/// Полный список стран (195 государств) с названием на русском и английском. +/// +/// Источник данных: +/// https://github.com/stefangabos/world_countries +final List allCountries = _buildCountries(); + +/// Быстрый доступ по ISO-коду. +final Map countriesByCode = { + for (final country in allCountries) country.code: country, +}; + +const Map primaryCountryByPhoneCode = {'+7': 'RU', '+1': 'US'}; + +bool isPrimaryForPhoneCode(CountryName country) => + primaryCountryByPhoneCode[country.phoneCode] == country.code; + +List sortedByDisplayName( + Iterable countries, + String languageCode, +) { + final list = countries.toList(); + list.sort( + (a, b) => a + .displayName(languageCode) + .toLowerCase() + .compareTo(b.displayName(languageCode).toLowerCase()), + ); + return list; +} + +List countriesInServerOrder(Iterable codes) { + final out = []; + for (final raw in codes) { + final c = countriesByCode[raw.toUpperCase()]; + if (c != null) out.add(c); + } + return out; +} + +/// Пример использования: +/// ```dart +/// final country = exampleCountryLookup('RU'); +/// print(country?['ru']); // Россия +/// print(country?['en']); // Russian Federation +/// ``` +Map? exampleCountryLookup(String code) { + final country = countriesByCode[code.toUpperCase()]; + if (country == null) return null; + + return {'code': country.code, 'ru': country.ru, 'en': country.en}; +} + +List _buildCountries() { + final enList = (jsonDecode(_countriesEnJson) as List) + .cast>(); + final ruList = (jsonDecode(_countriesRuJson) as List) + .cast>(); + + final ruByCode = { + for (final item in ruList) + (item['alpha2'] as String).toUpperCase(): item['name'] as String, + }; + + final countries = enList.map((item) { + final code = (item['alpha2'] as String).toUpperCase(); + final en = item['name'] as String; + final ru = ruByCode[code] ?? en; + + final phoneCode = item['phoneCode'] as String; + final phoneDigits = item['phoneDigits'] as int; + final phoneMask = item['phoneMask'] as String; + final phoneGroupSizes = (item['phoneGroupSizes'] as List).cast(); + final phoneGroupSeparators = (item['phoneGroupSeparators'] as List) + .cast(); + + return CountryName( + code: code, + en: en, + ru: ru, + phoneCode: phoneCode, + phoneDigits: phoneDigits, + phoneMask: phoneMask, + phoneGroupSizes: phoneGroupSizes, + phoneGroupSeparators: phoneGroupSeparators, + ); + }).toList(); + + countries.sort((a, b) => a.en.compareTo(b.en)); + return countries; +} + +const String _countriesEnJson = '''[ +{"id": 4, "alpha2": "af", "alpha3": "afg", "name": "Afghanistan", "phoneCode": "+93", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 8, "alpha2": "al", "alpha3": "alb", "name": "Albania", "phoneCode": "+355", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 12, "alpha2": "dz", "alpha3": "dza", "name": "Algeria", "phoneCode": "+213", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 20, "alpha2": "ad", "alpha3": "and", "name": "Andorra", "phoneCode": "+376", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]}, +{"id": 24, "alpha2": "ao", "alpha3": "ago", "name": "Angola", "phoneCode": "+244", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 28, "alpha2": "ag", "alpha3": "atg", "name": "Antigua and Barbuda", "phoneCode": "+1268", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 32, "alpha2": "ar", "alpha3": "arg", "name": "Argentina", "phoneCode": "+54", "phoneDigits": 10, "phoneMask": "(##) ####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 51, "alpha2": "am", "alpha3": "arm", "name": "Armenia", "phoneCode": "+374", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 36, "alpha2": "au", "alpha3": "aus", "name": "Australia", "phoneCode": "+61", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 40, "alpha2": "at", "alpha3": "aut", "name": "Austria", "phoneCode": "+43", "phoneDigits": 10, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 31, "alpha2": "az", "alpha3": "aze", "name": "Azerbaijan", "phoneCode": "+994", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 44, "alpha2": "bs", "alpha3": "bhs", "name": "Bahamas", "phoneCode": "+1242", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 48, "alpha2": "bh", "alpha3": "bhr", "name": "Bahrain", "phoneCode": "+973", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 50, "alpha2": "bd", "alpha3": "bgd", "name": "Bangladesh", "phoneCode": "+880", "phoneDigits": 10, "phoneMask": "####-######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 52, "alpha2": "bb", "alpha3": "brb", "name": "Barbados", "phoneCode": "+1246", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 112, "alpha2": "by", "alpha3": "blr", "name": "Belarus", "phoneCode": "+375", "phoneDigits": 9, "phoneMask": "## ###-##-##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", "-", "-", ""]}, +{"id": 56, "alpha2": "be", "alpha3": "bel", "name": "Belgium", "phoneCode": "+32", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 84, "alpha2": "bz", "alpha3": "blz", "name": "Belize", "phoneCode": "+501", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 204, "alpha2": "bj", "alpha3": "ben", "name": "Benin", "phoneCode": "+229", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 64, "alpha2": "bt", "alpha3": "btn", "name": "Bhutan", "phoneCode": "+975", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 68, "alpha2": "bo", "alpha3": "bol", "name": "Bolivia, Plurinational State of", "phoneCode": "+591", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]}, +{"id": 70, "alpha2": "ba", "alpha3": "bih", "name": "Bosnia and Herzegovina", "phoneCode": "+387", "phoneDigits": 8, "phoneMask": "## ###-###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", "-", ""]}, +{"id": 72, "alpha2": "bw", "alpha3": "bwa", "name": "Botswana", "phoneCode": "+267", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 76, "alpha2": "br", "alpha3": "bra", "name": "Brazil", "phoneCode": "+55", "phoneDigits": 11, "phoneMask": "(##) #####-####", "phoneGroupSizes": [2, 5, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 96, "alpha2": "bn", "alpha3": "brn", "name": "Brunei Darussalam", "phoneCode": "+673", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 100, "alpha2": "bg", "alpha3": "bgr", "name": "Bulgaria", "phoneCode": "+359", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 854, "alpha2": "bf", "alpha3": "bfa", "name": "Burkina Faso", "phoneCode": "+226", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 108, "alpha2": "bi", "alpha3": "bdi", "name": "Burundi", "phoneCode": "+257", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 132, "alpha2": "cv", "alpha3": "cpv", "name": "Cabo Verde", "phoneCode": "+238", "phoneDigits": 7, "phoneMask": "### ## ##", "phoneGroupSizes": [3, 2, 2], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 116, "alpha2": "kh", "alpha3": "khm", "name": "Cambodia", "phoneCode": "+855", "phoneDigits": 9, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 120, "alpha2": "cm", "alpha3": "cmr", "name": "Cameroon", "phoneCode": "+237", "phoneDigits": 9, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 124, "alpha2": "ca", "alpha3": "can", "name": "Canada", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 140, "alpha2": "cf", "alpha3": "caf", "name": "Central African Republic", "phoneCode": "+236", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 148, "alpha2": "td", "alpha3": "tcd", "name": "Chad", "phoneCode": "+235", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 152, "alpha2": "cl", "alpha3": "chl", "name": "Chile", "phoneCode": "+56", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 156, "alpha2": "cn", "alpha3": "chn", "name": "China", "phoneCode": "+86", "phoneDigits": 11, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 170, "alpha2": "co", "alpha3": "col", "name": "Colombia", "phoneCode": "+57", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 174, "alpha2": "km", "alpha3": "com", "name": "Comoros", "phoneCode": "+269", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 178, "alpha2": "cg", "alpha3": "cog", "name": "Congo", "phoneCode": "+242", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 180, "alpha2": "cd", "alpha3": "cod", "name": "Congo, Democratic Republic of the", "phoneCode": "+243", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 188, "alpha2": "cr", "alpha3": "cri", "name": "Costa Rica", "phoneCode": "+506", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 384, "alpha2": "ci", "alpha3": "civ", "name": "Côte d'Ivoire", "phoneCode": "+225", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 191, "alpha2": "hr", "alpha3": "hrv", "name": "Croatia", "phoneCode": "+385", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 192, "alpha2": "cu", "alpha3": "cub", "name": "Cuba", "phoneCode": "+53", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]}, +{"id": 196, "alpha2": "cy", "alpha3": "cyp", "name": "Cyprus", "phoneCode": "+357", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 203, "alpha2": "cz", "alpha3": "cze", "name": "Czechia", "phoneCode": "+420", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 208, "alpha2": "dk", "alpha3": "dnk", "name": "Denmark", "phoneCode": "+45", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 262, "alpha2": "dj", "alpha3": "dji", "name": "Djibouti", "phoneCode": "+253", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 212, "alpha2": "dm", "alpha3": "dma", "name": "Dominica", "phoneCode": "+1767", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 214, "alpha2": "do", "alpha3": "dom", "name": "Dominican Republic", "phoneCode": "+1809", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 218, "alpha2": "ec", "alpha3": "ecu", "name": "Ecuador", "phoneCode": "+593", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 818, "alpha2": "eg", "alpha3": "egy", "name": "Egypt", "phoneCode": "+20", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 222, "alpha2": "sv", "alpha3": "slv", "name": "El Salvador", "phoneCode": "+503", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 226, "alpha2": "gq", "alpha3": "gnq", "name": "Equatorial Guinea", "phoneCode": "+240", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 232, "alpha2": "er", "alpha3": "eri", "name": "Eritrea", "phoneCode": "+291", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 233, "alpha2": "ee", "alpha3": "est", "name": "Estonia", "phoneCode": "+372", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 748, "alpha2": "sz", "alpha3": "swz", "name": "Eswatini", "phoneCode": "+268", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 231, "alpha2": "et", "alpha3": "eth", "name": "Ethiopia", "phoneCode": "+251", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 242, "alpha2": "fj", "alpha3": "fji", "name": "Fiji", "phoneCode": "+679", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 246, "alpha2": "fi", "alpha3": "fin", "name": "Finland", "phoneCode": "+358", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 250, "alpha2": "fr", "alpha3": "fra", "name": "France", "phoneCode": "+33", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 266, "alpha2": "ga", "alpha3": "gab", "name": "Gabon", "phoneCode": "+241", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 270, "alpha2": "gm", "alpha3": "gmb", "name": "Gambia", "phoneCode": "+220", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 268, "alpha2": "ge", "alpha3": "geo", "name": "Georgia", "phoneCode": "+995", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 276, "alpha2": "de", "alpha3": "deu", "name": "Germany", "phoneCode": "+49", "phoneDigits": 10, "phoneMask": "#### #######", "phoneGroupSizes": [4, 7], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 288, "alpha2": "gh", "alpha3": "gha", "name": "Ghana", "phoneCode": "+233", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 300, "alpha2": "gr", "alpha3": "grc", "name": "Greece", "phoneCode": "+30", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 308, "alpha2": "gd", "alpha3": "grd", "name": "Grenada", "phoneCode": "+1473", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 320, "alpha2": "gt", "alpha3": "gtm", "name": "Guatemala", "phoneCode": "+502", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 324, "alpha2": "gn", "alpha3": "gin", "name": "Guinea", "phoneCode": "+224", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 624, "alpha2": "gw", "alpha3": "gnb", "name": "Guinea-Bissau", "phoneCode": "+245", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 328, "alpha2": "gy", "alpha3": "guy", "name": "Guyana", "phoneCode": "+592", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 332, "alpha2": "ht", "alpha3": "hti", "name": "Haiti", "phoneCode": "+509", "phoneDigits": 8, "phoneMask": "## ## ####", "phoneGroupSizes": [2, 2, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 340, "alpha2": "hn", "alpha3": "hnd", "name": "Honduras", "phoneCode": "+504", "phoneDigits": 8, "phoneMask": "####-####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 348, "alpha2": "hu", "alpha3": "hun", "name": "Hungary", "phoneCode": "+36", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 352, "alpha2": "is", "alpha3": "isl", "name": "Iceland", "phoneCode": "+354", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 356, "alpha2": "in", "alpha3": "ind", "name": "India", "phoneCode": "+91", "phoneDigits": 10, "phoneMask": "##### #####", "phoneGroupSizes": [5, 5], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Indonesia", "phoneCode": "+62", "phoneDigits": 11, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 364, "alpha2": "ir", "alpha3": "irn", "name": "Iran, Islamic Republic of", "phoneCode": "+98", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 368, "alpha2": "iq", "alpha3": "irq", "name": "Iraq", "phoneCode": "+964", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 372, "alpha2": "ie", "alpha3": "irl", "name": "Ireland", "phoneCode": "+353", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 376, "alpha2": "il", "alpha3": "isr", "name": "Israel", "phoneCode": "+972", "phoneDigits": 9, "phoneMask": "##-###-####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", "-", ""]}, +{"id": 380, "alpha2": "it", "alpha3": "ita", "name": "Italy", "phoneCode": "+39", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 388, "alpha2": "jm", "alpha3": "jam", "name": "Jamaica", "phoneCode": "+1876", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 392, "alpha2": "jp", "alpha3": "jpn", "name": "Japan", "phoneCode": "+81", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]}, +{"id": 400, "alpha2": "jo", "alpha3": "jor", "name": "Jordan", "phoneCode": "+962", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 398, "alpha2": "kz", "alpha3": "kaz", "name": "Kazakhstan", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]}, +{"id": 404, "alpha2": "ke", "alpha3": "ken", "name": "Kenya", "phoneCode": "+254", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 296, "alpha2": "ki", "alpha3": "kir", "name": "Kiribati", "phoneCode": "+686", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 408, "alpha2": "kp", "alpha3": "prk", "name": "Korea, Democratic People's Republic of", "phoneCode": "+850", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 410, "alpha2": "kr", "alpha3": "kor", "name": "Korea, Republic of", "phoneCode": "+82", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]}, +{"id": 414, "alpha2": "kw", "alpha3": "kwt", "name": "Kuwait", "phoneCode": "+965", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 417, "alpha2": "kg", "alpha3": "kgz", "name": "Kyrgyzstan", "phoneCode": "+996", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 418, "alpha2": "la", "alpha3": "lao", "name": "Lao People's Democratic Republic", "phoneCode": "+856", "phoneDigits": 9, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 428, "alpha2": "lv", "alpha3": "lva", "name": "Latvia", "phoneCode": "+371", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 422, "alpha2": "lb", "alpha3": "lbn", "name": "Lebanon", "phoneCode": "+961", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 426, "alpha2": "ls", "alpha3": "lso", "name": "Lesotho", "phoneCode": "+266", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 430, "alpha2": "lr", "alpha3": "lbr", "name": "Liberia", "phoneCode": "+231", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 434, "alpha2": "ly", "alpha3": "lby", "name": "Libya", "phoneCode": "+218", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 438, "alpha2": "li", "alpha3": "lie", "name": "Liechtenstein", "phoneCode": "+423", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 440, "alpha2": "lt", "alpha3": "ltu", "name": "Lithuania", "phoneCode": "+370", "phoneDigits": 8, "phoneMask": "### #####", "phoneGroupSizes": [3, 5], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 442, "alpha2": "lu", "alpha3": "lux", "name": "Luxembourg", "phoneCode": "+352", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 450, "alpha2": "mg", "alpha3": "mdg", "name": "Madagascar", "phoneCode": "+261", "phoneDigits": 9, "phoneMask": "## ## ### ##", "phoneGroupSizes": [2, 2, 3, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 454, "alpha2": "mw", "alpha3": "mwi", "name": "Malawi", "phoneCode": "+265", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 458, "alpha2": "my", "alpha3": "mys", "name": "Malaysia", "phoneCode": "+60", "phoneDigits": 10, "phoneMask": "##-#### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 462, "alpha2": "mv", "alpha3": "mdv", "name": "Maldives", "phoneCode": "+960", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 466, "alpha2": "ml", "alpha3": "mli", "name": "Mali", "phoneCode": "+223", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 470, "alpha2": "mt", "alpha3": "mlt", "name": "Malta", "phoneCode": "+356", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 584, "alpha2": "mh", "alpha3": "mhl", "name": "Marshall Islands", "phoneCode": "+692", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 478, "alpha2": "mr", "alpha3": "mrt", "name": "Mauritania", "phoneCode": "+222", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 480, "alpha2": "mu", "alpha3": "mus", "name": "Mauritius", "phoneCode": "+230", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 484, "alpha2": "mx", "alpha3": "mex", "name": "Mexico", "phoneCode": "+52", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 583, "alpha2": "fm", "alpha3": "fsm", "name": "Micronesia, Federated States of", "phoneCode": "+691", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 498, "alpha2": "md", "alpha3": "mda", "name": "Moldova, Republic of", "phoneCode": "+373", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 492, "alpha2": "mc", "alpha3": "mco", "name": "Monaco", "phoneCode": "+377", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 496, "alpha2": "mn", "alpha3": "mng", "name": "Mongolia", "phoneCode": "+976", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 499, "alpha2": "me", "alpha3": "mne", "name": "Montenegro", "phoneCode": "+382", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 504, "alpha2": "ma", "alpha3": "mar", "name": "Morocco", "phoneCode": "+212", "phoneDigits": 9, "phoneMask": "###-###-###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", "-", "-", ""]}, +{"id": 508, "alpha2": "mz", "alpha3": "moz", "name": "Mozambique", "phoneCode": "+258", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 104, "alpha2": "mm", "alpha3": "mmr", "name": "Myanmar", "phoneCode": "+95", "phoneDigits": 9, "phoneMask": "# ### ####", "phoneGroupSizes": [1, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 516, "alpha2": "na", "alpha3": "nam", "name": "Namibia", "phoneCode": "+264", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 520, "alpha2": "nr", "alpha3": "nru", "name": "Nauru", "phoneCode": "+674", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 524, "alpha2": "np", "alpha3": "npl", "name": "Nepal", "phoneCode": "+977", "phoneDigits": 10, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 528, "alpha2": "nl", "alpha3": "nld", "name": "Netherlands", "phoneCode": "+31", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 554, "alpha2": "nz", "alpha3": "nzl", "name": "New Zealand", "phoneCode": "+64", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 558, "alpha2": "ni", "alpha3": "nic", "name": "Nicaragua", "phoneCode": "+505", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 562, "alpha2": "ne", "alpha3": "ner", "name": "Niger", "phoneCode": "+227", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 566, "alpha2": "ng", "alpha3": "nga", "name": "Nigeria", "phoneCode": "+234", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 807, "alpha2": "mk", "alpha3": "mkd", "name": "North Macedonia", "phoneCode": "+389", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 578, "alpha2": "no", "alpha3": "nor", "name": "Norway", "phoneCode": "+47", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 512, "alpha2": "om", "alpha3": "omn", "name": "Oman", "phoneCode": "+968", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 586, "alpha2": "pk", "alpha3": "pak", "name": "Pakistan", "phoneCode": "+92", "phoneDigits": 10, "phoneMask": "### #######", "phoneGroupSizes": [3, 7], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 585, "alpha2": "pw", "alpha3": "plw", "name": "Palau", "phoneCode": "+680", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 591, "alpha2": "pa", "alpha3": "pan", "name": "Panama", "phoneCode": "+507", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 598, "alpha2": "pg", "alpha3": "png", "name": "Papua New Guinea", "phoneCode": "+675", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 600, "alpha2": "py", "alpha3": "pry", "name": "Paraguay", "phoneCode": "+595", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 604, "alpha2": "pe", "alpha3": "per", "name": "Peru", "phoneCode": "+51", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 608, "alpha2": "ph", "alpha3": "phl", "name": "Philippines", "phoneCode": "+63", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 616, "alpha2": "pl", "alpha3": "pol", "name": "Poland", "phoneCode": "+48", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 620, "alpha2": "pt", "alpha3": "prt", "name": "Portugal", "phoneCode": "+351", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 634, "alpha2": "qa", "alpha3": "qat", "name": "Qatar", "phoneCode": "+974", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 642, "alpha2": "ro", "alpha3": "rou", "name": "Romania", "phoneCode": "+40", "phoneDigits": 10, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 643, "alpha2": "ru", "alpha3": "rus", "name": "Russian Federation", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]}, +{"id": 646, "alpha2": "rw", "alpha3": "rwa", "name": "Rwanda", "phoneCode": "+250", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 659, "alpha2": "kn", "alpha3": "kna", "name": "Saint Kitts and Nevis", "phoneCode": "+1869", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 662, "alpha2": "lc", "alpha3": "lca", "name": "Saint Lucia", "phoneCode": "+1758", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 670, "alpha2": "vc", "alpha3": "vct", "name": "Saint Vincent and the Grenadines", "phoneCode": "+1784", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 882, "alpha2": "ws", "alpha3": "wsm", "name": "Samoa", "phoneCode": "+685", "phoneDigits": 7, "phoneMask": "## #####", "phoneGroupSizes": [2, 5], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 674, "alpha2": "sm", "alpha3": "smr", "name": "San Marino", "phoneCode": "+378", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 678, "alpha2": "st", "alpha3": "stp", "name": "Sao Tome and Principe", "phoneCode": "+239", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 682, "alpha2": "sa", "alpha3": "sau", "name": "Saudi Arabia", "phoneCode": "+966", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 686, "alpha2": "sn", "alpha3": "sen", "name": "Senegal", "phoneCode": "+221", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 688, "alpha2": "rs", "alpha3": "srb", "name": "Serbia", "phoneCode": "+381", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 690, "alpha2": "sc", "alpha3": "syc", "name": "Seychelles", "phoneCode": "+248", "phoneDigits": 7, "phoneMask": "# ### ###", "phoneGroupSizes": [1, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 694, "alpha2": "sl", "alpha3": "sle", "name": "Sierra Leone", "phoneCode": "+232", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 702, "alpha2": "sg", "alpha3": "sgp", "name": "Singapore", "phoneCode": "+65", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 703, "alpha2": "sk", "alpha3": "svk", "name": "Slovakia", "phoneCode": "+421", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 705, "alpha2": "si", "alpha3": "svn", "name": "Slovenia", "phoneCode": "+386", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 90, "alpha2": "sb", "alpha3": "slb", "name": "Solomon Islands", "phoneCode": "+677", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 706, "alpha2": "so", "alpha3": "som", "name": "Somalia", "phoneCode": "+252", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 710, "alpha2": "za", "alpha3": "zaf", "name": "South Africa", "phoneCode": "+27", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 728, "alpha2": "ss", "alpha3": "ssd", "name": "South Sudan", "phoneCode": "+211", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 724, "alpha2": "es", "alpha3": "esp", "name": "Spain", "phoneCode": "+34", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 144, "alpha2": "lk", "alpha3": "lka", "name": "Sri Lanka", "phoneCode": "+94", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 729, "alpha2": "sd", "alpha3": "sdn", "name": "Sudan", "phoneCode": "+249", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 740, "alpha2": "sr", "alpha3": "sur", "name": "Suriname", "phoneCode": "+597", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 752, "alpha2": "se", "alpha3": "swe", "name": "Sweden", "phoneCode": "+46", "phoneDigits": 9, "phoneMask": "##-### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", "-", " ", " ", ""]}, +{"id": 756, "alpha2": "ch", "alpha3": "che", "name": "Switzerland", "phoneCode": "+41", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 760, "alpha2": "sy", "alpha3": "syr", "name": "Syrian Arab Republic", "phoneCode": "+963", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 762, "alpha2": "tj", "alpha3": "tjk", "name": "Tajikistan", "phoneCode": "+992", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 834, "alpha2": "tz", "alpha3": "tza", "name": "Tanzania, United Republic of", "phoneCode": "+255", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 764, "alpha2": "th", "alpha3": "tha", "name": "Thailand", "phoneCode": "+66", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 626, "alpha2": "tl", "alpha3": "tls", "name": "Timor-Leste", "phoneCode": "+670", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 768, "alpha2": "tg", "alpha3": "tgo", "name": "Togo", "phoneCode": "+228", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 776, "alpha2": "to", "alpha3": "ton", "name": "Tonga", "phoneCode": "+676", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 780, "alpha2": "tt", "alpha3": "tto", "name": "Trinidad and Tobago", "phoneCode": "+1868", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 788, "alpha2": "tn", "alpha3": "tun", "name": "Tunisia", "phoneCode": "+216", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 792, "alpha2": "tr", "alpha3": "tur", "name": "Türkiye", "phoneCode": "+90", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 795, "alpha2": "tm", "alpha3": "tkm", "name": "Turkmenistan", "phoneCode": "+993", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 798, "alpha2": "tv", "alpha3": "tuv", "name": "Tuvalu", "phoneCode": "+688", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]}, +{"id": 800, "alpha2": "ug", "alpha3": "uga", "name": "Uganda", "phoneCode": "+256", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 804, "alpha2": "ua", "alpha3": "ukr", "name": "Ukraine", "phoneCode": "+380", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 784, "alpha2": "ae", "alpha3": "are", "name": "United Arab Emirates", "phoneCode": "+971", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 826, "alpha2": "gb", "alpha3": "gbr", "name": "United Kingdom of Great Britain and Northern Ireland", "phoneCode": "+44", "phoneDigits": 10, "phoneMask": "#### ######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 840, "alpha2": "us", "alpha3": "usa", "name": "United States of America", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 858, "alpha2": "uy", "alpha3": "ury", "name": "Uruguay", "phoneCode": "+598", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 860, "alpha2": "uz", "alpha3": "uzb", "name": "Uzbekistan", "phoneCode": "+998", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 548, "alpha2": "vu", "alpha3": "vut", "name": "Vanuatu", "phoneCode": "+678", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 862, "alpha2": "ve", "alpha3": "ven", "name": "Venezuela, Bolivarian Republic of", "phoneCode": "+58", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 704, "alpha2": "vn", "alpha3": "vnm", "name": "Viet Nam", "phoneCode": "+84", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 887, "alpha2": "ye", "alpha3": "yem", "name": "Yemen", "phoneCode": "+967", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 894, "alpha2": "zm", "alpha3": "zmb", "name": "Zambia", "phoneCode": "+260", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 716, "alpha2": "zw", "alpha3": "zwe", "name": "Zimbabwe", "phoneCode": "+263", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +]'''; + +const String _countriesRuJson = '''[ +{"id": 36, "alpha2": "au", "alpha3": "aus", "name": "Австралия", "phoneCode": "+61", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 40, "alpha2": "at", "alpha3": "aut", "name": "Австрия", "phoneCode": "+43", "phoneDigits": 10, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 31, "alpha2": "az", "alpha3": "aze", "name": "Азербайджан", "phoneCode": "+994", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 8, "alpha2": "al", "alpha3": "alb", "name": "Албания", "phoneCode": "+355", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 12, "alpha2": "dz", "alpha3": "dza", "name": "Алжир", "phoneCode": "+213", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 24, "alpha2": "ao", "alpha3": "ago", "name": "Ангола", "phoneCode": "+244", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 20, "alpha2": "ad", "alpha3": "and", "name": "Андорра", "phoneCode": "+376", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]}, +{"id": 28, "alpha2": "ag", "alpha3": "atg", "name": "Антигуа и Барбуда", "phoneCode": "+1268", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 32, "alpha2": "ar", "alpha3": "arg", "name": "Аргентина", "phoneCode": "+54", "phoneDigits": 10, "phoneMask": "(##) ####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 51, "alpha2": "am", "alpha3": "arm", "name": "Армения", "phoneCode": "+374", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 4, "alpha2": "af", "alpha3": "afg", "name": "Афганистан", "phoneCode": "+93", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 44, "alpha2": "bs", "alpha3": "bhs", "name": "Багамские Острова", "phoneCode": "+1242", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 50, "alpha2": "bd", "alpha3": "bgd", "name": "Бангладеш", "phoneCode": "+880", "phoneDigits": 10, "phoneMask": "####-######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 52, "alpha2": "bb", "alpha3": "brb", "name": "Барбадос", "phoneCode": "+1246", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 48, "alpha2": "bh", "alpha3": "bhr", "name": "Бахрейн", "phoneCode": "+973", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 84, "alpha2": "bz", "alpha3": "blz", "name": "Белиз", "phoneCode": "+501", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 112, "alpha2": "by", "alpha3": "blr", "name": "Беларусь", "phoneCode": "+375", "phoneDigits": 9, "phoneMask": "## ###-##-##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", "-", "-", ""]}, +{"id": 56, "alpha2": "be", "alpha3": "bel", "name": "Бельгия", "phoneCode": "+32", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 204, "alpha2": "bj", "alpha3": "ben", "name": "Бенин", "phoneCode": "+229", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 100, "alpha2": "bg", "alpha3": "bgr", "name": "Болгария", "phoneCode": "+359", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 68, "alpha2": "bo", "alpha3": "bol", "name": "Боливия", "phoneCode": "+591", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]}, +{"id": 70, "alpha2": "ba", "alpha3": "bih", "name": "Босния и Герцеговина", "phoneCode": "+387", "phoneDigits": 8, "phoneMask": "## ###-###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", "-", ""]}, +{"id": 72, "alpha2": "bw", "alpha3": "bwa", "name": "Ботсвана", "phoneCode": "+267", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 76, "alpha2": "br", "alpha3": "bra", "name": "Бразилия", "phoneCode": "+55", "phoneDigits": 11, "phoneMask": "(##) #####-####", "phoneGroupSizes": [2, 5, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 96, "alpha2": "bn", "alpha3": "brn", "name": "Бруней", "phoneCode": "+673", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 854, "alpha2": "bf", "alpha3": "bfa", "name": "Буркина-Фасо", "phoneCode": "+226", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 108, "alpha2": "bi", "alpha3": "bdi", "name": "Бурунди", "phoneCode": "+257", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 64, "alpha2": "bt", "alpha3": "btn", "name": "Бутан", "phoneCode": "+975", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 548, "alpha2": "vu", "alpha3": "vut", "name": "Вануату", "phoneCode": "+678", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 826, "alpha2": "gb", "alpha3": "gbr", "name": "Великобритания", "phoneCode": "+44", "phoneDigits": 10, "phoneMask": "#### ######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 348, "alpha2": "hu", "alpha3": "hun", "name": "Венгрия", "phoneCode": "+36", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 862, "alpha2": "ve", "alpha3": "ven", "name": "Венесуэла", "phoneCode": "+58", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 626, "alpha2": "tl", "alpha3": "tls", "name": "Восточный Тимор", "phoneCode": "+670", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 704, "alpha2": "vn", "alpha3": "vnm", "name": "Вьетнам", "phoneCode": "+84", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 266, "alpha2": "ga", "alpha3": "gab", "name": "Габон", "phoneCode": "+241", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 332, "alpha2": "ht", "alpha3": "hti", "name": "Гаити", "phoneCode": "+509", "phoneDigits": 8, "phoneMask": "## ## ####", "phoneGroupSizes": [2, 2, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 328, "alpha2": "gy", "alpha3": "guy", "name": "Гайана", "phoneCode": "+592", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 270, "alpha2": "gm", "alpha3": "gmb", "name": "Гамбия", "phoneCode": "+220", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 288, "alpha2": "gh", "alpha3": "gha", "name": "Гана", "phoneCode": "+233", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 320, "alpha2": "gt", "alpha3": "gtm", "name": "Гватемала", "phoneCode": "+502", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 324, "alpha2": "gn", "alpha3": "gin", "name": "Гвинея", "phoneCode": "+224", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 624, "alpha2": "gw", "alpha3": "gnb", "name": "Гвинея-Бисау", "phoneCode": "+245", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 276, "alpha2": "de", "alpha3": "deu", "name": "Германия", "phoneCode": "+49", "phoneDigits": 10, "phoneMask": "#### #######", "phoneGroupSizes": [4, 7], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 340, "alpha2": "hn", "alpha3": "hnd", "name": "Гондурас", "phoneCode": "+504", "phoneDigits": 8, "phoneMask": "####-####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 308, "alpha2": "gd", "alpha3": "grd", "name": "Гренада", "phoneCode": "+1473", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 300, "alpha2": "gr", "alpha3": "grc", "name": "Греция", "phoneCode": "+30", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 268, "alpha2": "ge", "alpha3": "geo", "name": "Грузия", "phoneCode": "+995", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 208, "alpha2": "dk", "alpha3": "dnk", "name": "Дания", "phoneCode": "+45", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 262, "alpha2": "dj", "alpha3": "dji", "name": "Джибути", "phoneCode": "+253", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 212, "alpha2": "dm", "alpha3": "dma", "name": "Доминика", "phoneCode": "+1767", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 214, "alpha2": "do", "alpha3": "dom", "name": "Доминиканская Республика", "phoneCode": "+1809", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 180, "alpha2": "cd", "alpha3": "cod", "name": "ДР Конго", "phoneCode": "+243", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 818, "alpha2": "eg", "alpha3": "egy", "name": "Египет", "phoneCode": "+20", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 894, "alpha2": "zm", "alpha3": "zmb", "name": "Замбия", "phoneCode": "+260", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 716, "alpha2": "zw", "alpha3": "zwe", "name": "Зимбабве", "phoneCode": "+263", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 376, "alpha2": "il", "alpha3": "isr", "name": "Израиль", "phoneCode": "+972", "phoneDigits": 9, "phoneMask": "##-###-####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", "-", ""]}, +{"id": 356, "alpha2": "in", "alpha3": "ind", "name": "Индия", "phoneCode": "+91", "phoneDigits": 10, "phoneMask": "##### #####", "phoneGroupSizes": [5, 5], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Индонезия", "phoneCode": "+62", "phoneDigits": 11, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 400, "alpha2": "jo", "alpha3": "jor", "name": "Иордания", "phoneCode": "+962", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 368, "alpha2": "iq", "alpha3": "irq", "name": "Ирак", "phoneCode": "+964", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 364, "alpha2": "ir", "alpha3": "irn", "name": "Иран", "phoneCode": "+98", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 372, "alpha2": "ie", "alpha3": "irl", "name": "Ирландия", "phoneCode": "+353", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 352, "alpha2": "is", "alpha3": "isl", "name": "Исландия", "phoneCode": "+354", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 724, "alpha2": "es", "alpha3": "esp", "name": "Испания", "phoneCode": "+34", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 380, "alpha2": "it", "alpha3": "ita", "name": "Италия", "phoneCode": "+39", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 887, "alpha2": "ye", "alpha3": "yem", "name": "Йемен", "phoneCode": "+967", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 132, "alpha2": "cv", "alpha3": "cpv", "name": "Кабо-Верде", "phoneCode": "+238", "phoneDigits": 7, "phoneMask": "### ## ##", "phoneGroupSizes": [3, 2, 2], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 398, "alpha2": "kz", "alpha3": "kaz", "name": "Казахстан", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]}, +{"id": 116, "alpha2": "kh", "alpha3": "khm", "name": "Камбоджа", "phoneCode": "+855", "phoneDigits": 9, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 120, "alpha2": "cm", "alpha3": "cmr", "name": "Камерун", "phoneCode": "+237", "phoneDigits": 9, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 124, "alpha2": "ca", "alpha3": "can", "name": "Канада", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 634, "alpha2": "qa", "alpha3": "qat", "name": "Катар", "phoneCode": "+974", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 404, "alpha2": "ke", "alpha3": "ken", "name": "Кения", "phoneCode": "+254", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 196, "alpha2": "cy", "alpha3": "cyp", "name": "Кипр", "phoneCode": "+357", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 417, "alpha2": "kg", "alpha3": "kgz", "name": "Кыргызстан", "phoneCode": "+996", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 296, "alpha2": "ki", "alpha3": "kir", "name": "Кирибати", "phoneCode": "+686", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 408, "alpha2": "kp", "alpha3": "prk", "name": "КНДР (Корейская Народно-Демократическая Республика)", "phoneCode": "+850", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 156, "alpha2": "cn", "alpha3": "chn", "name": "Китай (Китайская Народная Республика)", "phoneCode": "+86", "phoneDigits": 11, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 170, "alpha2": "co", "alpha3": "col", "name": "Колумбия", "phoneCode": "+57", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 174, "alpha2": "km", "alpha3": "com", "name": "Коморы", "phoneCode": "+269", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 188, "alpha2": "cr", "alpha3": "cri", "name": "Коста-Рика", "phoneCode": "+506", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 384, "alpha2": "ci", "alpha3": "civ", "name": "Кот-д’Ивуар", "phoneCode": "+225", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 192, "alpha2": "cu", "alpha3": "cub", "name": "Куба", "phoneCode": "+53", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]}, +{"id": 414, "alpha2": "kw", "alpha3": "kwt", "name": "Кувейт", "phoneCode": "+965", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 418, "alpha2": "la", "alpha3": "lao", "name": "Лаос", "phoneCode": "+856", "phoneDigits": 9, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 428, "alpha2": "lv", "alpha3": "lva", "name": "Латвия", "phoneCode": "+371", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 426, "alpha2": "ls", "alpha3": "lso", "name": "Лесото", "phoneCode": "+266", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 430, "alpha2": "lr", "alpha3": "lbr", "name": "Либерия", "phoneCode": "+231", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 422, "alpha2": "lb", "alpha3": "lbn", "name": "Ливан", "phoneCode": "+961", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 434, "alpha2": "ly", "alpha3": "lby", "name": "Ливия", "phoneCode": "+218", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 440, "alpha2": "lt", "alpha3": "ltu", "name": "Литва", "phoneCode": "+370", "phoneDigits": 8, "phoneMask": "### #####", "phoneGroupSizes": [3, 5], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 438, "alpha2": "li", "alpha3": "lie", "name": "Лихтенштейн", "phoneCode": "+423", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 442, "alpha2": "lu", "alpha3": "lux", "name": "Люксембург", "phoneCode": "+352", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 480, "alpha2": "mu", "alpha3": "mus", "name": "Маврикий", "phoneCode": "+230", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 478, "alpha2": "mr", "alpha3": "mrt", "name": "Мавритания", "phoneCode": "+222", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 450, "alpha2": "mg", "alpha3": "mdg", "name": "Мадагаскар", "phoneCode": "+261", "phoneDigits": 9, "phoneMask": "## ## ### ##", "phoneGroupSizes": [2, 2, 3, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 807, "alpha2": "mk", "alpha3": "mkd", "name": "Северная Македония", "phoneCode": "+389", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 454, "alpha2": "mw", "alpha3": "mwi", "name": "Малави", "phoneCode": "+265", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 458, "alpha2": "my", "alpha3": "mys", "name": "Малайзия", "phoneCode": "+60", "phoneDigits": 10, "phoneMask": "##-#### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 466, "alpha2": "ml", "alpha3": "mli", "name": "Мали", "phoneCode": "+223", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 462, "alpha2": "mv", "alpha3": "mdv", "name": "Мальдивы", "phoneCode": "+960", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 470, "alpha2": "mt", "alpha3": "mlt", "name": "Мальта", "phoneCode": "+356", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 504, "alpha2": "ma", "alpha3": "mar", "name": "Марокко", "phoneCode": "+212", "phoneDigits": 9, "phoneMask": "###-###-###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", "-", "-", ""]}, +{"id": 584, "alpha2": "mh", "alpha3": "mhl", "name": "Маршалловы Острова", "phoneCode": "+692", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 484, "alpha2": "mx", "alpha3": "mex", "name": "Мексика", "phoneCode": "+52", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 583, "alpha2": "fm", "alpha3": "fsm", "name": "Микронезия", "phoneCode": "+691", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 508, "alpha2": "mz", "alpha3": "moz", "name": "Мозамбик", "phoneCode": "+258", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 498, "alpha2": "md", "alpha3": "mda", "name": "Молдова", "phoneCode": "+373", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 492, "alpha2": "mc", "alpha3": "mco", "name": "Монако", "phoneCode": "+377", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 496, "alpha2": "mn", "alpha3": "mng", "name": "Монголия", "phoneCode": "+976", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 104, "alpha2": "mm", "alpha3": "mmr", "name": "Мьянма", "phoneCode": "+95", "phoneDigits": 9, "phoneMask": "# ### ####", "phoneGroupSizes": [1, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 516, "alpha2": "na", "alpha3": "nam", "name": "Намибия", "phoneCode": "+264", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 520, "alpha2": "nr", "alpha3": "nru", "name": "Науру", "phoneCode": "+674", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 524, "alpha2": "np", "alpha3": "npl", "name": "Непал", "phoneCode": "+977", "phoneDigits": 10, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 562, "alpha2": "ne", "alpha3": "ner", "name": "Нигер", "phoneCode": "+227", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 566, "alpha2": "ng", "alpha3": "nga", "name": "Нигерия", "phoneCode": "+234", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 528, "alpha2": "nl", "alpha3": "nld", "name": "Нидерланды", "phoneCode": "+31", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 558, "alpha2": "ni", "alpha3": "nic", "name": "Никарагуа", "phoneCode": "+505", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 554, "alpha2": "nz", "alpha3": "nzl", "name": "Новая Зеландия", "phoneCode": "+64", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 578, "alpha2": "no", "alpha3": "nor", "name": "Норвегия", "phoneCode": "+47", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 784, "alpha2": "ae", "alpha3": "are", "name": "ОАЭ", "phoneCode": "+971", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 512, "alpha2": "om", "alpha3": "omn", "name": "Оман", "phoneCode": "+968", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 586, "alpha2": "pk", "alpha3": "pak", "name": "Пакистан", "phoneCode": "+92", "phoneDigits": 10, "phoneMask": "### #######", "phoneGroupSizes": [3, 7], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 585, "alpha2": "pw", "alpha3": "plw", "name": "Палау", "phoneCode": "+680", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 591, "alpha2": "pa", "alpha3": "pan", "name": "Панама", "phoneCode": "+507", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 598, "alpha2": "pg", "alpha3": "png", "name": "Папуа — Новая Гвинея", "phoneCode": "+675", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 600, "alpha2": "py", "alpha3": "pry", "name": "Парагвай", "phoneCode": "+595", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 604, "alpha2": "pe", "alpha3": "per", "name": "Перу", "phoneCode": "+51", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 616, "alpha2": "pl", "alpha3": "pol", "name": "Польша", "phoneCode": "+48", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 620, "alpha2": "pt", "alpha3": "prt", "name": "Португалия", "phoneCode": "+351", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 178, "alpha2": "cg", "alpha3": "cog", "name": "Республика Конго", "phoneCode": "+242", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 410, "alpha2": "kr", "alpha3": "kor", "name": "Республика Корея", "phoneCode": "+82", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]}, +{"id": 643, "alpha2": "ru", "alpha3": "rus", "name": "Россия", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]}, +{"id": 646, "alpha2": "rw", "alpha3": "rwa", "name": "Руанда", "phoneCode": "+250", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 642, "alpha2": "ro", "alpha3": "rou", "name": "Румыния", "phoneCode": "+40", "phoneDigits": 10, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 222, "alpha2": "sv", "alpha3": "slv", "name": "Сальвадор", "phoneCode": "+503", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 882, "alpha2": "ws", "alpha3": "wsm", "name": "Самоа", "phoneCode": "+685", "phoneDigits": 7, "phoneMask": "## #####", "phoneGroupSizes": [2, 5], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 674, "alpha2": "sm", "alpha3": "smr", "name": "Сан-Марино", "phoneCode": "+378", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 678, "alpha2": "st", "alpha3": "stp", "name": "Сан-Томе и Принсипи", "phoneCode": "+239", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 682, "alpha2": "sa", "alpha3": "sau", "name": "Саудовская Аравия", "phoneCode": "+966", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 690, "alpha2": "sc", "alpha3": "syc", "name": "Сейшельские Острова", "phoneCode": "+248", "phoneDigits": 7, "phoneMask": "# ### ###", "phoneGroupSizes": [1, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 686, "alpha2": "sn", "alpha3": "sen", "name": "Сенегал", "phoneCode": "+221", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 670, "alpha2": "vc", "alpha3": "vct", "name": "Сент-Винсент и Гренадины", "phoneCode": "+1784", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 659, "alpha2": "kn", "alpha3": "kna", "name": "Сент-Китс и Невис", "phoneCode": "+1869", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 662, "alpha2": "lc", "alpha3": "lca", "name": "Сент-Люсия", "phoneCode": "+1758", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 688, "alpha2": "rs", "alpha3": "srb", "name": "Сербия", "phoneCode": "+381", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 702, "alpha2": "sg", "alpha3": "sgp", "name": "Сингапур", "phoneCode": "+65", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 760, "alpha2": "sy", "alpha3": "syr", "name": "Сирия", "phoneCode": "+963", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 703, "alpha2": "sk", "alpha3": "svk", "name": "Словакия", "phoneCode": "+421", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 705, "alpha2": "si", "alpha3": "svn", "name": "Словения", "phoneCode": "+386", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 90, "alpha2": "sb", "alpha3": "slb", "name": "Соломоновы Острова", "phoneCode": "+677", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 706, "alpha2": "so", "alpha3": "som", "name": "Сомали", "phoneCode": "+252", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 729, "alpha2": "sd", "alpha3": "sdn", "name": "Судан", "phoneCode": "+249", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 740, "alpha2": "sr", "alpha3": "sur", "name": "Суринам", "phoneCode": "+597", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 840, "alpha2": "us", "alpha3": "usa", "name": "США", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 694, "alpha2": "sl", "alpha3": "sle", "name": "Сьерра-Леоне", "phoneCode": "+232", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 762, "alpha2": "tj", "alpha3": "tjk", "name": "Таджикистан", "phoneCode": "+992", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 764, "alpha2": "th", "alpha3": "tha", "name": "Таиланд", "phoneCode": "+66", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 834, "alpha2": "tz", "alpha3": "tza", "name": "Танзания", "phoneCode": "+255", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 768, "alpha2": "tg", "alpha3": "tgo", "name": "Того", "phoneCode": "+228", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 776, "alpha2": "to", "alpha3": "ton", "name": "Тонга", "phoneCode": "+676", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 780, "alpha2": "tt", "alpha3": "tto", "name": "Тринидад и Тобаго", "phoneCode": "+1868", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 798, "alpha2": "tv", "alpha3": "tuv", "name": "Тувалу", "phoneCode": "+688", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]}, +{"id": 788, "alpha2": "tn", "alpha3": "tun", "name": "Тунис", "phoneCode": "+216", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 795, "alpha2": "tm", "alpha3": "tkm", "name": "Туркменистан", "phoneCode": "+993", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 792, "alpha2": "tr", "alpha3": "tur", "name": "Турция", "phoneCode": "+90", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 800, "alpha2": "ug", "alpha3": "uga", "name": "Уганда", "phoneCode": "+256", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 860, "alpha2": "uz", "alpha3": "uzb", "name": "Узбекистан", "phoneCode": "+998", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 804, "alpha2": "ua", "alpha3": "ukr", "name": "Украина", "phoneCode": "+380", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 858, "alpha2": "uy", "alpha3": "ury", "name": "Уругвай", "phoneCode": "+598", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 242, "alpha2": "fj", "alpha3": "fji", "name": "Фиджи", "phoneCode": "+679", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 608, "alpha2": "ph", "alpha3": "phl", "name": "Филиппины", "phoneCode": "+63", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 246, "alpha2": "fi", "alpha3": "fin", "name": "Финляндия", "phoneCode": "+358", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 250, "alpha2": "fr", "alpha3": "fra", "name": "Франция", "phoneCode": "+33", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 191, "alpha2": "hr", "alpha3": "hrv", "name": "Хорватия", "phoneCode": "+385", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 140, "alpha2": "cf", "alpha3": "caf", "name": "ЦАР", "phoneCode": "+236", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 148, "alpha2": "td", "alpha3": "tcd", "name": "Чад", "phoneCode": "+235", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 499, "alpha2": "me", "alpha3": "mne", "name": "Черногория", "phoneCode": "+382", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 203, "alpha2": "cz", "alpha3": "cze", "name": "Чехия", "phoneCode": "+420", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 152, "alpha2": "cl", "alpha3": "chl", "name": "Чили", "phoneCode": "+56", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 756, "alpha2": "ch", "alpha3": "che", "name": "Швейцария", "phoneCode": "+41", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 752, "alpha2": "se", "alpha3": "swe", "name": "Швеция", "phoneCode": "+46", "phoneDigits": 9, "phoneMask": "##-### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", "-", " ", " ", ""]}, +{"id": 144, "alpha2": "lk", "alpha3": "lka", "name": "Шри-Ланка", "phoneCode": "+94", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 218, "alpha2": "ec", "alpha3": "ecu", "name": "Эквадор", "phoneCode": "+593", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 226, "alpha2": "gq", "alpha3": "gnq", "name": "Экваториальная Гвинея", "phoneCode": "+240", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 232, "alpha2": "er", "alpha3": "eri", "name": "Эритрея", "phoneCode": "+291", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 748, "alpha2": "sz", "alpha3": "swz", "name": "Эсватини", "phoneCode": "+268", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 233, "alpha2": "ee", "alpha3": "est", "name": "Эстония", "phoneCode": "+372", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 231, "alpha2": "et", "alpha3": "eth", "name": "Эфиопия", "phoneCode": "+251", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 710, "alpha2": "za", "alpha3": "zaf", "name": "ЮАР", "phoneCode": "+27", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 728, "alpha2": "ss", "alpha3": "ssd", "name": "Южный Судан", "phoneCode": "+211", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 388, "alpha2": "jm", "alpha3": "jam", "name": "Ямайка", "phoneCode": "+1876", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 392, "alpha2": "jp", "alpha3": "jpn", "name": "Япония", "phoneCode": "+81", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]} +]'''; diff --git a/lib/core/config/custom_font_service.dart b/lib/core/config/custom_font_service.dart new file mode 100644 index 0000000..08d42d0 --- /dev/null +++ b/lib/core/config/custom_font_service.dart @@ -0,0 +1,191 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../utils/logger.dart'; +import 'font_metrics.dart'; +import 'font_file_info.dart'; + +class CustomFontService { + static const String prefKey = 'app_custom_fonts'; + static const String _userAgent = + 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us) ' + 'AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30'; + + static final Set _loaded = {}; + static final Map _metricScales = {}; + + static double metricScaleFor(String family) => _metricScales[family] ?? 1.0; + + static Future> families() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getStringList(prefKey) ?? const []; + } + + static Future preloadCached() async { + final dir = await _cacheDir(); + for (final family in await families()) { + if (_loaded.contains(family)) continue; + final file = _fileFor(dir, family); + if (!await file.exists()) continue; + try { + await _register(family, await file.readAsBytes()); + } catch (_) {} + } + } + + static Future addFamily(String family) async { + try { + final dir = await _cacheDir(); + final file = _fileFor(dir, family); + Uint8List? bytes; + if (await file.exists()) { + final cached = await file.readAsBytes(); + if (_isSfnt(cached)) bytes = cached; + } + if (bytes == null) { + bytes = await _download(family); + if (bytes == null) return null; + await file.writeAsBytes(bytes); + } + await _register(family, bytes); + await _persist(family); + return family; + } catch (e) { + logger.w('CustomFont: не удалось добавить «$family»: $e'); + return null; + } + } + + static Future removeFamily(String family) async { + final prefs = await SharedPreferences.getInstance(); + final list = prefs.getStringList(prefKey) ?? []; + list.remove(family); + await prefs.setStringList(prefKey, list); + final file = _fileFor(await _cacheDir(), family); + if (await file.exists()) await file.delete(); + } + + static Future addFromFile(Uint8List bytes, String fileName) async { + if (!_isSfnt(bytes)) { + throw const FormatException('not a ttf/otf/ttc font'); + } + final family = FontFileInfo.familyName(bytes) ?? + FontFileInfo.familyFromFileName(fileName); + final file = _fileFor(await _cacheDir(), family); + await _register(family, bytes); + await file.writeAsBytes(bytes, flush: true); + await _persist(family); + return family; + } + + static Future _cacheDir() async { + final base = await getApplicationSupportDirectory(); + final dir = Directory('${base.path}/custom_fonts'); + if (!await dir.exists()) await dir.create(recursive: true); + return dir; + } + + static File _fileFor(Directory dir, String family) { + final safe = family.replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_'); + final ascii = RegExp(r'^[A-Za-z0-9 _-]*$').hasMatch(family); + var hash = 0x811c9dc5; + for (final unit in family.codeUnits) { + hash = ((hash ^ unit) * 0x01000193) & 0xffffffff; + } + final name = ascii ? safe : '${safe}_${hash.toRadixString(16)}'; + return File('${dir.path}/$name.ttf'); + } + + static Future _register(String family, Uint8List bytes) async { + if (!_isSfnt(bytes)) { + throw const FormatException('downloaded data is not a ttf/otf font'); + } + final loader = FontLoader(family) + ..addFont(Future.value(ByteData.sublistView(bytes))); + await loader.load(); + _loaded.add(family); + final xHeight = FontMetrics.xHeightRatio(bytes); + if (xHeight != null) { + _metricScales[family] = FontMetrics.scaleForXHeight(xHeight); + } + } + + static bool _isSfnt(Uint8List b) { + if (b.length < 4) return false; + final tag = (b[0] << 24) | (b[1] << 16) | (b[2] << 8) | b[3]; + return tag == 0x00010000 || + tag == 0x4F54544F || + tag == 0x74727565 || + tag == 0x74746366; + } + + static Future _persist(String family) async { + final prefs = await SharedPreferences.getInstance(); + final list = prefs.getStringList(prefKey) ?? []; + if (!list.contains(family)) { + list.add(family); + await prefs.setStringList(prefKey, list); + } + } + + static Future _download(String family) async { + final encoded = Uri.encodeQueryComponent(family); + final variants = [ + 'https://fonts.googleapis.com/css2?family=$encoded', + 'https://fonts.googleapis.com/css2?family=$encoded:wght@400', + 'https://fonts.googleapis.com/css2?family=$encoded:wght@100..900', + ]; + final urlRegex = RegExp(r'url\((https://[^)]+)\)'); + final client = HttpClient() + ..connectionTimeout = const Duration(seconds: 15); + try { + for (final url in variants) { + final css = await _fetchText(client, Uri.parse(url)); + if (css == null) continue; + for (final match in urlRegex.allMatches(css)) { + final fontUrl = match.group(1); + if (fontUrl == null) continue; + final bytes = await _fetchBytes(client, Uri.parse(fontUrl)); + if (bytes != null && _isSfnt(bytes)) return bytes; + } + } + return null; + } catch (_) { + return null; + } finally { + client.close(force: true); + } + } + + static Future _fetchText(HttpClient client, Uri uri) async { + final req = await client.getUrl(uri); + req.headers.set(HttpHeaders.userAgentHeader, _userAgent); + final resp = await req.close().timeout(const Duration(seconds: 20)); + if (resp.statusCode != HttpStatus.ok) { + await resp.drain(); + return null; + } + return resp + .transform(const Utf8Decoder()) + .join() + .timeout(const Duration(seconds: 20)); + } + + static Future _fetchBytes(HttpClient client, Uri uri) async { + final req = await client.getUrl(uri); + req.headers.set(HttpHeaders.userAgentHeader, _userAgent); + final resp = await req.close().timeout(const Duration(seconds: 20)); + if (resp.statusCode != HttpStatus.ok) { + await resp.drain(); + return null; + } + final builder = BytesBuilder(copy: false); + await resp.forEach(builder.add).timeout(const Duration(seconds: 30)); + return builder.takeBytes(); + } +} diff --git a/lib/core/config/debug_test.dart b/lib/core/config/debug_test.dart new file mode 100644 index 0000000..fd30cd7 --- /dev/null +++ b/lib/core/config/debug_test.dart @@ -0,0 +1,45 @@ +class DebugTest { + static bool enabled = false; + static int contactCount = 0; + static bool berserk = false; + + static const int debugAccountId = -424242; + + static const bool _envEnabled = bool.fromEnvironment('DEBUG_TEST'); + static const bool _envBerserk = bool.fromEnvironment('BERSERK'); + static const int _envContacts = int.fromEnvironment( + 'DEBUG_CONTACTS', + defaultValue: -1, + ); + + static const String _flag = '--debug-test'; + static const String _berserkFlag = '--berserk'; + static const String _contactsFlag = '--contacts'; + + static void parse(List args) { + if (_envEnabled) enabled = true; + if (_envBerserk) berserk = true; + if (_envContacts >= 0) { + enabled = true; + contactCount = _envContacts; + } + + for (var i = 0; i < args.length; i++) { + final arg = args[i]; + if (arg == _flag) { + enabled = true; + } else if (arg == _berserkFlag) { + berserk = true; + } else if (arg.startsWith('$_contactsFlag=')) { + enabled = true; + contactCount = + int.tryParse(arg.substring(_contactsFlag.length + 1)) ?? + contactCount; + } else if (arg == _contactsFlag && i + 1 < args.length) { + enabled = true; + contactCount = int.tryParse(args[i + 1]) ?? contactCount; + i++; + } + } + } +} diff --git a/lib/core/config/device_presets.dart b/lib/core/config/device_presets.dart new file mode 100644 index 0000000..de7506e --- /dev/null +++ b/lib/core/config/device_presets.dart @@ -0,0 +1,1013 @@ +class DevicePreset { + final String deviceType; + final String userAgent; + final String deviceName; + final String osVersion; + final String screen; + final String timezone; + final String locale; + + DevicePreset({ + required this.deviceType, + required this.userAgent, + required this.deviceName, + required this.osVersion, + required this.screen, + required this.timezone, + required this.locale, + }); +} + +final List devicePresets = [ + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy S24 Ultra', + osVersion: 'Android 14', + screen: 'xxhdpi 450dpi 1440x3120', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36', + deviceName: 'Google Pixel 8 Pro', + osVersion: 'Android 14', + screen: 'xxhdpi 430dpi 1344x2992', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; 23021RAA2Y) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Xiaomi 13 Pro', + osVersion: 'Android 13', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Asia/Shanghai', + locale: 'zh-CN', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; CPH2521) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'OnePlus 12', + osVersion: 'Android 14', + screen: 'xxhdpi 450dpi 1440x3168', + timezone: 'Asia/Kolkata', + locale: 'en-IN', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; SM-G998B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy S21 Ultra', + osVersion: 'Android 13', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Europe/London', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + deviceName: 'Google Pixel 6', + osVersion: 'Android 12', + screen: 'xxhdpi 420dpi 1080x2400', + timezone: 'America/Chicago', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; RMX3371) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36', + deviceName: 'Realme GT Master Edition', + osVersion: 'Android 13', + screen: 'xxhdpi 400dpi 1080x2400', + timezone: 'Asia/Dubai', + locale: 'ar-AE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 11; M2101K6G) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Mobile Safari/537.36', + deviceName: 'Poco F3', + osVersion: 'Android 11', + screen: 'xxhdpi 420dpi 1080x2400', + timezone: 'Europe/Madrid', + locale: 'es-ES', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SO-51D) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36', + deviceName: 'Sony Xperia 1 V', + osVersion: 'Android 14', + screen: 'xxxhdpi 560dpi 1644x3840', + timezone: 'Asia/Tokyo', + locale: 'ja-JP', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; XT2201-2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Motorola Edge 30 Pro', + osVersion: 'Android 13', + screen: 'xxhdpi 400dpi 1080x2400', + timezone: 'America/Sao_Paulo', + locale: 'pt-BR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SM-A546E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy A54', + osVersion: 'Android 14', + screen: 'xxhdpi 400dpi 1080x2340', + timezone: 'Australia/Sydney', + locale: 'en-AU', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; 2201116SG) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + deviceName: 'Redmi Note 11 Pro', + osVersion: 'Android 12', + screen: 'xxhdpi 420dpi 1080x2400', + timezone: 'Europe/Rome', + locale: 'it-IT', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; ZS676KS) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Asus ROG Phone 6', + osVersion: 'Android 13', + screen: 'xxhdpi 420dpi 1080x2448', + timezone: 'Asia/Taipei', + locale: 'zh-TW', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 10; TA-1021) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Mobile Safari/537.36', + deviceName: 'Nokia 8', + osVersion: 'Android 10', + screen: 'xxhdpi 380dpi 1440x2560', + timezone: 'Europe/Helsinki', + locale: 'fi-FI', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; PGT-N19) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Huawei P60 Pro', + osVersion: 'Android 13 (EMUI)', + screen: 'xxhdpi 430dpi 1220x2700', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 9; LM-G710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Mobile Safari/537.36', + deviceName: 'LG G7 ThinQ', + osVersion: 'Android 9', + screen: 'xxhdpi 450dpi 1440x3120', + timezone: 'Asia/Seoul', + locale: 'ko-KR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; Nothing Phone (2)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Nothing Phone (2)', + osVersion: 'Android 14', + screen: 'xxhdpi 400dpi 1080x2412', + timezone: 'America/Toronto', + locale: 'en-CA', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; SM-F936U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy Z Fold 4', + osVersion: 'Android 13', + screen: 'xhdpi 350dpi 1812x2176', + timezone: 'America/Denver', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; LE2113) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36', + deviceName: 'OnePlus 9', + osVersion: 'Android 12', + screen: 'xxhdpi 420dpi 1080x2400', + timezone: 'Europe/Stockholm', + locale: 'sv-SE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; Pixel 7a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36', + deviceName: 'Google Pixel 7a', + osVersion: 'Android 14', + screen: 'xxhdpi 400dpi 1080x2400', + timezone: 'Europe/Amsterdam', + locale: 'nl-NL', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy S24', + osVersion: 'Android 14', + screen: 'xxhdpi 450dpi 1440x3120', + timezone: 'America/Vancouver', + locale: 'en-CA', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; M2101K6C) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36', + deviceName: 'Poco F3 GT', + osVersion: 'Android 13', + screen: 'xxhdpi 420dpi 1080x2400', + timezone: 'Asia/Kolkata', + locale: 'en-IN', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; V29) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Vivo V29', + osVersion: 'Android 14', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Asia/Bangkok', + locale: 'th-TH', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; K30 Ultra) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Xiaomi K30 Ultra', + osVersion: 'Android 13', + screen: 'xxhdpi 450dpi 1440x3200', + timezone: 'Asia/Shanghai', + locale: 'zh-CN', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; P80) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Oppo Find N3', + osVersion: 'Android 14', + screen: 'xxhdpi 430dpi 1440x3168', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; SM-G916B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy S20 FE', + osVersion: 'Android 13', + screen: 'xxhdpi 400dpi 1080x2400', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; CPH2135) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + deviceName: 'OnePlus 8 Pro', + osVersion: 'Android 12', + screen: 'xxhdpi 450dpi 1440x3168', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; S24E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy S24 Edge', + osVersion: 'Android 14', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Asia/Tokyo', + locale: 'ja-JP', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; LE2120) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'OnePlus 9 Pro', + osVersion: 'Android 13', + screen: 'xxhdpi 460dpi 1440x3216', + timezone: 'America/Toronto', + locale: 'en-CA', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; A14 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Google Pixel 9 Pro', + osVersion: 'Android 14', + screen: 'xxhdpi 430dpi 1344x2992', + timezone: 'Europe/London', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; 21091116AC) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Xiaomi 12T', + osVersion: 'Android 13', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Europe/Rome', + locale: 'it-IT', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; SM-F711B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy Z Flip 3', + osVersion: 'Android 12', + screen: 'xhdpi 370dpi 1080x2640', + timezone: 'America/Mexico_City', + locale: 'es-MX', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; XT2201-3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Motorola Edge 40', + osVersion: 'Android 13', + screen: 'xxhdpi 400dpi 1080x2400', + timezone: 'Asia/Dubai', + locale: 'ar-AE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; 23088RA9AC) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Xiaomi 14 Ultra', + osVersion: 'Android 14', + screen: 'xxhdpi 450dpi 1440x3200', + timezone: 'Europe/Moscow', + locale: 'ru-RU', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; CPH2487) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'OnePlus 11', + osVersion: 'Android 13', + screen: 'xxhdpi 450dpi 1440x3216', + timezone: 'Australia/Sydney', + locale: 'en-AU', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; M2004J19C) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36', + deviceName: 'Xiaomi Mi 10T Pro', + osVersion: 'Android 12', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'America/Sao_Paulo', + locale: 'pt-BR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SM-A546B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy A55', + osVersion: 'Android 14', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Europe/Madrid', + locale: 'es-ES', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; RMX3761) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Realme GT Neo 3', + osVersion: 'Android 13', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Asia/Hong_Kong', + locale: 'zh-HK', + ), + + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 15 Pro Max', + osVersion: 'iOS 17.5.1', + screen: '1290x2796 3.0x', + timezone: 'America/Los_Angeles', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 13', + osVersion: 'iOS 16.7', + screen: '1170x2532 3.0x', + timezone: 'Europe/London', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6367.88 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Pro 11-inch', + osVersion: 'iPadOS 17.5', + screen: '1668x2388 2.0x', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/125.0 Mobile/15E148', + deviceName: 'iPhone 14 Pro', + osVersion: 'iOS 17.4.1', + screen: '1179x2556 3.0x', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.3 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone SE (2020)', + osVersion: 'iOS 15.8', + screen: '750x1334 2.0x', + timezone: 'Australia/Melbourne', + locale: 'en-AU', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) OPR/55.0.2519.144889 Mobile/15E148', + deviceName: 'iPhone 15', + osVersion: 'iOS 17.1', + screen: '1179x2556 3.0x', + timezone: 'Asia/Tokyo', + locale: 'ja-JP', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Air 5th Gen', + osVersion: 'iPadOS 16.5', + screen: '1640x2360 2.0x', + timezone: 'America/Toronto', + locale: 'en-CA', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 15 Pro', + osVersion: 'iOS 17.5', + screen: '1179x2556 3.0x', + timezone: 'Asia/Singapore', + locale: 'en-SG', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 14', + osVersion: 'iOS 17.4', + screen: '1170x2532 3.0x', + timezone: 'Europe/Stockholm', + locale: 'sv-SE', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 12 Pro Max', + osVersion: 'iOS 16.6', + screen: '1284x2778 3.0x', + timezone: 'Asia/Bangkok', + locale: 'th-TH', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 11 Pro', + osVersion: 'iOS 15.7', + screen: '1125x2436 3.0x', + timezone: 'Europe/Istanbul', + locale: 'tr-TR', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Mini 6th Gen', + osVersion: 'iPadOS 17.5', + screen: '1488x2266 2.0x', + timezone: 'America/Mexico_City', + locale: 'es-MX', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.7 Mobile/15E148 Safari/604.1', + deviceName: 'iPad 10th Gen', + osVersion: 'iPadOS 16.7', + screen: '1620x2160 2.0x', + timezone: 'Asia/Hong_Kong', + locale: 'zh-HK', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 13 Pro', + osVersion: 'iOS 17.3', + screen: '1170x2532 3.0x', + timezone: 'Europe/Dublin', + locale: 'en-IE', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 12', + osVersion: 'iOS 16.5', + screen: '1125x2436 3.0x', + timezone: 'Asia/Mumbai', + locale: 'en-IN', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 15_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Pro 12.9-inch', + osVersion: 'iPadOS 15.8', + screen: '2048x2732 2.0x', + timezone: 'Europe/Vienna', + locale: 'de-AT', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone XS Max', + osVersion: 'iOS 14.8', + screen: '1125x2436 3.0x', + timezone: 'America/Los_Angeles', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 13 mini', + osVersion: 'iOS 17.2', + screen: '1080x2340 3.0x', + timezone: 'America/Miami', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Air 4th Gen', + osVersion: 'iPadOS 14.8', + screen: '1640x2360 2.0x', + timezone: 'Europe/Zurich', + locale: 'de-CH', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 11', + osVersion: 'iOS 16.4', + screen: '828x1792 2.0x', + timezone: 'America/Argentina/Buenos_Aires', + locale: 'es-AR', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 12 mini', + osVersion: 'iOS 17.1', + screen: '1080x2340 3.0x', + timezone: 'Europe/Brussels', + locale: 'nl-BE', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Air Pro 11-inch', + osVersion: 'iPadOS 17.4', + screen: '2388x1668 2.0x', + timezone: 'Asia/Bangkok', + locale: 'en-TH', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.3 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone XR', + osVersion: 'iOS 13.7', + screen: '828x1792 2.0x', + timezone: 'Europe/Lisbon', + locale: 'pt-PT', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1', + deviceName: 'iPad (9th generation)', + osVersion: 'iPadOS 17.3', + screen: '1620x2160 2.0x', + timezone: 'Europe/Prague', + locale: 'cs-CZ', + ), + + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Windows PC', + osVersion: 'Windows 11', + screen: '1920x1080 1.25x', + timezone: 'Europe/Moscow', + locale: 'ru-RU', + ), + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'MacBook Pro', + osVersion: 'macOS 14.5 Sonoma', + screen: '1728x1117 2.0x', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Linux PC', + osVersion: 'Ubuntu 24.04 LTS', + screen: '2560x1440 1.0x', + timezone: 'UTC', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Windows PC (Firefox)', + osVersion: 'Windows 10', + screen: '1536x864 1.0x', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15', + deviceName: 'iMac (Safari)', + osVersion: 'macOS 13.6 Ventura', + screen: '3840x2160 1.5x', + timezone: 'America/Los_Angeles', + locale: 'en-US', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Windows ', + screen: '1920x1080', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Windows', + screen: '2560x1440', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.2420.97', + deviceName: 'Edge', + osVersion: 'Windows', + screen: '1536x864', + timezone: 'Europe/London', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Windows', + screen: '1920x1200', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Windows', + screen: '1366x768', + timezone: 'Europe/Madrid', + locale: 'es-ES', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0', + deviceName: 'Firefox', + osVersion: 'Windows', + screen: '1920x1080', + timezone: 'Europe/Rome', + locale: 'it-IT', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Firefox', + osVersion: 'Windows', + screen: '1440x900', + timezone: 'Europe/Amsterdam', + locale: 'nl-NL', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 6.3; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0', + deviceName: 'Firefox', + osVersion: 'Windows', + screen: '1600x900', + timezone: 'Europe/Warsaw', + locale: 'pl-PL', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.2535.51', + deviceName: 'Edge', + osVersion: 'Windows', + screen: '1920x1080', + timezone: 'America/Chicago', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.2478.109', + deviceName: 'Edge', + osVersion: 'Windows', + screen: '1366x768', + timezone: 'America/Sao_Paulo', + locale: 'pt-BR', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'macOS 14.5', + screen: '2560x1440', + timezone: 'America/Los_Angeles', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'macOS 13.6', + screen: '1440x900', + timezone: 'America/Toronto', + locale: 'en-CA', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_7_10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'macOS 11.7', + screen: '1728x1117', + timezone: 'Australia/Sydney', + locale: 'en-AU', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'macOS 12.5', + screen: '2048x1152', + timezone: 'Europe/London', + locale: 'en-GB', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:126.0) Gecko/20100101 Firefox/126.0', + deviceName: 'Firefox', + osVersion: 'macOS 14.5', + screen: '1920x1080', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Firefox', + osVersion: 'macOS 13.0', + screen: '1680x1050', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15', + deviceName: 'Safari', + osVersion: 'macOS 14.5', + screen: '1440x900', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15', + deviceName: 'Safari', + osVersion: 'macOS 13.6', + screen: '2560x1600', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15', + deviceName: 'Safari', + osVersion: 'macOS 10.14', + screen: '1280x800', + timezone: 'Asia/Tokyo', + locale: 'ja-JP', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Linux', + screen: '1920x1080', + timezone: 'Europe/Moscow', + locale: 'ru-RU', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Linux', + screen: '1366x768', + timezone: 'Asia/Kolkata', + locale: 'en-IN', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Chrome OS', + screen: '1920x1080', + timezone: 'America/Mexico_City', + locale: 'es-MX', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Linux', + screen: '1600x900', + timezone: 'Asia/Shanghai', + locale: 'zh-CN', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0', + deviceName: 'Firefox', + osVersion: 'Linux', + screen: '1920x1080', + timezone: 'UTC', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Firefox', + osVersion: 'Linux', + screen: '2560x1440', + timezone: 'America/Denver', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0', + deviceName: 'Firefox', + osVersion: 'Linux', + screen: '1366x768', + timezone: 'Asia/Dubai', + locale: 'ar-AE', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 OPR/110.0.0.0', + deviceName: 'Opera', + osVersion: 'Windows', + screen: '1920x1080', + timezone: 'Europe/Oslo', + locale: 'no-NO', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Vivaldi/6.5.3206.63', + deviceName: 'Vivaldi', + osVersion: 'macOS 14.0', + screen: '1440x900', + timezone: 'Europe/Stockholm', + locale: 'sv-SE', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; rv:102.0) Gecko/20100101 Firefox/102.0', + deviceName: 'Firefox', + osVersion: 'Windows', + screen: '1280x720', + timezone: 'Asia/Seoul', + locale: 'ko-KR', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Linux', + screen: '1920x1080', + timezone: 'Europe/Helsinki', + locale: 'fi-FI', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.2 Safari/605.1.15', + deviceName: 'Safari', + osVersion: 'macOS 10.13', + screen: '1280x800', + timezone: 'America/Vancouver', + locale: 'en-CA', + ), +]; diff --git a/lib/core/config/device_profile.dart b/lib/core/config/device_profile.dart new file mode 100644 index 0000000..1b85554 --- /dev/null +++ b/lib/core/config/device_profile.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; + +// #***! реальные данные устройства для юзерагента +/// Данные устройства для user-agent, неизменные за время жизни процесса. +/// Читаются с платформы один раз: на каждый реконнект их запрашивать незачем. +class DeviceProfile { + const DeviceProfile({ + this.osVersion = '', + this.deviceName = 'Unknown', + this.manufacturer, + this.model, + this.sdkInt, + }); + + final String osVersion; + final String deviceName; + final String? manufacturer; + final String? model; + final int? sdkInt; + + // #***! читаем один раз за процесс, на каждый реконнект незачем + static DeviceProfile? _cached; + + static Future load() async { + final cached = _cached; + if (cached != null) return cached; + final profile = await _read(); + _cached = profile; + return profile; + } + + // #***! у каждой платформы свой источник, на незнакомой пусто + static Future _read() async { + final deviceInfo = DeviceInfoPlugin(); + if (Platform.isAndroid) { + final info = await deviceInfo.androidInfo; + return DeviceProfile( + osVersion: 'Android ${info.version.release}', + deviceName: '${info.manufacturer} ${info.model}', + manufacturer: info.manufacturer, + model: info.model, + sdkInt: info.version.sdkInt, + ); + } + if (Platform.isIOS) { + final info = await deviceInfo.iosInfo; + return DeviceProfile( + osVersion: info.systemVersion, + deviceName: info.utsname.machine, + ); + } + if (Platform.isLinux) { + final info = await deviceInfo.linuxInfo; + return DeviceProfile(osVersion: info.name); + } + if (Platform.isWindows) { + final info = await deviceInfo.windowsInfo; + return DeviceProfile(osVersion: info.productName); + } + return const DeviceProfile(); + } +} diff --git a/lib/core/config/font_file_info.dart b/lib/core/config/font_file_info.dart new file mode 100644 index 0000000..e94e7f4 --- /dev/null +++ b/lib/core/config/font_file_info.dart @@ -0,0 +1,98 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +abstract final class FontFileInfo { + static const int _collectionTag = 0x74746366; + static const int _typographicFamily = 16; + static const int _family = 1; + static const int _platformUnicode = 0; + static const int _platformMac = 1; + static const int _platformWindows = 3; + static const int _englishUs = 0x0409; + + static String? familyName(Uint8List bytes) { + try { + final data = ByteData.sublistView(bytes); + if (bytes.length < 12) return null; + final fontOffset = data.getUint32(0) == _collectionTag + ? data.getUint32(12) + : 0; + final nameTable = _tableOffset(bytes, data, fontOffset, 'name'); + if (nameTable == null) return null; + return _pick(bytes, data, nameTable, _typographicFamily) ?? + _pick(bytes, data, nameTable, _family); + } catch (_) { + return null; + } + } + + static int? _tableOffset( + Uint8List bytes, + ByteData data, + int fontOffset, + String tag, + ) { + final numTables = data.getUint16(fontOffset + 4); + for (var i = 0; i < numTables; i++) { + final record = fontOffset + 12 + i * 16; + if (record + 16 > bytes.length) return null; + if (String.fromCharCodes(bytes, record, record + 4) == tag) { + return data.getUint32(record + 8); + } + } + return null; + } + + static String? _pick(Uint8List bytes, ByteData data, int table, int nameId) { + final count = data.getUint16(table + 2); + final storage = table + data.getUint16(table + 4); + String? fallback; + for (var i = 0; i < count; i++) { + final record = table + 6 + i * 12; + if (record + 12 > bytes.length) break; + if (data.getUint16(record + 6) != nameId) continue; + final platform = data.getUint16(record); + final language = data.getUint16(record + 4); + final length = data.getUint16(record + 8); + final start = storage + data.getUint16(record + 10); + if (start + length > bytes.length) continue; + final raw = bytes.sublist(start, start + length); + final value = switch (platform) { + _platformUnicode || _platformWindows => _utf16be(raw), + _platformMac => latin1.decode(raw, allowInvalid: true), + _ => null, + }?.trim(); + if (value == null || value.isEmpty) continue; + if (platform == _platformWindows && language == _englishUs) { + return value; + } + fallback ??= value; + } + return fallback; + } + + static String _utf16be(Uint8List raw) { + final units = [ + for (var i = 0; i + 1 < raw.length; i += 2) (raw[i] << 8) | raw[i + 1], + ]; + return String.fromCharCodes(units); + } + + static String familyFromFileName(String fileName) { + final base = fileName.split(RegExp(r'[\\/]')).last; + final dot = base.lastIndexOf('.'); + final stem = dot > 0 ? base.substring(0, dot) : base; + final cleaned = stem + .replaceAll( + RegExp( + r'[-_ ]?(Regular|Bold|Italic|Medium|Light|Thin|Black|' + r'SemiBold|ExtraBold|ExtraLight|VariableFont.*|Variable)$', + caseSensitive: false, + ), + '', + ) + .replaceAll(RegExp(r'[_-]+'), ' ') + .trim(); + return cleaned.isEmpty ? stem : cleaned; + } +} diff --git a/lib/core/config/font_metrics.dart b/lib/core/config/font_metrics.dart new file mode 100644 index 0000000..abf5bec --- /dev/null +++ b/lib/core/config/font_metrics.dart @@ -0,0 +1,44 @@ +import 'dart:typed_data'; + +abstract class FontMetrics { + static const double referenceXHeight = 0.528; + static const double minScale = 0.85; + static const double maxScale = 1.15; + + static double scaleForXHeight(double xHeightRatio) { + if (xHeightRatio <= 0) return 1.0; + return (referenceXHeight / xHeightRatio) + .clamp(minScale, maxScale) + .toDouble(); + } + + static double? xHeightRatio(Uint8List bytes) { + try { + if (bytes.length < 12) return null; + final data = ByteData.sublistView(bytes); + if (data.getUint32(0) == 0x74746366) return null; + final numTables = data.getUint16(4); + int? headOffset; + int? os2Offset; + for (var i = 0; i < numTables; i++) { + final record = 12 + i * 16; + if (record + 16 > bytes.length) return null; + final tag = String.fromCharCodes(bytes, record, record + 4); + if (tag == 'head') headOffset = data.getUint32(record + 8); + if (tag == 'OS/2') os2Offset = data.getUint32(record + 8); + } + if (headOffset == null || os2Offset == null) return null; + if (headOffset + 20 > bytes.length || os2Offset + 88 > bytes.length) { + return null; + } + final unitsPerEm = data.getUint16(headOffset + 18); + if (unitsPerEm == 0) return null; + if (data.getUint16(os2Offset) < 2) return null; + final xHeight = data.getInt16(os2Offset + 86); + if (xHeight <= 0) return null; + return xHeight / unitsPerEm; + } catch (_) { + return null; + } + } +} diff --git a/lib/core/config/ios_release.dart b/lib/core/config/ios_release.dart new file mode 100644 index 0000000..2ef478d --- /dev/null +++ b/lib/core/config/ios_release.dart @@ -0,0 +1,23 @@ +import 'package:flutter/foundation.dart'; + +/// What every iOS build leaves out or does differently. +/// +/// Plugins, the update check and device spoofing depend on the App Store +/// build flag instead, see the App Store build configuration. +/// +/// Runtime checks on [defaultTargetPlatform] so Android and desktop keep +/// their behavior and tests can switch platforms with +/// `debugDefaultTargetPlatformOverride`. +abstract final class IosRelease { + static bool get isIOS => + !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; + + /// Contact exchange by tapping phones runs on Android host card + /// emulation; iOS has no counterpart, so the entry point is hidden. + static bool get nfcContactExchange => !isIOS; + + /// iOS always shows the message long-press menu as a list next to the + /// message, as context menus do in iOS; the radial style and its setting + /// are not offered. + static bool get messageActionsStyleChoice => !isIOS; +} diff --git a/lib/core/config/persisted_setting.dart b/lib/core/config/persisted_setting.dart new file mode 100644 index 0000000..19489a8 --- /dev/null +++ b/lib/core/config/persisted_setting.dart @@ -0,0 +1,72 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +typedef PrefReader = T? Function(SharedPreferences prefs, String key); +typedef PrefWriter = + Future Function(SharedPreferences prefs, String key, T value); + +class PersistedSetting { + PersistedSetting({ + required this.prefKey, + required this.defaultValue, + required this.read, + required this.write, + T Function(T value)? sanitize, + }) : sanitize = sanitize ?? ((value) => value), + current = ValueNotifier(defaultValue); + + final String prefKey; + final T defaultValue; + final PrefReader read; + final PrefWriter write; + final T Function(T value) sanitize; + final ValueNotifier current; + + Future load() async { + final prefs = await SharedPreferences.getInstance(); + final value = sanitize(read(prefs, prefKey) ?? defaultValue); + current.value = value; + return value; + } + + Future save(T value) async { + final sanitized = sanitize(value); + current.value = sanitized; + final prefs = await SharedPreferences.getInstance(); + await write(prefs, prefKey, sanitized); + } +} + +typedef EnumEncoder = String Function(T value); +typedef EnumDecoder = T Function(String? raw); + +T enumFromName(List values, String? raw, T fallback) => + values.firstWhere((v) => v.name == raw, orElse: () => fallback); + +class PersistedEnum { + PersistedEnum({ + required this.prefKey, + required this.defaultValue, + required this.encode, + required this.decode, + }) : current = ValueNotifier(defaultValue); + + final String prefKey; + final T defaultValue; + final EnumEncoder encode; + final EnumDecoder decode; + final ValueNotifier current; + + Future load() async { + final prefs = await SharedPreferences.getInstance(); + final value = decode(prefs.getString(prefKey)); + current.value = value; + return value; + } + + Future save(T value) async { + current.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(prefKey, encode(value)); + } +} diff --git a/lib/core/config/proxy_config.dart b/lib/core/config/proxy_config.dart new file mode 100644 index 0000000..55d4c70 --- /dev/null +++ b/lib/core/config/proxy_config.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../storage/token_storage.dart'; +import '../utils/logger.dart'; + +enum ProxyType { none, socks5, httpConnect } + +class ProxySettings { + final ProxyType type; + final String host; + final int port; + final String? username; + final String? password; + + const ProxySettings({ + this.type = ProxyType.none, + this.host = '', + this.port = 1080, + this.username, + this.password, + }); + + bool get isEnabled => type != ProxyType.none && host.isNotEmpty; + + bool get hasCredentials => + username != null && + username!.isNotEmpty && + password != null && + password!.isNotEmpty; +} + +abstract class ProxyConfig { + static const String _prefType = 'proxy_type'; + static const String _prefHost = 'proxy_host'; + static const String _prefPort = 'proxy_port'; + static const String _prefUsername = 'proxy_username'; + static const String _prefPassword = 'proxy_password'; + + static const Duration _secureReadTimeout = Duration(seconds: 5); + + static Future _readSecureSafe(String key) async { + try { + return await TokenStorage.readSecure(key).timeout(_secureReadTimeout); + } catch (e) { + logger.w('ProxyConfig: чтение secure "$key" не удалось/зависло: $e'); + return null; + } + } + + static Future _migrateLegacySecure(String key, String value) async { + try { + await TokenStorage.writeSecure(key, value).timeout(_secureReadTimeout); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(key); + } catch (e) { + logger.w('ProxyConfig: миграция legacy "$key" не удалась: $e'); + } + } + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + final typeIndex = prefs.getInt(_prefType) ?? 0; + final host = prefs.getString(_prefHost) ?? ''; + final port = prefs.getInt(_prefPort) ?? 1080; + if (ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)] == + ProxyType.none || + host.isEmpty) { + return ProxySettings( + type: ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)], + host: host, + port: port, + ); + } + var username = await _readSecureSafe(_prefUsername); + var password = await _readSecureSafe(_prefPassword); + final legacyUsername = prefs.getString(_prefUsername); + final legacyPassword = prefs.getString(_prefPassword); + if (username == null && legacyUsername != null) { + username = legacyUsername; + unawaited(_migrateLegacySecure(_prefUsername, legacyUsername)); + } + if (password == null && legacyPassword != null) { + password = legacyPassword; + unawaited(_migrateLegacySecure(_prefPassword, legacyPassword)); + } + return ProxySettings( + type: ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)], + host: host, + port: port, + username: username, + password: password, + ); + } + + static Future save(ProxySettings settings) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_prefType, settings.type.index); + await prefs.setString(_prefHost, settings.host); + await prefs.setInt(_prefPort, settings.port); + await prefs.remove(_prefUsername); + await prefs.remove(_prefPassword); + if (settings.username != null) { + await TokenStorage.writeSecure(_prefUsername, settings.username!); + } else { + await TokenStorage.deleteSecure(_prefUsername); + } + if (settings.password != null) { + await TokenStorage.writeSecure(_prefPassword, settings.password!); + } else { + await TokenStorage.deleteSecure(_prefPassword); + } + } + + static Future clear() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_prefType); + await prefs.remove(_prefHost); + await prefs.remove(_prefPort); + await prefs.remove(_prefUsername); + await prefs.remove(_prefPassword); + await TokenStorage.deleteSecure(_prefUsername); + await TokenStorage.deleteSecure(_prefPassword); + } +} diff --git a/lib/core/config/qlyra_settings.dart b/lib/core/config/qlyra_settings.dart new file mode 100644 index 0000000..0d8e6cc --- /dev/null +++ b/lib/core/config/qlyra_settings.dart @@ -0,0 +1,119 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class QlyraSettings { + static const _kViewDeleted = 'qlyra_view_deleted'; + static const _kViewRedacted = 'qlyra_view_redacted'; + static const _kFullTimestamp = 'qlyra_full_timestamp'; + static const _kShowForward = 'qlyra_show_forward'; + static const _kShowTypingTime = 'qlyra_show_typing_time'; + static const _kGhostMode = 'qlyra_ghost_mode'; + static const _kAntiRead = 'qlyra_anti_read'; + static const _kSelfOnlineCheck = 'qlyra_self_online_check'; + static const _kHideAllChatsFolder = 'qlyra_hide_all_chats_folder'; + static const _kShowHiddenChats = 'qlyra_show_hidden_chats'; + static const _kArchiveOnPull = 'qlyra_archive_on_pull'; + static const _kRecordDebugLogs = 'qlyra_record_debug_logs'; + + static final ValueNotifier viewDeleted = ValueNotifier(false); + static final ValueNotifier viewRedacted = ValueNotifier(false); + static final ValueNotifier fullTimestamp = ValueNotifier(false); + static final ValueNotifier showForward = ValueNotifier(false); + static final ValueNotifier showTypingTime = ValueNotifier(false); + static final ValueNotifier ghostMode = ValueNotifier(false); + static final ValueNotifier antiRead = ValueNotifier(false); + static final ValueNotifier selfOnlineCheck = ValueNotifier(true); + static final ValueNotifier hideAllChatsFolder = ValueNotifier(false); + static final ValueNotifier recordDebugLogs = ValueNotifier(true); + static final ValueNotifier archiveOnPull = ValueNotifier(false); + static final ValueNotifier showHiddenChats = ValueNotifier(false); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + recordDebugLogs.value = prefs.getBool(_kRecordDebugLogs) ?? true; + archiveOnPull.value = prefs.getBool(_kArchiveOnPull) ?? false; + viewDeleted.value = prefs.getBool(_kViewDeleted) ?? false; + viewRedacted.value = prefs.getBool(_kViewRedacted) ?? false; + fullTimestamp.value = prefs.getBool(_kFullTimestamp) ?? false; + showForward.value = prefs.getBool(_kShowForward) ?? false; + showTypingTime.value = prefs.getBool(_kShowTypingTime) ?? false; + ghostMode.value = prefs.getBool(_kGhostMode) ?? false; + antiRead.value = prefs.getBool(_kAntiRead) ?? false; + selfOnlineCheck.value = prefs.getBool(_kSelfOnlineCheck) ?? true; + hideAllChatsFolder.value = prefs.getBool(_kHideAllChatsFolder) ?? false; + showHiddenChats.value = prefs.getBool(_kShowHiddenChats) ?? false; + } + + static Future setRecordDebugLogs(bool value) async { + recordDebugLogs.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kRecordDebugLogs, value); + } + + static Future setArchiveOnPull(bool value) async { + archiveOnPull.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kArchiveOnPull, value); + } + + static Future setViewDeleted(bool value) async { + viewDeleted.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kViewDeleted, value); + } + + static Future setViewRedacted(bool value) async { + viewRedacted.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kViewRedacted, value); + } + + static Future setFullTimestamp(bool value) async { + fullTimestamp.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kFullTimestamp, value); + } + + static Future setShowForward(bool value) async { + showForward.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kShowForward, value); + } + + static Future setShowTypingTime(bool value) async { + showTypingTime.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kShowTypingTime, value); + } + + // #***! невидимка влияет на пинг, с interactive false сервер не считает нас онлайн + static Future setGhostMode(bool value) async { + ghostMode.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kGhostMode, value); + } + + static Future setAntiRead(bool value) async { + antiRead.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kAntiRead, value); + } + + static Future setSelfOnlineCheck(bool value) async { + selfOnlineCheck.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kSelfOnlineCheck, value); + } + + static Future setHideAllChatsFolder(bool value) async { + hideAllChatsFolder.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kHideAllChatsFolder, value); + } + + static Future setShowHiddenChats(bool value) async { + showHiddenChats.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kShowHiddenChats, value); + } +} diff --git a/lib/core/contacts/contact_labels.dart b/lib/core/contacts/contact_labels.dart new file mode 100644 index 0000000..8f44d7f --- /dev/null +++ b/lib/core/contacts/contact_labels.dart @@ -0,0 +1,59 @@ +import '../utils/format.dart'; +import '../utils/names.dart'; +import 'device_contacts_service.dart'; + +// #***! заголовок и подпись строки контакта +class ContactLabels { + final String title; + final String? subtitle; + + const ContactLabels({required this.title, this.subtitle}); +} + +// #***! имя из телефонной книги важнее серверного +String? contactName({Object? firstName, Object? lastName, Object? phone}) { + final book = phone is int ? DeviceContactsService.nameForPhone(phone) : null; + if (book != null && book.isNotEmpty) return book; + final full = displayName(firstName, lastName); + return full.isEmpty ? null : full; +} + +// #***! сортируем по имени, нет так по телефону +String contactSortKey({Object? firstName, Object? lastName, Object? phone}) => + contactName(firstName: firstName, lastName: lastName, phone: phone) ?? + formatPhone(phone) ?? + ''; + +// #***! подпись не дублируем +ContactLabels contactLabels({ + required String idLabel, + Object? firstName, + Object? lastName, + Object? phone, +}) { + final phoneLabel = formatPhone(phone); + final title = + contactName(firstName: firstName, lastName: lastName, phone: phone) ?? + phoneLabel ?? + idLabel; + final subtitle = phoneLabel ?? idLabel; + return ContactLabels( + title: title, + subtitle: subtitle == title ? null : subtitle, + ); +} + +bool contactMatchesQuery( + String query, { + required String title, + Object? firstName, + Object? lastName, + Object? phone, +}) { + final needle = query.trim().toLowerCase(); + if (needle.isEmpty) return true; + final names = [title, displayName(firstName, lastName)]; + if (names.any((name) => name.toLowerCase().contains(needle))) return true; + final digits = needle.replaceAll(RegExp(r'\D'), ''); + return digits.length >= 3 && phone is int && '$phone'.contains(digits); +} diff --git a/lib/core/contacts/contact_presence.dart b/lib/core/contacts/contact_presence.dart new file mode 100644 index 0000000..d4a8996 --- /dev/null +++ b/lib/core/contacts/contact_presence.dart @@ -0,0 +1,14 @@ +enum ContactPresence { unknown, online, recently, longAgo } + +ContactPresence contactPresence(Map? presence, DateTime now) { + if (presence == null) return ContactPresence.unknown; + final status = presence['status']; + if (status == 1) return ContactPresence.online; + if (status == 2 || status == 3) return ContactPresence.recently; + final seen = presence['seen']; + if (seen is! int || seen <= 0) return ContactPresence.unknown; + final elapsed = now.millisecondsSinceEpoch ~/ 1000 - seen; + return elapsed <= const Duration(days: 3).inSeconds + ? ContactPresence.recently + : ContactPresence.longAgo; +} diff --git a/lib/core/contacts/contact_search.dart b/lib/core/contacts/contact_search.dart new file mode 100644 index 0000000..604c607 --- /dev/null +++ b/lib/core/contacts/contact_search.dart @@ -0,0 +1,52 @@ +import '../../backend/modules/contacts.dart'; + +class ContactSearch { + static List> filterPhonebook( + Map phonebook, + String query, + ) { + final matches = filter([ + for (final entry in phonebook.entries) + CachedContact( + id: 0, + accountId: 0, + firstName: entry.value, + phone: int.parse(entry.key.replaceAll('+', '')), + updateTime: 0, + ), + ], query); + return [ + for (final contact in matches) + MapEntry('+${contact.phone}', contact.firstName), + ]..sort( + (a, b) => _normalizeName(a.value).compareTo(_normalizeName(b.value)), + ); + } + + static List filter( + List contacts, + String query, + ) { + final text = _normalizeName(query); + if (text.isEmpty) return List.of(contacts); + final isPhone = RegExp(r'^[+\d\s().-]+$').hasMatch(text); + var digits = isPhone ? text.replaceAll(RegExp(r'\D'), '') : ''; + if (digits.length == 11 && digits.startsWith('8')) { + digits = '7${digits.substring(1)}'; + } + final words = text.split(' '); + return contacts.where((contact) { + final name = _normalizeName( + '${contact.firstName} ${contact.lastName ?? ''}', + ); + return words.every(name.contains) || + (digits.isNotEmpty && contact.phone.toString().contains(digits)); + }).toList(); + } + + static String _normalizeName(String value) => value + .trim() + .toLowerCase() + .replaceAll('ё', 'е') + .replaceAll(RegExp(r'\s+'), ' '); +} diff --git a/lib/core/contacts/device_contacts_service.dart b/lib/core/contacts/device_contacts_service.dart new file mode 100644 index 0000000..5fd1a6a --- /dev/null +++ b/lib/core/contacts/device_contacts_service.dart @@ -0,0 +1,147 @@ +import 'dart:io'; + +import 'package:flutter_contacts/flutter_contacts.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../config/app_phonebook_names.dart'; +import '../utils/logger.dart'; + +class DeviceContactsService { + DeviceContactsService._(); + + static const _deniedKey = 'phonebook_denied'; + + static final Map _byLast10 = {}; + static final Map _phonebook = {}; + static Map get phonebook => Map.unmodifiable(_phonebook); + static bool _loaded = false; + static Future? _readInFlight; + + static bool get _supported => Platform.isAndroid || Platform.isIOS; + + static bool get isLoaded => _loaded; + + static int get knownNumbers => _byLast10.length; + + static String? _last10(String raw) { + final digits = raw.replaceAll(RegExp(r'[^\d]'), ''); + if (digits.length < 10) return null; + return digits.substring(digits.length - 10); + } + + static String? nameForPhone(int phone) { + if (!AppPhonebookNames.current.value) return null; + if (_byLast10.isEmpty) return null; + final key = _last10(phone.toString()); + if (key == null) return null; + final name = _byLast10[key]; + if (name == null || name.trim().isEmpty) return null; + return name.trim(); + } + + static Future hasPermission() async { + if (!_supported) return false; + try { + return await Permission.contacts.isGranted; + } catch (e) { + logger.w('Телефонная книга: не удалось прочитать статус разрешения: $e'); + return false; + } + } + + static Future loadFromStartup() async { + if (_loaded || !_supported) return; + if (!AppPhonebookNames.current.value) return; + if (!await hasPermission()) return; + await _forgetDenial(); + await _readBook(); + } + + static Future ensureLoadedInteractive({ + bool force = false, + bool forContacts = false, + bool refresh = false, + }) async { + if (!_supported) return false; + if (!forContacts && !AppPhonebookNames.current.value) return false; + if (_loaded && !force && !refresh) return false; + + if (await hasPermission()) { + await _forgetDenial(); + return _readBook(); + } + + final prefs = await SharedPreferences.getInstance(); + if (!force && prefs.getBool(_deniedKey) == true) return false; + + final granted = await FlutterContacts.requestPermission(readonly: true); + if (!granted) { + await prefs.setBool(_deniedKey, true); + return false; + } + await prefs.remove(_deniedKey); + return _readBook(); + } + + static Future reload() async { + _loaded = false; + _byLast10.clear(); + _phonebook.clear(); + return ensureLoadedInteractive(force: true); + } + + static Future _forgetDenial() async { + final prefs = await SharedPreferences.getInstance(); + if (prefs.getBool(_deniedKey) == true) await prefs.remove(_deniedKey); + } + + static Future _readBook() => _readInFlight ??= _readBookOnce() + .whenComplete(() => _readInFlight = null); + + static Future _readBookOnce() async { + _loaded = false; + _byLast10.clear(); + _phonebook.clear(); + try { + FlutterContacts.config.includeNonVisibleOnAndroid = true; + final contacts = await FlutterContacts.getContacts(withProperties: true); + _byLast10.clear(); + _phonebook.clear(); + for (final contact in contacts) { + final name = contact.displayName.trim(); + for (final phone in contact.phones) { + final normalized = normalizePhone(phone.number); + if (normalized != null) { + _phonebook.putIfAbsent( + normalized, + () => name.isEmpty ? normalized : name, + ); + } + final key = _last10(phone.number); + if (key != null) { + _byLast10.putIfAbsent(key, () => name); + } + } + } + _loaded = true; + logger.i( + 'Телефонная книга: прочитано ${contacts.length} записей, ' + '${_byLast10.length} номеров', + ); + return true; + } catch (e) { + logger.w('Телефонная книга: не удалось прочитать: $e'); + return false; + } + } + + static String? normalizePhone(String raw) { + var digits = raw.replaceAll(RegExp(r'[^\d]'), ''); + if (digits.length == 11 && digits.startsWith('8')) { + digits = '7${digits.substring(1)}'; + } + if (digits.length < 10 || digits.length > 15) return null; + return '+$digits'; + } +} diff --git a/lib/core/contacts/phonebook_contact_sync.dart b/lib/core/contacts/phonebook_contact_sync.dart new file mode 100644 index 0000000..e3650a8 --- /dev/null +++ b/lib/core/contacts/phonebook_contact_sync.dart @@ -0,0 +1,50 @@ +import 'package:flutter/widgets.dart'; + +import '../../backend/modules/contacts.dart'; +import 'device_contacts_service.dart'; + +/// Imports only discoverable MAX accounts, without renaming existing contacts. +class PhonebookContactSync { + static Future importContacts({ + required Map phonebook, + required List contacts, + required int accountId, + required Future Function(String) lookup, + required Future Function(String, String) add, + required bool Function() isCurrent, + Duration requestDelay = const Duration(milliseconds: 250), + }) async { + final ids = contacts.map((c) => c.id).toSet()..add(accountId); + final phones = contacts + .map((c) => DeviceContactsService.normalizePhone('${c.phone}')) + .whereType() + .toSet(); + var added = 0; + for (final entry in phonebook.entries) { + if (!isCurrent()) break; + final phone = DeviceContactsService.normalizePhone(entry.key); + if (phone == null || !phones.add(phone)) continue; + await Future.delayed(requestDelay); + if (!isCurrent()) break; + // Strict lookup errors stop this pass; absent accounts are retried next time. + final found = await lookup(phone); + if (!isCurrent()) break; + if (found == null || ids.contains(found.id)) continue; + final name = entry.value.trim(); + final result = await add( + phone, + (name.isEmpty ? phone : name).characters.take(60).toString(), + ); + if (!isCurrent()) break; + if (result.status == AddContactStatus.error) { + throw StateError('Phonebook contact import failed'); + } + if (result.status == AddContactStatus.added) { + ids.add(found.id); + if (result.contact != null) ids.add(result.contact!.id); + added++; + } + } + return added; + } +} diff --git a/lib/core/contacts/phonebook_max_contacts.dart b/lib/core/contacts/phonebook_max_contacts.dart new file mode 100644 index 0000000..bcbdd5d --- /dev/null +++ b/lib/core/contacts/phonebook_max_contacts.dart @@ -0,0 +1,44 @@ +import '../../backend/modules/contacts.dart'; +import 'device_contacts_service.dart'; + +class PhonebookMaxContacts { + static Future> resolve({ + required Map phonebook, + required List cached, + required int accountId, + required Future Function(String) lookup, + bool Function()? isCancelled, + }) async { + final known = {}; + for (final contact in cached) { + final phone = DeviceContactsService.normalizePhone('${contact.phone}'); + if (phone != null) known[phone] = contact; + } + final result = {}; + for (final entry in phonebook.entries) { + if (isCancelled?.call() == true) break; + final phone = DeviceContactsService.normalizePhone(entry.key); + if (phone == null) continue; + final existing = known[phone]; + final found = await lookup(phone); + if (isCancelled?.call() == true) break; + if (found == null) continue; + final id = found.id; + result.putIfAbsent( + id, + () => CachedContact( + id: id, + accountId: accountId, + firstName: entry.value, + phone: int.parse(phone.substring(1)), + baseUrl: found.avatarUrl, + updateTime: 0, + options: existing?.id == id ? existing!.options : const {}, + ), + ); + } + return result.values.toList()..sort( + (a, b) => a.firstName.toLowerCase().compareTo(b.firstName.toLowerCase()), + ); + } +} diff --git a/lib/core/crypto/chat_crypto_key_cache.dart b/lib/core/crypto/chat_crypto_key_cache.dart new file mode 100644 index 0000000..cb26f26 --- /dev/null +++ b/lib/core/crypto/chat_crypto_key_cache.dart @@ -0,0 +1,58 @@ +import 'dart:typed_data'; + +// #***! кэш ключей чатов: clear сдвигает поколение, и вывод, начатый до сброса, +// #***! в кэш уже не попадает +class ChatCryptoKeyCache { + ChatCryptoKeyCache(this._derive, {required void Function(Uint8List key) wipe}) + : _wipe = wipe; + + final Future Function(int accountId, int chatId) _derive; + final void Function(Uint8List key) _wipe; + final Map _keys = {}; + final Map> _pending = {}; + int _generation = 0; + + void clear() { + _generation++; + for (final key in _keys.values) { + _wipe(key); + } + _keys.clear(); + _pending.clear(); + } + + Future keyFor(int accountId, int chatId) { + final cacheKey = '$accountId/$chatId'; + final generation = _generation; + final cached = _keys[cacheKey]; + if (cached != null) { + return Future.value( + cached, + ).then((key) => generation == _generation ? key : null); + } + final existing = _pending[cacheKey]; + if (existing != null) return existing; + late final Future pending; + pending = _deriveKey(accountId, chatId, cacheKey, generation).whenComplete( + () { + if (identical(_pending[cacheKey], pending)) _pending.remove(cacheKey); + }, + ); + return _pending[cacheKey] = pending; + } + + Future _deriveKey( + int accountId, + int chatId, + String cacheKey, + int generation, + ) async { + final key = await _derive(accountId, chatId); + if (key == null) return null; + if (generation != _generation) { + _wipe(key); + return null; + } + return _keys[cacheKey] = key; + } +} diff --git a/lib/core/crypto/chat_crypto_service.dart b/lib/core/crypto/chat_crypto_service.dart new file mode 100644 index 0000000..ebc8642 --- /dev/null +++ b/lib/core/crypto/chat_crypto_service.dart @@ -0,0 +1,206 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:komet_crypto/komet_crypto.dart'; +import 'package:qlyra_crypto/qlyra_crypto.dart' as legacy; + +import '../storage/chat_encryption_store.dart'; +import '../utils/logger.dart'; +import 'noise_png.dart'; +import 'chat_crypto_key_cache.dart'; + +Future? _legacyInitialization; + +Future _derivePassword(String password) async { + if (!legacy.isInitialized) { + await (_legacyInitialization ??= legacy.RustLib.init()); + } + return legacy.deriveKey(password: password); +} + +const int kMaxEncryptedMessageLength = 1000; + +enum CryptoFailure { noKey, wrongKey, notEncrypted, malformed, unavailable } + +class CryptoResult { + final String? text; + final CryptoFailure? failure; + + const CryptoResult.ok(String this.text) : failure = null; + const CryptoResult.failed(CryptoFailure this.failure) : text = null; + + bool get isOk => text != null; +} + +// #***! байты либо причина неудачи +class CryptoBytesResult { + final Uint8List? bytes; + final CryptoFailure? failure; + + const CryptoBytesResult.ok(Uint8List this.bytes) : failure = null; + const CryptoBytesResult.failed(CryptoFailure this.failure) : bytes = null; + + bool get isOk => bytes != null; +} + +CryptoFailure cryptoFailureOf(Object error) { + if (error is KometCryptoException) { + switch (error.status) { + case CryptoStatus.wrongKey: + return CryptoFailure.wrongKey; + case CryptoStatus.notEncrypted: + return CryptoFailure.notEncrypted; + case CryptoStatus.malformed: + return CryptoFailure.malformed; + default: + return CryptoFailure.unavailable; + } + } + return CryptoFailure.unavailable; +} + +// #***! парольное шифрование групп и старой истории, сама крипта в C-ядре +class ChatCryptoService { + ChatCryptoService._() { + ChatEncryptionStore.instance.revision.addListener(clearKeys); + } + + static final ChatCryptoService instance = ChatCryptoService._(); + + late final ChatCryptoKeyCache _keyCache = ChatCryptoKeyCache( + _deriveKey, + wipe: KometCrypto.wipe, + ); + + bool get _unavailable => !KometCrypto.isAvailable; + + void clearKeys() => _keyCache.clear(); + + Future _keyFor(int accountId, int chatId) => + _keyCache.keyFor(accountId, chatId); + + Future _deriveKey(int accountId, int chatId) async { + try { + if (_unavailable) return null; + final password = await ChatEncryptionStore.instance.readKey( + accountId, + chatId, + ); + if (password == null || password.isEmpty) return null; + return await _derivePassword(password); + } catch (e) { + logger.w('derive key for chat $chatId: $e'); + return null; + } + } + + bool isEnabled(int accountId, int chatId) => + ChatEncryptionStore.instance.isEnabled(accountId, chatId); + + Future warmKey(int accountId, int chatId) => _keyFor(accountId, chatId); + + CryptoFailure _noKeyFailure() => + _unavailable ? CryptoFailure.unavailable : CryptoFailure.noKey; + + // #***! шифрование и расшифровка текста + Future encrypt( + int accountId, + int chatId, + String plaintext, + ) async { + final key = await _keyFor(accountId, chatId); + if (key == null) return CryptoResult.failed(_noKeyFailure()); + try { + return CryptoResult.ok(KometCrypto.encryptMessage(plaintext, key)); + } catch (e) { + logger.w('encrypt for chat $chatId: $e'); + return const CryptoResult.failed(CryptoFailure.unavailable); + } + } + + Future decrypt(int accountId, int chatId, String text) async { + final key = await _keyFor(accountId, chatId); + if (key == null) return CryptoResult.failed(_noKeyFailure()); + try { + return CryptoResult.ok(KometCrypto.decryptMessage(text, key)); + } catch (e) { + return CryptoResult.failed(cryptoFailureOf(e)); + } + } + + // #***! картинки байт в байт, шум заворачивается в PNG уже здесь + Future encryptImageBytes( + int accountId, + int chatId, + Uint8List png, + ) async { + final key = await _keyFor(accountId, chatId); + if (key == null) return CryptoBytesResult.failed(_noKeyFailure()); + try { + final blob = KometCrypto.encryptImageBlob(png, key); + final wrapped = wrapNoisePng(blob); + if (wrapped == null) { + return const CryptoBytesResult.failed(CryptoFailure.malformed); + } + return CryptoBytesResult.ok(wrapped); + } catch (e) { + logger.w('image encrypt for chat $chatId: $e'); + return CryptoBytesResult.failed(cryptoFailureOf(e)); + } + } + + Future decryptImageBytes( + int accountId, + int chatId, + Uint8List noisePng, + ) async { + final key = await _keyFor(accountId, chatId); + if (key == null) return CryptoBytesResult.failed(_noKeyFailure()); + final raw = unwrapNoisePng(noisePng); + if (raw == null) { + return const CryptoBytesResult.failed(CryptoFailure.notEncrypted); + } + try { + return CryptoBytesResult.ok(KometCrypto.decryptImageBlob(raw, key)); + } catch (e) { + logger.w('image decrypt for chat $chatId: $e'); + return CryptoBytesResult.failed(cryptoFailureOf(e)); + } + } + + // #***! похоже ли это вообще на наш шифр, чтоб отличить чужой ключ от обычного текста + bool looksEncryptedImage(Uint8List noisePng) { + if (_unavailable) return false; + final raw = unwrapNoisePng(noisePng); + if (raw == null) return false; + try { + return KometCrypto.looksEncryptedImageBlob(raw); + } catch (_) { + return false; + } + } + + // #***! разовая расшифровка чужим паролем, ключ не кэшируем + Future decryptWithPassword(String text, String password) async { + if (_unavailable) return null; + try { + final key = await _derivePassword(password); + try { + return KometCrypto.decryptMessage(text, key); + } finally { + KometCrypto.wipe(key); + } + } catch (_) { + return null; + } + } + + bool looksEncrypted(String text) { + if (_unavailable) return false; + try { + return KometCrypto.looksEncryptedMessage(text); + } catch (_) { + return false; + } + } +} diff --git a/lib/core/crypto/e2ee_service.dart b/lib/core/crypto/e2ee_service.dart new file mode 100644 index 0000000..7831457 --- /dev/null +++ b/lib/core/crypto/e2ee_service.dart @@ -0,0 +1,1114 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:komet_crypto/komet_crypto.dart'; + +import '../../backend/modules/messages.dart'; +import '../storage/app_database.dart'; +import '../storage/token_storage.dart'; +import '../utils/logger.dart'; + +Future _transferCrypto( + String? library, + Uint8List secret, + Uint8List data, { + required bool seal, +}) => Isolate.run(() { + KometCrypto.libraryPath = library; + return seal + ? KometCrypto.exportSeal( + password: secret, + memoryKib: 65536, + passes: 3, + container: data, + ) + : KometCrypto.exportOpen(password: secret, blob: data); +}); + +// #***! сквозное шифрование диалогов: identity-ключ, хендшейк сообщениями, рэтчет в C-ядре +enum E2eePhase { none, offered, pendingConsent, established, keyChanged } + +// #***! перенесённая сессия ждёт DH-шага, иначе оба устройства дадут один ключ +class E2eeAwaitingPeer implements Exception { + const E2eeAwaitingPeer(); +} + +const String kE2eeOfferPrefix = + '🔐 ИКАР: запрос сквозного шифрования. Откройте этот чат в ИКАР или совместимом Komet, чтобы принять.'; +const String kE2eeAnswerPrefix = '🔐 ИКАР: сквозное шифрование включено.'; +const String kE2eeOfferDonePrefix = '🔐 ИКАР: запрос сквозного шифрования.'; + +@immutable +class E2eeSessionInfo { + final E2eePhase phase; + final int peerId; + final Uint8List? peerPublic; + final bool verified; + final String? offerText; + final String? offerMessageId; + + const E2eeSessionInfo({ + required this.phase, + required this.peerId, + this.peerPublic, + this.verified = false, + this.offerText, + this.offerMessageId, + }); + + bool get needsAttention => + phase == E2eePhase.pendingConsent || phase == E2eePhase.keyChanged; + + // #***! keyChanged это живая сессия, выключить значит уронить чат в открытый текст + bool get hasSession => + phase == E2eePhase.established || phase == E2eePhase.keyChanged; + + E2eeSessionInfo copyWith({ + E2eePhase? phase, + int? peerId, + Uint8List? peerPublic, + bool? verified, + String? offerText, + bool clearOffer = false, + String? offerMessageId, + }) => E2eeSessionInfo( + phase: phase ?? this.phase, + peerId: peerId ?? this.peerId, + peerPublic: peerPublic ?? this.peerPublic, + verified: verified ?? this.verified, + offerText: clearOffer ? null : (offerText ?? this.offerText), + offerMessageId: offerMessageId ?? this.offerMessageId, + ); +} + +class E2eeService { + E2eeService({ + Future>> Function(int accountId) loadSessions = + AppDatabase.loadE2eeSessions, + }) : _loadSessions = loadSessions; + + static final E2eeService instance = E2eeService(); + + final Future>> Function(int accountId) + _loadSessions; + + // #***! фазы по чатам для синхронных проверок, состояния рэтчета грузим лениво + final ValueNotifier revision = ValueNotifier(0); + final Map _info = {}; + final Map _states = {}; + final Map> _chains = {}; + final Set _loaded = {}; + final Map future})> _loading = {}; + final Map _identities = {}; + final Map _localKeys = {}; + MessagesModule? _messages; + + bool get available => KometCrypto.isAvailable; + + void attach(MessagesModule messages) => _messages = messages; + + String _key(int accountId, int chatId) => '$accountId/$chatId'; + + bool isLoaded(int accountId) => _loaded.contains(accountId); + + Future ensureLoaded(int accountId) { + if (accountId == 0 || isLoaded(accountId)) return Future.value(); + final pending = _loading[accountId]; + if (pending != null) return pending.future; + final token = Object(); + final future = _loadAccount(accountId, token); + _loading[accountId] = (token: token, future: future); + return future; + } + + Future _loadAccount(int accountId, Object token) async { + try { + final rows = await Future.sync(() => _loadSessions(accountId)); + if (!identical(_loading[accountId]?.token, token)) { + throw StateError('E2EE account load was invalidated'); + } + final loadedInfo = {}; + for (final row in rows) { + loadedInfo[_key(accountId, row['chat_id'] as int)] = _infoFromRow(row); + } + _info.addAll(loadedInfo); + _loaded.add(accountId); + revision.value++; + } catch (e) { + logger.w('e2ee load: $e'); + rethrow; + } finally { + if (identical(_loading[accountId]?.token, token)) { + _loading.remove(accountId); + } + } + } + + E2eeSessionInfo? info(int accountId, int chatId) => + _info[_key(accountId, chatId)]; + + E2eePhase phaseOf(int accountId, int chatId) => + info(accountId, chatId)?.phase ?? E2eePhase.none; + + bool isActive(int accountId, int chatId) => + info(accountId, chatId)?.hasSession ?? false; + + bool isOn(int accountId, int chatId) => + phaseOf(accountId, chatId) != E2eePhase.none; + + E2eeSessionInfo _infoFromRow(Map row) => E2eeSessionInfo( + phase: E2eePhase.values.firstWhere( + (phase) => phase.name == row['phase'], + orElse: () => E2eePhase.none, + ), + peerId: row['peer_id'] as int, + peerPublic: row['peer_public'] is Uint8List + ? row['peer_public'] as Uint8List + : null, + verified: row['verified'] == 1, + offerText: row['offer_text'] as String?, + offerMessageId: row['offer_message_id'] as String?, + ); + + // #***! операции по одному чату строго по очереди, состояние рэтчета мутирует + Future _serial(String key, Future Function() body) { + final previous = _chains[key] ?? Future.value(); + final next = previous.then((_) => body()); + _chains[key] = next.then((_) {}, onError: (_) {}); + return next; + } + + final Map> _secretsInFlight = {}; + + // #***! схлопываем вывод, две гонки дадут два ключа и запечатанное не откроется + Future _secureBytes(String key, Uint8List Function() create) { + final running = _secretsInFlight[key]; + if (running != null) return running; + final future = _createSecret(key, create); + _secretsInFlight[key] = future; + return future.whenComplete(() => _secretsInFlight.remove(key)); + } + + Future _createSecret( + String key, + Uint8List Function() create, + ) async { + final stored = await TokenStorage.readSecure(key); + if (stored != null && stored.isNotEmpty) return base64Decode(stored); + final created = create(); + await TokenStorage.writeSecure(key, base64Encode(created)); + return created; + } + + // #***! отдаём копию, lock() затирает кэш и посреди операции + Future identity(int accountId) async { + final cached = _identities[accountId]; + if (cached != null) return Uint8List.fromList(cached); + final identity = await _secureBytes( + 'e2ee_identity_$accountId', + () => KometCrypto.identityCreate(), + ); + _identities[accountId] = identity; + return Uint8List.fromList(identity); + } + + Future identityPublic(int accountId) async => + KometCrypto.identityPublicKey(await identity(accountId)); + + Future _localKey(int accountId) async { + final cached = _localKeys[accountId]; + if (cached != null) return Uint8List.fromList(cached); + final key = await _secureBytes( + 'e2ee_local_$accountId', + () => KometCrypto.randomBytes(KometCryptoSizes.localKey), + ); + _localKeys[accountId] = key; + return Uint8List.fromList(key); + } + + Uint8List _aad(String kind, int accountId, int chatId) => + Uint8List.fromList(utf8.encode('$kind/$accountId/$chatId')); + + Future sealBytes( + int accountId, + int chatId, + Uint8List plaintext, + ) async => KometCrypto.localSeal( + key: await _localKey(accountId), + plaintext: plaintext, + aad: _aad('text', accountId, chatId), + ); + + Future sealText(int accountId, int chatId, String text) => + sealBytes(accountId, chatId, Uint8List.fromList(utf8.encode(text))); + + Future openBytes( + int accountId, + int chatId, + Uint8List sealed, + ) async { + try { + return KometCrypto.localOpen( + key: await _localKey(accountId), + blob: sealed, + aad: _aad('text', accountId, chatId), + ); + } catch (e) { + logger.w('e2ee local open: $e'); + return null; + } + } + + Future openText(int accountId, int chatId, Uint8List sealed) async { + final bytes = await openBytes(accountId, chatId, sealed); + return bytes == null ? null : utf8.decode(bytes, allowMalformed: true); + } + + Future _state(int accountId, int chatId) async { + final key = _key(accountId, chatId); + final cached = _states[key]; + if (cached != null) return Uint8List.fromList(cached); + final row = await AppDatabase.loadE2eeSession(accountId, chatId); + final sealed = row?['state']; + if (sealed is! Uint8List) return null; + try { + final state = KometCrypto.localOpen( + key: await _localKey(accountId), + blob: sealed, + aad: _aad('session', accountId, chatId), + ); + _states[key] = state; + return Uint8List.fromList(state); + } catch (e) { + logger.w('e2ee session open: $e'); + return null; + } + } + + Future _persist( + int accountId, + int chatId, + E2eeSessionInfo info, { + required Uint8List? state, + }) async { + final key = _key(accountId, chatId); + Uint8List? sealed; + if (state != null) { + sealed = KometCrypto.localSeal( + key: await _localKey(accountId), + plaintext: state, + aad: _aad('session', accountId, chatId), + ); + } + await AppDatabase.saveE2eeSession({ + 'account_id': accountId, + 'chat_id': chatId, + 'peer_id': info.peerId, + 'phase': info.phase.name, + 'state': sealed, + 'peer_public': info.peerPublic, + 'verified': info.verified ? 1 : 0, + 'offer_text': info.offerText, + 'offer_message_id': info.offerMessageId, + 'updated': DateTime.now().millisecondsSinceEpoch, + }); + if (state != null) { + _states[key] = state; + } else { + _states.remove(key); + } + final previous = _info[key]; + _info[key] = info; + if (previous?.phase != info.phase || + previous?.verified != info.verified || + previous?.offerText != info.offerText) { + revision.value++; + } + } + + // #***! сессию выбрасываем, личность собеседника и отметку проверки помним + Future _forget(int accountId, int chatId) async { + final key = _key(accountId, chatId); + final current = _info[key]; + final state = _states.remove(key); + if (state != null) KometCrypto.wipe(state); + if (current?.peerPublic == null) { + await AppDatabase.deleteE2eeSession(accountId, chatId); + _info.remove(key); + revision.value++; + return; + } + await _persist( + accountId, + chatId, + E2eeSessionInfo( + phase: E2eePhase.none, + peerId: current!.peerId, + peerPublic: current.peerPublic, + verified: current.verified, + ), + state: null, + ); + } + + bool _samePeer(E2eeSessionInfo? previous, Uint8List peerPublic) => + previous?.peerPublic != null && + listEquals(previous!.peerPublic, peerPublic); + + bool _carryVerified(E2eeSessionInfo? previous, Uint8List peerPublic) => + _samePeer(previous, peerPublic) && (previous?.verified ?? false); + + // #***! мы предлагаем шифрование: OFFER уходит обычным сообщением + Future startOffer({ + required int accountId, + required int chatId, + required int peerId, + }) => _serial(_key(accountId, chatId), () async { + final messages = _messages; + if (messages == null || !available) return false; + await ensureLoaded(accountId); + try { + final offer = KometCrypto.sessionOffer( + identity: await identity(accountId), + chatId: chatId, + myId: accountId, + peerId: peerId, + ); + final messageId = await messages.sendMessage( + accountId, + chatId, + '$kE2eeOfferPrefix\n${offer.text}', + ); + if (messageId.isEmpty) return false; + final previous = info(accountId, chatId); + await _persist( + accountId, + chatId, + E2eeSessionInfo( + phase: E2eePhase.offered, + peerId: peerId, + peerPublic: previous?.peerPublic, + verified: previous?.verified ?? false, + offerMessageId: messageId, + ), + state: offer.pending, + ); + return true; + } catch (e) { + logger.w('e2ee offer: $e'); + return false; + } + }); + + // #***! собеседник предложил, мы согласились: ANSWER и сессия установлена + Future acceptOffer({required int accountId, required int chatId}) => + _serial(_key(accountId, chatId), () => _answer(accountId, chatId)); + + Future _answer(int accountId, int chatId) async { + final messages = _messages; + final current = info(accountId, chatId); + final offerText = current?.offerText; + if (messages == null || current == null || offerText == null) return false; + try { + final hadSession = + current.phase == E2eePhase.established || + current.phase == E2eePhase.keyChanged; + final answer = KometCrypto.sessionAnswer( + identity: await identity(accountId), + chatId: chatId, + myId: accountId, + peerId: current.peerId, + offerText: offerText, + oldState: hadSession ? await _state(accountId, chatId) : null, + ); + final messageId = await messages.sendMessage( + accountId, + chatId, + '$kE2eeAnswerPrefix\n${answer.text}', + ); + if (messageId.isEmpty) return false; + final peek = KometCrypto.handshakePeek(offerText); + await _persist( + accountId, + chatId, + E2eeSessionInfo( + phase: E2eePhase.established, + peerId: current.peerId, + peerPublic: peek.publicKey, + verified: _carryVerified(current, peek.publicKey), + ), + state: answer.state, + ); + return true; + } catch (e) { + logger.w('e2ee answer: $e'); + return false; + } + } + + Future declineOffer({required int accountId, required int chatId}) => + _serial(_key(accountId, chatId), () async { + final current = info(accountId, chatId); + if (current == null) return; + if (current.phase == E2eePhase.keyChanged) { + await _persist( + accountId, + chatId, + current.copyWith(phase: E2eePhase.established, clearOffer: true), + state: await _state(accountId, chatId), + ); + return; + } + await _forget(accountId, chatId); + }); + + Future resetSession({required int accountId, required int chatId}) => + _serial(_key(accountId, chatId), () => _forget(accountId, chatId)); + + Future setVerified({ + required int accountId, + required int chatId, + required bool verified, + }) => _serial(_key(accountId, chatId), () async { + final current = info(accountId, chatId); + if (current == null) return; + await _persist( + accountId, + chatId, + current.copyWith(verified: verified), + state: await _state(accountId, chatId), + ); + }); + + // #***! в keyChanged ключ мог и не меняться, а разницу показать надо + bool offerChangesPeer(int accountId, int chatId) { + final current = info(accountId, chatId); + final offer = current?.offerText; + final known = current?.peerPublic; + if (offer == null || known == null) return true; + try { + return !listEquals(KometCrypto.handshakePeek(offer).publicKey, known); + } on KometCryptoException { + return true; + } + } + + Future fingerprint(int accountId, int chatId) async { + final current = info(accountId, chatId); + final peerPublic = current?.peerPublic; + if (current == null || peerPublic == null) return null; + try { + return KometCrypto.fingerprint( + myId: accountId, + myPublic: await identityPublic(accountId), + peerId: current.peerId, + peerPublic: peerPublic, + ); + } catch (e) { + logger.w('e2ee fingerprint: $e'); + return null; + } + } + + // #***! исходящее: рэтчет шагает, состояние сразу в базу + Future encryptText(int accountId, int chatId, String text) => + encryptBytes( + accountId, + chatId, + ContentType.text, + Uint8List.fromList(utf8.encode(text)), + ); + + Future encryptBytes( + int accountId, + int chatId, + ContentType contentType, + Uint8List plaintext, + ) => _serial(_key(accountId, chatId), () async { + final current = info(accountId, chatId); + if (current == null || !current.hasSession) return null; + final state = await _state(accountId, chatId); + if (state == null) return null; + try { + final result = KometCrypto.sessionEncrypt( + state: state, + contentType: contentType, + plaintext: plaintext, + ); + await _persist(accountId, chatId, current, state: result.state); + await AppDatabase.saveE2eeOutgoing( + accountId, + chatId, + result.text, + await sealBytes(accountId, chatId, plaintext), + contentType == ContentType.file + ? CachedMessage.e2eeFile + : CachedMessage.e2eeText, + ); + return result.text; + } on KometCryptoException catch (e) { + logger.w('e2ee encrypt: ${e.status.name_}'); + if (e.status == CryptoStatus.awaitingPeer) { + throw const E2eeAwaitingPeer(); + } + return null; + } + }); + + bool fitsTransport(int plaintextBytes) => + plaintextBytes <= KometCryptoSizes.sessionPlaintextMax; + + // #***! входящее: хендшейки двигают фазу, шифртекст расшифровывается один раз + Future inspect( + CachedMessage message, { + Future Function(CachedMessage message)? commit, + }) async { + final text = message.text; + if (!available || text == null || text.isEmpty) return message; + if (message.senderId == message.accountId) { + final saved = await AppDatabase.loadE2eeOutgoing( + message.accountId, + message.chatId, + text, + ); + return saved == null + ? message + : message.copyWith( + sealedText: saved['sealed'] as Uint8List, + e2ee: saved['e2ee'] as int, + ); + } + if (message.senderId == 0) { + return message; + } + await ensureLoaded(message.accountId); + switch (KometCrypto.classifyText(text)) { + case TextClass.offer: + await _serial( + _key(message.accountId, message.chatId), + () => _onOffer(message, text), + ); + return message; + case TextClass.answer: + await _serial( + _key(message.accountId, message.chatId), + () => _onAnswer(message, text), + ); + return message; + case TextClass.session: + return _serial( + _key(message.accountId, message.chatId), + () => _decryptIncoming(message, text, commit), + ); + case TextClass.legacy: + // #***! каждый 32-й legacy похож на v2 по длине, решает тег а не длина + if (isActive(message.accountId, message.chatId)) { + return _serial( + _key(message.accountId, message.chatId), + () => _decryptIncoming(message, text, commit), + ); + } + return message; + case TextClass.none: + return message; + } + } + + Future _isDialog(int accountId, int chatId) async { + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) return false; + final type = rows.first['type']?.toString() ?? 'DIALOG'; + return type == 'DIALOG'; + } + + Future _onOffer(CachedMessage message, String text) async { + final accountId = message.accountId; + final chatId = message.chatId; + if (!await _isDialog(accountId, chatId)) return; + final HandshakePeek peek; + try { + peek = KometCrypto.handshakePeek(text); + } on KometCryptoException { + return; + } + final current = info(accountId, chatId); + final peerId = message.senderId; + switch (current?.phase) { + case E2eePhase.established: + final samePeer = _samePeer(current, peek.publicKey); + // #***! повтор старого OFFER выглядит так же, живую сессию только со спросом + await _persist( + accountId, + chatId, + current!.copyWith( + phase: E2eePhase.keyChanged, + peerId: peerId, + offerText: text, + verified: samePeer && current.verified, + ), + state: await _state(accountId, chatId), + ); + case E2eePhase.offered: + if (peerId < accountId) { + await _persist( + accountId, + chatId, + E2eeSessionInfo( + phase: E2eePhase.pendingConsent, + peerId: peerId, + offerText: text, + ), + state: null, + ); + await _answer(accountId, chatId); + } + case E2eePhase.keyChanged: + case E2eePhase.pendingConsent: + case E2eePhase.none: + case null: + await _persist( + accountId, + chatId, + (current ?? E2eeSessionInfo(phase: E2eePhase.none, peerId: peerId)) + .copyWith( + phase: current?.phase == E2eePhase.keyChanged + ? E2eePhase.keyChanged + : E2eePhase.pendingConsent, + peerId: peerId, + offerText: text, + verified: _carryVerified(current, peek.publicKey), + ), + state: current?.phase == E2eePhase.keyChanged + ? await _state(accountId, chatId) + : null, + ); + } + } + + Future _onAnswer(CachedMessage message, String text) async { + final accountId = message.accountId; + final chatId = message.chatId; + final current = info(accountId, chatId); + if (current == null || current.phase != E2eePhase.offered) return; + if (current.peerId != message.senderId) return; + final pending = await _state(accountId, chatId); + if (pending == null) return; + final peek = KometCrypto.handshakePeek(text); + try { + final state = KometCrypto.sessionAccept( + identity: await identity(accountId), + pending: pending, + answerText: text, + expectedPeer: current.peerPublic, + ); + await _persist( + accountId, + chatId, + E2eeSessionInfo( + phase: E2eePhase.established, + peerId: current.peerId, + peerPublic: peek.publicKey, + verified: _carryVerified(current, peek.publicKey), + offerMessageId: current.offerMessageId, + ), + state: state, + ); + final offerMessageId = current.offerMessageId; + final messages = _messages; + if (offerMessageId != null && messages != null) { + unawaited( + messages + .editMessage(chatId, offerMessageId, text: kE2eeOfferDonePrefix) + .catchError((_) => false), + ); + } + } on KometCryptoException catch (e) { + logger.w('e2ee accept: ${e.status.name_}'); + // #***! ответил не тот, кого мы знали: спрашиваем пользователя, а не молча меняем пира + if (e.status == CryptoStatus.badPeer) { + await _persist( + accountId, + chatId, + current.copyWith( + phase: E2eePhase.keyChanged, + verified: false, + clearOffer: true, + ), + state: null, + ); + } + } + } + + CachedMessage _withStoredEncryption( + CachedMessage message, + Map row, + ) => message.copyWith( + sealedText: row['text_sealed'] is Uint8List + ? row['text_sealed'] as Uint8List + : null, + e2ee: row['e2ee'] as int, + ); + + Future _decryptIncoming( + CachedMessage message, + String text, + Future Function(CachedMessage message)? commit, { + bool preserveLegacyFailure = false, + }) async { + final accountId = message.accountId; + final chatId = message.chatId; + final stored = await AppDatabase.loadMessagesByIds(accountId, chatId, [ + message.id, + ]); + if (stored.isNotEmpty && + stored.first['text'] == message.text && + stored.first['text_sealed'] != null && + (stored.first['e2ee'] as int? ?? 0) != CachedMessage.e2eeNone) { + return _withStoredEncryption(message, stored.first); + } + final current = info(accountId, chatId); + if (current == null || !current.hasSession) { + return message; + } + final state = await _state(accountId, chatId); + if (state == null) return message; + try { + final result = KometCrypto.sessionDecrypt(state: state, text: text); + final decrypted = switch (result.contentType) { + ContentType.text => message.copyWith( + sealedText: await sealBytes(accountId, chatId, result.plaintext), + e2ee: CachedMessage.e2eeText, + ), + ContentType.file => message.copyWith( + sealedText: await sealBytes(accountId, chatId, result.plaintext), + e2ee: CachedMessage.e2eeFile, + ), + ContentType.control => message.copyWith(e2ee: CachedMessage.e2eeFailed), + }; + // #***! текст на диск раньше состояния рэтчета, ключ сообщения уже потрачен + if (commit != null) await commit(decrypted); + await _persist(accountId, chatId, current, state: result.state); + return decrypted; + } on KometCryptoException catch (e) { + if (preserveLegacyFailure) return message; + switch (e.status) { + case CryptoStatus.notEncrypted: + case CryptoStatus.handshakeMessage: + return message; + default: + logger.w('e2ee decrypt ${message.id}: ${e.status.name_}'); + return message.copyWith(e2ee: CachedMessage.e2eeFailed); + } + } + } + + // #***! история: уже расшифрованное не трогаем, новое расшифровываем по порядку + Future> inspectHistory( + int accountId, + int chatId, + List messages, + ) async { + if (!available || messages.isEmpty) return messages; + await ensureLoaded(accountId); + final existing = >{}; + for (final row in await AppDatabase.loadMessagesByIds( + accountId, + chatId, + messages.map((m) => m.id).toList(), + )) { + existing[row['id'].toString()] = row; + } + final ordered = [...messages]..sort((a, b) => a.time.compareTo(b.time)); + final result = {}; + for (final message in ordered) { + final row = existing[message.id]; + if (row != null && + row['text'] == message.text && + isActive(accountId, chatId)) { + final kind = KometCrypto.classifyText(message.text ?? ''); + if (kind == TextClass.offer || kind == TextClass.answer) { + result[message.id] = message; + continue; + } + } + if (row != null && + row['text'] == message.text && + row['text_sealed'] != null && + (row['e2ee'] as int? ?? 0) != CachedMessage.e2eeNone) { + result[message.id] = message.copyWith( + sealedText: row['text_sealed'] is Uint8List + ? row['text_sealed'] as Uint8List + : null, + e2ee: row['e2ee'] as int, + ); + continue; + } + if (row != null && + message.senderId != accountId && + KometCrypto.classifyText(message.text ?? '') == TextClass.legacy) { + result[message.id] = await _serial( + _key(accountId, chatId), + () => _decryptIncoming( + message, + message.text ?? '', + (decrypted) => AppDatabase.saveMessages([decrypted.toDbRow()]), + preserveLegacyFailure: true, + ), + ); + continue; + } + final inspected = await inspect( + message, + commit: (decrypted) => AppDatabase.saveMessages([decrypted.toDbRow()]), + ); + final hadSealed = row?['text_sealed'] != null; + result[message.id] = hadSealed && inspected.sealedText == null + ? CachedMessage.fromDbRow(row!) + : inspected; + } + return messages.map((m) => result[m.id] ?? m).toList(); + } + + // #***! новый identity-ключ: все сессии сбрасываются, собеседники увидят смену ключа + Future rotateIdentity(int accountId) async { + await ensureLoaded(accountId); + final chats = []; + for (final key in _info.keys) { + if (key.startsWith('$accountId/')) { + chats.add(int.parse(key.split('/').last)); + } + } + for (final chatId in chats) { + await _serial(_key(accountId, chatId), () async { + final current = info(accountId, chatId); + final state = _states.remove(_key(accountId, chatId)); + if (state != null) KometCrypto.wipe(state); + if (current == null) return; + await _persist( + accountId, + chatId, + E2eeSessionInfo( + phase: E2eePhase.none, + peerId: current.peerId, + peerPublic: current.peerPublic, + ), + state: null, + ); + }); + } + final previous = _identities.remove(accountId); + if (previous != null) KometCrypto.wipe(previous); + try { + final identity = KometCrypto.identityCreate(); + await TokenStorage.writeSecure( + 'e2ee_identity_$accountId', + base64Encode(identity), + ); + _identities[accountId] = identity; + revision.value++; + return true; + } on KometCryptoException catch (e) { + logger.w('e2ee rotate identity: ${e.status.name_}'); + return false; + } + } + + // #***! ушли в фон или сменили аккаунт, секреты из памяти вон + void lock() { + for (final state in _states.values) { + KometCrypto.wipe(state); + } + _states.clear(); + for (final identity in _identities.values) { + KometCrypto.wipe(identity); + } + _identities.clear(); + for (final key in _localKeys.values) { + KometCrypto.wipe(key); + } + _localKeys.clear(); + } + + void forgetAccount(int accountId) { + _loading.remove(accountId); + lock(); + _info.removeWhere((key, _) => key.startsWith('$accountId/')); + _loaded.remove(accountId); + revision.value++; + } + + // #***! без этого ключи и сессии переживают удаление аккаунта + Future eraseAccount(int accountId) async { + forgetAccount(accountId); + _identities.remove(accountId); + _localKeys.remove(accountId); + for (final key in ['e2ee_identity_$accountId', 'e2ee_local_$accountId']) { + try { + await TokenStorage.deleteSecure(key); + } catch (e) { + logger.w('e2ee erase $key: $e'); + } + } + try { + for (final row in await AppDatabase.loadE2eeSessions(accountId)) { + await AppDatabase.deleteE2eeSession(accountId, row['chat_id'] as int); + } + } catch (e) { + logger.w('e2ee erase sessions: $e'); + } finally { + forgetAccount(accountId); + } + } + + // #***! перенос на другое устройство: identity и сессии одним паролем + Future exportTransfer( + int accountId, + String password, { + Future Function(Uint8List bytes)? persist, + }) async { + await ensureLoaded(accountId); + final items = [await identity(accountId)]; + final exported = []; + for (final entry in _info.entries) { + if (!entry.key.startsWith('$accountId/')) continue; + if (!entry.value.hasSession) continue; + final chatId = int.parse(entry.key.split('/').last); + final state = await _state(accountId, chatId); + if (state != null) { + items.add(state); + exported.add(chatId); + } + } + final container = BytesBuilder(copy: false); + container.add(_be32(items.length)); + for (final item in items) { + container.add(_be32(item.length)); + container.add(item); + } + final Uint8List blob; + final payload = container.toBytes(); + final secret = Uint8List.fromList(utf8.encode(password)); + final library = KometCrypto.libraryPath; + try { + blob = await _transferCrypto(library, secret, payload, seal: true); + } on KometCryptoException catch (e) { + logger.w('e2ee export: ${e.status.name_}'); + return null; + } + if (persist != null) { + try { + await persist(blob); + } catch (error) { + logger.w('e2ee transfer could not be saved: $error'); + return null; + } + } + // #***! источник отключаем сразу, две копии сессии шлют под одним ключом + for (final chatId in exported) { + await _serial(_key(accountId, chatId), () => _forget(accountId, chatId)); + } + return blob; + } + + Future importTransfer( + int accountId, + Uint8List blob, + String password, + ) async { + final Uint8List container; + final secret = Uint8List.fromList(utf8.encode(password)); + final library = KometCrypto.libraryPath; + try { + container = await _transferCrypto(library, secret, blob, seal: false); + } on KometCryptoException catch (e) { + logger.w('e2ee import: ${e.status.name_}'); + return null; + } + final items = []; + if (container.length < 4) return null; + var offset = 4; + final count = _readBe32(container, 0); + for ( + var index = 0; + index < count && offset + 4 <= container.length; + index++ + ) { + final length = _readBe32(container, offset); + offset += 4; + if (offset + length > container.length) return null; + items.add(Uint8List.sublistView(container, offset, offset + length)); + offset += length; + } + if (items.isEmpty || items.first.length != KometCryptoSizes.identity) { + return null; + } + if (items.length != count || offset != container.length) return null; + final seenChats = {}; + for (final state in items.skip(1)) { + if (state.length < 66 || + _readBe64(state, 10) != accountId || + !seenChats.add(_readBe64(state, 2))) { + return null; + } + try { + KometCrypto.sessionPeerPublicKey(state); + } on KometCryptoException { + return null; + } + } + lock(); + await TokenStorage.writeSecure( + 'e2ee_identity_$accountId', + base64Encode(items.first), + ); + await ensureLoaded(accountId); + var imported = 0; + for (final state in items.skip(1)) { + if (state.length < 66) continue; + final chatId = _readBe64(state, 2); + final myId = _readBe64(state, 10); + final peerId = _readBe64(state, 18); + if (myId != accountId) continue; + final peerPublic = Uint8List.fromList( + Uint8List.sublistView(state, 34, 66), + ); + await _persist( + accountId, + chatId, + E2eeSessionInfo( + phase: E2eePhase.established, + peerId: peerId, + peerPublic: peerPublic, + verified: _carryVerified(info(accountId, chatId), peerPublic), + ), + state: Uint8List.fromList(state), + ); + imported++; + } + return imported; + } + + static Uint8List _be32(int value) => Uint8List(4) + ..[0] = (value >> 24) & 0xff + ..[1] = (value >> 16) & 0xff + ..[2] = (value >> 8) & 0xff + ..[3] = value & 0xff; + + static int _readBe32(Uint8List bytes, int offset) => + (bytes[offset] << 24) | + (bytes[offset + 1] << 16) | + (bytes[offset + 2] << 8) | + bytes[offset + 3]; + + static int _readBe64(Uint8List bytes, int offset) { + var value = 0; + for (var index = 0; index < 8; index++) { + value = (value << 8) | bytes[offset + index]; + } + return value; + } +} diff --git a/lib/core/crypto/encrypted_photo.dart b/lib/core/crypto/encrypted_photo.dart new file mode 100644 index 0000000..b98edbc --- /dev/null +++ b/lib/core/crypto/encrypted_photo.dart @@ -0,0 +1,215 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:komet_crypto/komet_crypto.dart'; +import 'package:crypto/crypto.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../utils/logger.dart'; +import '../utils/media_cache.dart'; +import 'chat_crypto_service.dart'; + +// #***! парольное фото уходит на сервер как png, сквозное как шифртекст без обёртки +const String kEncryptedPhotoExtension = '.png'; +const String kE2eePhotoExtension = '.kce'; +const int _ticketHeader = + KometCryptoSizes.fileKey + KometCryptoSizes.fileNonce + 8; + +// #***! имя из билета а не из дескриптора, иначе сервер подсунет чужую расшифровку +String decryptedCacheName(int accountId, int chatId, String cacheName) => + 'decrypted_${accountId}_${chatId}_$cacheName'; + +String e2eeDecryptedCacheName(Uint8List ticket) { + final digest = sha256.convert(ticket.sublist(0, _ticketHeader)); + return 'decrypted_e2ee_${digest.toString().substring(0, 40)}'; +} + +class EncryptedPhotoResult { + final File? file; + final CryptoFailure? failure; + + const EncryptedPhotoResult.ok(File this.file) : failure = null; + const EncryptedPhotoResult.failed(CryptoFailure this.failure) : file = null; + + bool get isOk => file != null; +} + +// #***! перед шифрованием гоним в png в памяти, жпег после перекодирования байт в байт не совпадёт +Future reencodeAsPng(File source) async { + try { + final bytes = await source.readAsBytes(); + final codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + final data = await frame.image.toByteData(format: ui.ImageByteFormat.png); + frame.image.dispose(); + codec.dispose(); + return data?.buffer.asUint8List(); + } catch (e) { + logger.w('png re-encode failed: $e'); + return null; + } +} + +// #***! временная папка под шифртекст, открытого текста на диске нет +Future _scratchDir() async { + final dir = Directory('${(await getTemporaryDirectory()).path}/qlyra_enc'); + if (!await dir.exists()) await dir.create(recursive: true); + return dir; +} + +Future prepareEncryptedPhoto({ + required int accountId, + required int chatId, + required File source, + required String stamp, +}) async { + final png = await reencodeAsPng(source); + if (png == null) { + return const EncryptedPhotoResult.failed(CryptoFailure.malformed); + } + final result = await ChatCryptoService.instance.encryptImageBytes( + accountId, + chatId, + png, + ); + if (!result.isOk) return EncryptedPhotoResult.failed(result.failure!); + final dir = await _scratchDir(); + final encrypted = File('${dir.path}/enc_$stamp.png'); + await encrypted.writeAsBytes(result.bytes!, flush: true); + return EncryptedPhotoResult.ok(encrypted); +} + +// #***! скачанный шум обратно в фото, кладём в кэш просмотрщика +Future openEncryptedPhoto({ + required int accountId, + required int chatId, + required File encrypted, + required String cacheName, +}) async { + final target = await MediaCache.fileFor( + decryptedCacheName(accountId, chatId, cacheName), + ); + if (await target.exists() && await target.length() > 0) { + return EncryptedPhotoResult.ok(target); + } + final result = await ChatCryptoService.instance.decryptImageBytes( + accountId, + chatId, + await encrypted.readAsBytes(), + ); + if (!result.isOk) return EncryptedPhotoResult.failed(result.failure!); + await target.writeAsBytes(result.bytes!, flush: true); + return EncryptedPhotoResult.ok(target); +} + +// #***! сквозное фото: свой ключ на файл, билет едет внутри рэтчет-сообщения +class E2eePhotoPrepared { + final File file; + final Uint8List ticket; + + const E2eePhotoPrepared(this.file, this.ticket); +} + +Future prepareE2eePhoto({ + required File source, + required String stamp, +}) async { + final png = await reencodeAsPng(source); + if (png == null) return null; + final key = KometCrypto.randomBytes(KometCryptoSizes.fileKey); + final nonce = KometCrypto.randomBytes(KometCryptoSizes.fileNonce); + final out = BytesBuilder(copy: false); + try { + final sealer = KometCrypto.fileSealer(key, nonce); + var offset = 0; + while (true) { + final last = png.length - offset <= KometCryptoSizes.fileChunk; + final end = last ? png.length : offset + KometCryptoSizes.fileChunk; + out.add(sealer.chunk(png.sublist(offset, end), last: last)); + offset = end; + if (last) break; + } + sealer.dispose(); + } catch (e) { + logger.w('e2ee photo seal: $e'); + return null; + } + final dir = await _scratchDir(); + final file = File('${dir.path}/enc_$stamp$kE2eePhotoExtension'); + await file.writeAsBytes(out.toBytes(), flush: true); + final name = Uint8List.fromList(utf8.encode('photo_$stamp.png')); + final ticket = Uint8List(_ticketHeader + name.length); + ticket.setAll(0, key); + ticket.setAll(KometCryptoSizes.fileKey, nonce); + var size = png.length; + for (var i = _ticketHeader - 1; i >= _ticketHeader - 8; i--) { + ticket[i] = size & 0xff; + size >>= 8; + } + ticket.setAll(_ticketHeader, name); + KometCrypto.wipe(key); + return E2eePhotoPrepared(file, ticket); +} + +Future openE2eePhoto({ + required File encrypted, + required Uint8List ticket, +}) async { + if (ticket.length < _ticketHeader) { + return const EncryptedPhotoResult.failed(CryptoFailure.malformed); + } + final target = await MediaCache.fileFor(e2eeDecryptedCacheName(ticket)); + var expectedSize = 0; + for (final value in ticket.sublist(_ticketHeader - 8, _ticketHeader)) { + expectedSize = (expectedSize << 8) | value; + } + if (await target.exists() && await target.length() > 0) { + return EncryptedPhotoResult.ok(target); + } + final key = ticket.sublist(0, KometCryptoSizes.fileKey); + final nonce = ticket.sublist(KometCryptoSizes.fileKey, _ticketHeader - 8); + const step = KometCryptoSizes.fileChunk + KometCryptoSizes.tag; + final total = await encrypted.length(); + // #***! блоками, размер выбирает сервер и в память вложение не влезет + final source = await encrypted.open(); + final partial = File( + '${target.path}.${DateTime.now().microsecondsSinceEpoch}.part', + ); + final sink = partial.openWrite(); + try { + final opener = KometCrypto.fileOpener(key, nonce); + try { + var offset = 0; + while (true) { + final last = total - offset <= step; + final take = last ? total - offset : step; + final block = await source.read(take); + if (block.length != take) throw const FormatException('truncated'); + sink.add(opener.chunk(block, last: last)); + offset += take; + if (last) break; + } + } finally { + opener.dispose(); + } + await sink.close(); + if (await partial.length() != expectedSize) + throw const FormatException('size mismatch'); + await partial.rename(target.path); + } catch (e) { + logger.w('e2ee photo open: $e'); + try { + await sink.close(); + } catch (_) {} + try { + if (await partial.exists()) await partial.delete(); + } catch (_) {} + return EncryptedPhotoResult.failed(cryptoFailureOf(e)); + } finally { + KometCrypto.wipe(key); + await source.close(); + } + return EncryptedPhotoResult.ok(target); +} diff --git a/lib/core/crypto/encrypted_photo_cache.dart b/lib/core/crypto/encrypted_photo_cache.dart new file mode 100644 index 0000000..d2c930a --- /dev/null +++ b/lib/core/crypto/encrypted_photo_cache.dart @@ -0,0 +1,305 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import '../storage/chat_encryption_store.dart'; +import 'e2ee_service.dart'; +import '../utils/download_progress.dart'; +import '../utils/logger.dart'; +import '../utils/media_cache.dart'; +import 'chat_crypto_service.dart'; +import 'encrypted_photo.dart'; + +typedef EncryptedPhotoUrlLoader = Future Function(); + +enum EncryptedPhotoStatus { plain, decrypted, wrongKey, locked } + +@immutable +class EncryptedPhotoView { + final File? file; + final EncryptedPhotoStatus status; + + const EncryptedPhotoView.plain() + : file = null, + status = EncryptedPhotoStatus.plain; + + const EncryptedPhotoView.decrypted(File this.file) + : status = EncryptedPhotoStatus.decrypted; + + const EncryptedPhotoView.wrongKey() + : file = null, + status = EncryptedPhotoStatus.wrongKey; + + const EncryptedPhotoView.locked() + : file = null, + status = EncryptedPhotoStatus.locked; + + bool get isDecrypted => status == EncryptedPhotoStatus.decrypted; +} + +class _AutoRequest { + final int accountId; + final int chatId; + final String cacheName; + final EncryptedPhotoUrlLoader urlLoader; + final int size; + final Uint8List? sealedTicket; + + const _AutoRequest({ + required this.accountId, + required this.chatId, + required this.cacheName, + required this.urlLoader, + required this.size, + required this.sealedTicket, + }); +} + +class EncryptedPhotoCache { + EncryptedPhotoCache._() { + ChatEncryptionStore.instance.revision.addListener(clear); + } + + static final EncryptedPhotoCache instance = EncryptedPhotoCache._(); + + static const int _maxEntries = 200; + static const int _maxAutoBytes = 32 * 1024 * 1024; + static const int _maxConcurrentAuto = 3; + + final LinkedHashMap> _entries = + LinkedHashMap(); + final Map> _inFlight = {}; + final Queue<_AutoRequest> _queue = Queue(); + final Set _queued = {}; + int _running = 0; + + ValueListenable listenableFor(String cacheName) => + _entryFor(cacheName); + + ValueNotifier _entryFor(String cacheName) => + _entries[cacheName] ??= ValueNotifier(null); + + void _evictStale(String keep) { + while (_entries.length > _maxEntries) { + final oldest = _entries.keys.first; + if (oldest == keep) break; + _entries.remove(oldest); + } + } + + void request({ + required int accountId, + required int chatId, + required String cacheName, + required EncryptedPhotoUrlLoader urlLoader, + required int size, + Uint8List? sealedTicket, + }) { + if (sealedTicket == null && + !ChatCryptoService.instance.isEnabled(accountId, chatId)) { + return; + } + if (_entryFor(cacheName).value != null) return; + if (_inFlight.containsKey(cacheName)) return; + if (!_queued.add(cacheName)) return; + _evictStale(cacheName); + _queue.add( + _AutoRequest( + accountId: accountId, + chatId: chatId, + cacheName: cacheName, + urlLoader: urlLoader, + size: size, + sealedTicket: sealedTicket, + ), + ); + _pump(); + } + + Future resolve({ + required int accountId, + required int chatId, + required String cacheName, + required EncryptedPhotoUrlLoader urlLoader, + int? size, + Uint8List? sealedTicket, + }) { + final known = _entryFor(cacheName).value; + if (known != null && known.status != EncryptedPhotoStatus.locked) { + return Future.value(known); + } + _dequeue(cacheName); + return _start(accountId, chatId, cacheName, urlLoader, size, sealedTicket); + } + + void clear() { + _queue.clear(); + _queued.clear(); + for (final entry in _entries.values) { + entry.value = null; + } + } + + void _dequeue(String cacheName) { + if (!_queued.remove(cacheName)) return; + _queue.removeWhere((request) => request.cacheName == cacheName); + } + + void _pump() { + while (_running < _maxConcurrentAuto && _queue.isNotEmpty) { + final next = _queue.removeLast(); + _queued.remove(next.cacheName); + _running++; + final done = _start( + next.accountId, + next.chatId, + next.cacheName, + next.urlLoader, + next.size, + next.sealedTicket, + ); + unawaited( + done.whenComplete(() { + _running--; + _pump(); + }), + ); + } + } + + Future _start( + int accountId, + int chatId, + String cacheName, + EncryptedPhotoUrlLoader urlLoader, + int? autoSize, + Uint8List? sealedTicket, + ) { + final running = _inFlight[cacheName]; + if (running != null) return running; + final future = _resolve( + accountId, + chatId, + cacheName, + urlLoader, + autoSize, + sealedTicket, + ); + _inFlight[cacheName] = future; + unawaited(future.whenComplete(() => _inFlight.remove(cacheName))); + return future; + } + + Future _resolve( + int accountId, + int chatId, + String cacheName, + EncryptedPhotoUrlLoader urlLoader, + int? autoSize, + Uint8List? sealedTicket, + ) async { + EncryptedPhotoView view; + try { + view = await _decrypt( + accountId, + chatId, + cacheName, + urlLoader, + autoSize, + sealedTicket, + ); + } catch (e) { + logger.w('encrypted preview $cacheName: $e'); + view = const EncryptedPhotoView.locked(); + } + _entryFor(cacheName).value = view; + return view; + } + + Future _decrypt( + int accountId, + int chatId, + String cacheName, + EncryptedPhotoUrlLoader urlLoader, + int? autoSize, + Uint8List? sealedTicket, + ) async { + // #***! имя расшифрованного берётся из билета, поэтому билет первым + Uint8List? ticket; + if (sealedTicket != null) { + ticket = await E2eeService.instance.openBytes( + accountId, + chatId, + sealedTicket, + ); + if (ticket == null) return const EncryptedPhotoView.locked(); + final ready = await MediaCache.existing(e2eeDecryptedCacheName(ticket)); + if (ready != null) return EncryptedPhotoView.decrypted(ready); + } else { + final ready = await MediaCache.existing( + decryptedCacheName(accountId, chatId, cacheName), + ); + if (ready != null) return EncryptedPhotoView.decrypted(ready); + } + + var encrypted = await MediaCache.existing(cacheName); + if (encrypted == null) { + if (autoSize != null && autoSize > _maxAutoBytes) { + return const EncryptedPhotoView.locked(); + } + // #***! потолок по объявленному размеру, но не выше нашего собственного + final cap = autoSize == null || autoSize <= 0 + ? _maxAutoBytes + : (autoSize < _maxAutoBytes ? autoSize : _maxAutoBytes); + encrypted = await _download(cacheName, urlLoader, cap); + } + if (encrypted == null) return const EncryptedPhotoView.locked(); + + if (ticket != null) { + final opened = await openE2eePhoto(encrypted: encrypted, ticket: ticket); + if (opened.isOk) return EncryptedPhotoView.decrypted(opened.file!); + return opened.failure == CryptoFailure.unavailable + ? const EncryptedPhotoView.locked() + : const EncryptedPhotoView.wrongKey(); + } + + if (!ChatCryptoService.instance.looksEncryptedImage( + await encrypted.readAsBytes(), + )) { + return const EncryptedPhotoView.plain(); + } + + final result = await openEncryptedPhoto( + accountId: accountId, + chatId: chatId, + encrypted: encrypted, + cacheName: cacheName, + ); + if (result.isOk) return EncryptedPhotoView.decrypted(result.file!); + return result.failure == CryptoFailure.unavailable + ? const EncryptedPhotoView.locked() + : const EncryptedPhotoView.wrongKey(); + } + + Future _download( + String cacheName, + EncryptedPhotoUrlLoader urlLoader, + int maxBytes, + ) async { + final url = await urlLoader(); + if (url == null || url.isEmpty) return null; + MediaDownloadProgress.set(cacheName, 0); + try { + return await MediaCache.getOrDownload( + cacheName, + url, + onProgress: (p) => MediaDownloadProgress.set(cacheName, p), + maxBytes: maxBytes, + ); + } finally { + MediaDownloadProgress.set(cacheName, null); + } + } +} diff --git a/lib/core/crypto/message_decryption_cache.dart b/lib/core/crypto/message_decryption_cache.dart new file mode 100644 index 0000000..7734174 --- /dev/null +++ b/lib/core/crypto/message_decryption_cache.dart @@ -0,0 +1,185 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; +import 'package:komet_crypto/komet_crypto.dart'; + +import '../../backend/modules/messages.dart'; +import '../storage/app_database.dart'; +import '../storage/chat_encryption_store.dart'; +import 'chat_crypto_service.dart'; +import 'e2ee_service.dart'; + +// #***! расшифровалось, ключ не тот, либо копии текста на этом устройстве нет +enum MessageDecryptionState { decrypted, wrongKey, unavailable } + +@immutable +class MessageDecryption { + final String? plaintext; + final MessageDecryptionState state; + + const MessageDecryption.decrypted(String this.plaintext) + : state = MessageDecryptionState.decrypted; + + const MessageDecryption.wrongKey() + : plaintext = null, + state = MessageDecryptionState.wrongKey; + + const MessageDecryption.unavailable() + : plaintext = null, + state = MessageDecryptionState.unavailable; + + bool get isDecrypted => state == MessageDecryptionState.decrypted; +} + +class MessageDecryptionCache { + MessageDecryptionCache._() { + ChatEncryptionStore.instance.revision.addListener(clear); + E2eeService.instance.revision.addListener(clear); + } + + static final MessageDecryptionCache instance = MessageDecryptionCache._(); + + static const int _maxEntries = 1000; + + final LinkedHashMap> _entries = + LinkedHashMap(); + final Set _inFlight = {}; + + ValueListenable listenableFor(String messageId) => + _entryFor(messageId); + + ValueNotifier _entryFor(String messageId) => + _entries[messageId] ??= ValueNotifier(null); + + void _evictStale(String keep) { + while (_entries.length > _maxEntries) { + final oldest = _entries.keys.first; + if (oldest == keep) break; + _entries.remove(oldest); + } + } + + void seed(String messageId, String plaintext) { + _entryFor(messageId).value = MessageDecryption.decrypted(plaintext); + } + + void adopt(String fromMessageId, String toMessageId) { + final value = _entries[fromMessageId]?.value; + if (value != null) _entryFor(toMessageId).value = value; + } + + void request({ + required int accountId, + required int chatId, + required String messageId, + required String cipherText, + }) { + if (cipherText.isEmpty) return; + final e2ee = + E2eeService.instance.available && + KometCrypto.classifyText(cipherText) != TextClass.none; + if (!e2ee && !ChatCryptoService.instance.isEnabled(accountId, chatId)) { + return; + } + if (_entryFor(messageId).value != null) return; + if (!_inFlight.add(messageId)) return; + _evictStale(messageId); + unawaited( + e2ee + ? _resolveSealed(accountId, chatId, messageId, cipherText) + : _resolve(accountId, chatId, messageId, cipherText), + ); + } + + // #***! сквозное: открытый текст лежит в базе под локальным ключом + Future _resolveSealed( + int accountId, + int chatId, + String messageId, + String cipherText, + ) async { + try { + final row = await AppDatabase.loadMessage(accountId, chatId, messageId); + if (row == null) return; + final flag = row['e2ee'] as int? ?? 0; + final sealed = row['text_sealed']; + if (flag == CachedMessage.e2eeText && sealed is Uint8List) { + final text = await E2eeService.instance.openText( + accountId, + chatId, + sealed, + ); + _entryFor(messageId).value = text == null + ? const MessageDecryption.unavailable() + : MessageDecryption.decrypted(text); + return; + } + if (flag == CachedMessage.e2eeFailed) { + _entryFor(messageId).value = const MessageDecryption.wrongKey(); + return; + } + if (flag == CachedMessage.e2eeFile) { + _entryFor(messageId).value = const MessageDecryption.decrypted(''); + return; + } + if (flag != CachedMessage.e2eeNone || !E2eeService.instance.available) { + return; + } + switch (KometCrypto.classifyText(cipherText)) { + case TextClass.session: + _entryFor(messageId).value = const MessageDecryption.unavailable(); + case TextClass.offer: + case TextClass.answer: + _entryFor(messageId).value = MessageDecryption.decrypted( + cipherText.split('\n').first, + ); + case TextClass.legacy: + if (ChatCryptoService.instance.isEnabled(accountId, chatId)) { + _inFlight.remove(messageId); + await _resolve(accountId, chatId, messageId, cipherText); + } + case TextClass.none: + break; + } + } finally { + _inFlight.remove(messageId); + } + } + + Future _resolve( + int accountId, + int chatId, + String messageId, + String cipherText, + ) async { + try { + final crypto = ChatCryptoService.instance; + final result = await crypto.decrypt(accountId, chatId, cipherText); + if (result.isOk) { + _entryFor(messageId).value = MessageDecryption.decrypted(result.text!); + return; + } + switch (result.failure) { + case CryptoFailure.wrongKey: + _entryFor(messageId).value = const MessageDecryption.wrongKey(); + case CryptoFailure.noKey: + if (crypto.looksEncrypted(cipherText)) { + _entryFor(messageId).value = const MessageDecryption.wrongKey(); + } + case CryptoFailure.notEncrypted: + case CryptoFailure.malformed: + case CryptoFailure.unavailable: + case null: + break; + } + } finally { + _inFlight.remove(messageId); + } + } + + void clear() { + _entries.clear(); + _inFlight.clear(); + } +} diff --git a/lib/core/crypto/noise_png.dart b/lib/core/crypto/noise_png.dart new file mode 100644 index 0000000..bea4413 --- /dev/null +++ b/lib/core/crypto/noise_png.dart @@ -0,0 +1,205 @@ +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +// #***! шифрованное фото едет как валидный RGB8 PNG из шума, пиксели = байты блоба +const int _channels = 3; +const int _maxDimension = 16384; +// #***! zlib раздувает без предела, распаковываем порциями по бюджету из IHDR +const int _inflateChunk = 1 << 16; +const List _signature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + +final Uint32List _crcTable = _buildCrcTable(); + +Uint32List _buildCrcTable() { + final table = Uint32List(256); + for (var n = 0; n < 256; n++) { + var c = n; + for (var k = 0; k < 8; k++) { + c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1; + } + table[n] = c; + } + return table; +} + +int _crc32(List bytes) { + var crc = 0xFFFFFFFF; + for (final byte in bytes) { + crc = _crcTable[(crc ^ byte) & 0xFF] ^ (crc >> 8); + } + return crc ^ 0xFFFFFFFF; +} + +void _writeChunk(BytesBuilder out, String type, List data) { + final typeBytes = type.codeUnits; + out.add(_be32(data.length)); + out.add(typeBytes); + out.add(data); + out.add(_be32(_crc32([...typeBytes, ...data]))); +} + +Uint8List _be32(int value) => Uint8List(4) + ..[0] = (value >> 24) & 0xFF + ..[1] = (value >> 16) & 0xFF + ..[2] = (value >> 8) & 0xFF + ..[3] = value & 0xFF; + +int _readBe32(Uint8List bytes, int offset) => + (bytes[offset] << 24) | + (bytes[offset + 1] << 16) | + (bytes[offset + 2] << 8) | + bytes[offset + 3]; + +Uint8List? wrapNoisePng(Uint8List blob, {Random? random}) { + final rng = random ?? Random.secure(); + final pixels = (blob.length + _channels - 1) ~/ _channels; + final width = max(1, sqrt(pixels).ceil()); + if (width > _maxDimension) return null; + final height = max(1, (pixels + width - 1) ~/ width); + final stride = width * _channels; + final raw = Uint8List((stride + 1) * height); + var source = 0; + for (var row = 0; row < height; row++) { + final start = row * (stride + 1) + 1; + for (var column = 0; column < stride; column++) { + raw[start + column] = source < blob.length + ? blob[source] + : rng.nextInt(256); + source++; + } + } + final out = BytesBuilder(copy: false); + out.add(_signature); + _writeChunk(out, 'IHDR', [..._be32(width), ..._be32(height), 8, 2, 0, 0, 0]); + _writeChunk(out, 'IDAT', ZLibEncoder(level: 1).convert(raw)); + _writeChunk(out, 'IEND', const []); + return out.toBytes(); +} + +// #***! обратно из PNG в байты, null если это не наш RGB8 без interlace +Uint8List? unwrapNoisePng(Uint8List png) { + if (png.length < _signature.length + 8) return null; + for (var i = 0; i < _signature.length; i++) { + if (png[i] != _signature[i]) return null; + } + var offset = _signature.length; + int? width; + int? height; + final idat = BytesBuilder(copy: false); + while (offset + 8 <= png.length) { + final length = _readBe32(png, offset); + final type = String.fromCharCodes(png, offset + 4, offset + 8); + final dataStart = offset + 8; + final dataEnd = dataStart + length; + if (length < 0 || dataEnd + 4 > png.length) return null; + if (type == 'IHDR') { + if (length != 13) return null; + width = _readBe32(png, dataStart); + height = _readBe32(png, dataStart + 4); + final depth = png[dataStart + 8]; + final color = png[dataStart + 9]; + final interlace = png[dataStart + 12]; + if (depth != 8 || color != 2 || interlace != 0) return null; + if (width <= 0 || height <= 0) return null; + if (width > _maxDimension || height > _maxDimension) return null; + } else if (type == 'IDAT') { + idat.add(Uint8List.sublistView(png, dataStart, dataEnd)); + } else if (type == 'IEND') { + break; + } + offset = dataEnd + 4; + } + if (width == null || height == null) return null; + final stride = width * _channels; + final expected = (stride + 1) * height; + final Uint8List? inflated; + try { + inflated = _inflateBounded(idat.toBytes(), expected); + } catch (_) { + return null; + } + if (inflated == null) return null; + final out = Uint8List(stride * height); + final previous = Uint8List(stride); + final current = Uint8List(stride); + for (var row = 0; row < height; row++) { + final start = row * (stride + 1); + final filter = inflated[start]; + current.setRange(0, stride, inflated, start + 1); + for (var i = 0; i < stride; i++) { + final left = i >= _channels ? current[i - _channels] : 0; + final up = previous[i]; + final upLeft = i >= _channels ? previous[i - _channels] : 0; + final int predictor; + switch (filter) { + case 0: + predictor = 0; + case 1: + predictor = left; + case 2: + predictor = up; + case 3: + predictor = (left + up) >> 1; + case 4: + predictor = _paeth(left, up, upLeft); + default: + return null; + } + current[i] = (current[i] + predictor) & 0xFF; + } + out.setRange(row * stride, (row + 1) * stride, current); + previous.setAll(0, current); + } + return out; +} + +// #***! ровно обещанный объём, больше заголовка нам всё равно не нужно +Uint8List? _inflateBounded(Uint8List input, int limit) { + final out = BytesBuilder(copy: false); + final sink = _BoundedSink(out, limit); + final converter = ZLibDecoder().startChunkedConversion(sink); + try { + for (var offset = 0; offset < input.length; offset += _inflateChunk) { + final end = offset + _inflateChunk < input.length + ? offset + _inflateChunk + : input.length; + converter.add(Uint8List.sublistView(input, offset, end)); + if (sink.full) break; + } + if (!sink.full) converter.close(); + } catch (_) { + return null; + } + final bytes = out.toBytes(); + return bytes.length < limit ? null : Uint8List.sublistView(bytes, 0, limit); +} + +class _BoundedSink implements Sink> { + _BoundedSink(this._out, this._limit); + + final BytesBuilder _out; + final int _limit; + + bool get full => _out.length >= _limit; + + @override + void add(List data) { + if (full) return; + final room = _limit - _out.length; + _out.add(data.length <= room ? data : data.sublist(0, room)); + } + + @override + void close() {} +} + +int _paeth(int a, int b, int c) { + final p = a + b - c; + final pa = (p - a).abs(); + final pb = (p - b).abs(); + final pc = (p - c).abs(); + if (pa <= pb && pa <= pc) return a; + if (pb <= pc) return b; + return c; +} diff --git a/lib/core/games/checkers.dart b/lib/core/games/checkers.dart new file mode 100644 index 0000000..45bb7f5 --- /dev/null +++ b/lib/core/games/checkers.dart @@ -0,0 +1,222 @@ +enum CheckersSide { white, black } + +class Checkers { + static const int size = 8; + static const int empty = 0; + static const int whiteMan = 1; + static const int whiteKing = 2; + static const int blackMan = 3; + static const int blackKing = 4; + + static const List> _dirs = [ + [-1, -1], + [-1, 1], + [1, -1], + [1, 1], + ]; + + static List initial() { + final board = List.filled(size * size, empty); + for (var r = 0; r < size; r++) { + for (var c = 0; c < size; c++) { + if ((r + c) % 2 == 0) continue; + final i = r * size + c; + if (r <= 2) board[i] = blackMan; + if (r >= 5) board[i] = whiteMan; + } + } + return board; + } + + static CheckersSide? sideOf(int piece) { + if (piece == whiteMan || piece == whiteKing) return CheckersSide.white; + if (piece == blackMan || piece == blackKing) return CheckersSide.black; + return null; + } + + static bool isKing(int piece) => piece == whiteKing || piece == blackKing; + + static CheckersSide opponent(CheckersSide side) => + side == CheckersSide.white ? CheckersSide.black : CheckersSide.white; + + static int _row(int i) => i ~/ size; + static int _col(int i) => i % size; + static bool _inB(int r, int c) => r >= 0 && r < size && c >= 0 && c < size; + static int _idx(int r, int c) => r * size + c; + static int _lastRow(CheckersSide side) => + side == CheckersSide.white ? 0 : size - 1; + static int _kingOf(CheckersSide side) => + side == CheckersSide.white ? whiteKing : blackKing; + + static List> legalMoves(List board, CheckersSide side) { + final captures = >[]; + for (var i = 0; i < board.length; i++) { + if (sideOf(board[i]) != side) continue; + _collectCaptures(List.of(board), i, side, [i], {}, captures); + } + if (captures.isNotEmpty) return captures; + + final quiet = >[]; + for (var i = 0; i < board.length; i++) { + if (sideOf(board[i]) != side) continue; + _collectQuiet(board, i, side, quiet); + } + return quiet; + } + + static void _collectCaptures( + List work, + int at, + CheckersSide side, + List path, + Set captured, + List> out, + ) { + final steps = _captureSteps(work, at, captured); + if (steps.isEmpty) { + if (path.length > 1) out.add(List.of(path)); + return; + } + final piece = work[at]; + for (final step in steps) { + final landing = step[0]; + final victim = step[1]; + final promote = !isKing(piece) && _row(landing) == _lastRow(side); + final moved = promote ? _kingOf(side) : piece; + + work[at] = empty; + work[landing] = moved; + captured.add(victim); + path.add(landing); + + _collectCaptures(work, landing, side, path, captured, out); + + path.removeLast(); + captured.remove(victim); + work[landing] = empty; + work[at] = piece; + } + } + + static List> _captureSteps( + List work, + int at, + Set captured, + ) { + final piece = work[at]; + final side = sideOf(piece); + if (side == null) return const []; + final king = isKing(piece); + final r0 = _row(at); + final c0 = _col(at); + final result = >[]; + + for (final d in _dirs) { + var r = r0 + d[0]; + var c = c0 + d[1]; + if (king) { + while (_inB(r, c) && work[_idx(r, c)] == empty) { + r += d[0]; + c += d[1]; + } + if (!_inB(r, c)) continue; + final vi = _idx(r, c); + if (sideOf(work[vi]) == side || captured.contains(vi)) continue; + var lr = r + d[0]; + var lc = c + d[1]; + while (_inB(lr, lc) && work[_idx(lr, lc)] == empty) { + result.add([_idx(lr, lc), vi]); + lr += d[0]; + lc += d[1]; + } + } else { + if (!_inB(r, c)) continue; + final vi = _idx(r, c); + if (work[vi] == empty || + sideOf(work[vi]) == side || + captured.contains(vi)) { + continue; + } + final lr = r + d[0]; + final lc = c + d[1]; + if (_inB(lr, lc) && work[_idx(lr, lc)] == empty) { + result.add([_idx(lr, lc), vi]); + } + } + } + return result; + } + + static void _collectQuiet( + List board, + int at, + CheckersSide side, + List> out, + ) { + final piece = board[at]; + final r0 = _row(at); + final c0 = _col(at); + if (isKing(piece)) { + for (final d in _dirs) { + var r = r0 + d[0]; + var c = c0 + d[1]; + while (_inB(r, c) && board[_idx(r, c)] == empty) { + out.add([at, _idx(r, c)]); + r += d[0]; + c += d[1]; + } + } + } else { + final forward = side == CheckersSide.white ? -1 : 1; + for (final dc in const [-1, 1]) { + final r = r0 + forward; + final c = c0 + dc; + if (_inB(r, c) && board[_idx(r, c)] == empty) { + out.add([at, _idx(r, c)]); + } + } + } + } + + static List applyMove(List board, List path) { + final next = List.of(board); + if (path.length < 2) return next; + final from = path.first; + final side = sideOf(board[from]); + if (side == null) return next; + final piece = board[from]; + next[from] = empty; + var promoted = isKing(piece); + + for (var k = 0; k < path.length - 1; k++) { + final a = path[k]; + final b = path[k + 1]; + final dr = (_row(b) - _row(a)).sign; + final dc = (_col(b) - _col(a)).sign; + var r = _row(a) + dr; + var c = _col(a) + dc; + while (r != _row(b) || c != _col(b)) { + final vi = _idx(r, c); + if (next[vi] != empty && sideOf(next[vi]) != side) { + next[vi] = empty; + } + r += dr; + c += dc; + } + if (_row(b) == _lastRow(side)) promoted = true; + } + + next[path.last] = promoted ? _kingOf(side) : piece; + return next; + } + + static bool _hasPieces(List board, CheckersSide side) => + board.any((p) => sideOf(p) == side); + + static CheckersSide? winner(List board, CheckersSide toMove) { + if (!_hasPieces(board, toMove) || legalMoves(board, toMove).isEmpty) { + return opponent(toMove); + } + return null; + } +} diff --git a/lib/core/links/deep_link_service.dart b/lib/core/links/deep_link_service.dart new file mode 100644 index 0000000..673ba10 --- /dev/null +++ b/lib/core/links/deep_link_service.dart @@ -0,0 +1,262 @@ +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:app_links/app_links.dart'; +import 'package:flutter/widgets.dart'; + +import '../../l10n/app_localizations.dart'; + +import '../../backend/api.dart'; +import '../../frontend/debug/log_export.dart'; +import '../../frontend/screens/digital_id/digital_id_web_screen.dart'; +import '../../frontend/widgets/custom_notification.dart'; +import '../../frontend/widgets/max_link_handler.dart'; +import '../../frontend/widgets/swipe_route.dart'; +import '../../main.dart'; +import '../webpush/max_web_socket.dart'; +import '../webpush/web_push_service.dart'; +import 'desktop_url_scheme.dart'; +import 'max_link.dart'; + +class DeepLinkService { + DeepLinkService._(); + + static final DeepLinkService instance = DeepLinkService._(); + + final AppLinks _appLinks = AppLinks(); + StreamSubscription? _sub; + StreamSubscription? _stateSub; + String? _pending; + bool _pendingLogExport = false; + String? _pendingExternalCallback; + WebPushSubscription? _pendingWebPush; + String? _lastExternalCallback; + Timer? _externalCallbackRetry; + Timer? _webPushRetry; + Timer? _logExportRetry; + bool _ready = false; + bool _started = false; + + Future init() async { + if (_started) return; + _started = true; + + await DesktopUrlScheme.register(); + + _stateSub = api.stateStream.listen((state) { + if (state == SessionState.online) _flushPending(); + }); + + _sub = _appLinks.uriLinkStream.listen(_onUri); + try { + final initial = await _appLinks.getInitialLink(); + if (initial != null) _onUri(initial); + } catch (_) {} + } + + void markReady() { + _ready = true; + _flushPending(); + } + + void handle(Uri uri) => _onUri(uri); + + void _onUri(Uri uri) { + if (_isLogExportLink(uri)) { + _pendingLogExport = true; + _flushPending(); + return; + } + final webPush = _parseWebPushLink(uri); + if (webPush != null) { + _pendingWebPush = webPush; + _flushPending(); + return; + } + if (_isExternalCallback(uri)) { + final callbackUrl = uri.toString(); + if (callbackUrl == _lastExternalCallback) return; + _lastExternalCallback = callbackUrl; + _pendingExternalCallback = callbackUrl; + _flushPending(); + return; + } + final url = _normalize(uri); + if (url == null) return; + _pending = url; + _flushPending(); + } + + void _flushPending() { + final context = QlyraApp.navigatorKey.currentContext; + + if (_pendingLogExport) { + if (context == null) { + _logExportRetry ??= Timer(const Duration(milliseconds: 300), () { + _logExportRetry = null; + _flushPending(); + }); + } else { + _pendingLogExport = false; + exportDebugLog(context); + } + } + + if (_pendingExternalCallback != null) { + if (context == null || api.state != SessionState.online) { + _externalCallbackRetry ??= Timer(const Duration(milliseconds: 300), () { + _externalCallbackRetry = null; + _flushPending(); + }); + } else { + final url = _pendingExternalCallback!; + _pendingExternalCallback = null; + _handleExternalCallback(context, url); + } + } + + if (_pendingWebPush != null) { + if (context == null) { + _webPushRetry ??= Timer(const Duration(milliseconds: 300), () { + _webPushRetry = null; + _flushPending(); + }); + } else { + final subscription = _pendingWebPush!; + _pendingWebPush = null; + _handleWebPush(context, subscription); + } + } + + if (!_ready || context == null) return; + final pending = _pending; + if (pending == null) return; + final needsConnection = MaxLink.parse(pending)?.needsConnection ?? true; + if (needsConnection && api.state != SessionState.online) return; + _pending = null; + tryHandleMaxLink(context, pending); + } + + bool _isExternalCallback(Uri uri) { + final scheme = uri.scheme.toLowerCase(); + if (scheme != 'https' && scheme != 'http' && scheme != 'ikar') return false; + final host = uri.host.toLowerCase(); + if (host != 'ikar.kusoft.xyz' && host != 'ikar.kusoft.xyz') return false; + return uri.queryParameters['externalCallback'] == '1'; + } + + Future _handleExternalCallback(BuildContext context, String url) async { + try { + final launch = await webAppModule.handleExternalCallback(url); + if (!context.mounted) return; + await pushSwipeable( + context, + (_) => DigitalIdWebScreen(initialLaunch: launch), + ); + } catch (e) { + if (context.mounted) { + showCustomNotification(context, 'Не удалось завершить Цифровой ID: $e'); + } + } + } + + WebPushSubscription? _parseWebPushLink(Uri uri) { + if (!Platform.isIOS) return null; + final scheme = uri.scheme.toLowerCase(); + if (scheme != 'ikar') return null; + + final segments = [ + if (uri.host.isNotEmpty) uri.host, + ...uri.pathSegments, + ].where((s) => s.isNotEmpty).toList(); + if (segments.length != 1 || segments.first != 'webpush') return null; + + final endpoint = uri.queryParameters['endpoint'] ?? ''; + final publicKey = uri.queryParameters['p256dh'] ?? ''; + final authKey = uri.queryParameters['auth'] ?? ''; + if (endpoint.isEmpty || publicKey.isEmpty || authKey.isEmpty) return null; + if (Uri.tryParse(endpoint)?.isScheme('https') != true) return null; + + return WebPushSubscription( + endpoint: endpoint, + publicKey: publicKey, + authKey: authKey, + ); + } + + Future _handleWebPush( + BuildContext context, + WebPushSubscription subscription, + ) async { + final l10n = AppLocalizations.of(context)!; + + if (!await WebPushService.instance.isAuthorized()) { + if (context.mounted) { + showCustomNotification(context, l10n.webPushNotAuthorized); + } + return; + } + + try { + await WebPushService.instance.registerSubscription(subscription); + if (context.mounted) { + showCustomNotification(context, l10n.webPushLinked); + } + } on MaxWebException catch (e) { + if (context.mounted) { + showCustomNotification(context, l10n.webPushLinkFailed(e.message)); + } + } catch (e) { + if (context.mounted) { + showCustomNotification(context, l10n.webPushLinkFailed('$e')); + } + } + } + + bool _isLogExportLink(Uri uri) { + final scheme = uri.scheme.toLowerCase(); + final segments = [ + if (scheme == 'ikar' && uri.host.isNotEmpty) uri.host, + ...uri.pathSegments, + ].where((s) => s.isNotEmpty).toList(); + + if (scheme == 'ikar') { + return segments.length == 1 && segments.first == 'export-logs'; + } + return false; + } + + String? _normalize(Uri uri) { + final scheme = uri.scheme.toLowerCase(); + + if (scheme == 'https' || scheme == 'http') { + final host = uri.host.toLowerCase(); + if (host == 'ikar.kusoft.xyz' || host == 'ikar.kusoft.xyz') return uri.toString(); + return null; + } + + if (scheme == 'ikar' || scheme == 'max') { + final segments = [ + if (uri.host.isNotEmpty && uri.host.toLowerCase() != 'ikar.kusoft.xyz') uri.host, + ...uri.pathSegments, + ].where((s) => s.isNotEmpty).toList(); + if (segments.isEmpty) return null; + final query = uri.query.isNotEmpty ? '?${uri.query}' : ''; + return 'https://ikar.kusoft.xyz/${segments.join('/')}$query'; + } + + return null; + } + + void dispose() { + _logExportRetry?.cancel(); + _logExportRetry = null; + _webPushRetry?.cancel(); + _webPushRetry = null; + _sub?.cancel(); + _sub = null; + _stateSub?.cancel(); + _stateSub = null; + _started = false; + } +} diff --git a/lib/core/links/desktop_url_scheme.dart b/lib/core/links/desktop_url_scheme.dart new file mode 100644 index 0000000..36b3f16 --- /dev/null +++ b/lib/core/links/desktop_url_scheme.dart @@ -0,0 +1,74 @@ +import 'dart:io'; + +import '../utils/logger.dart'; + +const List _schemes = ['ikar', 'max']; + +abstract class DesktopUrlScheme { + static Future register() async { + try { + if (Platform.isWindows) { + await _registerWindows(); + } else if (Platform.isLinux) { + await _registerLinux(); + } + } catch (e) { + logger.w('DesktopUrlScheme: регистрация не удалась: $e'); + } + } + + static Future _registerWindows() async { + final exe = Platform.resolvedExecutable; + for (final scheme in _schemes) { + final capitalized = '${scheme[0].toUpperCase()}${scheme.substring(1)}'; + final base = 'HKCU\\Software\\Classes\\$scheme'; + + await _run('reg', ['add', base, '/ve', '/d', 'URL:$capitalized', '/f']); + await _run('reg', ['add', base, '/v', 'URL Protocol', '/d', '', '/f']); + await _run('reg', [ + 'add', + '$base\\shell\\open\\command', + '/ve', + '/d', + '"$exe" "%1"', + '/f', + ]); + } + } + + static Future _registerLinux() async { + final home = Platform.environment['HOME']; + if (home == null || home.isEmpty) return; + + final exe = Platform.resolvedExecutable; + final appsDir = Directory('$home/.local/share/applications'); + await appsDir.create(recursive: true); + + const fileName = 'qlyra-url-handler.desktop'; + final mimeTypes = _schemes.map((s) => 'x-scheme-handler/$s').join(';'); + final desktop = + '[Desktop Entry]\n' + 'Type=Application\n' + 'Name=ИКАР\n' + 'Exec="$exe" %u\n' + 'Terminal=false\n' + 'NoDisplay=true\n' + 'MimeType=$mimeTypes;\n'; + + final file = File('${appsDir.path}/$fileName'); + if (!file.existsSync() || await file.readAsString() != desktop) { + await file.writeAsString(desktop); + } + + for (final scheme in _schemes) { + await _run('xdg-mime', ['default', fileName, 'x-scheme-handler/$scheme']); + } + await _run('update-desktop-database', [appsDir.path]); + } + + static Future _run(String executable, List args) async { + try { + await Process.run(executable, args); + } catch (_) {} + } +} diff --git a/lib/core/links/max_link.dart b/lib/core/links/max_link.dart new file mode 100644 index 0000000..a5dc389 --- /dev/null +++ b/lib/core/links/max_link.dart @@ -0,0 +1,299 @@ +import 'message_link_token.dart'; + +enum MaxContentKind { public, invite, user, content } + +sealed class MaxLink { + const MaxLink(); + + bool get needsConnection => false; + + static final RegExp _schemeless = RegExp( + r'^(?:www\.)?ikar\.kusoft\.xyz(?:[/?#]|$)', + caseSensitive: false, + ); + + static final RegExp _segment = RegExp(r'^[A-Za-z0-9_]+$'); + + static const Set _reserved = { + 'login', + 'ps', + 'tos', + 'privacy', + 'about', + 'help', + }; + + static bool isMaxLink(String url) => parse(url) != null; + + static MaxLink? parse(String input) { + final uri = _canonical(input); + if (uri == null) return null; + + final segments = uri.pathSegments + .where((s) => s.isNotEmpty) + .toList(growable: false); + final params = uri.queryParameters; + final url = uri.replace(scheme: 'https', host: 'ikar.kusoft.xyz').toString(); + + if (segments.isEmpty) return _parseQueryOnly(params); + if (segments.first.startsWith(':')) { + return _parseRoute(segments, params, url); + } + return _parseContent(segments, params, url); + } + + static Uri? _canonical(String input) { + var value = input.trim(); + if (value.isEmpty) return null; + if (!value.contains('://')) { + if (!_schemeless.hasMatch(value)) return null; + value = 'https://$value'; + } + final uri = Uri.tryParse(value); + if (uri == null) return null; + final scheme = uri.scheme.toLowerCase(); + if (scheme != 'https' && scheme != 'http' && scheme != 'max') return null; + final host = uri.host.toLowerCase(); + if (host != 'ikar.kusoft.xyz' && host != 'ikar.kusoft.xyz') return null; + return uri; + } + + static MaxLink _parseQueryOnly(Map params) { + final userId = _idParam(params, 'uid'); + if (userId != null) return MaxContactIdLink(userId); + final chatId = _idParam(params, 'cid'); + if (chatId != null) return MaxChatIdLink(chatId); + return const MaxRootLink(); + } + + static MaxLink _parseRoute( + List segments, + Map params, + String url, + ) { + final route = segments.join('/').toLowerCase(); + switch (route) { + case ':auth': + return MaxAuthLink(url); + case ':current': + return const MaxCurrentLink(); + case ':share-self-out': + return const MaxShareSelfLink(); + case ':share': + return MaxShareTextLink(params['text']?.trim() ?? ''); + case ':folder': + final id = params['id']?.trim(); + if (id != null && id.isNotEmpty) return MaxFolderLink(id); + } + if (segments.length > 1 && segments.first.toLowerCase() == ':auth') { + return MaxAuthLink(url); + } + return MaxRouteLink(route, params); + } + + static MaxLink? _parseContent( + List segments, + Map params, + String url, + ) { + final first = segments.first; + final lower = first.toLowerCase(); + + if (segments.length == 1) { + final name = _publicName(first); + if (name == null) return null; + final startApp = params['startapp'] ?? params['startApp']; + if (startApp != null && startApp.isNotEmpty) { + return MaxWebAppLink('https://ikar.kusoft.xyz/$name', startApp.split('&').first); + } + return MaxContentLink( + kind: MaxContentKind.public, + url: url, + baseUrl: 'https://ikar.kusoft.xyz/$name', + startPayload: _startPayload(params), + ); + } + + if (segments.length == 2) { + switch (lower) { + case 'stickerset': + return MaxStickerSetLink('$lower/${segments[1]}'); + case 'joincall': + return MaxCallLink(url); + case 'join': + return MaxContentLink( + kind: MaxContentKind.invite, + url: url, + baseUrl: url, + ); + case 'u': + return MaxContentLink( + kind: MaxContentKind.user, + url: url, + baseUrl: url, + ); + } + final messageId = _messageId(segments[1]); + final name = _publicName(first); + if (messageId == null || name == null) return null; + return MaxContentLink( + kind: MaxContentKind.public, + url: url, + baseUrl: 'https://ikar.kusoft.xyz/$name', + messageId: messageId, + lookup: '$name/${segments[1]}', + ); + } + + if (lower == 'c' && segments.length == 3) { + final chatId = int.tryParse(segments[1]); + final messageId = _messageId(segments[2]); + if (chatId == null || messageId == null) return null; + return MaxContentLink( + kind: MaxContentKind.content, + url: url, + baseUrl: 'https://ikar.kusoft.xyz/c/$chatId', + messageId: messageId, + lookup: '/c/$chatId/${segments[2]}', + ); + } + + if (lower == 'join') { + return MaxContentLink( + kind: MaxContentKind.invite, + url: url, + baseUrl: url, + ); + } + return null; + } + + static String? _publicName(String segment) { + final name = segment.startsWith('@') ? segment.substring(1) : segment; + if (name.isEmpty) return null; + if (_reserved.contains(name.toLowerCase())) return null; + if (!_segment.hasMatch(name)) return null; + return name; + } + + static int? _messageId(String segment) => + int.tryParse(segment) ?? + int.tryParse(MessageLinkToken.decode(segment) ?? ''); + + static String? _startPayload(Map params) { + final value = params['start']?.trim(); + return (value == null || value.isEmpty) ? null : value; + } + + static int? _idParam(Map params, String key) { + final raw = params[key]?.trim(); + if (raw == null || raw.isEmpty) return null; + final value = int.tryParse(raw); + return (value == null || value <= 0) ? null : value; + } +} + +class MaxRootLink extends MaxLink { + const MaxRootLink(); +} + +class MaxCurrentLink extends MaxLink { + const MaxCurrentLink(); +} + +class MaxShareSelfLink extends MaxLink { + const MaxShareSelfLink(); +} + +class MaxAuthLink extends MaxLink { + final String url; + + const MaxAuthLink(this.url); + + @override + bool get needsConnection => true; +} + +class MaxCallLink extends MaxLink { + final String url; + + const MaxCallLink(this.url); + + @override + bool get needsConnection => true; +} + +class MaxStickerSetLink extends MaxLink { + final String path; + + const MaxStickerSetLink(this.path); + + @override + bool get needsConnection => true; +} + +class MaxShareTextLink extends MaxLink { + final String text; + + const MaxShareTextLink(this.text); +} + +class MaxFolderLink extends MaxLink { + final String folderId; + + const MaxFolderLink(this.folderId); +} + +class MaxRouteLink extends MaxLink { + final String route; + final Map params; + + const MaxRouteLink(this.route, this.params); +} + +class MaxContactIdLink extends MaxLink { + final int userId; + + const MaxContactIdLink(this.userId); + + @override + bool get needsConnection => true; +} + +class MaxChatIdLink extends MaxLink { + final int chatId; + final int? messageId; + + const MaxChatIdLink(this.chatId, {this.messageId}); +} + +class MaxWebAppLink extends MaxLink { + final String url; + final String startApp; + + const MaxWebAppLink(this.url, this.startApp); + + @override + bool get needsConnection => true; +} + +class MaxContentLink extends MaxLink { + final MaxContentKind kind; + final String url; + final String baseUrl; + final String? startPayload; + final int? messageId; + final String lookup; + + const MaxContentLink({ + required this.kind, + required this.url, + required this.baseUrl, + this.startPayload, + this.messageId, + String? lookup, + }) : lookup = lookup ?? url; + + @override + bool get needsConnection => true; +} diff --git a/lib/core/links/message_link_token.dart b/lib/core/links/message_link_token.dart new file mode 100644 index 0000000..6af4159 --- /dev/null +++ b/lib/core/links/message_link_token.dart @@ -0,0 +1,49 @@ +import 'dart:convert'; + +abstract final class MessageLinkToken { + static final RegExp _token = RegExp(r'^[A-Za-z0-9_-]{11}$'); + static final BigInt _byteMask = BigInt.from(0xff); + static final BigInt _maxId = (BigInt.one << 64) - BigInt.one; + + static String? encode(String messageId) { + final parsed = BigInt.tryParse(messageId); + if (parsed == null || parsed <= BigInt.zero || parsed > _maxId) return null; + var value = parsed; + final bytes = List.filled(8, 0); + for (var i = 7; i >= 0; i--) { + bytes[i] = (value & _byteMask).toInt(); + value = value >> 8; + } + return base64Url.encode(bytes).replaceAll('=', ''); + } + + static String? decode(String token) { + if (!_token.hasMatch(token)) return null; + final List bytes; + try { + bytes = base64Url.decode('$token='); + } on FormatException { + return null; + } + var value = BigInt.zero; + for (final byte in bytes) { + value = (value << 8) | BigInt.from(byte); + } + return value == BigInt.zero ? null : value.toString(); + } + + static String? messageUrl({ + required int chatId, + required String messageId, + String? publicLink, + }) { + final token = encode(messageId); + if (token == null) return null; + final base = publicLink?.trim() ?? ''; + if (base.isEmpty) return 'https://ikar.kusoft.xyz/c/$chatId/$token'; + final trimmed = base.endsWith('/') + ? base.substring(0, base.length - 1) + : base; + return '$trimmed/$token'; + } +} diff --git a/lib/core/links/profile_link.dart b/lib/core/links/profile_link.dart new file mode 100644 index 0000000..c8fd919 --- /dev/null +++ b/lib/core/links/profile_link.dart @@ -0,0 +1,35 @@ +import '../cache/info_cache.dart'; +import '../storage/app_database.dart'; + +const String _host = 'https://ikar.kusoft.xyz'; + +// #***! ссылка на профиль из имени или готового адреса +String? profileLinkOf(String? rawLink) { + final link = rawLink?.trim(); + if (link == null || link.isEmpty) return null; + if (!link.contains('://')) { + final name = link.startsWith('@') ? link.substring(1) : link; + return name.isEmpty ? null : '$_host/$name'; + } + final path = Uri.tryParse(link)?.path.replaceAll('/', '') ?? ''; + return path.isEmpty ? null : link; +} + +// #***! своя ссылка, сначала конфиг потом кэш потом сервер +Future ownProfileLink() async { + final profile = await AppDatabase.loadActiveProfile(); + final id = profile?.id ?? 0; + if (id == 0) return null; + + final invite = profileLinkOf( + await AppDatabase.getSyncValue(id, SyncKey.profileInviteLink), + ); + if (invite != null) return invite; + + final cached = await ContactInfoFetch.get(id); + final short = profileLinkOf(cached?.raw['link'] as String?); + if (short != null) return short; + + final fresh = await ContactInfoFetch.get(id, forceRefresh: true); + return profileLinkOf(fresh?.raw['link'] as String?); +} diff --git a/lib/core/media/audio_file_track.dart b/lib/core/media/audio_file_track.dart new file mode 100644 index 0000000..3ee6d8d --- /dev/null +++ b/lib/core/media/audio_file_track.dart @@ -0,0 +1,22 @@ +// #***! аудиофайл в плеере плюс откуда он взялся +class AudioFileTrack { + const AudioFileTrack({ + required this.cacheName, + required this.path, + required this.name, + required this.sourceName, + this.chatId, + this.messageId, + this.messageTime, + this.thumbnailUrl, + }); + + final String cacheName; + final String path; + final String name; + final String sourceName; + final int? chatId; + final String? messageId; + final int? messageTime; + final String? thumbnailUrl; +} diff --git a/lib/core/media/audio_playback_controller.dart b/lib/core/media/audio_playback_controller.dart new file mode 100644 index 0000000..ab24418 --- /dev/null +++ b/lib/core/media/audio_playback_controller.dart @@ -0,0 +1,156 @@ +import 'dart:async'; + +import 'package:audio_service/audio_service.dart'; +import 'package:audio_session/audio_session.dart'; +import 'package:flutter/foundation.dart'; + +import '../calls/active_call.dart'; +import 'audio_file_track.dart'; +import 'background_audio_handler.dart'; +import 'desktop_audio_backend.dart'; + +// #***! плеер музыки с управлением из шторки +class AudioPlaybackController { + AudioPlaybackController._(this._handler) { + _subscriptions.add( + _handler.playbackState.listen((state) { + playing.value = state.playing; + processingState.value = state.processingState; + bufferedPosition.value = state.bufferedPosition; + }), + ); + _subscriptions.add( + _handler.mediaItem.listen((item) { + duration.value = item?.duration ?? Duration.zero; + }), + ); + _subscriptions.add( + _handler.positionStream.listen((value) => position.value = value), + ); + _subscriptions.add( + _handler.bufferedPositionStream.listen( + (value) => bufferedPosition.value = value, + ), + ); + _subscriptions.add( + _handler.errors.stream.listen((value) => error.value = value), + ); + ActiveCall.instance.current.addListener(_onActiveCallChanged); + } + + // #***! синглтон с ленивой инициализацией, audio_service поднимать дорого + static AudioPlaybackController? _instance; + static Future? _pending; + + static AudioPlaybackController get instance { + final value = _instance; + if (value == null) throw StateError('Audio playback is not initialized'); + return value; + } + + static bool get isInitialized => _instance != null; + + static final ValueNotifier error = ValueNotifier(null); + + static Future ensureInitialized( + String notificationChannelName, + ) async { + final ready = _instance; + if (ready != null) return ready; + final pending = _pending ??= _create(notificationChannelName); + try { + return await pending; + } catch (_) { + _pending = null; + rethrow; + } + } + + static Future _create(String channelName) async { + DesktopAudioBackend.ensureRegistered(); + final handler = switch (defaultTargetPlatform) { + TargetPlatform.windows || + TargetPlatform.linux => BackgroundAudioHandler(), + _ => await AudioService.init( + builder: BackgroundAudioHandler.new, + config: AudioServiceConfig( + androidNotificationChannelId: 'qlyra.audio.playback', + androidNotificationChannelName: channelName, + androidNotificationOngoing: false, + androidStopForegroundOnPause: true, + ), + ), + }; + final session = await AudioSession.instance; + await session.configure(const AudioSessionConfiguration.music()); + return _instance = AudioPlaybackController._(handler); + } + + final BackgroundAudioHandler _handler; + final List> _subscriptions = []; + bool _pausedByCall = false; + + // #***! состояние отдельными notifier, плашки плеера подписаны + final ValueNotifier playing = ValueNotifier(false); + final ValueNotifier position = ValueNotifier(Duration.zero); + final ValueNotifier bufferedPosition = ValueNotifier(Duration.zero); + final ValueNotifier duration = ValueNotifier(Duration.zero); + final ValueNotifier processingState = ValueNotifier( + AudioProcessingState.idle, + ); + + // #***! запуск трека + Future playTrack(AudioFileTrack track) async { + error.value = null; + _pausedByCall = false; + position.value = Duration.zero; + bufferedPosition.value = Duration.zero; + duration.value = Duration.zero; + await _handler.load(track); + await _handler.play(); + } + + Future toggle() { + _pausedByCall = false; + return playing.value ? _handler.pause() : _handler.play(); + } + + Future seek(Duration value) => _handler.seek(value); + + Future stop() { + _pausedByCall = false; + return _handler.stop(); + } + + Future dispose() async { + ActiveCall.instance.current.removeListener(_onActiveCallChanged); + for (final subscription in _subscriptions) { + await subscription.cancel(); + } + _subscriptions.clear(); + await _handler.dispose(); + playing.dispose(); + position.dispose(); + bufferedPosition.dispose(); + duration.dispose(); + processingState.dispose(); + if (identical(_instance, this)) { + _instance = null; + _pending = null; + } + } + + // #***! на звонок ставим на паузу потом возвращаем + void _onActiveCallChanged() { + if (ActiveCall.instance.current.value != null) { + if (!playing.value) return; + _pausedByCall = true; + unawaited(_handler.pause()); + return; + } + if (!_pausedByCall) return; + _pausedByCall = false; + if (processingState.value == AudioProcessingState.idle) return; + unawaited(_handler.play()); + } +} diff --git a/lib/core/media/background_audio_handler.dart b/lib/core/media/background_audio_handler.dart new file mode 100644 index 0000000..251f300 --- /dev/null +++ b/lib/core/media/background_audio_handler.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:audio_service/audio_service.dart'; +import 'package:just_audio/just_audio.dart'; + +import 'audio_file_track.dart'; + +// #***! обвязка audio_service, рисует уведомление и ловит кнопки наушников +class BackgroundAudioHandler extends BaseAudioHandler with SeekHandler { + BackgroundAudioHandler() { + _subscriptions.add( + _player.playbackEventStream.listen((_) => _broadcastState()), + ); + _subscriptions.add( + _player.errorStream.listen((error) => errors.add(error.message ?? '')), + ); + _subscriptions.add(_player.durationStream.listen(_updateDuration)); + } + + final AudioPlayer _player = AudioPlayer(); + final StreamController errors = StreamController.broadcast(); + final List> _subscriptions = []; + + Stream get positionStream => _player.positionStream; + Stream get bufferedPositionStream => _player.bufferedPositionStream; + + // #***! загрузка трека с метаданными для шторки + Future load(AudioFileTrack track) async { + final item = MediaItem( + id: track.cacheName, + title: track.name, + album: track.sourceName.isEmpty ? null : track.sourceName, + artUri: _artUri(track.thumbnailUrl), + extras: { + 'path': track.path, + 'chatId': track.chatId, + 'messageId': track.messageId, + 'messageTime': track.messageTime, + }, + ); + mediaItem.add(item); + final duration = await _player.setFilePath(track.path); + if (duration != null) mediaItem.add(item.copyWith(duration: duration)); + _broadcastState(); + } + + Uri? _artUri(String? value) { + if (value == null || value.isEmpty) return null; + final uri = Uri.tryParse(value); + if (uri == null || !const {'http', 'https', 'file'}.contains(uri.scheme)) { + return null; + } + return uri; + } + + void _updateDuration(Duration? duration) { + final item = mediaItem.value; + if (item == null || duration == null || item.duration == duration) return; + mediaItem.add(item.copyWith(duration: duration)); + } + + @override + Future play() async { + if (_player.processingState == ProcessingState.completed) { + await _player.seek(Duration.zero); + } + await _player.play(); + } + + @override + Future pause() => _player.pause(); + + @override + Future seek(Duration position) => _player.seek(position); + + @override + Future stop() async { + await _player.stop(); + await super.stop(); + } + + Future dispose() async { + for (final subscription in _subscriptions) { + await subscription.cancel(); + } + _subscriptions.clear(); + await _player.dispose(); + await errors.close(); + } + + // #***! состояние транслируем системе чтоб уведомление совпадало с плеером + void _broadcastState() { + playbackState.add( + playbackState.value.copyWith( + controls: [ + if (_player.playing) MediaControl.pause else MediaControl.play, + MediaControl.stop, + ], + androidCompactActionIndices: const [0], + processingState: _processingState(_player.processingState), + playing: _player.playing, + updatePosition: _player.position, + bufferedPosition: _player.bufferedPosition, + speed: _player.speed, + ), + ); + } + + AudioProcessingState _processingState(ProcessingState state) { + return switch (state) { + ProcessingState.idle => AudioProcessingState.idle, + ProcessingState.loading => AudioProcessingState.loading, + ProcessingState.buffering => AudioProcessingState.buffering, + ProcessingState.ready => AudioProcessingState.ready, + ProcessingState.completed => AudioProcessingState.completed, + }; + } +} diff --git a/lib/core/media/clipboard/clipboard_channel.dart b/lib/core/media/clipboard/clipboard_channel.dart new file mode 100644 index 0000000..075bef8 --- /dev/null +++ b/lib/core/media/clipboard/clipboard_channel.dart @@ -0,0 +1,53 @@ +import 'package:flutter/services.dart'; + +import 'raw_clipboard_media.dart'; + +// #***! мост к нативному буферу, ошибки канала это пусто +class ClipboardChannel { + const ClipboardChannel._(); + + static const MethodChannel _channel = MethodChannel('ru.ikar.app/clipboard'); + + static Future hasMedia() async { + try { + return await _channel.invokeMethod('hasMedia') ?? false; + } catch (_) { + return false; + } + } + + // #***! приоритет у файлов, есть пути картинку не разбираем + static Future read() async { + Map? raw; + try { + raw = await _channel.invokeMapMethod('read'); + } catch (_) { + return null; + } + if (raw == null) return null; + + final paths = (raw['files'] as List?)?.whereType().toList( + growable: false, + ); + if (paths != null && paths.isNotEmpty) { + return RawClipboardMedia(paths: paths); + } + + final image = raw['image']; + if (image is Uint8List && image.isNotEmpty) { + return RawClipboardMedia( + png: image, + imageExtension: _extension(raw['imageExtension']), + ); + } + return null; + } + + static String? _extension(Object? value) { + if (value is! String) return null; + final trimmed = value.startsWith('.') ? value.substring(1) : value; + if (trimmed.isEmpty || trimmed.length > 5) return null; + if (!RegExp(r'^[A-Za-z0-9]+$').hasMatch(trimmed)) return null; + return '.${trimmed.toLowerCase()}'; + } +} diff --git a/lib/core/media/clipboard/clipboard_media.dart b/lib/core/media/clipboard/clipboard_media.dart new file mode 100644 index 0000000..e1ce0f0 --- /dev/null +++ b/lib/core/media/clipboard/clipboard_media.dart @@ -0,0 +1,4 @@ +// #***! на вебе буфера нет, заглушка +export 'clipboard_media_stub.dart' + if (dart.library.io) 'clipboard_media_io.dart'; +export 'clipboard_media_types.dart'; diff --git a/lib/core/media/clipboard/clipboard_media_io.dart b/lib/core/media/clipboard/clipboard_media_io.dart new file mode 100644 index 0000000..b98d5d1 --- /dev/null +++ b/lib/core/media/clipboard/clipboard_media_io.dart @@ -0,0 +1,90 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import 'clipboard_channel.dart'; +import 'clipboard_media_types.dart'; +import 'dib_image.dart'; +import 'raw_clipboard_media.dart'; +import 'win32_clipboard.dart'; + +// #***! вставка файлов и картинок из буфера +class ClipboardMedia { + const ClipboardMedia._(); + + // #***! на винде WinAPI, на остальных нативный канал + static bool get supported { + if (Platform.isWindows) return Win32Clipboard.instance != null; + return Platform.isMacOS || + Platform.isLinux || + Platform.isAndroid || + Platform.isIOS; + } + + static Future hasMedia() async { + if (Platform.isWindows) { + return Win32Clipboard.instance?.hasMedia ?? false; + } + if (!supported) return false; + return ClipboardChannel.hasMedia(); + } + + // #***! сначала файлы, потом PNG, потом виндовый DIB + static Future read() async { + final raw = await _readRaw(); + if (raw == null || raw.isEmpty) return null; + + if (raw.paths.isNotEmpty) { + final files = []; + for (final path in raw.paths) { + final file = File(path); + if (!file.existsSync()) continue; + files.add( + ClipboardFileRef( + path: path, + name: _basename(path), + size: file.lengthSync(), + ), + ); + } + if (files.isNotEmpty) return ClipboardMediaPayload(files: files); + } + + final png = raw.png; + if (png != null && png.isNotEmpty) { + return ClipboardMediaPayload( + image: ClipboardImageData( + bytes: png, + extension: raw.imageExtension ?? '.png', + ), + ); + } + + // #***! DIB в PNG в отдельном изоляте + final dib = raw.dib; + if (dib != null && dib.isNotEmpty) { + final decoded = await compute(dibToPng, dib); + if (decoded != null && decoded.isNotEmpty) { + return ClipboardMediaPayload( + image: ClipboardImageData(bytes: decoded, extension: '.png'), + ); + } + } + return null; + } + + static Future _readRaw() { + if (Platform.isWindows) { + final clipboard = Win32Clipboard.instance; + if (clipboard == null) return Future.value(); + return clipboard.read(); + } + if (!supported) return Future.value(); + return ClipboardChannel.read(); + } +} + +String _basename(String path) { + final separator = path.lastIndexOf(RegExp(r'[\\/]')); + return separator < 0 ? path : path.substring(separator + 1); +} diff --git a/lib/core/media/clipboard/clipboard_media_stub.dart b/lib/core/media/clipboard/clipboard_media_stub.dart new file mode 100644 index 0000000..38daee5 --- /dev/null +++ b/lib/core/media/clipboard/clipboard_media_stub.dart @@ -0,0 +1,12 @@ +import 'clipboard_media_types.dart'; + +// #***! заглушка, буфер всегда пуст +class ClipboardMedia { + const ClipboardMedia._(); + + static bool get supported => false; + + static Future hasMedia() async => false; + + static Future read() async => null; +} diff --git a/lib/core/media/clipboard/clipboard_media_types.dart b/lib/core/media/clipboard/clipboard_media_types.dart new file mode 100644 index 0000000..a35f31f --- /dev/null +++ b/lib/core/media/clipboard/clipboard_media_types.dart @@ -0,0 +1,35 @@ +import 'dart:typed_data'; + +// #***! файл из буфера +class ClipboardFileRef { + const ClipboardFileRef({ + required this.path, + required this.name, + required this.size, + }); + + final String path; + final String name; + final int size; +} + +// #***! картинка из буфера с расширением +class ClipboardImageData { + const ClipboardImageData({required this.bytes, required this.extension}); + + final Uint8List bytes; + final String extension; +} + +// #***! что реально достали из буфера +class ClipboardMediaPayload { + const ClipboardMediaPayload({ + this.files = const [], + this.image, + }); + + final List files; + final ClipboardImageData? image; + + bool get isEmpty => files.isEmpty && image == null; +} diff --git a/lib/core/media/clipboard/dib_image.dart b/lib/core/media/clipboard/dib_image.dart new file mode 100644 index 0000000..793097b --- /dev/null +++ b/lib/core/media/clipboard/dib_image.dart @@ -0,0 +1,68 @@ +import 'dart:typed_data'; + +import 'package:image/image.dart' as img; + +// #***! константы BMP +const int _biBitfields = 3; +const int _biAlphaBitfields = 6; +const int _fileHeaderSize = 14; +const int _coreHeaderSize = 12; +const int _infoHeaderSize = 40; + +// #***! винда отдаёт картинку как DIB, это BMP без файлового заголовка +Uint8List? dibToPng(Uint8List dib) { + final bmp = _wrapDibAsBmp(dib); + if (bmp == null) return null; + try { + final decoded = img.decodeBmp(bmp); + if (decoded == null) return null; + return img.encodePng(decoded); + } catch (_) { + return null; + } +} + +// #***! дописываем 14 байт заголовка, смещение пикселей считаем по палитре и маскам +Uint8List? _wrapDibAsBmp(Uint8List dib) { + if (dib.length < _coreHeaderSize) return null; + final source = ByteData.sublistView(dib); + final headerSize = source.getUint32(0, Endian.little); + if (headerSize != _coreHeaderSize && headerSize < _infoHeaderSize) { + return null; + } + if (dib.length < headerSize) return null; + + final int bitCount; + final int compression; + final int paletteEntries; + if (headerSize == _coreHeaderSize) { + bitCount = source.getUint16(10, Endian.little); + compression = 0; + paletteEntries = bitCount <= 8 ? 1 << bitCount : 0; + } else { + bitCount = source.getUint16(14, Endian.little); + compression = source.getUint32(16, Endian.little); + final declared = source.getUint32(32, Endian.little); + paletteEntries = bitCount <= 8 + ? (declared != 0 ? declared : 1 << bitCount) + : declared; + } + + var extra = paletteEntries * (headerSize == _coreHeaderSize ? 3 : 4); + if (headerSize == _infoHeaderSize) { + if (compression == _biBitfields) extra += 12; + if (compression == _biAlphaBitfields) extra += 16; + } + + final pixelOffset = _fileHeaderSize + headerSize + extra; + if (pixelOffset >= _fileHeaderSize + dib.length) return null; + + final bmp = Uint8List(_fileHeaderSize + dib.length); + final header = ByteData.sublistView(bmp, 0, _fileHeaderSize); + header.setUint8(0, 0x42); + header.setUint8(1, 0x4D); + header.setUint32(2, bmp.length, Endian.little); + header.setUint32(10, pixelOffset, Endian.little); + bmp.setRange(_fileHeaderSize, bmp.length, dib); + return bmp; +} diff --git a/lib/core/media/clipboard/pasted_attachment.dart b/lib/core/media/clipboard/pasted_attachment.dart new file mode 100644 index 0000000..911f45e --- /dev/null +++ b/lib/core/media/clipboard/pasted_attachment.dart @@ -0,0 +1,112 @@ +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; + +import '../gallery_source.dart'; +import 'clipboard_media.dart'; + +// #***! вставленные картинки лежат сутки +const Duration _pasteRetention = Duration(hours: 24); + +// #***! как показать вставленное +enum PastedAttachmentKind { image, video, file } + +// #***! файл из буфера готовый к отправке +class PastedAttachment { + const PastedAttachment({ + required this.file, + required this.name, + required this.size, + required this.kind, + }); + + final File file; + final String name; + final int size; + final PastedAttachmentKind kind; + + bool get isMedia => kind != PastedAttachmentKind.file; +} + +// #***! содержимое буфера в файлы на диске +Future> materializeClipboardMedia( + ClipboardMediaPayload payload, +) async { + final result = []; + + final image = payload.image; + if (image != null) { + final stored = await storePastedImage(image); + if (stored != null) result.add(stored); + } + + for (final ref in payload.files) { + final file = File(ref.path); + if (!await file.exists()) continue; + result.add( + PastedAttachment( + file: file, + name: ref.name, + size: ref.size, + kind: _kindOf(ref.path), + ), + ); + } + return result; +} + +// #***! картинку сначала сохраняем иначе её не отправить +Future storePastedImage(ClipboardImageData image) async { + try { + final dir = Directory( + '${(await getTemporaryDirectory()).path}/qlyra_paste', + ); + await dir.create(recursive: true); + await _prune(dir); + final name = + 'paste_${DateTime.now().millisecondsSinceEpoch}' + '${image.extension}'; + final file = File('${dir.path}/$name'); + await file.writeAsBytes(image.bytes, flush: true); + return PastedAttachment( + file: file, + name: name, + size: image.bytes.length, + kind: PastedAttachmentKind.image, + ); + } catch (_) { + return null; + } +} + +const Map _imageExtensions = { + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/gif': '.gif', + 'image/webp': '.webp', + 'image/heic': '.heic', + 'image/bmp': '.bmp', +}; + +// #***! клавиатура отдаёт mime, файлу нужно расширение +String pastedImageExtension(String mimeType) => + _imageExtensions[mimeType.toLowerCase()] ?? '.png'; + +// #***! тип по расширению +PastedAttachmentKind _kindOf(String path) { + if (isVideoPath(path)) return PastedAttachmentKind.video; + if (isImagePath(path)) return PastedAttachmentKind.image; + return PastedAttachmentKind.file; +} + +// #***! старые вставки чистим иначе папка растёт +Future _prune(Directory dir) async { + final cutoff = DateTime.now().subtract(_pasteRetention); + try { + await for (final entry in dir.list()) { + if (entry is! File) continue; + if ((await entry.stat()).modified.isBefore(cutoff)) await entry.delete(); + } + } catch (_) {} +} diff --git a/lib/core/media/clipboard/raw_clipboard_media.dart b/lib/core/media/clipboard/raw_clipboard_media.dart new file mode 100644 index 0000000..b52ce30 --- /dev/null +++ b/lib/core/media/clipboard/raw_clipboard_media.dart @@ -0,0 +1,18 @@ +import 'dart:typed_data'; + +// #***! сырой ответ натива, пути или байты картинки +class RawClipboardMedia { + const RawClipboardMedia({ + this.paths = const [], + this.png, + this.dib, + this.imageExtension, + }); + + final List paths; + final Uint8List? png; + final Uint8List? dib; + final String? imageExtension; + + bool get isEmpty => paths.isEmpty && png == null && dib == null; +} diff --git a/lib/core/media/clipboard/win32_clipboard.dart b/lib/core/media/clipboard/win32_clipboard.dart new file mode 100644 index 0000000..918d04f --- /dev/null +++ b/lib/core/media/clipboard/win32_clipboard.dart @@ -0,0 +1,196 @@ +import 'dart:async'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; + +import 'raw_clipboard_media.dart'; + +const int _cfDib = 8; +const int _cfUnicodeText = 13; +const int _cfHdrop = 15; +const int _cfDibV5 = 17; +const int _pathBufferChars = 32768; +const int _maxFiles = 20; +const int _openAttempts = 8; +// #***! буфер может быть занят другим приложением, повторяем +const Duration _openRetryDelay = Duration(milliseconds: 25); + +// #***! сигнатуры WinAPI для FFI +typedef _OpenClipboardC = Int32 Function(IntPtr); +typedef _OpenClipboardDart = int Function(int); +typedef _CloseClipboardC = Int32 Function(); +typedef _CloseClipboardDart = int Function(); +typedef _FormatAvailableC = Int32 Function(Uint32); +typedef _FormatAvailableDart = int Function(int); +typedef _GetClipboardDataC = IntPtr Function(Uint32); +typedef _GetClipboardDataDart = int Function(int); +typedef _RegisterFormatC = Uint32 Function(Pointer); +typedef _RegisterFormatDart = int Function(Pointer); +typedef _GlobalLockC = Pointer Function(IntPtr); +typedef _GlobalLockDart = Pointer Function(int); +typedef _GlobalUnlockC = Int32 Function(IntPtr); +typedef _GlobalUnlockDart = int Function(int); +typedef _GlobalSizeC = IntPtr Function(IntPtr); +typedef _GlobalSizeDart = int Function(int); +typedef _DragQueryFileC = + Uint32 Function(IntPtr, Uint32, Pointer, Uint32); +typedef _DragQueryFileDart = int Function(int, int, Pointer, int); + +// #***! буфер винды напрямую через WinAPI, флаттер файлы оттуда не отдаёт +class Win32Clipboard { + Win32Clipboard._() { + final user32 = DynamicLibrary.open('user32.dll'); + final kernel32 = DynamicLibrary.open('kernel32.dll'); + final shell32 = DynamicLibrary.open('shell32.dll'); + + _open = user32.lookupFunction<_OpenClipboardC, _OpenClipboardDart>( + 'OpenClipboard', + ); + _close = user32.lookupFunction<_CloseClipboardC, _CloseClipboardDart>( + 'CloseClipboard', + ); + _formatAvailable = user32 + .lookupFunction<_FormatAvailableC, _FormatAvailableDart>( + 'IsClipboardFormatAvailable', + ); + _getData = user32.lookupFunction<_GetClipboardDataC, _GetClipboardDataDart>( + 'GetClipboardData', + ); + _registerFormat = user32 + .lookupFunction<_RegisterFormatC, _RegisterFormatDart>( + 'RegisterClipboardFormatW', + ); + _globalLock = kernel32.lookupFunction<_GlobalLockC, _GlobalLockDart>( + 'GlobalLock', + ); + _globalUnlock = kernel32.lookupFunction<_GlobalUnlockC, _GlobalUnlockDart>( + 'GlobalUnlock', + ); + _globalSize = kernel32.lookupFunction<_GlobalSizeC, _GlobalSizeDart>( + 'GlobalSize', + ); + _dragQueryFile = shell32 + .lookupFunction<_DragQueryFileC, _DragQueryFileDart>('DragQueryFileW'); + _pngFormat = _registerNamedFormat('PNG'); + } + + // #***! инстанс один раз, функции не нашлись значит фича выключена + static Win32Clipboard? _instance; + static bool _resolved = false; + + static Win32Clipboard? get instance { + if (!Platform.isWindows) return null; + if (!_resolved) { + _resolved = true; + try { + _instance = Win32Clipboard._(); + } catch (_) { + _instance = null; + } + } + return _instance; + } + + late final _OpenClipboardDart _open; + late final _CloseClipboardDart _close; + late final _FormatAvailableDart _formatAvailable; + late final _GetClipboardDataDart _getData; + late final _RegisterFormatDart _registerFormat; + late final _GlobalLockDart _globalLock; + late final _GlobalUnlockDart _globalUnlock; + late final _GlobalSizeDart _globalSize; + late final _DragQueryFileDart _dragQueryFile; + late final int _pngFormat; + + // #***! в буфере или файлы или картинка + bool get hasMedia => _has(_cfHdrop) || _hasImage; + + bool get _hasImage => + !_has(_cfUnicodeText) && + (_has(_pngFormat) || _has(_cfDibV5) || _has(_cfDib)); + + // #***! чтение с ретраями, буфер надо открыть забрать и обязательно закрыть + Future read() async { + if (!hasMedia) return null; + for (var attempt = 0; attempt < _openAttempts; attempt++) { + if (_open(0) != 0) { + try { + final raw = _readOpened(); + return raw == null || raw.isEmpty ? null : raw; + } finally { + _close(); + } + } + await Future.delayed(_openRetryDelay); + } + return null; + } + + // #***! формат PNG не стандартный, регистрируем по имени + int _registerNamedFormat(String name) { + final native = name.toNativeUtf16(); + try { + return _registerFormat(native); + } finally { + calloc.free(native); + } + } + + bool _has(int format) => format != 0 && _formatAvailable(format) != 0; + + RawClipboardMedia? _readOpened() { + if (_has(_cfHdrop)) { + final paths = _readPaths(); + if (paths.isNotEmpty) return RawClipboardMedia(paths: paths); + } + if (!_hasImage) return null; + if (_has(_pngFormat)) { + final png = _copyGlobal(_getData(_pngFormat)); + if (png != null) return RawClipboardMedia(png: png); + } + for (final format in const [_cfDibV5, _cfDib]) { + if (!_has(format)) continue; + final dib = _copyGlobal(_getData(format)); + if (dib != null) return RawClipboardMedia(dib: dib); + } + return null; + } + + // #***! пути достаём через DragQueryFile + List _readPaths() { + final handle = _getData(_cfHdrop); + if (handle == 0) return const []; + final total = _dragQueryFile(handle, 0xFFFFFFFF, nullptr, 0); + if (total == 0) return const []; + final limit = total > _maxFiles ? _maxFiles : total; + final buffer = calloc(_pathBufferChars); + try { + final target = buffer.cast(); + final paths = []; + for (var i = 0; i < limit; i++) { + if (_dragQueryFile(handle, i, target, _pathBufferChars) == 0) continue; + final path = target.toDartString(); + if (path.isNotEmpty) paths.add(path); + } + return paths; + } finally { + calloc.free(buffer); + } + } + + // #***! данные надо залочить скопировать и разлочить + Uint8List? _copyGlobal(int handle) { + if (handle == 0) return null; + final size = _globalSize(handle); + if (size <= 0) return null; + final block = _globalLock(handle); + if (block == nullptr) return null; + try { + return Uint8List.fromList(block.asTypedList(size)); + } finally { + _globalUnlock(handle); + } + } +} diff --git a/lib/core/media/deleted_media_keeper.dart b/lib/core/media/deleted_media_keeper.dart new file mode 100644 index 0000000..f7f670c --- /dev/null +++ b/lib/core/media/deleted_media_keeper.dart @@ -0,0 +1,92 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +import '../../models/attachment.dart'; +import '../config/qlyra_settings.dart'; +import '../storage/app_database.dart'; +import '../utils/logger.dart'; +import '../utils/media_cache.dart'; +import '../utils/media_cache_names.dart'; + +// #***! удалённое сообщение остаётся, а файл на сервере умирает: пока ссылка +// ещё жива, снимаем копию в закреплённый кэш и прописываем её в payload +Future keepDeletedMedia( + int accountId, + int chatId, + List messageIds, +) async { + if (!QlyraSettings.viewDeleted.value || messageIds.isEmpty) return; + + final List> rows; + try { + rows = await AppDatabase.loadMessagesByIds(accountId, chatId, messageIds); + } catch (e) { + logger.w('keepDeletedMedia: не прочитал сообщения: $e'); + return; + } + + for (final row in rows) { + final payload = _decodePayload(row['payload']); + final attaches = payload?['attaches']; + if (payload == null || attaches is! List) continue; + var changed = false; + for (final attach in attaches.whereType()) { + if (await _keepPhoto(attach)) changed = true; + } + if (!changed) continue; + if (!QlyraSettings.viewDeleted.value) return; + try { + await AppDatabase.updateMessageMediaPayload( + accountId, chatId, row['id'].toString(), + row['payload'] as String, jsonEncode(payload), + ); + } catch (e) { + logger.w('keepDeletedMedia: не сохранил локальные пути: $e'); + } + } +} + +Future _keepPhoto(Map attach) async { + if ((attach['_type'] as String?)?.toUpperCase() != 'PHOTO') return false; + final saved = attach['localPath'] as String?; + if (saved != null && saved.isNotEmpty && await File(saved).exists()) { + return false; + } + + final photo = PhotoAttachment.fromMap(Map.from(attach)); + final url = photo.baseUrl ?? ''; + if (url.isEmpty || url.startsWith('data:')) return false; + + final name = photoCacheName(photo, url); + final source = await MediaCache.existing(name) ?? await _shownCopy(url); + final kept = await MediaCache.keep(name, source: source, url: url); + if (kept == null) return false; + + attach['localPath'] = kept.path; + return true; +} + +// #***! то что уже показывали лежит в кэше картинок, из него и копируем +Future _shownCopy(String url) async { + try { + final info = await DefaultCacheManager().getFileFromCache(url); + final path = info?.file.path; + if (path == null) return null; + final file = File(path); + return await file.exists() ? file : null; + } catch (_) { + return null; + } +} + +Map? _decodePayload(Object? raw) { + if (raw is! String || raw.isEmpty) return null; + try { + final decoded = jsonDecode(raw); + return decoded is Map ? Map.from(decoded) : null; + } catch (_) { + return null; + } +} diff --git a/lib/core/media/desktop_audio_backend.dart b/lib/core/media/desktop_audio_backend.dart new file mode 100644 index 0000000..65b8bdd --- /dev/null +++ b/lib/core/media/desktop_audio_backend.dart @@ -0,0 +1,11 @@ +import 'package:just_audio_media_kit/just_audio_media_kit.dart'; + +abstract final class DesktopAudioBackend { + static bool _registered = false; + + static void ensureRegistered() { + if (_registered) return; + _registered = true; + JustAudioMediaKit.ensureInitialized(linux: true, windows: true); + } +} diff --git a/lib/core/media/desktop_video_probe.dart b/lib/core/media/desktop_video_probe.dart new file mode 100644 index 0000000..d16f7b4 --- /dev/null +++ b/lib/core/media/desktop_video_probe.dart @@ -0,0 +1,186 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'video_transcoder.dart' show VideoInfo; + +class DesktopVideoProbe { + static const Duration _timeout = Duration(seconds: 6); + static const int _maxCache = 60; + + static bool get supported => + !Platform.isAndroid && !Platform.isIOS && !Platform.isFuchsia; + + static bool? _hasTools; + static final Map _durations = {}; + static final Map _thumbs = {}; + + static Future toolsAvailable() => _toolsAvailable(); + + static Future _toolsAvailable() async { + if (_hasTools != null) return _hasTools!; + if (!supported) return _hasTools = false; + try { + final probe = await Process.run('ffprobe', const [ + '-version', + ]).timeout(_timeout); + _hasTools = probe.exitCode == 0; + } catch (_) { + _hasTools = false; + } + return _hasTools!; + } + + static Future duration(String path) async { + if (_durations.containsKey(path)) return _durations[path]; + if (!await _toolsAvailable()) return null; + Duration? result; + try { + final out = await Process.run('ffprobe', [ + '-v', + 'error', + '-show_entries', + 'format=duration', + '-of', + 'default=noprint_wrappers=1:nokey=1', + path, + ]).timeout(_timeout); + final seconds = double.tryParse('${out.stdout}'.trim()); + if (seconds != null && seconds > 0) { + result = Duration(milliseconds: (seconds * 1000).round()); + } + } catch (_) {} + _remember(_durations, path, result); + return result; + } + + static final Map _sizes = {}; + + static Future<(int, int)?> dimensions(String path) async { + if (_sizes.containsKey(path)) return _sizes[path]; + if (!await _toolsAvailable()) return null; + (int, int)? result; + try { + final out = await Process.run('ffprobe', [ + '-v', + 'error', + '-select_streams', + 'v:0', + '-show_entries', + 'stream=width,height', + '-of', + 'csv=s=x:p=0', + path, + ]).timeout(_timeout); + final parts = '${out.stdout}'.trim().split('x'); + if (parts.length >= 2) { + final w = int.tryParse(parts[0].trim()); + final h = int.tryParse(parts[1].trim()); + if (w != null && h != null && w > 0 && h > 0) result = (w, h); + } + } catch (_) {} + _remember(_sizes, path, result); + return result; + } + + static Future info(String path) async { + if (!await _toolsAvailable()) return null; + try { + final out = await Process.run('ffprobe', [ + '-v', + 'error', + '-show_entries', + 'stream=codec_type,width,height,r_frame_rate:format=duration', + '-of', + 'json', + path, + ]).timeout(_timeout); + final root = jsonDecode('${out.stdout}') as Map; + final streams = (root['streams'] as List?) ?? const []; + Map? video; + var hasAudio = false; + for (final raw in streams) { + final s = raw as Map; + if (s['codec_type'] == 'video') { + video ??= s; + } else if (s['codec_type'] == 'audio') { + hasAudio = true; + } + } + if (video == null) return null; + final seconds = + double.tryParse('${(root['format'] as Map?)?['duration']}') ?? 0; + return VideoInfo( + width: (video['width'] as num?)?.toInt() ?? 0, + height: (video['height'] as num?)?.toInt() ?? 0, + durationMs: (seconds * 1000).round(), + fps: _parseRate('${video['r_frame_rate']}'), + hasAudio: hasAudio, + ); + } catch (_) { + return null; + } + } + + static double _parseRate(String value) { + final parts = value.split('/'); + if (parts.length == 2) { + final num = double.tryParse(parts[0]); + final den = double.tryParse(parts[1]); + if (num != null && den != null && den > 0) return num / den; + } + return double.tryParse(value) ?? 30; + } + + static Future frameAt(String path, int timeMs, int size) async { + if (!await _toolsAvailable()) return null; + return _grabFrame(path, size, (timeMs / 1000).toStringAsFixed(3)); + } + + static Future thumbnail(String path, int size) async { + final key = '$path@$size'; + if (_thumbs.containsKey(key)) return _thumbs[key]; + if (!await _toolsAvailable()) return null; + var bytes = await _grabFrame(path, size, '1'); + bytes ??= await _grabFrame(path, size, '0'); + _remember(_thumbs, key, bytes); + return bytes; + } + + static Future _grabFrame( + String path, + int size, + String seek, + ) async { + try { + final out = await Process.run('ffmpeg', [ + '-v', + 'error', + '-ss', + seek, + '-i', + path, + '-frames:v', + '1', + '-vf', + 'scale=$size:-2:force_original_aspect_ratio=decrease', + '-f', + 'image2', + '-vcodec', + 'mjpeg', + 'pipe:1', + ], stdoutEncoding: null).timeout(_timeout); + final data = out.stdout; + if (data is List && data.isNotEmpty) { + return Uint8List.fromList(data); + } + } catch (_) {} + return null; + } + + static void _remember(Map cache, String key, T value) { + if (cache.length > _maxCache) cache.clear(); + cache[key] = value; + } +} diff --git a/lib/core/media/dominant_color.dart b/lib/core/media/dominant_color.dart new file mode 100644 index 0000000..b256b8b --- /dev/null +++ b/lib/core/media/dominant_color.dart @@ -0,0 +1,144 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +class DominantColorCache { + DominantColorCache._(); + + static final DominantColorCache instance = DominantColorCache._(); + + static const int _sampleExtent = 8; + static const int _maxEntries = 256; + static const int _minAlpha = 8; + static const double _achromaticFloor = 0.15; + static const Duration _missingFileCooldown = Duration(seconds: 3); + + final Map _resolved = {}; + final Set _inFlight = {}; + final Set _rejected = {}; + final Map _retryAfter = {}; + + Color? lookup(String url) => _resolved[url]; + + void request(String url, VoidCallback onResolved) { + if (_resolved.containsKey(url) || + _inFlight.contains(url) || + _rejected.contains(url)) { + return; + } + final retryAt = _retryAfter[url]; + if (retryAt != null && DateTime.now().isBefore(retryAt)) return; + + _inFlight.add(url); + _extract(url).then((outcome) { + _inFlight.remove(url); + switch (outcome) { + case _ExtractionMissing(): + _retryAfter[url] = DateTime.now().add(_missingFileCooldown); + case _ExtractionRejected(): + _rejected.add(url); + _retryAfter.remove(url); + case _ExtractionResolved(color: final color): + _retryAfter.remove(url); + _store(url, color); + onResolved(); + } + }); + } + + void _store(String url, Color color) { + if (_resolved.length >= _maxEntries) { + _resolved.remove(_resolved.keys.first); + } + _resolved[url] = color; + } + + Future<_ExtractionOutcome> _extract(String url) async { + ui.Codec? codec; + ui.Image? image; + try { + final cached = await DefaultCacheManager().getFileFromCache(url); + if (cached == null) return const _ExtractionMissing(); + + final bytes = await cached.file.readAsBytes(); + codec = await ui.instantiateImageCodec( + bytes, + targetWidth: _sampleExtent, + targetHeight: _sampleExtent, + ); + final frame = await codec.getNextFrame(); + image = frame.image; + final raw = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + if (raw == null) return const _ExtractionRejected(); + + final color = _average(raw.buffer.asUint8List()); + if (color == null) return const _ExtractionRejected(); + return _ExtractionResolved(color); + } catch (_) { + return const _ExtractionRejected(); + } finally { + image?.dispose(); + codec?.dispose(); + } + } + + Color? _average(Uint8List pixels) { + var accumulatedRed = 0.0; + var accumulatedGreen = 0.0; + var accumulatedBlue = 0.0; + var accumulatedWeight = 0.0; + + for (var offset = 0; offset + 3 < pixels.length; offset += 4) { + final alpha = pixels[offset + 3]; + if (alpha < _minAlpha) continue; + + final red = pixels[offset].toDouble(); + final green = pixels[offset + 1].toDouble(); + final blue = pixels[offset + 2].toDouble(); + + final brightest = red > green + ? (red > blue ? red : blue) + : (green > blue ? green : blue); + final darkest = red < green + ? (red < blue ? red : blue) + : (green < blue ? green : blue); + final saturation = brightest <= 0 + ? 0.0 + : (brightest - darkest) / brightest; + + final weight = (alpha / 255) * (_achromaticFloor + saturation); + accumulatedRed += red * weight; + accumulatedGreen += green * weight; + accumulatedBlue += blue * weight; + accumulatedWeight += weight; + } + + if (accumulatedWeight <= 0) return null; + return Color.fromARGB( + 255, + (accumulatedRed / accumulatedWeight).round().clamp(0, 255), + (accumulatedGreen / accumulatedWeight).round().clamp(0, 255), + (accumulatedBlue / accumulatedWeight).round().clamp(0, 255), + ); + } +} + +sealed class _ExtractionOutcome { + const _ExtractionOutcome(); +} + +class _ExtractionMissing extends _ExtractionOutcome { + const _ExtractionMissing(); +} + +class _ExtractionRejected extends _ExtractionOutcome { + const _ExtractionRejected(); +} + +class _ExtractionResolved extends _ExtractionOutcome { + const _ExtractionResolved(this.color); + + final Color color; +} diff --git a/lib/core/media/gallery_source.dart b/lib/core/media/gallery_source.dart new file mode 100644 index 0000000..3346e55 --- /dev/null +++ b/lib/core/media/gallery_source.dart @@ -0,0 +1,358 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:photo_manager/photo_manager.dart'; + +import 'desktop_video_probe.dart'; + +// #***! доступ к галерее, полный частичный или запрещён +enum GalleryPermission { granted, limited, denied } + +// #***! элемент галереи, за ним системный ассет или просто файл +abstract class GalleryItem { + String get id; + bool get isVideo; + Duration? get duration; + File? get localFile; + Future thumbnail(int size); + Future originFile(); + Future<(int, int)?> dimensions(); + Future encodeForUpload({ + required int maxDimension, + required int quality, + }); + + static GalleryItem fromFile(File file) => _FileGalleryItem(file); +} + +// #***! страница выдачи +class GalleryPage { + const GalleryPage({required this.items, required this.hasMore}); + + static const empty = GalleryPage(items: [], hasMore: false); + + final List items; + final bool hasMore; +} + +// #***! выбранное фото плюс результат редактирования +class PickedPhoto { + final GalleryItem item; + final File? editedFile; + + const PickedPhoto({required this.item, this.editedFile}); +} + +// #***! размеры картинки без полного декодирования +Future<(int, int)?> imageFileDimensions(File file) async { + ui.ImmutableBuffer? buffer; + ui.ImageDescriptor? descriptor; + try { + final bytes = await file.readAsBytes(); + buffer = await ui.ImmutableBuffer.fromUint8List(bytes); + descriptor = await ui.ImageDescriptor.encoded(buffer); + return (descriptor.width, descriptor.height); + } catch (_) { + return null; + } finally { + descriptor?.dispose(); + buffer?.dispose(); + } +} + +// #***! галерея, на мобилках photo_manager на десктопе обход папок +abstract class GallerySource { + // #***! по 120 штук, столько влезает в несколько экранов сетки + static const int pageSize = 120; + static const Duration maxInt32Duration = Duration(milliseconds: 0x7fffffff); + + // #***! фильтр без ограничений, иначе часть видео пропадает + static FilterOptionGroup mediaFilter() => FilterOptionGroup( + imageOption: const FilterOption( + sizeConstraint: SizeConstraint(ignoreSize: true), + ), + videoOption: const FilterOption( + sizeConstraint: SizeConstraint(ignoreSize: true), + durationConstraint: DurationConstraint( + max: maxInt32Duration, + allowNullable: true, + ), + ), + createTimeCond: DateTimeCond.def().copyWith(ignore: true), + orders: const [OrderOption(type: OrderOptionType.createDate, asc: false)], + ); + + Future ensurePermission(); + Future currentPermission(); + Future load({int offset, int limit}); + Future openSettings(); + Future manageAccess(); + + // #***! альбом все спрашиваем один раз, дальше листаем диапазонами + factory GallerySource.create() { + if (Platform.isAndroid || Platform.isIOS) { + return _PhotoManagerSource(); + } + return _DesktopGallerySource(); + } +} + +class _PhotoManagerSource implements GallerySource { + @override + Future currentPermission() async { + final state = await PhotoManager.getPermissionState( + requestOption: const PermissionRequestOption(), + ); + if (state.isAuth) return GalleryPermission.granted; + return state.hasAccess + ? GalleryPermission.limited + : GalleryPermission.denied; + } + + @override + Future ensurePermission() async { + final state = await PhotoManager.requestPermissionExtend(); + if (state.isAuth) return GalleryPermission.granted; + if (state.hasAccess) return GalleryPermission.limited; + return GalleryPermission.denied; + } + + AssetPathEntity? _album; + int _total = 0; + + @override + Future load({ + int offset = 0, + int limit = GallerySource.pageSize, + }) async { + if (offset == 0 || _album == null) { + final paths = await PhotoManager.getAssetPathList( + type: RequestType.common, + onlyAll: true, + filterOption: GallerySource.mediaFilter(), + ); + if (paths.isEmpty) { + _album = null; + _total = 0; + return GalleryPage.empty; + } + _album = paths.first; + _total = await paths.first.assetCountAsync; + } + final album = _album; + if (album == null || offset >= _total) return GalleryPage.empty; + final end = offset + limit < _total ? offset + limit : _total; + final assets = await album.getAssetListRange(start: offset, end: end); + return GalleryPage( + items: assets.map((a) => _AssetGalleryItem(a)).toList(), + hasMore: end < _total, + ); + } + + @override + Future openSettings() => PhotoManager.openSetting(); + + @override + Future manageAccess() => PhotoManager.presentLimited(); +} + +// #***! обёртка над системным ассетом +class _AssetGalleryItem implements GalleryItem { + final AssetEntity asset; + + _AssetGalleryItem(this.asset); + + @override + String get id => asset.id; + + @override + bool get isVideo => asset.type == AssetType.video; + + @override + Duration? get duration => isVideo ? Duration(seconds: asset.duration) : null; + + @override + File? get localFile => null; + + @override + Future thumbnail(int size) => + asset.thumbnailDataWithSize(ThumbnailSize.square(size)); + + @override + Future originFile() => asset.file; + + @override + Future encodeForUpload({ + required int maxDimension, + required int quality, + }) => asset.thumbnailDataWithSize( + ThumbnailSize(maxDimension, maxDimension), + format: ThumbnailFormat.jpeg, + quality: quality, + ); + + @override + Future<(int, int)?> dimensions() async { + if (asset.width > 0 && asset.height > 0) { + return (asset.width, asset.height); + } + return null; + } +} + +const Set kGalleryImageExtensions = { + '.jpg', + '.jpeg', + '.png', + '.gif', + '.webp', + '.bmp', + '.heic', + '.heif', +}; + +const Set kGalleryVideoExtensions = { + '.mp4', + '.mov', + '.m4v', + '.mkv', + '.webm', + '.avi', + '.3gp', +}; + +// #***! тип по расширению +String _fileExtension(String path) { + final dot = path.lastIndexOf('.'); + if (dot < 0) return ''; + return path.substring(dot).toLowerCase(); +} + +bool isVideoPath(String path) => + kGalleryVideoExtensions.contains(_fileExtension(path)); + +bool isImagePath(String path) => + kGalleryImageExtensions.contains(_fileExtension(path)); + +// #***! на десктопе галереи нет, сканируем стандартные папки +class _DesktopGallerySource implements GallerySource { + @override + Future currentPermission() async => + GalleryPermission.granted; + @override + Future ensurePermission() async => + GalleryPermission.granted; + + List<_FileGalleryItem> _all = const []; + + @override + Future load({ + int offset = 0, + int limit = GallerySource.pageSize, + }) async { + if (offset == 0 || _all.isEmpty) _all = _scan(); + if (offset >= _all.length) return GalleryPage.empty; + final end = offset + limit < _all.length ? offset + limit : _all.length; + final items = _all.sublist(offset, end); + const batch = 8; + const eager = 24; + + Future probeRange(int from, int to) async { + for (var i = from; i < to; i += batch) { + final stop = i + batch > to ? to : i + batch; + await Future.wait(items.sublist(i, stop).map((it) => it.probe())); + } + } + + final head = items.length < eager ? items.length : eager; + await probeRange(0, head); + if (head < items.length) unawaited(probeRange(head, items.length)); + return GalleryPage(items: items, hasMore: end < _all.length); + } + + List<_FileGalleryItem> _scan() { + final entries = <({File file, DateTime modified})>[]; + for (final dir in _candidateDirs()) { + if (!dir.existsSync()) continue; + try { + for (final entity in dir.listSync(followLinks: false)) { + if (entity is! File || !_isMedia(entity.path)) continue; + entries.add((file: entity, modified: entity.statSync().modified)); + } + } catch (_) {} + } + entries.sort((a, b) => b.modified.compareTo(a.modified)); + return entries.map((e) => _FileGalleryItem(e.file)).toList(); + } + + @override + Future openSettings() async {} + + @override + Future manageAccess() async {} + + List _candidateDirs() { + final home = + Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; + if (home == null || home.isEmpty) return const []; + return [ + Directory('$home/Pictures'), + Directory('$home/Изображения'), + Directory('$home/Images'), + Directory('$home/Videos'), + Directory('$home/Видео'), + Directory('$home/Movies'), + ]; + } + + bool _isMedia(String path) { + final ext = _fileExtension(path); + return kGalleryImageExtensions.contains(ext) || + kGalleryVideoExtensions.contains(ext); + } +} + +// #***! обёртка над обычным файлом +class _FileGalleryItem implements GalleryItem { + final File file; + Duration? _duration; + + _FileGalleryItem(this.file, {Duration? duration}) : _duration = duration; + + Future probe() async { + if (!isVideo || _duration != null) return; + _duration = await DesktopVideoProbe.duration(file.path); + } + + @override + String get id => file.path; + + @override + bool get isVideo => isVideoPath(file.path); + + @override + Duration? get duration => _duration; + + @override + File? get localFile => file; + + @override + Future thumbnail(int size) async => + isVideo ? DesktopVideoProbe.thumbnail(file.path, size) : null; + + @override + Future originFile() async => file; + + @override + Future encodeForUpload({ + required int maxDimension, + required int quality, + }) async => null; + + @override + Future<(int, int)?> dimensions() => isVideo + ? DesktopVideoProbe.dimensions(file.path) + : imageFileDimensions(file); +} diff --git a/lib/core/media/image_optimizer.dart b/lib/core/media/image_optimizer.dart new file mode 100644 index 0000000..ad597be --- /dev/null +++ b/lib/core/media/image_optimizer.dart @@ -0,0 +1,96 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:image/image.dart' as img; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'gallery_source.dart'; + +const int kPhotoUploadLimitBytes = 5 * 1024 * 1024; + +const int _photoMaxDimension = 2560; +const int _photoJpegQuality = 85; +const int _photoTargetBytes = 4700 * 1024; + +const Set _heicExtensions = {'.heic', '.heif'}; + +bool isHeicPath(String path) { + final dot = path.lastIndexOf('.'); + if (dot < 0) return false; + return _heicExtensions.contains(path.substring(dot).toLowerCase()); +} + +Future optimizePhotoForUpload(File source, {GalleryItem? item}) async { + final heic = isHeicPath(source.path); + var length = 0; + try { + length = await source.length(); + } catch (_) {} + + if (!heic && length > 0 && length <= kPhotoUploadLimitBytes) { + return source; + } + + if (item != null) { + final native = await item.encodeForUpload( + maxDimension: _photoMaxDimension, + quality: _photoJpegQuality, + ); + if (native != null) return writePhotoJpeg(native); + } + + final fallback = await _encodeWithImagePackage(source); + return fallback ?? source; +} + +Future _encodeWithImagePackage(File source) async { + Uint8List bytes; + try { + bytes = await source.readAsBytes(); + } catch (_) { + return null; + } + final jpeg = await compute(_encodePhotoIsolate, ( + bytes, + _photoMaxDimension, + _photoJpegQuality, + _photoTargetBytes, + )); + if (jpeg == null) return null; + return writePhotoJpeg(jpeg); +} + +Uint8List? _encodePhotoIsolate((Uint8List, int, int, int) args) { + final (bytes, maxDim, quality, target) = args; + final decoded = img.decodeImage(bytes); + if (decoded == null) return null; + final oriented = img.bakeOrientation(decoded); + final scaled = oriented.width > maxDim || oriented.height > maxDim + ? img.copyResize( + oriented, + width: oriented.width >= oriented.height ? maxDim : null, + height: oriented.height > oriented.width ? maxDim : null, + interpolation: img.Interpolation.average, + ) + : oriented; + var quality0 = quality; + var out = img.encodeJpg(scaled, quality: quality0); + while (out.lengthInBytes > target && quality0 > 40) { + quality0 -= 10; + out = img.encodeJpg(scaled, quality: quality0); + } + return out; +} + +Future writePhotoJpeg(Uint8List bytes) async { + final dir = await getTemporaryDirectory(); + final file = File( + p.join( + dir.path, + 'qlyra_photo_${DateTime.now().microsecondsSinceEpoch}.jpg', + ), + ); + await file.writeAsBytes(bytes, flush: true); + return file; +} diff --git a/lib/core/media/max_media_http.dart b/lib/core/media/max_media_http.dart new file mode 100644 index 0000000..d2973ba --- /dev/null +++ b/lib/core/media/max_media_http.dart @@ -0,0 +1,98 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:http/http.dart' as http; +import 'package:http/io_client.dart'; + +/// MAX's media CDN uses the Russian Trusted CA. Keep that additional trust +/// confined to MAX hosts, including after redirects; never accept bad certs. +class MaxMediaHttpClient extends http.BaseClient { + MaxMediaHttpClient({ + http.Client? standard, + Future Function()? trusted, + }) : _standard = + standard ?? + IOClient( + HttpClient()..connectionTimeout = const Duration(seconds: 30), + ), + _trustedFactory = trusted ?? _createTrusted; + + final http.Client _standard; + final Future Function() _trustedFactory; + Future? _trusted; + + static bool usesAdditionalTrust(Uri uri) => + uri.scheme == 'https' && + (uri.host == 'oneme.ru' || uri.host.endsWith('.oneme.ru')); + + static Future _createTrusted() async { + final certificate = await rootBundle.load( + 'assets/certificates/russian_trusted_root_ca.pem', + ); + final context = SecurityContext(withTrustedRoots: true) + ..setTrustedCertificatesBytes( + certificate.buffer.asUint8List( + certificate.offsetInBytes, + certificate.lengthInBytes, + ), + ); + return IOClient( + HttpClient(context: context) + ..connectionTimeout = const Duration(seconds: 30), + ); + } + + @override + Future send(http.BaseRequest request) async { + if (request.method != 'GET' && request.method != 'HEAD') { + throw ArgumentError('Media client supports GET and HEAD only'); + } + var uri = request.url; + for (var redirects = 0; ; redirects++) { + final client = usesAdditionalTrust(uri) + ? await (_trusted ??= _trustedFactory()) + : _standard; + final next = http.Request(request.method, uri)..followRedirects = false; + if (uri.origin == request.url.origin) { + next.headers.addAll(request.headers); + } + final response = await client.send(next); + final location = response.headers['location']; + if (!request.followRedirects || + location == null || + !const [301, 302, 303, 307, 308].contains(response.statusCode)) { + return response; + } + await response.stream.drain(); + if (redirects >= request.maxRedirects) { + throw http.ClientException('Too many media redirects'); + } + final target = uri.resolve(location); + if ((target.scheme != 'http' && target.scheme != 'https') || + (uri.scheme == 'https' && target.scheme != 'https')) { + throw http.ClientException('Unsafe media redirect'); + } + uri = target; + } + } + + @override + void close() { + _standard.close(); + _trusted?.then( + (client) => client.close(), + onError: (Object error, StackTrace stack) {}, + ); + super.close(); + } +} + +class MaxMediaCache { + static final instance = CacheManager( + Config( + 'qlyraMaxMedia', + fileService: HttpFileService(httpClient: MaxMediaHttpClient()), + ), + ); +} diff --git a/lib/core/media/media_kit_video_platform.dart b/lib/core/media/media_kit_video_platform.dart new file mode 100644 index 0000000..c329faf --- /dev/null +++ b/lib/core/media/media_kit_video_platform.dart @@ -0,0 +1,288 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:video_player_platform_interface/video_player_platform_interface.dart'; + +import '../utils/logger.dart'; + +class MediaKitVideoPlatform extends VideoPlayerPlatform { + static void registerOnDesktop() { + if (kIsWeb) return; + switch (defaultTargetPlatform) { + case TargetPlatform.windows || + TargetPlatform.linux || + TargetPlatform.macOS: + MediaKit.ensureInitialized(); + VideoPlayerPlatform.instance = MediaKitVideoPlatform(); + case TargetPlatform.android || + TargetPlatform.iOS || + TargetPlatform.fuchsia: + return; + } + } + + final Map _sessions = {}; + int _nextPlayerId = 1; + + _VideoSession _session(int playerId) { + final session = _sessions[playerId]; + if (session == null) { + throw StateError('Video player $playerId is disposed'); + } + return session; + } + + @override + Future init() async { + for (final playerId in _sessions.keys.toList()) { + await dispose(playerId); + } + } + + @override + Future dispose(int playerId) async { + await _sessions.remove(playerId)?.dispose(); + } + + @override + Future create(DataSource dataSource) async { + final playerId = _nextPlayerId++; + final session = _VideoSession(_resourceOf(dataSource)); + _sessions[playerId] = session; + await session.open(dataSource.httpHeaders); + return playerId; + } + + @override + Stream videoEventsFor(int playerId) => _session(playerId).events; + + @override + Future setLooping(int playerId, bool looping) => _session( + playerId, + ).player.setPlaylistMode(looping ? PlaylistMode.single : PlaylistMode.none); + + @override + Future play(int playerId) => _session(playerId).player.play(); + + @override + Future pause(int playerId) => _session(playerId).player.pause(); + + @override + Future setVolume(int playerId, double volume) => + _session(playerId).player.setVolume(volume * 100); + + @override + Future seekTo(int playerId, Duration position) => + _session(playerId).player.seek(position); + + @override + Future setPlaybackSpeed(int playerId, double speed) => + _session(playerId).player.setRate(speed); + + @override + Future getPosition(int playerId) async => + _session(playerId).player.state.position; + + @override + Widget buildView(int playerId) { + final video = _session(playerId).video; + return Video( + key: ValueKey(video), + controller: video, + controls: NoVideoControls, + fill: const Color(0x00000000), + wakelock: false, + pauseUponEnteringBackgroundMode: false, + resumeUponEnteringForegroundMode: false, + ); + } + + @override + Future setMixWithOthers(bool mixWithOthers) async {} + + static String _resourceOf(DataSource dataSource) { + switch (dataSource.sourceType) { + case DataSourceType.asset: + final package = dataSource.package; + final asset = package == null + ? dataSource.asset + : 'packages/$package/${dataSource.asset}'; + return 'asset:///$asset'; + case DataSourceType.network || + DataSourceType.file || + DataSourceType.contentUri: + final uri = dataSource.uri; + if (uri == null) throw ArgumentError('uri must not be null'); + return uri; + } + } +} + +class _VideoSession { + _VideoSession(this._resource) { + video = VideoController(player); + _subscriptions.addAll([ + player.stream.duration.listen(_onDuration), + player.stream.videoParams.listen(_onVideoParams), + player.stream.tracks.listen(_onTracks), + player.stream.playing.listen( + (playing) => _emitWhenInitialized( + VideoEvent( + eventType: VideoEventType.isPlayingStateUpdate, + isPlaying: playing, + ), + ), + ), + player.stream.completed.listen((completed) { + if (!completed) return; + _emitWhenInitialized(VideoEvent(eventType: VideoEventType.completed)); + }), + player.stream.buffering.listen( + (buffering) => _emitWhenInitialized( + VideoEvent( + eventType: buffering + ? VideoEventType.bufferingStart + : VideoEventType.bufferingEnd, + ), + ), + ), + player.stream.buffer.listen( + (buffered) => _emitWhenInitialized( + VideoEvent( + eventType: VideoEventType.bufferingUpdate, + buffered: [DurationRange(Duration.zero, buffered)], + ), + ), + ), + player.stream.error.listen(_onError), + player.stream.log.listen(_onLog), + ]); + } + + static const Duration _startTimeout = Duration(seconds: 20); + static const int _maxLoggedLines = 40; + static const List _loadFailures = [ + 'Failed to open', + 'Failed to recognize file format', + ]; + static final RegExp _url = RegExp(r'(https?)://([^/\s?#]+)\S*'); + + final String _resource; + final Player player = Player( + configuration: const PlayerConfiguration(logLevel: MPVLogLevel.warn), + ); + late final VideoController video; + final StreamController _events = StreamController(); + final List> _subscriptions = []; + Timer? _startWatchdog; + Duration? _duration; + Size? _size; + bool _initialized = false; + bool _failed = false; + String? _lastError; + int _loggedLines = 0; + + Stream get events => _events.stream; + + Future open(Map httpHeaders) async { + _startWatchdog = Timer( + _startTimeout, + () => _fail( + _lastError ?? 'playback did not start in ${_startTimeout.inSeconds}s', + ), + ); + try { + await player.open( + Media(_resource, httpHeaders: httpHeaders), + play: false, + ); + } catch (error) { + _fail(_redact('$error')); + } + } + + void _onDuration(Duration duration) { + if (duration <= Duration.zero) return; + _duration = duration; + _maybeInitialize(); + } + + void _onVideoParams(VideoParams params) { + final width = params.dw ?? 0; + final height = params.dh ?? 0; + if (width <= 0 || height <= 0) return; + _size = Size(width.toDouble(), height.toDouble()); + _maybeInitialize(); + } + + void _onTracks(Tracks tracks) { + final audioOnly = tracks.video.length == 2 && tracks.audio.length > 2; + if (!audioOnly) return; + _size = Size.zero; + _maybeInitialize(); + } + + void _maybeInitialize() { + final duration = _duration; + final size = _size; + if (_initialized || _failed || _events.isClosed) return; + if (duration == null || size == null) return; + _initialized = true; + _startWatchdog?.cancel(); + _events.add( + VideoEvent( + eventType: VideoEventType.initialized, + duration: duration, + size: size, + ), + ); + } + + void _emitWhenInitialized(VideoEvent event) { + if (_initialized && !_events.isClosed) _events.add(event); + } + + void _onError(String message) { + _lastError = _redact(message); + if (_loadFailures.any(message.startsWith)) _fail(_lastError!); + } + + void _fail(String message) { + if (_failed || _events.isClosed) return; + _failed = true; + _startWatchdog?.cancel(); + logger.w('Video playback failed (${_describeSource()}): $message'); + _events.addError(PlatformException(code: 'media_kit', message: message)); + } + + void _onLog(PlayerLog log) { + if (log.level != 'error' && log.level != 'warn') return; + if (_loggedLines >= _maxLoggedLines) return; + _loggedLines++; + logger.w('mpv ${log.prefix}/${log.level}: ${_redact(log.text)}'); + } + + String _describeSource() { + final uri = Uri.tryParse(_resource); + if (uri == null || !uri.hasScheme) return 'file'; + return uri.host.isEmpty ? uri.scheme : uri.host; + } + + static String _redact(String text) => text.trim().replaceAllMapped( + _url, + (match) => '${match[1]}://${match[2]}', + ); + + Future dispose() async { + _startWatchdog?.cancel(); + for (final subscription in _subscriptions) { + await subscription.cancel(); + } + await _events.close(); + await player.dispose(); + } +} diff --git a/lib/core/media/media_playback.dart b/lib/core/media/media_playback.dart new file mode 100644 index 0000000..e94eeef --- /dev/null +++ b/lib/core/media/media_playback.dart @@ -0,0 +1,292 @@ +import 'dart:async'; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:video_player/video_player.dart'; + +import 'audio_file_track.dart'; +import 'audio_playback_controller.dart'; +import 'voice_audio_controller.dart'; + +// #***! что играет сейчас, голосовое кружок или музыка +enum PlaybackKind { voice, videoNote, audioFile } + +// #***! играющее голосовое плюс сообщение откуда оно +class VoiceTrack { + const VoiceTrack({ + required this.cacheName, + required this.chatId, + required this.messageId, + required this.senderId, + required this.isMe, + required this.time, + required this.audio, + }); + + final String cacheName; + final int chatId; + final String messageId; + final int senderId; + final bool isMe; + final int time; + final VoiceAudioController audio; +} + +// #***! играющий кружок +class VideoNoteTrack { + const VideoNoteTrack({ + required this.cacheName, + required this.chatId, + required this.messageId, + required this.senderId, + required this.isMe, + required this.time, + required this.controller, + required this.preview, + }); + + final String cacheName; + final int chatId; + final String messageId; + final int senderId; + final bool isMe; + final int time; + final VideoPlayerController controller; + final Uint8List? preview; +} + +// #***! единый диспетчер, одновременно играет только что то одно +class MediaPlayback { + MediaPlayback._(); + + static final MediaPlayback instance = MediaPlayback._(); + + // #***! скорости которые перебирает кнопка + static const List speeds = [1.0, 1.5, 2.0]; + + // #***! primary говорит юишке какую плашку рисовать + final ValueNotifier primary = ValueNotifier(null); + final ValueNotifier audioFile = ValueNotifier(null); + bool _audioCompletionListenerAttached = false; + + // #***! ушли из чата, играет дальше но плашка меняется + final ValueNotifier visibleChatId = ValueNotifier(null); + + void enterChat(int chatId) => visibleChatId.value = chatId; + + void leaveChat(int chatId) { + if (visibleChatId.value == chatId) visibleChatId.value = null; + } + + final ValueNotifier voice = ValueNotifier(null); + final ValueNotifier voiceSpeed = ValueNotifier(speeds.first); + + // #***! держатели не дают освободить контроллер пока его рисует виджет, + // считаем их по головам: один и тот же контроллер могут рисовать сразу + // два бабла, отпускать его можно только когда ушёл последний + final Map _heldVoice = {}; + + VoiceAudioController acquireVoice({ + required String cacheName, + required Future Function() resolveUrl, + required Duration fallbackDuration, + }) { + final active = voice.value; + if (active != null && active.cacheName == cacheName) { + _retain(_heldVoice, active.audio); + return active.audio; + } + final created = VoiceAudioController( + cacheName: cacheName, + resolveUrl: resolveUrl, + fallbackDuration: fallbackDuration, + ); + _retain(_heldVoice, created); + return created; + } + + void releaseVoice(VoiceAudioController audio) { + if (_releasedByLastHolder(_heldVoice, audio)) _disposeVoiceIfIdle(audio); + } + + static void _retain(Map holders, T value) => + holders.update(value, (count) => count + 1, ifAbsent: () => 1); + + static bool _releasedByLastHolder(Map holders, T value) { + final left = (holders[value] ?? 0) - 1; + if (left > 0) { + holders[value] = left; + return false; + } + holders.remove(value); + return true; + } + + // #***! включили голосовое, кружок и музыка гаснут + void activateVoice(VoiceTrack track) { + _clearAudioFile(); + _clearVideoNote(); + final previous = voice.value; + if (previous != null && previous.audio != track.audio) { + previous.audio.pause(); + voice.value = null; + _disposeVoiceIfIdle(previous.audio); + } + voice.value = track; + primary.value = PlaybackKind.voice; + track.audio.setSpeed(voiceSpeed.value); + } + + // #***! перебор скорости по кругу + void cycleVoiceSpeed() { + final next = speeds[(speeds.indexOf(voiceSpeed.value) + 1) % speeds.length]; + voiceSpeed.value = next; + voice.value?.audio.setSpeed(next); + } + + void closeVoice() { + if (!_clearVoice()) return; + primary.value = videoNote.value == null ? null : PlaybackKind.videoNote; + } + + bool _clearVoice() { + final track = voice.value; + if (track == null) return false; + voice.value = null; + track.audio.stopAndReset(); + _disposeVoiceIfIdle(track.audio); + return true; + } + + // #***! освобождаем только когда никто не держит и он не активен + void _disposeVoiceIfIdle(VoiceAudioController audio) { + if (_heldVoice.containsKey(audio)) return; + if (voice.value?.audio == audio) return; + audio.dispose(); + } + + // #***! то же для кружков + final ValueNotifier videoNote = ValueNotifier(null); + final ValueNotifier videoNoteSpeed = ValueNotifier(speeds.first); + + final Map _heldNotes = {}; + + VideoPlayerController? liveVideoNote(String cacheName) { + final active = videoNote.value; + if (active == null || active.cacheName != cacheName) return null; + _retain(_heldNotes, active.controller); + return active.controller; + } + + void holdVideoNote(VideoPlayerController controller) => + _retain(_heldNotes, controller); + + bool isActiveVideoNote(VideoPlayerController controller) => + videoNote.value?.controller == controller; + + void releaseVideoNote(VideoPlayerController controller) { + if (_releasedByLastHolder(_heldNotes, controller)) { + _disposeNoteIfIdle(controller); + } + } + + void activateVideoNote(VideoNoteTrack track) { + _clearAudioFile(); + _clearVoice(); + final previous = videoNote.value; + if (previous != null && previous.controller != track.controller) { + previous.controller.pause(); + videoNote.value = null; + _disposeNoteIfIdle(previous.controller); + } + videoNote.value = track; + primary.value = PlaybackKind.videoNote; + track.controller.setPlaybackSpeed(videoNoteSpeed.value); + } + + void cycleVideoNoteSpeed() { + final index = speeds.indexOf(videoNoteSpeed.value); + final next = speeds[(index + 1) % speeds.length]; + videoNoteSpeed.value = next; + videoNote.value?.controller.setPlaybackSpeed(next); + } + + void closeVideoNote() { + if (!_clearVideoNote()) return; + primary.value = voice.value == null ? null : PlaybackKind.voice; + } + + bool _clearVideoNote() { + final track = videoNote.value; + if (track == null) return false; + track.controller.pause(); + track.controller.seekTo(Duration.zero); + videoNote.value = null; + _disposeNoteIfIdle(track.controller); + return true; + } + + void _disposeNoteIfIdle(VideoPlayerController controller) { + if (_heldNotes.containsKey(controller)) return; + if (videoNote.value?.controller == controller) return; + controller.dispose(); + } + + Future activateAudioFile( + AudioFileTrack track, { + required String notificationChannelName, + }) async { + final audio = await AudioPlaybackController.ensureInitialized( + notificationChannelName, + ); + _attachAudioCompletionListener(audio); + final current = audioFile.value; + if (current?.cacheName == track.cacheName) { + await audio.toggle(); + return; + } + _clearVoice(); + _clearVideoNote(); + audioFile.value = track; + primary.value = PlaybackKind.audioFile; + try { + await audio.playTrack(track); + } catch (_) { + audioFile.value = null; + primary.value = null; + rethrow; + } + } + + void closeAudioFile() { + if (!_clearAudioFile()) return; + primary.value = voice.value != null + ? PlaybackKind.voice + : videoNote.value != null + ? PlaybackKind.videoNote + : null; + } + + bool _clearAudioFile() { + if (audioFile.value == null) return false; + audioFile.value = null; + if (AudioPlaybackController.isInitialized) { + unawaited(AudioPlaybackController.instance.stop()); + } + return true; + } + + void _attachAudioCompletionListener(AudioPlaybackController audio) { + if (_audioCompletionListenerAttached) return; + _audioCompletionListenerAttached = true; + audio.processingState.addListener(_onAudioProcessingStateChanged); + } + + void _onAudioProcessingStateChanged() { + if (AudioPlaybackController.instance.processingState.value != + AudioProcessingState.completed) { + return; + } + closeAudioFile(); + } +} diff --git a/lib/core/media/native_video_note_recorder.dart b/lib/core/media/native_video_note_recorder.dart new file mode 100644 index 0000000..74413b6 --- /dev/null +++ b/lib/core/media/native_video_note_recorder.dart @@ -0,0 +1,96 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +import '../utils/logger.dart'; + +class VideoNoteAccess { + const VideoNoteAccess({required this.camera, required this.microphone}); + + static const denied = VideoNoteAccess(camera: false, microphone: false); + + final bool camera; + final bool microphone; + + bool get granted => camera && microphone; +} + +class NativeVideoNoteRecorder { + static const _channel = MethodChannel('ru.ikar.app/video_note'); + + int? textureId; + bool hasFlash = false; + bool get isAvailable => Platform.isAndroid || Platform.isIOS; + + Future requestAccess() async { + if (!isAvailable) return VideoNoteAccess.denied; + try { + final res = await _channel.invokeMapMethod('permission'); + return VideoNoteAccess( + camera: res?['camera'] as bool? ?? false, + microphone: res?['microphone'] as bool? ?? false, + ); + } catch (e) { + logger.w('NativeVideoNoteRecorder.requestAccess: $e'); + return VideoNoteAccess.denied; + } + } + + Future init({bool front = true, String? cameraId, int size = 480, int fps = 30}) async { + if (!isAvailable) return false; + final res = await _channel.invokeMapMethod('init', { + 'front': front, + if (cameraId != null) 'cameraId': cameraId, + 'size': size, + 'fps': fps, + }); + textureId = res?['textureId'] as int?; + hasFlash = res?['hasFlash'] as bool? ?? false; + return textureId != null; + } + + Future switchCamera() async { + if (!isAvailable) return false; + try { + await _channel.invokeMethod('switch'); + return true; + } catch (e) { + logger.w('NativeVideoNoteRecorder.switchCamera: $e'); + return false; + } + } + + Future setTorch(bool on) async { + if (!isAvailable || !hasFlash) return false; + try { + return await _channel.invokeMethod('torch', {'on': on}) ?? false; + } catch (e) { + logger.w('NativeVideoNoteRecorder.setTorch: $e'); + return false; + } + } + + Future start() async { + if (!isAvailable) { + throw PlatformException( + code: 'UNSUPPORTED', + message: 'video notes are not supported on this platform', + ); + } + await _channel.invokeMethod('start'); + } + + Future stop() async { + if (!isAvailable) return null; + return _channel.invokeMethod('stop'); + } + + Future dispose() async { + if (!isAvailable) return; + try { + await _channel.invokeMethod('dispose'); + } catch (_) {} + textureId = null; + hasFlash = false; + } +} diff --git a/lib/core/media/ogg_page_writer.dart b/lib/core/media/ogg_page_writer.dart new file mode 100644 index 0000000..624cc8a --- /dev/null +++ b/lib/core/media/ogg_page_writer.dart @@ -0,0 +1,156 @@ +import 'dart:typed_data'; + +class OggPageWriter { + static const int maxSegmentsPerPage = 255; + static const int continuedPacket = 0x01; + static const int beginningOfStream = 0x02; + static const int endOfStream = 0x04; + + static const int headerSize = 27; + static const int _granuleOffset = 6; + static const int _serialOffset = 14; + static const int _sequenceOffset = 18; + static const int _crcOffset = 22; + static const int _segmentCountOffset = 26; + + static int segmentsFor(int length) => (length ~/ 255) + 1; + + static int lengthFor(List packets) { + var segments = 0; + var body = 0; + for (final packet in packets) { + segments += segmentsFor(packet.length); + body += packet.length; + } + return headerSize + segments + body; + } + + static Uint8List page({ + required int headerType, + required int granulePos, + required int serial, + required int sequence, + required List packets, + }) { + final out = Uint8List(lengthFor(packets)); + writeInto( + out, + 0, + headerType: headerType, + granulePos: granulePos, + serial: serial, + sequence: sequence, + packets: packets, + ); + return out; + } + + static int writeInto( + Uint8List out, + int offset, { + required int headerType, + required int granulePos, + required int serial, + required int sequence, + required List packets, + }) { + final view = ByteData.sublistView(out); + out[offset] = 0x4f; + out[offset + 1] = 0x67; + out[offset + 2] = 0x67; + out[offset + 3] = 0x53; + view.setUint8(offset + 4, 0); + view.setUint8(offset + 5, headerType); + view.setInt64(offset + _granuleOffset, granulePos, Endian.little); + view.setUint32(offset + _serialOffset, serial, Endian.little); + view.setUint32(offset + _sequenceOffset, sequence, Endian.little); + view.setUint32(offset + _crcOffset, 0, Endian.little); + + var table = offset + headerSize; + for (final packet in packets) { + var remaining = packet.length; + while (remaining >= 255) { + out[table++] = 255; + remaining -= 255; + } + out[table++] = remaining; + } + view.setUint8(offset + _segmentCountOffset, table - offset - headerSize); + + var cursor = table; + for (final packet in packets) { + out.setRange(cursor, cursor + packet.length, packet); + cursor += packet.length; + } + + view.setUint32( + offset + _crcOffset, + crc32(out, offset, cursor), + Endian.little, + ); + return cursor; + } + + static final List _crcTables = _buildCrcTables(); + + static List _buildCrcTables() { + final base = Uint32List(256); + for (var i = 0; i < 256; i++) { + var r = (i << 24) & 0xffffffff; + for (var j = 0; j < 8; j++) { + if ((r & 0x80000000) != 0) { + r = ((r << 1) ^ 0x04c11db7) & 0xffffffff; + } else { + r = (r << 1) & 0xffffffff; + } + } + base[i] = r; + } + + final tables = [base]; + for (var slice = 1; slice < 4; slice++) { + final previous = tables[slice - 1]; + final next = Uint32List(256); + for (var i = 0; i < 256; i++) { + next[i] = + (((previous[i] << 8) & 0xffffffff) ^ + base[(previous[i] >> 24) & 0xff]) & + 0xffffffff; + } + tables.add(next); + } + return tables; + } + + static int crc32(Uint8List data, [int start = 0, int? end]) { + final stop = end ?? data.length; + final t0 = _crcTables[0]; + final t1 = _crcTables[1]; + final t2 = _crcTables[2]; + final t3 = _crcTables[3]; + + var crc = 0; + var i = start; + final wordEnd = stop - ((stop - start) & 3); + while (i < wordEnd) { + crc ^= + (data[i] << 24) | + (data[i + 1] << 16) | + (data[i + 2] << 8) | + data[i + 3]; + crc = + t3[(crc >> 24) & 0xff] ^ + t2[(crc >> 16) & 0xff] ^ + t1[(crc >> 8) & 0xff] ^ + t0[crc & 0xff]; + i += 4; + } + while (i < stop) { + crc = + (((crc << 8) & 0xffffffff) ^ t0[((crc >> 24) & 0xff) ^ data[i]]) & + 0xffffffff; + i++; + } + return crc & 0xffffffff; + } +} diff --git a/lib/core/media/opus_ogg_encoder.dart b/lib/core/media/opus_ogg_encoder.dart new file mode 100644 index 0000000..e16ab41 --- /dev/null +++ b/lib/core/media/opus_ogg_encoder.dart @@ -0,0 +1,217 @@ +import 'dart:ffi'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:opus_dart/opus_dart.dart'; + +import '../utils/logger.dart'; +import 'ogg_page_writer.dart'; + +/// Кодирует PCM в Ogg/Opus через libopus (FFI) на платформах, где у системы нет +/// своего Opus-энкодера (Windows). Сырые Opus-пакеты выдаёт [opus_dart], а +/// Ogg-контейнер (страницы, лейсинг, CRC32, OpusHead/OpusTags) собирается здесь. +/// +/// Формат совпадает с тем, что шлёт оригинальный клиент: моно, 48000 Hz, +/// pre-skip 312, vendor «libopus unknown». +class OpusOggEncoder { + static const int _sampleRate = 48000; + static const int _channels = 1; + static const int _preSkip = 312; + static const int _frameSamples = 960; // 20 мс @ 48 кГц + static const int _serial = 0x4b6f6d74; // 'Komt' + static const String _vendor = 'libopus unknown'; + + static bool _initialized = false; + static bool _available = false; + + /// Лениво загружает libopus и инициализирует opus_dart: на Windows — + /// вендоренную `opus.dll` рядом с exe, на Android — через + /// `opus_flutter_android`, на iOS/macOS — статически слинкованную + /// `ogg_opus_player` (см. `-force_load` в ios/Podfile). + /// Возвращает `false`, если кодек недоступен. + static Future ensureAvailable() async { + if (_initialized) return _available; + _initialized = true; + try { + final DynamicLibrary lib; + if (Platform.isIOS || Platform.isMacOS) { + lib = DynamicLibrary.process(); + } else if (Platform.isWindows) { + lib = DynamicLibrary.open('opus.dll'); + } else if (Platform.isAndroid) { + lib = DynamicLibrary.open('libopus.so'); + } else { + return false; + } + initOpus(lib as dynamic); + _available = true; + } catch (e) { + logger.w('OpusOggEncoder: libopus недоступна: $e'); + _available = false; + } + return _available; + } + + /// Парсит WAV (16-bit PCM моно 48 кГц) и кодирует его в Ogg/Opus. + /// Возвращает `null`, если кодек недоступен или WAV не распознан. + static Future wavToOggOpus(Uint8List wav) async { + if (!await ensureAvailable()) return null; + final pcm = _pcmFromWav(wav); + if (pcm == null || pcm.isEmpty) return null; + try { + return _encodePcm(pcm); + } catch (e) { + logger.w('OpusOggEncoder: ошибка кодирования: $e'); + return null; + } + } + + static Uint8List _encodePcm(Int16List pcm) { + final encoder = SimpleOpusEncoder( + sampleRate: _sampleRate, + channels: _channels, + application: Application.audio, + ); + final packets = []; + try { + for (var off = 0; off < pcm.length; off += _frameSamples) { + final end = off + _frameSamples; + final Int16List frame; + if (end <= pcm.length) { + frame = Int16List.sublistView(pcm, off, end); + } else { + frame = Int16List(_frameSamples) + ..setRange(0, pcm.length - off, pcm, off); + } + packets.add(encoder.encode(input: frame)); + } + } finally { + encoder.destroy(); + } + return _buildOgg(packets, totalSamples: pcm.length); + } + + static Uint8List _buildOgg( + List packets, { + required int totalSamples, + }) { + final out = BytesBuilder(); + var seq = 0; + + out.add( + _page( + headerType: 0x02, + granulePos: 0, + seq: seq++, + packets: [_opusHead()], + ), + ); + out.add( + _page( + headerType: 0x00, + granulePos: 0, + seq: seq++, + packets: [_opusTags()], + ), + ); + + var pagePackets = []; + var pageSegments = 0; + var samples = 0; + + void flush({required bool last}) { + final granule = last ? totalSamples + _preSkip : samples + _preSkip; + out.add( + _page( + headerType: last ? 0x04 : 0x00, + granulePos: granule, + seq: seq++, + packets: pagePackets, + ), + ); + pagePackets = []; + pageSegments = 0; + } + + for (var i = 0; i < packets.length; i++) { + final p = packets[i]; + final segs = (p.length ~/ 255) + 1; + if (pagePackets.isNotEmpty && pageSegments + segs > 255) { + flush(last: false); + } + pagePackets.add(p); + pageSegments += segs; + samples += _frameSamples; + } + flush(last: true); + + return out.toBytes(); + } + + static Uint8List _opusHead() { + final b = BytesBuilder(); + b.add(_ascii('OpusHead')); + final d = ByteData(11); + d.setUint8(0, 1); // version + d.setUint8(1, _channels); + d.setUint16(2, _preSkip, Endian.little); + d.setUint32(4, _sampleRate, Endian.little); + d.setUint16(8, 0, Endian.little); // output gain + d.setUint8(10, 0); // channel mapping family + b.add(d.buffer.asUint8List()); + return b.toBytes(); + } + + static Uint8List _opusTags() { + final vendor = _ascii(_vendor); + final b = BytesBuilder(); + b.add(_ascii('OpusTags')); + final len = ByteData(4)..setUint32(0, vendor.length, Endian.little); + b.add(len.buffer.asUint8List()); + b.add(vendor); + final count = ByteData(4)..setUint32(0, 0, Endian.little); + b.add(count.buffer.asUint8List()); + return b.toBytes(); + } + + static Uint8List _page({ + required int headerType, + required int granulePos, + required int seq, + required List packets, + }) => OggPageWriter.page( + headerType: headerType, + granulePos: granulePos, + serial: _serial, + sequence: seq, + packets: packets, + ); + + static Uint8List _ascii(String s) => Uint8List.fromList(s.codeUnits); + + static Int16List? _pcmFromWav(Uint8List bytes) { + if (bytes.length < 12) return null; + if (String.fromCharCodes(bytes, 0, 4) != 'RIFF' || + String.fromCharCodes(bytes, 8, 12) != 'WAVE') { + return null; + } + final bd = ByteData.sublistView(bytes); + var off = 12; + while (off + 8 <= bytes.length) { + final id = String.fromCharCodes(bytes, off, off + 4); + final size = bd.getUint32(off + 4, Endian.little); + final body = off + 8; + if (id == 'data') { + final end = (body + size) <= bytes.length ? body + size : bytes.length; + final n = (end - body) ~/ 2; + final pcm = Int16List(n); + for (var i = 0; i < n; i++) { + pcm[i] = bd.getInt16(body + i * 2, Endian.little); + } + return pcm; + } + off = body + size + (size & 1); + } + return null; + } +} diff --git a/lib/core/media/opus_ogg_index.dart b/lib/core/media/opus_ogg_index.dart new file mode 100644 index 0000000..d72446f --- /dev/null +++ b/lib/core/media/opus_ogg_index.dart @@ -0,0 +1,366 @@ +import 'dart:typed_data'; + +import 'ogg_page_writer.dart'; + +class OpusOggIndex { + static const int sampleRate = 48000; + + static const int _prerollSamples = 3840; + static const int _maxPreSkip = 65535; + static const int _maxPacketSamples = 5760; + static const int _preSkipOffset = 10; + static const int _opusHeadMinLength = 19; + static const int _pageHeaderSize = 27; + + OpusOggIndex._({ + required Uint8List head, + required Uint8List tags, + required List packets, + required List packetStarts, + required int preSkip, + required int serial, + required int endGranule, + }) : _head = head, + _tags = tags, + _packets = packets, + _packetStarts = packetStarts, + _preSkip = preSkip, + _serial = serial, + _endGranule = endGranule; + + final Uint8List _head; + final Uint8List _tags; + final List _packets; + final List _packetStarts; + final int _preSkip; + final int _serial; + final int _endGranule; + + double get duration { + final playable = _endGranule - _preSkip; + return playable <= 0 ? 0 : playable / sampleRate; + } + + static OpusOggIndex? parse(Uint8List bytes) { + Uint8List? head; + Uint8List? tags; + int? serial; + var lastGranule = 0; + final packets = []; + final pendingParts = []; + var pendingStart = -1; + var pendingLength = 0; + var pendingContiguous = true; + var offset = 0; + + while (offset + _pageHeaderSize <= bytes.length) { + if (!_hasCapture(bytes, offset)) { + final resync = _findCapture(bytes, offset + 1); + if (resync < 0) break; + offset = resync; + continue; + } + + final view = ByteData.sublistView(bytes, offset); + final headerType = bytes[offset + 5]; + final pageSerial = view.getUint32(14, Endian.little); + final segmentCount = bytes[offset + 26]; + final tableStart = offset + _pageHeaderSize; + final bodyStart = tableStart + segmentCount; + if (bodyStart > bytes.length) break; + + var bodyLength = 0; + for (var i = 0; i < segmentCount; i++) { + bodyLength += bytes[tableStart + i]; + } + final bodyEnd = bodyStart + bodyLength; + if (bodyEnd > bytes.length) break; + + serial ??= pageSerial; + if (pageSerial != serial) { + offset = bodyEnd; + continue; + } + + final granule = view.getInt64(6, Endian.little); + if (granule > lastGranule) lastGranule = granule; + + if ((headerType & OggPageWriter.continuedPacket) == 0) { + pendingParts.clear(); + pendingStart = -1; + pendingLength = 0; + pendingContiguous = true; + } else if (pendingLength > 0 && pendingContiguous) { + pendingParts.add( + Uint8List.sublistView( + bytes, + pendingStart, + pendingStart + pendingLength, + ), + ); + pendingContiguous = false; + } + + var cursor = bodyStart; + for (var i = 0; i < segmentCount; i++) { + final length = bytes[tableStart + i]; + if (length > 0) { + if (pendingContiguous) { + if (pendingLength == 0) pendingStart = cursor; + } else { + pendingParts.add( + Uint8List.sublistView(bytes, cursor, cursor + length), + ); + } + pendingLength += length; + } + cursor += length; + if (length == 255) continue; + + final Uint8List packet; + if (pendingContiguous) { + packet = pendingLength == 0 + ? _empty + : Uint8List.sublistView( + bytes, + pendingStart, + pendingStart + pendingLength, + ); + } else { + packet = _join(pendingParts); + } + pendingParts.clear(); + pendingStart = -1; + pendingLength = 0; + pendingContiguous = true; + if (packet.isEmpty) continue; + if (head == null) { + if (!_startsWith(packet, 'OpusHead')) return null; + head = packet; + } else if (tags == null) { + tags = packet; + } else { + packets.add(packet); + } + } + offset = bodyEnd; + } + + if (head == null || tags == null || packets.isEmpty || serial == null) { + return null; + } + if (head.length < _opusHeadMinLength) return null; + + final starts = []; + var total = 0; + for (final packet in packets) { + final samples = _packetDuration(packet); + if (samples <= 0) return null; + starts.add(total); + total += samples; + } + + final preSkip = ByteData.sublistView( + head, + ).getUint16(_preSkipOffset, Endian.little); + if (preSkip >= total) return null; + + final endGranule = lastGranule > preSkip && lastGranule <= total + ? lastGranule + : total; + + return OpusOggIndex._( + head: head, + tags: tags, + packets: packets, + packetStarts: starts, + preSkip: preSkip, + serial: serial, + endGranule: endGranule, + ); + } + + Uint8List? sliceFrom(double seconds) { + if (seconds <= 0) return null; + final target = (seconds * sampleRate).round() + _preSkip; + if (target >= _endGranule) return null; + + final floor = target - _prerollSamples; + var first = 0; + for (var i = 0; i < _packetStarts.length; i++) { + if (_packetStarts[i] > floor) break; + first = i; + } + + final base = _packetStarts[first]; + final preSkip = target - base; + if (preSkip < 0 || preSkip > _maxPreSkip) return null; + + final head = _headWithPreSkip(preSkip); + final plans = <_PagePlan>[]; + var pageStart = first; + var pageSegments = 0; + var pageBytes = 0; + + for (var i = first; i < _packets.length; i++) { + final packet = _packets[i]; + final segments = OggPageWriter.segmentsFor(packet.length); + if (segments > OggPageWriter.maxSegmentsPerPage) return null; + if (i > pageStart && + pageSegments + segments > OggPageWriter.maxSegmentsPerPage) { + plans.add( + _PagePlan( + start: pageStart, + end: i, + granulePos: _packetStarts[i] - base, + bytes: OggPageWriter.headerSize + pageSegments + pageBytes, + ), + ); + pageStart = i; + pageSegments = 0; + pageBytes = 0; + } + pageSegments += segments; + pageBytes += packet.length; + } + plans.add( + _PagePlan( + start: pageStart, + end: _packets.length, + granulePos: _endGranule - base, + bytes: OggPageWriter.headerSize + pageSegments + pageBytes, + last: true, + ), + ); + + var total = + OggPageWriter.lengthFor([head]) + OggPageWriter.lengthFor([_tags]); + for (final plan in plans) { + total += plan.bytes; + } + + final out = Uint8List(total); + var sequence = 0; + var offset = OggPageWriter.writeInto( + out, + 0, + headerType: OggPageWriter.beginningOfStream, + granulePos: 0, + serial: _serial, + sequence: sequence++, + packets: [head], + ); + offset = OggPageWriter.writeInto( + out, + offset, + headerType: 0, + granulePos: 0, + serial: _serial, + sequence: sequence++, + packets: [_tags], + ); + for (final plan in plans) { + offset = OggPageWriter.writeInto( + out, + offset, + headerType: plan.last ? OggPageWriter.endOfStream : 0, + granulePos: plan.granulePos, + serial: _serial, + sequence: sequence++, + packets: _packets.sublist(plan.start, plan.end), + ); + } + return out; + } + + Uint8List _headWithPreSkip(int preSkip) { + final head = Uint8List.fromList(_head); + ByteData.sublistView( + head, + ).setUint16(_preSkipOffset, preSkip, Endian.little); + return head; + } + + static bool _hasCapture(Uint8List bytes, int offset) => + bytes[offset] == 0x4f && + bytes[offset + 1] == 0x67 && + bytes[offset + 2] == 0x67 && + bytes[offset + 3] == 0x53; + + static int _findCapture(Uint8List bytes, int from) { + for (var i = from; i + 4 <= bytes.length; i++) { + if (_hasCapture(bytes, i)) return i; + } + return -1; + } + + static final Uint8List _empty = Uint8List(0); + + static Uint8List _join(List parts) { + if (parts.isEmpty) return _empty; + if (parts.length == 1) return parts.first; + var length = 0; + for (final part in parts) { + length += part.length; + } + final out = Uint8List(length); + var offset = 0; + for (final part in parts) { + out.setRange(offset, offset + part.length, part); + offset += part.length; + } + return out; + } + + static bool _startsWith(Uint8List bytes, String magic) { + if (bytes.length < magic.length) return false; + for (var i = 0; i < magic.length; i++) { + if (bytes[i] != magic.codeUnitAt(i)) return false; + } + return true; + } + + static int _packetDuration(Uint8List packet) { + if (packet.isEmpty) return 0; + final toc = packet[0]; + final frameSamples = _frameSamples(toc >> 3); + final int frames; + switch (toc & 0x03) { + case 0: + frames = 1; + case 1: + case 2: + frames = 2; + default: + if (packet.length < 2) return 0; + frames = packet[1] & 0x3f; + } + if (frames <= 0) return 0; + final total = frameSamples * frames; + return total > _maxPacketSamples ? 0 : total; + } + + static int _frameSamples(int config) { + const silkOrHybrid = [480, 960, 1920, 2880]; + const celt = [120, 240, 480, 960]; + if (config < 12) return silkOrHybrid[config & 0x03]; + if (config < 16) return (config & 0x01) == 0 ? 480 : 960; + return celt[config & 0x03]; + } +} + +class _PagePlan { + const _PagePlan({ + required this.start, + required this.end, + required this.granulePos, + required this.bytes, + this.last = false, + }); + + final int start; + final int end; + final int granulePos; + final int bytes; + final bool last; +} diff --git a/lib/core/media/preview_image.dart b/lib/core/media/preview_image.dart new file mode 100644 index 0000000..34567ff --- /dev/null +++ b/lib/core/media/preview_image.dart @@ -0,0 +1,20 @@ +import 'dart:convert'; + +import 'package:flutter/widgets.dart'; + +final Expando _providers = Expando('preview'); + +ImageProvider? dataUriImage(Object owner, String? data) { + if (data == null || !data.startsWith('data:')) return null; + final cached = _providers[owner]; + if (cached != null) return cached; + final comma = data.indexOf(','); + if (comma < 0) return null; + try { + final provider = MemoryImage(base64Decode(data.substring(comma + 1))); + _providers[owner] = provider; + return provider; + } catch (_) { + return null; + } +} diff --git a/lib/core/media/raster.dart b/lib/core/media/raster.dart new file mode 100644 index 0000000..edbfa4f --- /dev/null +++ b/lib/core/media/raster.dart @@ -0,0 +1,34 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../utils/image_utils.dart'; + +Future rasterPictureToJpegFile( + ui.Picture picture, + int width, + int height, { + required String prefix, + void Function()? onPictureDisposed, +}) async { + final rendered = await picture.toImage(width, height); + picture.dispose(); + onPictureDisposed?.call(); + final bd = await rendered.toByteData(format: ui.ImageByteFormat.rawRgba); + rendered.dispose(); + if (bd == null) return null; + + final jpeg = await encodeRgbaToJpeg(bd.buffer.asUint8List(), width, height); + if (jpeg == null) return null; + final dir = await getTemporaryDirectory(); + final out = File( + p.join( + dir.path, + 'qlyra_${prefix}_${DateTime.now().microsecondsSinceEpoch}.jpg', + ), + ); + await out.writeAsBytes(jpeg); + return out; +} diff --git a/lib/core/media/send_media_separately.dart b/lib/core/media/send_media_separately.dart new file mode 100644 index 0000000..c8da0bc --- /dev/null +++ b/lib/core/media/send_media_separately.dart @@ -0,0 +1,12 @@ +Future sendMediaSeparately( + List items, + String caption, { + required Future Function(T item, String caption) send, + required bool Function() canSend, +}) async { + final pending = List.of(items); + for (var i = 0; i < pending.length; i++) { + if (!canSend()) return; + await send(pending[i], i == 0 ? caption : ''); + } +} diff --git a/lib/core/media/share_thumbnail.dart b/lib/core/media/share_thumbnail.dart new file mode 100644 index 0000000..ff8676b --- /dev/null +++ b/lib/core/media/share_thumbnail.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart' show ImageProvider, MemoryImage; +import 'package:flutter/services.dart' show MissingPluginException; +import 'package:image/image.dart' as img; + +import '../../models/shared_payload.dart'; +import '../utils/logger.dart'; +import 'video_transcoder.dart'; + +const int _thumbMaxDimension = 128; +const int _thumbQuality = 70; + +Future sharedThumbnailDataUri(SharedFile source) async { + switch (source.kind) { + case SharedFileKind.photo: + return _photoThumb(source.file); + case SharedFileKind.video: + return _videoThumb(source.file); + case SharedFileKind.file: + return null; + } +} + +Future _photoThumb(File file) async { + Uint8List bytes; + try { + bytes = await file.readAsBytes(); + } catch (e) { + logger.w('Поделиться: не прочитать ${file.path}: $e'); + return null; + } + final jpeg = await compute(_encodeThumbIsolate, bytes); + return _asDataUri(jpeg); +} + +Future _videoThumb(File file) async { + try { + final frames = await VideoTranscoder.frames(file.path, const [ + 0, + ], size: _thumbMaxDimension); + if (frames.isEmpty) return null; + return _asDataUri(frames.first); + } on MissingPluginException { + return null; + } catch (e) { + logger.w('Поделиться: не взять кадр из ${file.path}: $e'); + return null; + } +} + +String? _asDataUri(Uint8List? bytes) { + if (bytes == null || bytes.isEmpty) return null; + return 'data:image/jpeg;base64,${base64Encode(bytes)}'; +} + +Uint8List? _encodeThumbIsolate(Uint8List bytes) { + final decoded = img.decodeImage(bytes); + if (decoded == null) return null; + final oriented = img.bakeOrientation(decoded); + final longest = oriented.width >= oriented.height + ? oriented.width + : oriented.height; + final scaled = longest > _thumbMaxDimension + ? img.copyResize( + oriented, + width: oriented.width >= oriented.height ? _thumbMaxDimension : null, + height: oriented.height > oriented.width ? _thumbMaxDimension : null, + interpolation: img.Interpolation.average, + ) + : oriented; + return img.encodeJpg(scaled, quality: _thumbQuality); +} + +final Map _sharedThumbCache = {}; + +ImageProvider? decodeSharedThumb(String? dataUri) { + if (dataUri == null || dataUri.isEmpty) return null; + final cached = _sharedThumbCache[dataUri]; + if (cached != null) return cached; + final comma = dataUri.indexOf(','); + if (comma < 0) return null; + try { + final provider = MemoryImage(base64Decode(dataUri.substring(comma + 1))); + if (_sharedThumbCache.length >= 32) { + _sharedThumbCache.remove(_sharedThumbCache.keys.first); + } + _sharedThumbCache[dataUri] = provider; + return provider; + } catch (_) { + return null; + } +} diff --git a/lib/core/media/tlottie/tlottie.dart b/lib/core/media/tlottie/tlottie.dart new file mode 100644 index 0000000..5ca39a9 --- /dev/null +++ b/lib/core/media/tlottie/tlottie.dart @@ -0,0 +1,3 @@ +// #***! на вебе tlottie нет, заглушка +export 'tlottie_engine_stub.dart' + if (dart.library.io) 'tlottie_engine.dart'; diff --git a/lib/core/media/tlottie/tlottie_disk_cache.dart b/lib/core/media/tlottie/tlottie_disk_cache.dart new file mode 100644 index 0000000..484532c --- /dev/null +++ b/lib/core/media/tlottie/tlottie_disk_cache.dart @@ -0,0 +1,218 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../../utils/logger.dart'; + +// #***! распакованные кадры стикера с диска +class DiskClip { + DiskClip({ + required this.px, + required this.frameCount, + required this.frameRate, + required this.durationMs, + required this.frames, + }); + + final int px; + final int frameCount; + final double frameRate; + final int durationMs; + final List frames; +} + +// #***! кадры кэшируем на диск, рендерить заново слишком дорого +class TlottieDiskCache { + TlottieDiskCache._(); + static final TlottieDiskCache instance = TlottieDiskCache._(); + + // #***! magic и версия формата, сменил версию старые файлы игнорятся + static const _magic = 0x4b544c46; + static const _version = 2; + static const _extension = '.ktlf'; + static const _legacyDirName = 'rlottie_frames'; + // #***! под кадры отдаём 256 МБ + static const int _maxBytes = 256 * 1024 * 1024; + + Directory? _dir; + Future? _dirFuture; + + Future _directory() { + return _dirFuture ??= () async { + final base = await getApplicationSupportDirectory(); + final dir = Directory('${base.path}/tlottie_frames'); + if (!await dir.exists()) await dir.create(recursive: true); + unawaited(_dropLegacy(base)); + _dir = dir; + return dir; + }(); + } + + Future _dropLegacy(Directory base) async { + final legacy = Directory('${base.path}/$_legacyDirName'); + try { + if (await legacy.exists()) await legacy.delete(recursive: true); + } catch (e) { + logger.w('TlottieDiskCache legacy cleanup failed: $e'); + } + } + + // #***! ключ это url плюс размер, на каждый размер свои кадры + String _key(String url, int px) { + final digest = sha1.convert(url.codeUnits).toString().substring(0, 20); + return '${digest}_$px$_extension'; + } + + Future _file(String url, int px) async { + final dir = await _directory(); + return File('${dir.path}/${_key(url, px)}'); + } + + // #***! битый файл кэша игнорим и рендерим заново + Future load(String url, int px) async { + try { + final file = await _file(url, px); + if (!await file.exists()) return null; + final bytes = await file.readAsBytes(); + final clip = await _decode(bytes); + if (clip != null) { + unawaited(file.setLastModified(DateTime.now()).catchError((_) {})); + } + return clip; + } catch (e) { + logger.w('TlottieDiskCache.load failed: $e'); + return null; + } + } + + Future store({ + required String url, + required int px, + required int frameCount, + required double frameRate, + required int durationMs, + required List frames, + }) async { + if (frames.isEmpty || frames.length != frameCount) return; + try { + final bytes = await _encode(px, frameCount, frameRate, durationMs, frames); + final file = await _file(url, px); + await file.writeAsBytes(bytes, flush: false); + unawaited(_evict()); + } catch (e) { + logger.w('TlottieDiskCache.store failed: $e'); + } + } + + // #***! кодируем и декодируем в изоляте, файлы большие + static Future _encode( + int px, + int frameCount, + double frameRate, + int durationMs, + List frames, + ) { + return Isolate.run(() { + final frameBytes = px * px * 4; + final payload = Uint8List(frameBytes * frameCount); + for (var i = 0; i < frameCount; i++) { + payload.setRange(i * frameBytes, (i + 1) * frameBytes, frames[i]); + } + for (var i = frameCount - 1; i >= 1; i--) { + final cur = i * frameBytes; + final prev = (i - 1) * frameBytes; + for (var b = 0; b < frameBytes; b++) { + payload[cur + b] ^= payload[prev + b]; + } + } + final compressed = GZipCodec(level: 1).encode(payload); + final header = ByteData(28); + header.setUint32(0, _magic); + header.setUint8(4, _version); + header.setUint32(8, px); + header.setUint32(12, frameCount); + header.setFloat64(16, frameRate); + header.setUint32(24, durationMs); + final out = BytesBuilder(); + out.add(header.buffer.asUint8List()); + out.add(compressed); + return out.toBytes(); + }); + } + + static Future _decode(Uint8List bytes) { + return Isolate.run(() { + if (bytes.length < 28) return null; + final header = ByteData.sublistView(bytes, 0, 28); + if (header.getUint32(0) != _magic) return null; + if (header.getUint8(4) != _version) return null; + final px = header.getUint32(8); + final frameCount = header.getUint32(12); + final frameRate = header.getFloat64(16); + final durationMs = header.getUint32(24); + final frameBytes = px * px * 4; + final payload = Uint8List.fromList(gzip.decode(bytes.sublist(28))); + if (payload.length != frameBytes * frameCount) return null; + for (var i = 1; i < frameCount; i++) { + final cur = i * frameBytes; + final prev = (i - 1) * frameBytes; + for (var b = 0; b < frameBytes; b++) { + payload[cur + b] ^= payload[prev + b]; + } + } + final frames = []; + for (var i = 0; i < frameCount; i++) { + frames.add(Uint8List.sublistView( + payload, i * frameBytes, (i + 1) * frameBytes)); + } + return DiskClip( + px: px, + frameCount: frameCount, + frameRate: frameRate, + durationMs: durationMs, + frames: frames, + ); + }); + } + + // #***! вытеснение по времени доступа + Future _evict() async { + try { + final dir = _dir ?? await _directory(); + final files = await dir + .list() + .where((e) => e is File && e.path.endsWith(_extension)) + .cast() + .toList(); + var total = 0; + final stats = <(File, FileStat)>[]; + for (final f in files) { + final st = await f.stat(); + total += st.size; + stats.add((f, st)); + } + if (total <= _maxBytes) return; + stats.sort((a, b) => a.$2.modified.compareTo(b.$2.modified)); + for (final (file, st) in stats) { + if (total <= _maxBytes) break; + total -= st.size; + await file.delete().catchError((_) => file); + } + } catch (e) { + logger.w('TlottieDiskCache.evict failed: $e'); + } + } + + Future clear() async { + try { + final dir = await _directory(); + if (await dir.exists()) await dir.delete(recursive: true); + _dir = null; + _dirFuture = null; + } catch (_) {} + } +} diff --git a/lib/core/media/tlottie/tlottie_engine.dart b/lib/core/media/tlottie/tlottie_engine.dart new file mode 100644 index 0000000..3aa5ceb --- /dev/null +++ b/lib/core/media/tlottie/tlottie_engine.dart @@ -0,0 +1,373 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io' show Platform; +import 'dart:isolate'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:komet_tlottie/komet_tlottie.dart'; + +import '../../utils/logger.dart'; +import 'tlottie_disk_cache.dart'; +import 'tlottie_worker.dart'; + +// #***! один анимированный стикер, кадры готовыми ui.Image +class TlottieClip { + TlottieClip({required this.key, required this.px}); + + final String key; + final int px; + + int frameCount = 0; + int durationMs = 1000; + double frameRate = 60; + + List _images = const []; + ui.Image? _lastImage; + + // #***! ready это сколько кадров подряд готово, виджет подписан + final ValueNotifier ready = ValueNotifier(0); + + bool complete = false; + int bytes = 0; + int lastUsed = 0; + int active = 0; + + bool get playable => frameCount > 0; + + // #***! кадра нет, показываем последний готовый чтоб не мигало + ui.Image? frameAt(int index) { + if (index < 0 || index >= _images.length) return _lastImage; + return _images[index] ?? _lastImage; + } + + void _allocate(int count) { + _images = List.filled(count, null); + } + + // #***! кадры складываются подряд, ready двигается по сплошной части + void _setFrame(int index, ui.Image image) { + if (index < 0 || index >= _images.length) { + image.dispose(); + return; + } + _images[index] = image; + _lastImage = image; + bytes += px * px * 4; + var r = ready.value; + while (r < _images.length && _images[r] != null) { + r++; + } + if (r != ready.value) ready.value = r; + } + + void dispose() { + for (final img in _images) { + img?.dispose(); + } + _images = const []; + _lastImage = null; + bytes = 0; + ready.dispose(); + } +} + +// #***! задание в работе +class _Job { + _Job(this.clip, this.url, this.completer); + final TlottieClip clip; + final String url; + final Completer completer; + List rawFrames = const []; +} + +// #***! движок анимированных стикеров, пул изолятов и кэш +class TlottieEngine { + TlottieEngine._(); + static final TlottieEngine instance = TlottieEngine._(); + + static String? debugLibraryPath; + + // #***! под кадры в памяти 192 МБ, дальше LRU + static const int _maxBytes = 192 * 1024 * 1024; + + // #***! _loading схлопывает параллельные запросы одного стикера + final Map _clips = {}; + final Map> _loading = {}; + final Map _jobs = {}; + + int _totalBytes = 0; + int _clock = 0; + int _nextJobId = 1; + // #***! _rrIndex раскидывает задания по воркерам по кругу + int _rrIndex = 0; + + // #***! декод с диска идёт на главном изоляте (кадры уже готовые байты, + // изолят тут не нужен) — без лимита пачка закэшированных стикеров разом + // заваливает decodeImageFromPixels и роняет FPS до нуля, пока все не + // прогрузятся. Ограничиваем сколько стикеров декодятся параллельно + static const int _maxConcurrentDiskDecodes = 3; + int _activeDiskDecodes = 0; + final List> _diskDecodeQueue = []; + + Future _acquireDiskDecodeSlot() async { + if (_activeDiskDecodes < _maxConcurrentDiskDecodes) { + _activeDiskDecodes++; + return; + } + final completer = Completer(); + _diskDecodeQueue.add(completer); + await completer.future; + _activeDiskDecodes++; + } + + void _releaseDiskDecodeSlot() { + _activeDiskDecodes--; + if (_diskDecodeQueue.isNotEmpty) { + _diskDecodeQueue.removeAt(0).complete(); + } + } + + bool? _available; + Future>? _poolFuture; + + int _tick() => ++_clock; + String _keyFor(String url, int px) => '$url@$px'; + + // #***! без нативки движок недоступен + bool get available => + _available ??= TlottieBindings.open(path: debugLibraryPath) != null; + + Future _worker() async { + final pool = await (_poolFuture ??= _spawnPool()); + return pool[_rrIndex++ % pool.length]; + } + + // #***! пул изолятов поднимается при первом стикере + Future> _spawnPool() async { + final count = (Platform.numberOfProcessors - 1).clamp(1, 3); + final ports = []; + for (var i = 0; i < count; i++) { + final receive = ReceivePort(); + await Isolate.spawn(tlottieWorkerMain, receive.sendPort); + final broadcast = receive.asBroadcastStream(); + final port = await broadcast.first as SendPort; + broadcast.listen(_onWorkerMessage); + ports.add(port); + } + return ports; + } + + // #***! роутер сообщений от воркеров + void _onWorkerMessage(dynamic message) { + if (message is ClipMeta) { + _onMeta(message); + } else if (message is RenderedFrame) { + _onFrame(message); + } else if (message is RenderDone) { + _onDone(message); + } else if (message is RenderError) { + _onError(message); + } + } + + void _onMeta(ClipMeta meta) { + final job = _jobs[meta.jobId]; + if (job == null) return; + final clip = job.clip + ..frameCount = meta.totalFrame + ..frameRate = meta.frameRate + ..durationMs = meta.durationMs; + clip._allocate(meta.totalFrame); + job.rawFrames = List.filled(meta.totalFrame, null); + if (!job.completer.isCompleted) { + job.completer.complete(clip); + } + } + + void _onFrame(RenderedFrame frame) { + final job = _jobs[frame.jobId]; + if (job == null) return; + final bytes = frame.data.materialize().asUint8List(); + if (frame.index < job.rawFrames.length) { + job.rawFrames[frame.index] = bytes; + } + ui.decodeImageFromPixels( + bytes, + frame.px, + frame.px, + ui.PixelFormat.bgra8888, + (image) { + job.clip._setFrame(frame.index, image); + _totalBytes += frame.px * frame.px * 4; + _evictIfNeeded(); + }, + ); + } + + void _onDone(RenderDone done) { + final job = _jobs.remove(done.jobId); + if (job == null) return; + final clip = job.clip..complete = true; + final raw = job.rawFrames; + if (raw.length == clip.frameCount && !raw.contains(null)) { + unawaited(TlottieDiskCache.instance.store( + url: job.url, + px: clip.px, + frameCount: clip.frameCount, + frameRate: clip.frameRate, + durationMs: clip.durationMs, + frames: raw.cast(), + )); + } + job.rawFrames = const []; + } + + void _onError(RenderError error) { + final job = _jobs.remove(error.jobId); + if (job == null) return; + logger.w('tlottie render failed (${job.url}): ${error.message}'); + if (!job.completer.isCompleted) job.completer.complete(null); + if (job.clip.frameCount == 0) { + _clips.remove(job.clip.key); + job.clip.dispose(); + } + } + + // #***! кэш в памяти, потом диск, потом рендер + Future acquire(String url, int px, {String? inlineJson}) async { + if (!available) return null; + final key = _keyFor(url, px); + + final cached = _clips[key]; + if (cached != null) { + cached.lastUsed = _tick(); + cached.active++; + return cached; + } + final pending = _loading[key]; + if (pending != null) { + final clip = await pending; + if (clip != null) { + clip.lastUsed = _tick(); + clip.active++; + } + return clip; + } + + final future = _load(url, px, key, inlineJson); + _loading[key] = future; + final clip = await future; + _loading.remove(key); + if (clip != null) { + clip.lastUsed = _tick(); + clip.active++; + } + return clip; + } + + Future _load( + String url, int px, String key, String? inlineJson) async { + if (inlineJson == null) { + final disk = await TlottieDiskCache.instance.load(url, px); + if (disk != null) { + final clip = TlottieClip(key: key, px: px) + ..frameCount = disk.frameCount + ..frameRate = disk.frameRate + ..durationMs = disk.durationMs; + clip._allocate(disk.frameCount); + _clips[key] = clip; + unawaited(_decodeDiskProgressive(clip, disk)); + return clip; + } + } + + final json = + inlineJson != null ? utf8.encode(inlineJson) : await _fetchJson(url); + if (json == null) return null; + + final clip = TlottieClip(key: key, px: px); + _clips[key] = clip; + final jobId = _nextJobId++; + final completer = Completer(); + _jobs[jobId] = _Job(clip, url, completer); + + final port = await _worker(); + port.send(RenderJob( + jobId: jobId, + json: json, + px: px, + libPath: debugLibraryPath, + )); + return completer.future; + } + + // #***! кадры с диска декодируем по одному чтоб стикер заиграл раньше. + // Пачка стикеров может прийти разом (открыли чат/панель) — придерживаем + // лишние очередью, иначе главный изолят захлёбывается decodeImageFromPixels + Future _decodeDiskProgressive(TlottieClip clip, DiskClip disk) async { + await _acquireDiskDecodeSlot(); + try { + for (var i = 0; i < disk.frameCount; i++) { + if (!identical(_clips[clip.key], clip)) return; + final image = await _decode(disk.frames[i], clip.px); + if (!identical(_clips[clip.key], clip)) { + image.dispose(); + return; + } + clip._setFrame(i, image); + _totalBytes += clip.px * clip.px * 4; + } + clip.complete = true; + _evictIfNeeded(); + } finally { + _releaseDiskDecodeSlot(); + } + } + + Future _decode(Uint8List bgra, int px) { + final completer = Completer(); + ui.decodeImageFromPixels( + bgra, px, px, ui.PixelFormat.bgra8888, completer.complete); + return completer.future; + } + + // #***! джейсон анимации качаем обычным кэшем файлов + Future _fetchJson(String url) async { + try { + final file = await DefaultCacheManager().getSingleFile(url); + return await file.readAsBytes(); + } catch (e) { + logger.w('tlottie fetch failed ($url): $e'); + return null; + } + } + + // #***! прогрев, рендерим пока стикер ещё не на экране + Future prewarm(String url, int px) async { + if (!available) return; + final clip = await acquire(url, px); + if (clip != null) release(clip); + } + + // #***! клип живёт пока его кто то использует + void release(TlottieClip clip) { + if (clip.active > 0) clip.active--; + clip.lastUsed = _tick(); + _evictIfNeeded(); + } + + // #***! LRU когда кадры в памяти перевалили за лимит + void _evictIfNeeded() { + if (_totalBytes <= _maxBytes) return; + final candidates = _clips.values.where((c) => c.active <= 0).toList() + ..sort((a, b) => a.lastUsed.compareTo(b.lastUsed)); + for (final clip in candidates) { + if (_totalBytes <= _maxBytes) break; + _totalBytes -= clip.bytes; + _clips.remove(clip.key); + clip.dispose(); + } + } +} diff --git a/lib/core/media/tlottie/tlottie_engine_stub.dart b/lib/core/media/tlottie/tlottie_engine_stub.dart new file mode 100644 index 0000000..ff51d37 --- /dev/null +++ b/lib/core/media/tlottie/tlottie_engine_stub.dart @@ -0,0 +1,32 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; + +// #***! заглушка для платформ без нативки +class TlottieClip { + TlottieClip({this.px = 0}); + + final int px; + int frameCount = 0; + int durationMs = 1000; + double frameRate = 60; + final ValueNotifier ready = ValueNotifier(0); + + ui.Image? frameAt(int index) => null; +} + +class TlottieEngine { + TlottieEngine._(); + static final TlottieEngine instance = TlottieEngine._(); + + static String? debugLibraryPath; + + bool get available => false; + + Future acquire(String url, int px, {String? inlineJson}) async => + null; + + Future prewarm(String url, int px) async {} + + void release(TlottieClip clip) {} +} diff --git a/lib/core/media/tlottie/tlottie_worker.dart b/lib/core/media/tlottie/tlottie_worker.dart new file mode 100644 index 0000000..97220dd --- /dev/null +++ b/lib/core/media/tlottie/tlottie_worker.dart @@ -0,0 +1,155 @@ +import 'dart:ffi'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; +import 'package:komet_tlottie/komet_tlottie.dart'; + +// #***! задание воркеру +class RenderJob { + const RenderJob({ + required this.jobId, + required this.json, + required this.px, + this.libPath, + }); + + final int jobId; + final Uint8List json; + final int px; + final String? libPath; +} + +// #***! метаданные клипа +class ClipMeta { + const ClipMeta({ + required this.jobId, + required this.totalFrame, + required this.frameRate, + required this.durationMs, + }); + + final int jobId; + final int totalFrame; + final double frameRate; + final int durationMs; +} + +// #***! кадр, TransferableTypedData отдаёт байты между изолятами без копий +class RenderedFrame { + const RenderedFrame({ + required this.jobId, + required this.index, + required this.data, + required this.px, + }); + + final int jobId; + final int index; + final TransferableTypedData data; + final int px; +} + +// #***! дальше служебные сообщения воркера +class RenderDone { + const RenderDone(this.jobId); + final int jobId; +} + +class RenderError { + const RenderError(this.jobId, this.message); + final int jobId; + final String message; +} + +class CancelJob { + const CancelJob(this.jobId); + final int jobId; +} + +const double _maxCacheFps = 30.0; + +// #***! точка входа воркера, рендерит кадры и шлёт обратно +void tlottieWorkerMain(SendPort toMain) { + final port = ReceivePort(); + toMain.send(port.sendPort); + + // #***! отменённые пропускаем, стикер уехал с экрана + final cancelled = {}; + TlottieBindings? bindings; + String? boundLibPath; + + port.listen((message) { + if (message is CancelJob) { + cancelled.add(message.jobId); + return; + } + if (message is! RenderJob) return; + + final job = message; + cancelled.remove(job.jobId); + + if (bindings == null || boundLibPath != job.libPath) { + bindings = TlottieBindings.open(path: job.libPath); + boundLibPath = job.libPath; + } + final tl = bindings; + if (tl == null) { + toMain.send(RenderError(job.jobId, 'tlottie unavailable')); + return; + } + + final anim = tl.parse(job.json); + if (anim == null) { + toMain.send(RenderError(job.jobId, 'parse failed')); + return; + } + + try { + final total = tl.frameCount(anim); + final fps = tl.frameRate(anim); + final durationMs = fps <= 0 ? 1000 : (total / fps * 1000).round(); + + var outCount = total; + if (fps > _maxCacheFps && total > 1) { + outCount = (durationMs / 1000.0 * _maxCacheFps).round().clamp(2, total); + } + final outFps = durationMs <= 0 ? fps : outCount * 1000.0 / durationMs; + toMain.send(ClipMeta( + jobId: job.jobId, + totalFrame: outCount, + frameRate: outFps, + durationMs: durationMs, + )); + + final px = job.px; + final buffer = calloc(px * px); + final byteView = buffer.cast().asTypedList(px * px * 4); + try { + for (var i = 0; i < outCount; i++) { + if (cancelled.contains(job.jobId)) break; + final src = outCount == total + ? i + : (i * (total - 1) / (outCount - 1)).round().clamp(0, total - 1); + if (!tl.render(anim, src, buffer, px)) { + throw StateError('render failed at frame $src'); + } + toMain.send(RenderedFrame( + jobId: job.jobId, + index: i, + data: TransferableTypedData.fromList([Uint8List.fromList(byteView)]), + px: px, + )); + } + } finally { + calloc.free(buffer); + } + toMain.send(RenderDone(job.jobId)); + } catch (e) { + toMain.send(RenderError(job.jobId, e.toString())); + } finally { + tl.drop(anim); + cancelled.remove(job.jobId); + } + }); +} diff --git a/lib/core/media/video_note_cropper.dart b/lib/core/media/video_note_cropper.dart new file mode 100644 index 0000000..6ae099d --- /dev/null +++ b/lib/core/media/video_note_cropper.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +import '../utils/logger.dart'; + +/// Центр-кроп записанного видео в квадрат для видеосообщений-кружков. +/// Выполняется нативно: на Android — media3 Transformer, на iOS — +/// AVAssetExportSession. Без искажений: заполняет квадрат и обрезает +/// лишнее по бокам. На других платформах возвращает `null`. +class VideoNoteCropper { + static const _channel = MethodChannel('ru.ikar.app/video'); + + static Future cropSquare(String input, {int size = 480}) async { + if (!Platform.isAndroid && !Platform.isIOS) return null; + try { + final dot = input.lastIndexOf('.'); + final base = dot > 0 ? input.substring(0, dot) : input; + final output = '${base}_sq.mp4'; + final res = await _channel.invokeMethod('cropSquare', { + 'input': input, + 'output': output, + 'size': size, + }); + return res; + } catch (e) { + logger.w('VideoNoteCropper: $e'); + return null; + } + } +} diff --git a/lib/core/media/video_note_frame.dart b/lib/core/media/video_note_frame.dart new file mode 100644 index 0000000..1eb806f --- /dev/null +++ b/lib/core/media/video_note_frame.dart @@ -0,0 +1,11 @@ +import 'dart:ui'; + +Size videoNoteFrameSize(Size frame, double fallback) { + final width = frame.width.isFinite && frame.width > 0 + ? frame.width + : fallback; + final height = frame.height.isFinite && frame.height > 0 + ? frame.height + : fallback; + return Size(width, height); +} diff --git a/lib/core/media/video_note_preloader.dart b/lib/core/media/video_note_preloader.dart new file mode 100644 index 0000000..f48fbde --- /dev/null +++ b/lib/core/media/video_note_preloader.dart @@ -0,0 +1,78 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:io'; + +import '../utils/media_cache.dart'; + +class VideoNotePreloader { + static const int autoLoadMaxMs = 30000; + static const int _maxConcurrent = 2; + + static int _running = 0; + static final Queue<_PreloadJob> _queue = Queue(); + + static bool autoLoads(int? durationMs) => + durationMs != null && durationMs > 0 && durationMs <= autoLoadMaxMs; + + static Future load( + String cacheName, + Future Function() resolveUrl, { + bool priority = false, + void Function(double progress)? onProgress, + bool Function()? cancelled, + }) async { + final cached = await MediaCache.existing(cacheName); + if (cached != null) return cached; + + final job = _PreloadJob(cacheName, resolveUrl, onProgress, cancelled); + if (priority) { + _queue.addFirst(job); + } else { + _queue.addLast(job); + } + _pump(); + return job.result.future; + } + + static void _pump() { + while (_running < _maxConcurrent && _queue.isNotEmpty) { + final job = _queue.removeFirst(); + _running++; + _run(job).whenComplete(() { + _running--; + _pump(); + }); + } + } + + static Future _run(_PreloadJob job) async { + if (job.cancelled?.call() ?? false) { + job.result.complete(null); + return; + } + File? file; + try { + final url = await job.resolveUrl(); + if (url != null && url.isNotEmpty) { + file = await MediaCache.getOrDownload( + job.cacheName, + url, + onProgress: job.onProgress, + ); + } + } catch (_) { + file = null; + } + if (!job.result.isCompleted) job.result.complete(file); + } +} + +class _PreloadJob { + _PreloadJob(this.cacheName, this.resolveUrl, this.onProgress, this.cancelled); + + final String cacheName; + final Future Function() resolveUrl; + final void Function(double progress)? onProgress; + final bool Function()? cancelled; + final Completer result = Completer(); +} diff --git a/lib/core/media/video_request_headers.dart b/lib/core/media/video_request_headers.dart new file mode 100644 index 0000000..bb2ba5b --- /dev/null +++ b/lib/core/media/video_request_headers.dart @@ -0,0 +1,42 @@ +/// Headers for a remote video request. +/// +/// Links bound to a client (`srcAg=UNKNOWN_ANDROID` and the like) are +/// checked by the CDN against the `User-Agent` of the request, and a +/// mismatch is answered with HTTP 400. The link is issued for the Android +/// handshake identity, so the agent sent to the CDN must not name another +/// platform: the session agent is used when it reads as Android. An agent +/// built from another device (an iOS build without a spoofed profile +/// reports `OKMessages/ (18.6; iPhone15,2; ...)`) keeps only its +/// product token and is marked as Android. Players never fall back to +/// their own agent, which on iOS names the iPhone. +Map videoRequestHeaders( + Uri uri, { + required String? sessionUserAgent, +}) { + if (!_isAgentBound(uri)) return const {}; + final userAgent = sessionUserAgent?.trim(); + if (userAgent == null || userAgent.isEmpty) return const {}; + return {'User-Agent': cdnUserAgent(userAgent)}; +} + +/// The agent sent to the CDN for a session agent. +String cdnUserAgent(String sessionUserAgent) { + final agent = sessionUserAgent.trim(); + if (_androidToken.hasMatch(agent)) return agent; + final product = _productToken.firstMatch(agent)?.group(0); + return '${product ?? _fallbackProduct} (Android)'; +} + +const _agentBoundHosts = ['okcdn.ru', 'vkuser.net']; +const _fallbackProduct = 'OKMessages'; + +final _androidToken = RegExp(r'\bandroid\b', caseSensitive: false); +final _productToken = RegExp(r'^[^\s/()]+/[^\s()]+'); + +bool _isAgentBound(Uri uri) { + if (uri.queryParameters.containsKey('srcAg')) return true; + final host = uri.host.toLowerCase(); + return _agentBoundHosts.any( + (domain) => host == domain || host.endsWith('.$domain'), + ); +} diff --git a/lib/core/media/video_transcoder.dart b/lib/core/media/video_transcoder.dart new file mode 100644 index 0000000..0fed828 --- /dev/null +++ b/lib/core/media/video_transcoder.dart @@ -0,0 +1,372 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../utils/logger.dart'; +import 'desktop_video_probe.dart'; + +class VideoInfo { + final int width; + final int height; + final int durationMs; + final double fps; + final bool hasAudio; + + const VideoInfo({ + required this.width, + required this.height, + required this.durationMs, + required this.fps, + required this.hasAudio, + }); +} + +class VideoExportSpec { + final String input; + final String output; + final int? startMs; + final int? endMs; + final bool removeAudio; + + final double rotationDegrees; + final bool flipH; + + final Rect? crop; + final int outWidth; + final int outHeight; + + final List? rgbMatrix; + final String? overlayPath; + final int? bitrate; + + const VideoExportSpec({ + required this.input, + required this.output, + required this.outWidth, + required this.outHeight, + this.startMs, + this.endMs, + this.removeAudio = false, + this.rotationDegrees = 0, + this.flipH = false, + this.crop, + this.rgbMatrix, + this.overlayPath, + this.bitrate, + }); + + bool get hasGeometry => + rotationDegrees.abs() > 0.01 || + flipH || + (crop != null && crop != const Rect.fromLTRB(0, 0, 1, 1)); +} + +class VideoTranscoder { + static const _channel = MethodChannel('ru.ikar.app/video'); + + static bool get _native => Platform.isAndroid || Platform.isIOS; + + static Process? _desktopProcess; + static bool _desktopCancelled = false; + + static bool get supported => + _native || (DesktopVideoProbe.supported && _ffmpegReady); + + static bool _ffmpegReady = false; + + static Future ensureAvailable() async { + if (_native) return true; + if (!DesktopVideoProbe.supported) return false; + _ffmpegReady = await DesktopVideoProbe.toolsAvailable(); + return _ffmpegReady; + } + + static Future probe(String path) async { + if (_native) { + try { + final res = await _channel.invokeMapMethod('probe', { + 'input': path, + }); + if (res == null) return null; + return VideoInfo( + width: (res['width'] as num?)?.toInt() ?? 0, + height: (res['height'] as num?)?.toInt() ?? 0, + durationMs: (res['durationMs'] as num?)?.toInt() ?? 0, + fps: (res['fps'] as num?)?.toDouble() ?? 30, + hasAudio: res['hasAudio'] == true, + ); + } catch (e) { + logger.w('VideoTranscoder.probe: $e'); + return null; + } + } + if (!await ensureAvailable()) return null; + return DesktopVideoProbe.info(path); + } + + static Future> frames( + String path, + List timesMs, { + int size = 256, + bool precise = false, + }) async { + if (timesMs.isEmpty) return const []; + if (_native) { + try { + final res = await _channel.invokeListMethod('frames', { + 'input': path, + 'times': timesMs, + 'size': size, + 'precise': precise, + }); + if (res == null) return List.filled(timesMs.length, null); + return res.map((e) => e as Uint8List?).toList(); + } catch (e) { + logger.w('VideoTranscoder.frames: $e'); + return List.filled(timesMs.length, null); + } + } + if (!await ensureAvailable()) return List.filled(timesMs.length, null); + final out = []; + for (final t in timesMs) { + out.add(await DesktopVideoProbe.frameAt(path, t, size)); + } + return out; + } + + static Future outputFile(String prefix) async { + final dir = await getTemporaryDirectory(); + return File( + p.join( + dir.path, + 'qlyra_${prefix}_${DateTime.now().microsecondsSinceEpoch}.mp4', + ), + ); + } + + static Future export( + VideoExportSpec spec, { + void Function(double progress)? onProgress, + }) async { + if (_native) return _exportNative(spec, onProgress); + if (!await ensureAvailable()) return false; + return _exportFfmpeg(spec, onProgress); + } + + static Future cancel() async { + if (_native) { + try { + await _channel.invokeMethod('editCancel'); + } catch (_) {} + return; + } + _desktopCancelled = true; + _desktopProcess?.kill(); + } + + static Future _exportNative( + VideoExportSpec spec, + void Function(double)? onProgress, + ) async { + final poll = onProgress == null + ? null + : Timer.periodic(const Duration(milliseconds: 250), (_) async { + try { + final value = await _channel.invokeMethod('editProgress'); + if (value != null && value >= 0) onProgress(value / 100); + } catch (_) {} + }); + try { + final ok = await _channel.invokeMethod('edit', _nativeArgs(spec)); + return ok == true; + } catch (e) { + logger.w('VideoTranscoder.export: $e'); + return false; + } finally { + poll?.cancel(); + } + } + + static Map _nativeArgs(VideoExportSpec spec) { + final crop = spec.crop; + return { + 'input': spec.input, + 'output': spec.output, + 'startMs': spec.startMs, + 'endMs': spec.endMs, + 'removeAudio': spec.removeAudio, + 'rotationDegrees': spec.rotationDegrees, + 'flipH': spec.flipH, + 'crop': crop == null + ? null + : [ + crop.left * 2 - 1, + crop.right * 2 - 1, + 1 - crop.bottom * 2, + 1 - crop.top * 2, + ], + 'outWidth': spec.outWidth, + 'outHeight': spec.outHeight, + 'rgbMatrix': spec.rgbMatrix, + 'overlay': spec.overlayPath, + 'bitrate': spec.bitrate, + }; + } + + static Future _exportFfmpeg( + VideoExportSpec spec, + void Function(double)? onProgress, + ) async { + _desktopCancelled = false; + File? lut; + try { + final args = [ + '-y', + '-v', + 'error', + '-progress', + 'pipe:1', + '-nostats', + ]; + final startMs = spec.startMs ?? 0; + if (startMs > 0) { + args.addAll(['-ss', (startMs / 1000).toStringAsFixed(3)]); + } + args.addAll(['-i', File(spec.input).absolute.path]); + final overlay = spec.overlayPath; + if (overlay != null) args.addAll(['-i', File(overlay).absolute.path]); + final endMs = spec.endMs; + if (endMs != null && endMs > startMs) { + args.addAll(['-t', ((endMs - startMs) / 1000).toStringAsFixed(3)]); + } + + final matrix = spec.rgbMatrix; + if (matrix != null) lut = await _writeCubeLut(matrix); + + final chain = []; + if (spec.flipH) chain.add('hflip'); + final rotation = spec.rotationDegrees; + if (rotation.abs() > 0.01) { + final radians = -rotation * math.pi / 180; + chain.add( + 'rotate=${radians.toStringAsFixed(6)}:' + "ow='rotw(${radians.toStringAsFixed(6)})':" + "oh='roth(${radians.toStringAsFixed(6)})':c=black", + ); + } + final crop = spec.crop; + if (crop != null && crop != const Rect.fromLTRB(0, 0, 1, 1)) { + chain.add( + 'crop=iw*${crop.width.toStringAsFixed(6)}:' + 'ih*${crop.height.toStringAsFixed(6)}:' + 'iw*${crop.left.toStringAsFixed(6)}:' + 'ih*${crop.top.toStringAsFixed(6)}', + ); + } + chain.add('scale=${spec.outWidth}:${spec.outHeight}'); + if (lut != null) { + // The generated basename is safe in FFmpeg's filter syntax. Use its + // directory as cwd so drive letters and user directory names never + // need to pass through the filter parser's two escaping layers. + chain.add('lut3d=file=${p.basename(lut.path)}'); + } + chain.add('format=yuv420p'); + + if (overlay != null) { + args.addAll([ + '-filter_complex', + '[0:v]${chain.join(',')}[base];[base][1:v]overlay=0:0', + ]); + } else { + args.addAll(['-vf', chain.join(',')]); + } + + args.addAll([ + '-c:v', + 'libx264', + '-preset', + 'veryfast', + '-pix_fmt', + 'yuv420p', + ]); + final bitrate = spec.bitrate; + if (bitrate != null) { + args.addAll(['-b:v', '$bitrate', '-maxrate', '$bitrate']); + } + if (spec.removeAudio) { + args.add('-an'); + } else { + args.addAll(['-c:a', 'aac', '-b:a', '128k']); + } + args.addAll(['-movflags', '+faststart', File(spec.output).absolute.path]); + + final process = await Process.start( + 'ffmpeg', + args, + workingDirectory: lut?.parent.absolute.path, + ); + _desktopProcess = process; + final totalMs = (endMs ?? 0) - startMs; + final progress = process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) { + if (onProgress == null || totalMs <= 0) return; + if (!line.startsWith('out_time_ms=')) return; + final us = int.tryParse(line.substring(12).trim()); + if (us == null) return; + onProgress((us / 1000 / totalMs).clamp(0.0, 1.0)); + }); + final stderr = process.stderr.transform(utf8.decoder).join(); + final code = await process.exitCode; + await progress.cancel(); + if (code != 0 && !_desktopCancelled) { + logger.w('ffmpeg exited $code: ${await stderr}'); + } + return code == 0; + } catch (e) { + logger.w('VideoTranscoder ffmpeg: $e'); + return false; + } finally { + _desktopProcess = null; + lut?.delete().then((_) {}, onError: (_) {}); + } + } + + static Future _writeCubeLut(List m) async { + const n = 17; + final buffer = StringBuffer('LUT_3D_SIZE $n\n'); + double apply(int row, double r, double g, double b) => + (m[row] * r + m[4 + row] * g + m[8 + row] * b + m[12 + row]).clamp( + 0.0, + 1.0, + ); + for (var bi = 0; bi < n; bi++) { + for (var gi = 0; gi < n; gi++) { + for (var ri = 0; ri < n; ri++) { + final r = ri / (n - 1); + final g = gi / (n - 1); + final b = bi / (n - 1); + buffer.writeln( + '${apply(0, r, g, b).toStringAsFixed(6)} ' + '${apply(1, r, g, b).toStringAsFixed(6)} ' + '${apply(2, r, g, b).toStringAsFixed(6)}', + ); + } + } + } + final dir = await getTemporaryDirectory(); + final file = File( + p.join( + dir.path, + 'qlyra_lut_${DateTime.now().microsecondsSinceEpoch}.cube', + ), + ); + await file.writeAsString(buffer.toString()); + return file; + } +} diff --git a/lib/core/media/voice_audio_controller.dart b/lib/core/media/voice_audio_controller.dart new file mode 100644 index 0000000..fbc3701 --- /dev/null +++ b/lib/core/media/voice_audio_controller.dart @@ -0,0 +1,359 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:ogg_opus_player/ogg_opus_player.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../utils/download_progress.dart'; +import '../utils/logger.dart'; +import '../utils/media_cache.dart'; +import 'opus_ogg_index.dart'; + +enum VoiceAudioFailure { none, download, playback } + +class VoiceAudioController { + VoiceAudioController({ + required this.cacheName, + required this.resolveUrl, + required Duration fallbackDuration, + }) : duration = ValueNotifier( + fallbackDuration.inMicroseconds / Duration.microsecondsPerSecond, + ); + + final String cacheName; + final Future Function() resolveUrl; + + static const double _endEpsilon = 0.05; + + static VoiceAudioController? _active; + static int _sliceCounter = 0; + static Directory? _sliceDir; + + final ValueNotifier playing = ValueNotifier(false); + final ValueNotifier position = ValueNotifier(0); + final ValueNotifier duration; + final ValueNotifier failure = ValueNotifier( + VoiceAudioFailure.none, + ); + + ValueListenable get downloadProgress => + MediaDownloadProgress.notifier(cacheName); + + ValueListenable get downloaded => MediaCache.presence(cacheName); + + bool get scrubbing => _scrubbing; + + File? _file; + OpusOggIndex? _index; + OggOpusPlayer? _player; + File? _slice; + Timer? _ticker; + Future? _loading; + double _sliceOffset = 0; + double _speed = 1; + int _startGeneration = 0; + bool _scrubbing = false; + bool _resumeAfterScrub = false; + bool _finished = false; + bool _disposed = false; + + Future toggle() async { + if (playing.value) { + pause(); + return; + } + await play(); + } + + Future play() async { + if (_disposed) return; + if (!await _ensureLoaded()) return; + if (_disposed) return; + + if (_active != this) { + _active?.pause(); + _active = this; + } + + if (_finished) { + _finished = false; + _disposePlayer(); + position.value = 0; + } + + final player = _player; + if (player != null) { + player.play(); + _applySpeed(); + playing.value = true; + _startTicker(); + return; + } + + final total = duration.value; + final from = total > 0 && position.value >= total - _endEpsilon + ? 0.0 + : position.value; + await _startAt(from); + } + + void pause() { + _startGeneration++; + _player?.pause(); + playing.value = false; + _stopTicker(); + } + + void setSpeed(double speed) { + if (_disposed) return; + _speed = speed; + _applySpeed(); + } + + void stopAndReset() { + if (_disposed) return; + pause(); + _disposePlayer(); + _finished = false; + _sliceOffset = 0; + position.value = 0; + } + + void _applySpeed() { + final player = _player; + if (player == null) return; + try { + player.setPlaybackRate(_speed); + } catch (e) { + logger.w('VoiceAudioController.setSpeed($cacheName): $e'); + } + } + + Future seekTo(double seconds) async { + scrubStart(); + scrubTo(seconds); + await scrubEnd(); + } + + void scrubStart() { + if (_scrubbing) return; + _scrubbing = true; + _resumeAfterScrub = playing.value; + _startGeneration++; + if (playing.value) pause(); + } + + void scrubTo(double seconds) { + final total = duration.value; + position.value = total <= 0 ? 0 : seconds.clamp(0.0, total); + } + + Future scrubEnd() async { + if (!_scrubbing) return; + _scrubbing = false; + final resume = _resumeAfterScrub; + _resumeAfterScrub = false; + _finished = false; + + if (_file == null) { + if (resume) await play(); + return; + } + + _disposePlayer(); + if (resume) await _startAt(position.value); + } + + Future _ensureLoaded() async { + if (_file != null) return true; + final running = _loading; + if (running != null) { + await running; + return _file != null; + } + final future = _load(); + _loading = future; + try { + await future; + } finally { + _loading = null; + } + return _file != null; + } + + Future _load() async { + failure.value = VoiceAudioFailure.none; + try { + var file = await MediaCache.existing(cacheName); + if (file == null) { + MediaDownloadProgress.set(cacheName, 0); + try { + final url = await resolveUrl(); + if (url != null && url.isNotEmpty) { + file = await MediaCache.getOrDownload( + cacheName, + url, + onProgress: (value) => + MediaDownloadProgress.set(cacheName, value), + ); + } + } finally { + MediaDownloadProgress.set(cacheName, null); + } + } + if (_disposed) return; + if (file == null) { + failure.value = VoiceAudioFailure.download; + return; + } + _file = file; + await _buildIndex(file); + } catch (e) { + logger.w('VoiceAudioController._load($cacheName): $e'); + if (!_disposed) failure.value = VoiceAudioFailure.download; + } + } + + Future _buildIndex(File file) async { + try { + final bytes = await file.readAsBytes(); + if (_disposed) return; + final index = OpusOggIndex.parse(bytes); + if (index == null) return; + _index = index; + if (index.duration > 0) duration.value = index.duration; + } catch (e) { + logger.w('VoiceAudioController: индекс не построен ($cacheName): $e'); + } + } + + Future _startAt(double seconds) async { + final file = _file; + if (file == null) return; + final generation = ++_startGeneration; + _disposePlayer(); + + final total = duration.value; + if (total > 0 && seconds >= total - _endEpsilon) { + _finished = true; + playing.value = false; + position.value = total; + return; + } + + var path = file.path; + var offset = 0.0; + final index = _index; + if (seconds > 0 && index != null) { + final bytes = index.sliceFrom(seconds); + if (bytes != null) { + final slice = await _writeSlice(bytes); + if (_disposed || generation != _startGeneration) return; + if (slice != null) { + path = slice.path; + offset = seconds; + } + } + } + + _sliceOffset = offset; + position.value = offset; + + try { + final player = OggOpusPlayer(path); + _player = player; + player.state.addListener(_onPlayerState); + player.play(); + _applySpeed(); + playing.value = true; + _startTicker(); + } catch (e) { + logger.w('VoiceAudioController._startAt($cacheName): $e'); + failure.value = VoiceAudioFailure.playback; + playing.value = false; + } + } + + Future _writeSlice(Uint8List bytes) async { + try { + final dir = _sliceDir ??= await getTemporaryDirectory(); + final next = File(p.join(dir.path, 'voice_slice_${_sliceCounter++}.ogg')); + await next.writeAsBytes(bytes); + final previous = _slice; + _slice = next; + await _deleteQuietly(previous); + return next; + } catch (e) { + logger.w('VoiceAudioController._writeSlice($cacheName): $e'); + return null; + } + } + + void _onPlayerState() { + final state = _player?.state.value; + if (state == null || _disposed) return; + if (state == PlayerState.ended) { + _finished = true; + playing.value = false; + position.value = duration.value; + _stopTicker(); + return; + } + if (state == PlayerState.error) { + failure.value = VoiceAudioFailure.playback; + playing.value = false; + _stopTicker(); + } + } + + void _startTicker() { + _ticker ??= Timer.periodic( + const Duration(milliseconds: 50), + (_) => _onTick(), + ); + } + + void _stopTicker() { + _ticker?.cancel(); + _ticker = null; + } + + void _onTick() { + final player = _player; + if (player == null || _scrubbing || _finished) return; + final total = duration.value; + final value = _sliceOffset + player.currentPosition; + position.value = total > 0 ? value.clamp(0.0, total) : value; + } + + void _disposePlayer() { + final player = _player; + _player = null; + _stopTicker(); + if (player == null) return; + player.state.removeListener(_onPlayerState); + player.dispose(); + } + + static Future _deleteQuietly(File? file) async { + if (file == null) return; + try { + if (await file.exists()) await file.delete(); + } catch (_) {} + } + + void dispose() { + _disposed = true; + _disposePlayer(); + if (_active == this) _active = null; + final slice = _slice; + _slice = null; + _deleteQuietly(slice).ignore(); + playing.dispose(); + position.dispose(); + duration.dispose(); + failure.dispose(); + } +} diff --git a/lib/core/media/voice_transcription.dart b/lib/core/media/voice_transcription.dart new file mode 100644 index 0000000..492fa07 --- /dev/null +++ b/lib/core/media/voice_transcription.dart @@ -0,0 +1,99 @@ +import '../../backend/modules/messages.dart'; +import '../../models/attachment.dart'; + +typedef RequestVoiceTranscription = + Future Function( + int chatId, + int messageId, + int mediaId, + ); + +class VoiceTranscriptionTarget { + const VoiceTranscriptionTarget({ + required this.chatId, + required this.messageId, + required this.audioId, + }); + + final int chatId; + final String messageId; + final int audioId; + + static VoiceTranscriptionTarget? fromMessage(CachedMessage message) { + if (message.deleted || + const {'sending', 'pending', 'error'}.contains(message.status)) { + return null; + } + for (final attachment in message.attachments ?? []) { + if (attachment is AudioAttachment) { + return _fromAudio(attachment, message.chatId, message.id); + } + if (attachment is ForwardedMessageAttachment) { + for (final original + in attachment.originalAttachments ?? []) { + if (original is AudioAttachment) { + return _fromAudio( + original, + attachment.originalChatId ?? message.chatId, + attachment.originalMessageId ?? message.id, + ); + } + } + } + } + return null; + } + + static VoiceTranscriptionTarget? _fromAudio( + AudioAttachment audio, + int chatId, + String messageId, + ) { + final audioId = audio.audioId; + if (audioId == null || audioId <= 0 || int.tryParse(messageId) == null) { + return null; + } + return VoiceTranscriptionTarget( + chatId: chatId, + messageId: messageId, + audioId: audioId, + ); + } +} + +class VoiceTranscription { + static final Set _pending = {}; + + static Future toggle( + VoiceTranscriptionTarget target, + RequestVoiceTranscription request, + ) async { + final id = target.messageId; + if (_pending.contains(id)) return; + final cached = TranscriptionCache.get(id); + if (cached?.status == 1) { + TranscriptionCache.setExpanded(id, !TranscriptionCache.isExpanded(id)); + return; + } + _pending.add(id); + TranscriptionCache.put(id, TranscriptionResult(status: 0), expanded: true); + try { + final result = await request( + target.chatId, + int.parse(id), + target.audioId, + ); + // A push can finish recognition before the queued response arrives. + if (result.status != 1 && TranscriptionCache.get(id)?.status == 1) return; + TranscriptionCache.put(id, result, expanded: true); + } catch (_) { + TranscriptionCache.put( + id, + TranscriptionResult(status: -1), + expanded: true, + ); + } finally { + _pending.remove(id); + } + } +} diff --git a/lib/core/nfc/nfc_exchange_service.dart b/lib/core/nfc/nfc_exchange_service.dart new file mode 100644 index 0000000..77e30c9 --- /dev/null +++ b/lib/core/nfc/nfc_exchange_service.dart @@ -0,0 +1,82 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/services.dart'; + +import '../utils/parse.dart'; + +enum NfcEventType { received, exchanging, cancelled, error } + +class NfcEvent { + final NfcEventType type; + final int? id; + final int? phone; + final String? reason; + + const NfcEvent(this.type, this.id, {this.phone, this.reason}); +} + +class NfcStatus { + final bool supported; + final bool enabled; + + const NfcStatus({required this.supported, required this.enabled}); + + bool get ready => supported && enabled; +} + +class NfcExchangeService { + NfcExchangeService._(); + static final NfcExchangeService instance = NfcExchangeService._(); + + static const MethodChannel _method = MethodChannel('ru.ikar.app/nfc'); + static const EventChannel _events = EventChannel('ru.ikar.app/nfc_events'); + + bool get _supported => Platform.isAndroid; + + Future status() async { + if (!_supported) return const NfcStatus(supported: false, enabled: false); + try { + final res = await _method.invokeMapMethod('status'); + return NfcStatus( + supported: res?['supported'] == true, + enabled: res?['enabled'] == true, + ); + } catch (_) { + return const NfcStatus(supported: false, enabled: false); + } + } + + Stream get events => + _events.receiveBroadcastStream().map(_decodeEvent); + + Future start(int selfId, int selfPhone) => + _method.invokeMethod('start', {'selfId': selfId, 'selfPhone': selfPhone}); + + Future stop() async { + if (!_supported) return; + try { + await _method.invokeMethod('stop'); + } catch (_) {} + } + + NfcEvent _decodeEvent(dynamic raw) { + final map = raw is Map ? raw : const {}; + final parsedId = parseIntOrNull(map['id']); + final parsedPhone = parseIntOrNull(map['phone']); + switch (map['event']) { + case 'received': + return NfcEvent(NfcEventType.received, parsedId, phone: parsedPhone); + case 'exchanging': + return const NfcEvent(NfcEventType.exchanging, null); + case 'error': + return NfcEvent( + NfcEventType.error, + null, + reason: map['reason'] as String?, + ); + default: + return const NfcEvent(NfcEventType.cancelled, null); + } + } +} diff --git a/lib/core/plugins/plugin_host.dart b/lib/core/plugins/plugin_host.dart new file mode 100644 index 0000000..db12f85 --- /dev/null +++ b/lib/core/plugins/plugin_host.dart @@ -0,0 +1,20 @@ +import 'dart:typed_data'; + +abstract interface class PluginHost { + String get args; + Map get arguments; + Map? get replyMessage; + bool get isOnline; + bool get isActive; + + Future sendText(String text); + Future editText(String messageId, String text); + Future sendPhoto( + Uint8List bytes, { + required String filename, + required String caption, + }); + Future sendFile(Uint8List bytes, {required String filename}); + Future notify(String message); + Future?> getPeer(); +} diff --git a/lib/core/plugins/plugin_installer.dart b/lib/core/plugins/plugin_installer.dart new file mode 100644 index 0000000..f05ffea --- /dev/null +++ b/lib/core/plugins/plugin_installer.dart @@ -0,0 +1,44 @@ +import 'dart:async'; +import 'dart:io'; + +import 'plugin_models.dart'; +import 'plugin_package.dart'; + +class PluginInstaller { + const PluginInstaller(); + + static const _timeout = Duration(seconds: 20); + static const _maxBytes = 5 * 1024 * 1024; + + Future preview(List bytes) => + PluginPackage.preview(bytes); + + Future download(Uri uri) async { + if (uri.scheme != 'https') { + throw const FormatException('Плагин можно загрузить только по HTTPS'); + } + final client = HttpClient()..connectionTimeout = _timeout; + try { + final request = await client.getUrl(uri); + request.headers.set( + HttpHeaders.userAgentHeader, + 'QlyraPluginInstaller/1', + ); + final response = await request.close().timeout(_timeout); + if (response.statusCode != HttpStatus.ok) { + await response.drain(); + throw HttpException('HTTP ${response.statusCode}', uri: uri); + } + final bytes = []; + await for (final chunk in response.timeout(_timeout)) { + bytes.addAll(chunk); + if (bytes.length > _maxBytes) { + throw const FormatException('.kinet слишком большой'); + } + } + return await preview(bytes); + } finally { + client.close(force: true); + } + } +} diff --git a/lib/core/plugins/plugin_manifest.dart b/lib/core/plugins/plugin_manifest.dart new file mode 100644 index 0000000..c94ad9a --- /dev/null +++ b/lib/core/plugins/plugin_manifest.dart @@ -0,0 +1,336 @@ +import 'dart:convert'; + +const int kPluginApiVersion = 1; +const String kPluginPackageExtension = '.kinet'; + +enum PluginPermission { + chatWrite('chat.write'), + chatEdit('chat.edit'), + uiNotify('ui.notify'), + contactRead('contact.read'), + replyRead('message.readReply'), + network('network'), + photoWrite('chat.photo'), + fileWrite('chat.file'), + storage('storage'); + + const PluginPermission(this.id); + + final String id; + + static PluginPermission? fromId(String id) { + for (final permission in values) { + if (permission.id == id) return permission; + } + return null; + } +} + +class PluginCommandManifest { + const PluginCommandManifest({ + required this.name, + required this.description, + required this.handler, + this.arguments = const [], + this.hidden = false, + }); + + final String name; + final String description; + final String handler; + final List arguments; + final bool hidden; + + factory PluginCommandManifest.fromJson(Map json) { + final rawName = _requiredString(json, 'name'); + final name = rawName.startsWith('/') ? rawName : '/$rawName'; + if (!RegExp(r'^/[A-Za-z][A-Za-z0-9_-]{0,31}$').hasMatch(name)) { + throw const FormatException('Некорректное имя команды'); + } + final handler = _requiredString(json, 'handler'); + if (!RegExp(r'^[A-Za-z_$][A-Za-z0-9_$]*$').hasMatch(handler)) { + throw const FormatException('Некорректное имя обработчика'); + } + final rawArguments = json['arguments']; + final arguments = []; + if (rawArguments != null) { + if (rawArguments is! List) { + throw const FormatException('arguments должен быть массивом'); + } + for (final raw in rawArguments) { + if (raw is! Map) { + throw const FormatException('Некорректный аргумент команды'); + } + arguments.add( + PluginCommandArgumentManifest.fromJson( + Map.from(raw), + ), + ); + } + } + final argumentNames = {}; + for (var index = 0; index < arguments.length; index++) { + final argument = arguments[index]; + if (!argumentNames.add(argument.name)) { + throw FormatException('Аргумент ${argument.name} объявлен дважды'); + } + if (argument.rest && index != arguments.length - 1) { + throw const FormatException('rest-аргумент должен быть последним'); + } + } + return PluginCommandManifest( + name: name, + description: _requiredString(json, 'description'), + handler: handler, + arguments: List.unmodifiable(arguments), + hidden: json['hidden'] == true, + ); + } + + Map toJson() => { + 'name': name, + 'description': description, + 'handler': handler, + if (arguments.isNotEmpty) + 'arguments': arguments.map((argument) => argument.toJson()).toList(), + if (hidden) 'hidden': true, + }; +} + +class PluginCommandArgumentManifest { + const PluginCommandArgumentManifest({ + required this.name, + required this.description, + required this.required, + required this.rest, + }); + + final String name; + final String description; + final bool required; + final bool rest; + + factory PluginCommandArgumentManifest.fromJson(Map json) { + final name = _requiredString(json, 'name'); + if (!RegExp(r'^[A-Za-z][A-Za-z0-9_-]{0,31}$').hasMatch(name)) { + throw const FormatException('Некорректное имя аргумента'); + } + return PluginCommandArgumentManifest( + name: name, + description: _optionalString(json, 'description'), + required: json['required'] != false, + rest: json['rest'] == true, + ); + } + + Map toJson() => { + 'name': name, + 'description': description, + 'required': required, + if (rest) 'rest': true, + }; +} + +class PluginManifest { + const PluginManifest({ + required this.id, + required this.name, + required this.version, + required this.apiVersion, + required this.description, + required this.author, + required this.main, + required this.permissions, + required this.commands, + this.updateUrl, + this.signature, + }); + + final String id; + final String name; + final String version; + final int apiVersion; + final String description; + final String author; + final String main; + final Set permissions; + final List commands; + final Uri? updateUrl; + final PluginSignatureManifest? signature; + + factory PluginManifest.fromJson(Map json) { + final schemaVersion = json['schemaVersion']; + if (schemaVersion != 1) { + throw const FormatException('Неподдерживаемая версия manifest'); + } + final id = _requiredString(json, 'id'); + if (!RegExp(r'^[a-z][a-z0-9]*(?:\.[a-z0-9]+)+$').hasMatch(id)) { + throw const FormatException('Некорректный id плагина'); + } + final version = _requiredString(json, 'version'); + if (!RegExp(r'^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$').hasMatch(version)) { + throw const FormatException('Некорректная версия плагина'); + } + final apiVersion = json['apiVersion']; + if (apiVersion is! int || apiVersion < 1) { + throw const FormatException('Некорректная версия API'); + } + if (apiVersion > kPluginApiVersion) { + throw FormatException('Плагину требуется API $apiVersion'); + } + final main = _safeRelativePath(_requiredString(json, 'main')); + if (!main.endsWith('.js')) { + throw const FormatException( + 'Главный модуль должен быть JavaScript-файлом', + ); + } + final rawPermissions = json['permissions']; + if (rawPermissions is! List) { + throw const FormatException('permissions должен быть массивом'); + } + final permissions = {}; + for (final raw in rawPermissions) { + if (raw is! String) { + throw const FormatException('Некорректное разрешение'); + } + final permission = PluginPermission.fromId(raw); + if (permission == null) { + throw FormatException('Неизвестное разрешение: $raw'); + } + permissions.add(permission); + } + final rawCommands = json['commands']; + if (rawCommands is! List || rawCommands.isEmpty) { + throw const FormatException('Плагин должен содержать команды'); + } + final commands = rawCommands + .map((raw) { + if (raw is! Map) { + throw const FormatException('Некорректная команда'); + } + return PluginCommandManifest.fromJson(Map.from(raw)); + }) + .toList(growable: false); + final commandNames = {}; + for (final command in commands) { + if (!commandNames.add(command.name.toLowerCase())) { + throw FormatException('Команда ${command.name} объявлена дважды'); + } + } + Uri? updateUrl; + final rawUpdateUrl = json['updateUrl']; + if (rawUpdateUrl != null) { + if (rawUpdateUrl is! String) { + throw const FormatException('Некорректный updateUrl'); + } + updateUrl = Uri.tryParse(rawUpdateUrl.trim()); + if (updateUrl == null || updateUrl.scheme != 'https') { + throw const FormatException('updateUrl должен использовать HTTPS'); + } + } + PluginSignatureManifest? signature; + final rawSignature = json['signature']; + if (rawSignature != null) { + if (rawSignature is! Map) { + throw const FormatException('Некорректная подпись плагина'); + } + signature = PluginSignatureManifest.fromJson( + Map.from(rawSignature), + ); + } + return PluginManifest( + id: id, + name: _requiredString(json, 'name'), + version: version, + apiVersion: apiVersion, + description: _optionalString(json, 'description'), + author: _optionalString(json, 'author'), + main: main, + permissions: Set.unmodifiable(permissions), + commands: List.unmodifiable(commands), + updateUrl: updateUrl, + signature: signature, + ); + } + + factory PluginManifest.decode(String source) { + final decoded = jsonDecode(source); + if (decoded is! Map) { + throw const FormatException('manifest.json должен быть объектом'); + } + return PluginManifest.fromJson(Map.from(decoded)); + } + + Map toJson() => { + 'schemaVersion': 1, + 'id': id, + 'name': name, + 'version': version, + 'apiVersion': apiVersion, + 'description': description, + 'author': author, + 'main': main, + 'permissions': permissions.map((permission) => permission.id).toList(), + 'commands': commands.map((command) => command.toJson()).toList(), + if (updateUrl != null) 'updateUrl': updateUrl.toString(), + if (signature != null) 'signature': signature!.toJson(), + }; + + Map toUnsignedJson() { + final json = toJson(); + json.remove('signature'); + return json; + } +} + +class PluginSignatureManifest { + const PluginSignatureManifest({ + required this.algorithm, + required this.publicKey, + required this.value, + }); + + final String algorithm; + final String publicKey; + final String value; + + factory PluginSignatureManifest.fromJson(Map json) { + final algorithm = _requiredString(json, 'algorithm'); + if (algorithm != 'ed25519') { + throw FormatException('Неподдерживаемый алгоритм подписи: $algorithm'); + } + return PluginSignatureManifest( + algorithm: algorithm, + publicKey: _requiredString(json, 'publicKey'), + value: _requiredString(json, 'value'), + ); + } + + Map toJson() => { + 'algorithm': algorithm, + 'publicKey': publicKey, + 'value': value, + }; +} + +String _requiredString(Map json, String key) { + final value = json[key]; + if (value is! String || value.trim().isEmpty) { + throw FormatException('Поле $key обязательно'); + } + return value.trim(); +} + +String _optionalString(Map json, String key) { + final value = json[key]; + return value is String ? value.trim() : ''; +} + +String _safeRelativePath(String path) { + final normalized = path.replaceAll('\\', '/'); + if (normalized.startsWith('/') || + normalized.split('/').any((part) => part.isEmpty || part == '..')) { + throw const FormatException('Некорректный путь в плагине'); + } + return normalized; +} diff --git a/lib/core/plugins/plugin_media_downloader.dart b/lib/core/plugins/plugin_media_downloader.dart new file mode 100644 index 0000000..eae51b9 --- /dev/null +++ b/lib/core/plugins/plugin_media_downloader.dart @@ -0,0 +1,138 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +class PluginMediaDownloader { + PluginMediaDownloader._(); + + static const int _attempts = 3; + static const int _maxRedirects = 5; + static const Duration _connectionTimeout = Duration(seconds: 10); + static const Duration _responseTimeout = Duration(seconds: 20); + + static Future download( + Uri uri, { + required int maxBytes, + bool allowHttpForTesting = false, + }) async { + Object? lastError; + for (var attempt = 0; attempt < _attempts; attempt++) { + try { + return await _downloadOnce( + uri, + maxBytes: maxBytes, + allowHttpForTesting: allowHttpForTesting, + ); + } on FormatException { + rethrow; + } on HttpException catch (error) { + lastError = error; + } on SocketException catch (error) { + lastError = error; + } on TimeoutException catch (error) { + lastError = error; + } + if (attempt + 1 < _attempts) { + await Future.delayed(Duration(milliseconds: 250 * (attempt + 1))); + } + } + throw HttpException( + 'Не удалось полностью загрузить файл после $_attempts попыток: $lastError', + uri: uri, + ); + } + + static Future _downloadOnce( + Uri initialUri, { + required int maxBytes, + required bool allowHttpForTesting, + }) async { + final client = HttpClient()..connectionTimeout = _connectionTimeout; + try { + var uri = initialUri; + for (var redirect = 0; redirect <= _maxRedirects; redirect++) { + _validateUri(uri, allowHttpForTesting: allowHttpForTesting); + final request = await client.getUrl(uri); + request + ..followRedirects = false + ..persistentConnection = false; + request.headers + ..set(HttpHeaders.userAgentHeader, 'QlyraPluginMedia/1') + ..set( + HttpHeaders.acceptHeader, + 'image/*, application/octet-stream;q=0.9, */*;q=0.1', + ) + ..set(HttpHeaders.cacheControlHeader, 'no-cache'); + final response = await request.close().timeout(_responseTimeout); + if (response.isRedirect) { + final location = response.headers.value(HttpHeaders.locationHeader); + await response.drain(); + if (location == null || redirect == _maxRedirects) { + throw HttpException('Некорректный HTTP redirect', uri: uri); + } + uri = uri.resolve(location); + continue; + } + if (response.statusCode < 200 || response.statusCode >= 300) { + await response.drain(); + throw HttpException('HTTP ${response.statusCode}', uri: uri); + } + final contentLength = response.contentLength; + if (contentLength > maxBytes) { + await response.drain(); + throw const FormatException('Файл слишком большой'); + } + final bytes = []; + await for (final chunk in response.timeout(_responseTimeout)) { + bytes.addAll(chunk); + if (bytes.length > maxBytes) { + throw const FormatException('Файл слишком большой'); + } + } + if (contentLength >= 0 && bytes.length != contentLength) { + throw HttpException( + 'Получено ${bytes.length} из $contentLength байт', + uri: uri, + ); + } + return Uint8List.fromList(bytes); + } + throw HttpException('Слишком много HTTP redirect', uri: initialUri); + } finally { + client.close(force: true); + } + } + + static void _validateUri(Uri uri, {required bool allowHttpForTesting}) { + final validScheme = + uri.scheme == 'https' || (allowHttpForTesting && uri.scheme == 'http'); + if (!validScheme || uri.host.isEmpty) { + throw const FormatException('Разрешены только корректные HTTPS URL'); + } + if (allowHttpForTesting) return; + final host = uri.host.toLowerCase(); + if (host == 'localhost' || host.endsWith('.localhost')) { + throw const FormatException('Локальные адреса запрещены'); + } + final address = InternetAddress.tryParse(host); + if (address != null && _isPrivateAddress(address)) { + throw const FormatException('Локальные адреса запрещены'); + } + } + + static bool _isPrivateAddress(InternetAddress address) { + if (address.isLoopback || address.isLinkLocal) return true; + final bytes = address.rawAddress; + if (address.type == InternetAddressType.IPv4) { + return bytes[0] == 10 || + bytes[0] == 127 || + bytes[0] == 0 || + (bytes[0] == 169 && bytes[1] == 254) || + (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) || + (bytes[0] == 192 && bytes[1] == 168); + } + return bytes.every((byte) => byte == 0) || + (bytes[0] & 0xfe) == 0xfc || + (bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80); + } +} diff --git a/lib/core/plugins/plugin_models.dart b/lib/core/plugins/plugin_models.dart new file mode 100644 index 0000000..0a98eac --- /dev/null +++ b/lib/core/plugins/plugin_models.dart @@ -0,0 +1,72 @@ +import 'plugin_manifest.dart'; + +enum PluginOrigin { bundled, installed } + +enum PluginSignatureStatus { unsigned, verified, bundled } + +class PluginDescriptor { + const PluginDescriptor({ + required this.manifest, + required this.origin, + required this.enabled, + required this.grantedPermissions, + required this.loadModules, + required this.signatureStatus, + this.signerFingerprint, + }); + + final PluginManifest manifest; + final PluginOrigin origin; + final bool enabled; + final Set grantedPermissions; + final Future> Function() loadModules; + final PluginSignatureStatus signatureStatus; + final String? signerFingerprint; + + PluginDescriptor copyWith({bool? enabled}) => PluginDescriptor( + manifest: manifest, + origin: origin, + enabled: enabled ?? this.enabled, + grantedPermissions: grantedPermissions, + loadModules: loadModules, + signatureStatus: signatureStatus, + signerFingerprint: signerFingerprint, + ); +} + +class PluginCommandDescriptor { + const PluginCommandDescriptor({required this.plugin, required this.command}); + + final PluginDescriptor plugin; + final PluginCommandManifest command; +} + +class PluginPackagePreview { + const PluginPackagePreview({ + required this.manifest, + required this.bytes, + required this.signatureStatus, + this.signerFingerprint, + }); + + final PluginManifest manifest; + final List bytes; + final PluginSignatureStatus signatureStatus; + final String? signerFingerprint; +} + +class PluginUpdateInfo { + const PluginUpdateInfo({ + required this.plugin, + required this.version, + required this.packageUrl, + required this.size, + required this.sha256, + }); + + final PluginDescriptor plugin; + final String version; + final Uri packageUrl; + final int size; + final String sha256; +} diff --git a/lib/core/plugins/plugin_outgoing_text.dart b/lib/core/plugins/plugin_outgoing_text.dart new file mode 100644 index 0000000..ab4455d --- /dev/null +++ b/lib/core/plugins/plugin_outgoing_text.dart @@ -0,0 +1,30 @@ +class PluginOutgoingText { + const PluginOutgoingText({required this.plaintext, required this.wireText}); + + final String plaintext; + final String wireText; + + bool get encrypted => plaintext != wireText; +} + +class PluginOutgoingTextException implements Exception { + const PluginOutgoingTextException(this.message); + + final String message; + + @override + String toString() => message; +} + +Future preparePluginOutgoingText( + String plaintext, + Future Function(String plaintext) encrypt, +) async { + final wireText = await encrypt(plaintext); + if (wireText == null) { + throw const PluginOutgoingTextException( + 'Не удалось зашифровать сообщение плагина', + ); + } + return PluginOutgoingText(plaintext: plaintext, wireText: wireText); +} diff --git a/lib/core/plugins/plugin_package.dart b/lib/core/plugins/plugin_package.dart new file mode 100644 index 0000000..3dde92a --- /dev/null +++ b/lib/core/plugins/plugin_package.dart @@ -0,0 +1,88 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; + +import 'plugin_manifest.dart'; +import 'plugin_models.dart'; +import 'plugin_signing.dart'; + +const int _maxPackageBytes = 5 * 1024 * 1024; +const int _maxExtractedBytes = 10 * 1024 * 1024; +const int _maxFiles = 128; +const int _maxFileBytes = 2 * 1024 * 1024; + +class PluginPackage { + const PluginPackage({required this.manifest, required this.files}); + + final PluginManifest manifest; + final Map files; + + static Future preview(List bytes) async { + final package = decode(bytes); + final verification = await PluginSigning.verify( + package.manifest, + package.files, + ); + return PluginPackagePreview( + manifest: package.manifest, + bytes: bytes, + signatureStatus: verification.status, + signerFingerprint: verification.fingerprint, + ); + } + + static PluginPackage decode(List bytes) { + if (bytes.isEmpty || bytes.length > _maxPackageBytes) { + throw const FormatException('Некорректный размер .kinet'); + } + final archive = ZipDecoder().decodeBytes(bytes, verify: true); + if (archive.isEmpty || archive.length > _maxFiles) { + throw const FormatException('Некорректное количество файлов'); + } + final files = {}; + var extractedSize = 0; + for (final entry in archive) { + if (entry.isSymbolicLink) { + throw const FormatException('Символические ссылки запрещены'); + } + if (!entry.isFile) continue; + final name = _safePath(entry.name); + if (entry.size > _maxFileBytes) { + throw FormatException('Файл $name слишком большой'); + } + final content = entry.content; + extractedSize += content.length; + if (extractedSize > _maxExtractedBytes) { + throw const FormatException('Распакованный плагин слишком большой'); + } + if (files.containsKey(name)) { + throw FormatException('Файл $name объявлен дважды'); + } + files[name] = content; + } + final manifestBytes = files['manifest.json']; + if (manifestBytes == null) { + throw const FormatException('manifest.json не найден'); + } + final manifest = PluginManifest.decode(utf8.decode(manifestBytes)); + if (!files.containsKey(manifest.main)) { + throw FormatException('${manifest.main} не найден'); + } + for (final path in files.keys) { + if (path != 'manifest.json' && !path.endsWith('.js')) { + throw FormatException('Неподдерживаемый файл: $path'); + } + } + return PluginPackage(manifest: manifest, files: Map.unmodifiable(files)); + } +} + +String _safePath(String raw) { + final path = raw.replaceAll('\\', '/'); + if (path.startsWith('/') || + path.split('/').any((part) => part.isEmpty || part == '..')) { + throw const FormatException('Опасный путь в архиве'); + } + return path; +} diff --git a/lib/core/plugins/plugin_permission_label.dart b/lib/core/plugins/plugin_permission_label.dart new file mode 100644 index 0000000..45615f5 --- /dev/null +++ b/lib/core/plugins/plugin_permission_label.dart @@ -0,0 +1,16 @@ +import '../../l10n/app_localizations.dart'; +import 'plugin_manifest.dart'; + +extension PluginPermissionLabel on PluginPermission { + String label(AppLocalizations l10n) => switch (this) { + PluginPermission.chatWrite => l10n.pluginPermissionChatWrite, + PluginPermission.chatEdit => l10n.pluginPermissionChatEdit, + PluginPermission.uiNotify => l10n.pluginPermissionUiNotify, + PluginPermission.contactRead => l10n.pluginPermissionContactRead, + PluginPermission.replyRead => l10n.pluginPermissionReplyRead, + PluginPermission.network => l10n.pluginPermissionNetwork, + PluginPermission.photoWrite => l10n.pluginPermissionPhotoWrite, + PluginPermission.fileWrite => l10n.pluginPermissionFileWrite, + PluginPermission.storage => l10n.pluginPermissionStorage, + }; +} diff --git a/lib/core/plugins/plugin_runtime.dart b/lib/core/plugins/plugin_runtime.dart new file mode 100644 index 0000000..84c0d85 --- /dev/null +++ b/lib/core/plugins/plugin_runtime.dart @@ -0,0 +1,498 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:fjs/fjs.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart' + show ExternalLibrary; +import 'package:path/path.dart' as p; + +import 'plugin_host.dart'; +import 'plugin_manifest.dart'; +import 'plugin_media_downloader.dart'; +import 'plugin_models.dart'; +import 'plugin_storage.dart'; + +class PluginRuntime { + PluginRuntime._(); + + static Future? _initialization; + + static Future initialize({ExternalLibrary? externalLibrary}) async { + final pending = _initialization ??= LibFjs.init( + externalLibrary: externalLibrary, + ); + try { + await pending; + } catch (_) { + if (identical(_initialization, pending)) _initialization = null; + rethrow; + } + } + + static Future run( + PluginCommandDescriptor command, + PluginHost host, + ) async { + await initialize(); + final plugin = command.plugin; + final storage = PluginStorage(plugin.manifest.id); + final sentMessageIds = {}; + final engine = await JsEngine.create( + builtins: JsBuiltinOptions.none(), + runtimeOptions: JsEngineRuntimeOptions( + memoryLimit: BigInt.from(16 * 1024 * 1024), + gcThreshold: BigInt.from(4 * 1024 * 1024), + maxStackSize: BigInt.from(256 * 1024), + info: plugin.manifest.id, + ), + ); + var closed = false; + Future close() async { + if (closed) return; + closed = true; + await engine.close(); + } + + try { + await engine.init( + bridge: (value) => + _bridge(plugin, host, storage, sentMessageIds, value), + ); + await engine.declareNewModule( + module: JsModule.code(module: 'komet:api', code: _apiModule), + ); + final sources = await plugin.loadModules(); + final prefix = 'plugin:${plugin.manifest.id}/'; + await engine.declareNewModules( + modules: sources.entries + .map( + (entry) => JsModule.code( + module: '$prefix${entry.key}', + code: _rewriteImports(entry.value, prefix, entry.key), + ), + ) + .toList(), + ); + final moduleName = '$prefix${plugin.manifest.main}'; + final call = engine.call( + module: moduleName, + method: command.command.handler, + params: [ + JsValue.from({ + 'args': host.args, + 'arguments': host.arguments, + 'reply': + plugin.grantedPermissions.contains(PluginPermission.replyRead) + ? host.replyMessage + : null, + 'apiVersion': kPluginApiVersion, + }), + ], + ); + await call.timeout( + const Duration(seconds: 30), + onTimeout: () async { + await close(); + throw TimeoutException('Плагин превысил лимит времени'); + }, + ); + } finally { + await close(); + } + } + + static Future _bridge( + PluginDescriptor plugin, + PluginHost host, + PluginStorage storage, + Set sentMessageIds, + JsValue value, + ) async { + try { + if (!host.isActive) throw StateError('Чат плагина закрыт'); + final request = value.value; + if (request is! Map) { + throw const FormatException('Некорректный вызов API'); + } + final method = request['method']; + final args = request['args']; + if (method is! String || args is! List) { + throw const FormatException('Некорректный вызов API'); + } + final Object? result; + switch (method) { + case 'chat.sendText': + result = await _sendText(plugin, host, sentMessageIds, args); + case 'chat.editText': + await _editText(plugin, host, sentMessageIds, args); + result = null; + case 'chat.sendPhoto': + await _sendPhoto(plugin, host, args); + result = null; + case 'chat.sendFile': + await _sendFile(plugin, host, args); + result = null; + case 'ui.notify': + await _notify(plugin, host, args); + result = null; + case 'contact.getPeer': + result = await _getPeer(plugin, host); + case 'runtime.sleep': + await _sleep(args); + result = null; + case 'runtime.isOnline': + result = host.isOnline; + case 'runtime.isActive': + result = host.isActive; + case 'network.fetch': + result = await _fetch(plugin, args); + case 'storage.get': + result = await _storageGet(plugin, storage, args); + case 'storage.set': + await _storageSet(plugin, storage, args); + result = null; + case 'storage.remove': + await _storageRemove(plugin, storage, args); + result = null; + default: + throw FormatException('Неизвестный метод API: $method'); + } + return JsResult.ok(JsValue.from({'ok': true, 'value': result})); + } catch (error) { + return JsResult.ok(JsValue.from({'ok': false, 'error': '$error'})); + } + } + + static Future _sendText( + PluginDescriptor plugin, + PluginHost host, + Set sentMessageIds, + List args, + ) async { + _require(plugin, PluginPermission.chatWrite); + final id = await host.sendText(_stringArg(args, 0)); + if (id.isNotEmpty) sentMessageIds.add(id); + return id; + } + + static Future _editText( + PluginDescriptor plugin, + PluginHost host, + Set sentMessageIds, + List args, + ) { + _require(plugin, PluginPermission.chatEdit); + final messageId = _stringArg(args, 0); + if (!sentMessageIds.contains(messageId)) { + throw const FormatException( + 'Плагин может редактировать только созданные им сообщения', + ); + } + return host.editText(messageId, _stringArg(args, 1)); + } + + static Future _notify( + PluginDescriptor plugin, + PluginHost host, + List args, + ) { + _require(plugin, PluginPermission.uiNotify); + return host.notify(_stringArg(args, 0)); + } + + static Future _sendPhoto( + PluginDescriptor plugin, + PluginHost host, + List args, + ) async { + _require(plugin, PluginPermission.photoWrite); + final options = _mapArg(args, 0); + final bytes = await _mediaBytes( + plugin, + options, + maxBytes: 15 * 1024 * 1024, + ); + await host.sendPhoto( + bytes, + filename: _filename(options, 'plugin_photo.jpg'), + caption: options['caption']?.toString() ?? '', + ); + } + + static Future _sendFile( + PluginDescriptor plugin, + PluginHost host, + List args, + ) async { + _require(plugin, PluginPermission.fileWrite); + final options = _mapArg(args, 0); + final bytes = await _mediaBytes( + plugin, + options, + maxBytes: 25 * 1024 * 1024, + ); + await host.sendFile(bytes, filename: _filename(options, 'plugin_file.bin')); + } + + static Future> _fetch( + PluginDescriptor plugin, + List args, + ) async { + _require(plugin, PluginPermission.network); + final url = _stringArg(args, 0); + final options = args.length > 1 && args[1] is Map + ? Map.from(args[1] as Map) + : const {}; + final uri = _httpsUri(url); + final method = (options['method']?.toString() ?? 'GET').toUpperCase(); + if (!const {'GET', 'POST', 'PUT', 'PATCH', 'DELETE'}.contains(method)) { + throw const FormatException('Неподдерживаемый HTTP-метод'); + } + final client = HttpClient() + ..connectionTimeout = const Duration(seconds: 10); + try { + final request = await client.openUrl(method, uri); + request.followRedirects = false; + final rawHeaders = options['headers']; + if (rawHeaders is Map) { + rawHeaders.forEach((key, value) { + final name = key.toString(); + if (_forbiddenHeader(name)) return; + request.headers.set(name, value.toString()); + }); + } + final body = options['body']; + if (body != null) request.write(body.toString()); + final response = await request.close().timeout( + const Duration(seconds: 15), + ); + if (response.isRedirect) { + await response.drain(); + throw const HttpException('HTTP redirects are disabled for plugins'); + } + final bytes = await _readLimited(response, 1024 * 1024); + final headers = {}; + response.headers.forEach((name, values) { + headers[name] = values.join(', '); + }); + return { + 'status': response.statusCode, + 'headers': headers, + 'body': utf8.decode(bytes, allowMalformed: true), + 'base64': base64Encode(bytes), + }; + } finally { + client.close(force: true); + } + } + + static Future _mediaBytes( + PluginDescriptor plugin, + Map options, { + required int maxBytes, + }) async { + final encoded = options['base64']; + if (encoded is String && encoded.isNotEmpty) { + final bytes = base64Decode(encoded); + if (bytes.length > maxBytes) { + throw const FormatException('Файл слишком большой'); + } + return bytes; + } + final url = options['url']; + if (url is! String || url.isEmpty) { + throw const FormatException('Нужно указать url или base64'); + } + _require(plugin, PluginPermission.network); + return PluginMediaDownloader.download(_httpsUri(url), maxBytes: maxBytes); + } + + static Future _readLimited( + HttpClientResponse response, + int maxBytes, + ) async { + final bytes = []; + await for (final chunk in response.timeout(const Duration(seconds: 20))) { + bytes.addAll(chunk); + if (bytes.length > maxBytes) { + throw const FormatException('Ответ слишком большой'); + } + } + return Uint8List.fromList(bytes); + } + + static Uri _httpsUri(String source) { + final uri = Uri.tryParse(source); + if (uri == null || uri.scheme != 'https' || uri.host.isEmpty) { + throw const FormatException('Разрешены только корректные HTTPS URL'); + } + final host = uri.host.toLowerCase(); + if (host == 'localhost' || host.endsWith('.localhost')) { + throw const FormatException('Локальные адреса запрещены'); + } + final address = InternetAddress.tryParse(host); + if (address != null && _isPrivateAddress(address)) { + throw const FormatException('Локальные адреса запрещены'); + } + return uri; + } + + static bool _isPrivateAddress(InternetAddress address) { + if (address.isLoopback || address.isLinkLocal) return true; + final bytes = address.rawAddress; + if (address.type == InternetAddressType.IPv4) { + return bytes[0] == 10 || + bytes[0] == 127 || + bytes[0] == 0 || + (bytes[0] == 169 && bytes[1] == 254) || + (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) || + (bytes[0] == 192 && bytes[1] == 168); + } + return bytes.every((byte) => byte == 0) || + (bytes[0] & 0xfe) == 0xfc || + (bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80); + } + + static bool _forbiddenHeader(String name) { + final lower = name.toLowerCase(); + return lower == 'host' || + lower == 'content-length' || + lower == 'connection' || + lower == 'proxy-authorization'; + } + + static Map _mapArg(List args, int index) { + if (index >= args.length || args[index] is! Map) { + throw const FormatException('Ожидался объект'); + } + return Map.from(args[index] as Map); + } + + static String _filename(Map options, String fallback) { + final raw = options['filename']?.toString().trim() ?? ''; + final value = raw.isEmpty ? fallback : raw; + return value + .replaceAll(RegExp(r'[^A-Za-zА-Яа-яЁё0-9._ -]'), '_') + .substring(0, value.length > 120 ? 120 : value.length); + } + + static Future?> _getPeer( + PluginDescriptor plugin, + PluginHost host, + ) { + _require(plugin, PluginPermission.contactRead); + return host.getPeer(); + } + + static Future _sleep(List args) async { + final value = args.isEmpty ? null : args.first; + if (value is! num || value < 0 || value > 10000) { + throw const FormatException('Некорректная задержка'); + } + await Future.delayed(Duration(milliseconds: value.round())); + } + + static Future _storageGet( + PluginDescriptor plugin, + PluginStorage storage, + List args, + ) { + _require(plugin, PluginPermission.storage); + return storage.get(_storageKey(args)); + } + + static Future _storageSet( + PluginDescriptor plugin, + PluginStorage storage, + List args, + ) { + _require(plugin, PluginPermission.storage); + if (args.length < 2) throw const FormatException('Не задано значение'); + return storage.set(_storageKey(args), args[1]); + } + + static Future _storageRemove( + PluginDescriptor plugin, + PluginStorage storage, + List args, + ) { + _require(plugin, PluginPermission.storage); + return storage.remove(_storageKey(args)); + } + + static void _require(PluginDescriptor plugin, PluginPermission permission) { + if (!plugin.grantedPermissions.contains(permission)) { + throw FormatException('Нет разрешения ${permission.id}'); + } + } + + static String _stringArg(List args, int index) { + if (index >= args.length || args[index] is! String) { + throw const FormatException('Ожидалась строка'); + } + final value = args[index] as String; + if (value.length > 65536) { + throw const FormatException('Строка слишком длинная'); + } + return value; + } + + static String _storageKey(List args) { + final key = _stringArg(args, 0); + if (!RegExp(r'^[A-Za-z0-9._-]{1,64}$').hasMatch(key)) { + throw const FormatException('Некорректный ключ хранилища'); + } + return key; + } + + static String _rewriteImports( + String source, + String prefix, + String modulePath, + ) { + return source.replaceAllMapped( + RegExp(r'''(from\s+|import\s+|import\s*\()(['"])(\.?\.?/[^'"]+)\2'''), + (match) { + final relative = match.group(3)!; + final resolved = p.posix.normalize( + p.posix.join(p.posix.dirname(modulePath), relative), + ); + if (resolved == '..' || resolved.startsWith('../')) { + throw const FormatException('Импорт за пределы плагина запрещён'); + } + return '${match.group(1)}${match.group(2)}$prefix$resolved${match.group(2)}'; + }, + ); + } + + static const String _apiModule = ''' +const call = async (method, args = []) => { + const response = await fjs.bridge_call({ method, args }); + if (!response.ok) throw new Error(response.error || 'ИКАР API error'); + return response.value; +}; +export const chat = Object.freeze({ + sendText: text => call('chat.sendText', [text]), + editText: (messageId, text) => call('chat.editText', [messageId, text]), + sendPhoto: options => call('chat.sendPhoto', [options]), + sendFile: options => call('chat.sendFile', [options]) +}); +export const ui = Object.freeze({ notify: message => call('ui.notify', [message]) }); +export const contact = Object.freeze({ getPeer: () => call('contact.getPeer') }); +export const runtime = Object.freeze({ + sleep: milliseconds => call('runtime.sleep', [milliseconds]), + isOnline: () => call('runtime.isOnline'), + isActive: () => call('runtime.isActive') +}); +export const network = Object.freeze({ + fetch: (url, options = {}) => call('network.fetch', [url, options]) +}); +export const storage = Object.freeze({ + get: key => call('storage.get', [key]), + set: (key, value) => call('storage.set', [key, value]), + remove: key => call('storage.remove', [key]) +}); +'''; +} diff --git a/lib/core/plugins/plugin_signing.dart b/lib/core/plugins/plugin_signing.dart new file mode 100644 index 0000000..036cfc1 --- /dev/null +++ b/lib/core/plugins/plugin_signing.dart @@ -0,0 +1,128 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:cryptography/cryptography.dart'; + +import 'plugin_manifest.dart'; +import 'plugin_models.dart'; + +class PluginSignatureVerification { + const PluginSignatureVerification({required this.status, this.fingerprint}); + + final PluginSignatureStatus status; + final String? fingerprint; +} + +class PluginSigning { + PluginSigning._(); + + static final Ed25519 _algorithm = Ed25519(); + + static Future verify( + PluginManifest manifest, + Map files, + ) async { + final signature = manifest.signature; + if (signature == null) { + return const PluginSignatureVerification( + status: PluginSignatureStatus.unsigned, + ); + } + try { + final publicKeyBytes = base64Decode(signature.publicKey); + final signatureBytes = base64Decode(signature.value); + if (publicKeyBytes.length != 32 || signatureBytes.length != 64) { + throw const FormatException('Некорректный размер ключа или подписи'); + } + final publicKey = SimplePublicKey( + publicKeyBytes, + type: KeyPairType.ed25519, + ); + final valid = await _algorithm.verify( + payload(manifest, files), + signature: Signature(signatureBytes, publicKey: publicKey), + ); + if (!valid) { + throw const FormatException('Подпись плагина недействительна'); + } + return PluginSignatureVerification( + status: PluginSignatureStatus.verified, + fingerprint: fingerprint(publicKeyBytes), + ); + } on FormatException { + rethrow; + } catch (_) { + throw const FormatException('Подпись плагина недействительна'); + } + } + + static Uint8List payload( + PluginManifest manifest, + Map files, + ) { + final modules = {}; + final paths = files.keys.where((path) => path.endsWith('.js')).toList() + ..sort(); + for (final path in paths) { + modules[path] = sha256.convert(files[path]!).toString(); + } + final canonical = _canonicalJson({ + 'format': 'komet-plugin-signature-v1', + 'manifest': manifest.toUnsignedJson(), + 'modules': modules, + }); + return Uint8List.fromList(utf8.encode(canonical)); + } + + static Future sign( + PluginManifest manifest, + Map files, + List privateKeyBytes, + ) async { + if (privateKeyBytes.length != 32) { + throw const FormatException( + 'Приватный Ed25519 ключ должен содержать 32 байта', + ); + } + final keyPair = await _algorithm.newKeyPairFromSeed(privateKeyBytes); + final signed = await _algorithm.sign( + payload(manifest, files), + keyPair: keyPair, + ); + final publicKey = await keyPair.extractPublicKey(); + return PluginSignatureManifest( + algorithm: 'ed25519', + publicKey: base64Encode(publicKey.bytes), + value: base64Encode(signed.bytes), + ); + } + + static Future<({List privateKey, List publicKey})> + generateKeyPair() async { + final keyPair = await _algorithm.newKeyPair(); + return ( + privateKey: await keyPair.extractPrivateKeyBytes(), + publicKey: (await keyPair.extractPublicKey()).bytes, + ); + } + + static String fingerprint(List publicKey) { + final digest = sha256.convert(publicKey).toString().toUpperCase(); + return List.generate( + 8, + (index) => digest.substring(index * 4, index * 4 + 4), + ).join(':'); + } + + static String _canonicalJson(Object? value) { + if (value is Map) { + final keys = value.keys.map((key) => key.toString()).toList()..sort(); + return '{${keys.map((key) => '${jsonEncode(key)}:${_canonicalJson(value[key])}').join(',')}}'; + } + if (value is List) { + return '[${value.map(_canonicalJson).join(',')}]'; + } + return jsonEncode(value); + } +} diff --git a/lib/core/plugins/plugin_storage.dart b/lib/core/plugins/plugin_storage.dart new file mode 100644 index 0000000..9cd0b67 --- /dev/null +++ b/lib/core/plugins/plugin_storage.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +class PluginStorage { + PluginStorage(this.pluginId); + + final String pluginId; + + String get _key => 'plugin_storage_v1_$pluginId'; + + Future> _readAll() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key); + if (raw == null) return {}; + try { + final decoded = jsonDecode(raw); + return decoded is Map ? Map.from(decoded) : {}; + } catch (_) { + return {}; + } + } + + Future get(String key) async => (await _readAll())[key]; + + Future set(String key, Object? value) async { + final all = await _readAll(); + all[key] = value; + final encoded = jsonEncode(all); + if (encoded.length > 65536) { + throw const FormatException('Хранилище плагина переполнено'); + } + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_key, encoded); + } + + Future remove(String key) async { + final all = await _readAll(); + if (all.remove(key) == null) return; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_key, jsonEncode(all)); + } + + Future clear() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_key); + } +} diff --git a/lib/core/plugins/plugin_store.dart b/lib/core/plugins/plugin_store.dart new file mode 100644 index 0000000..b2fedde --- /dev/null +++ b/lib/core/plugins/plugin_store.dart @@ -0,0 +1,299 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../storage/app_instance.dart'; +import 'plugin_manifest.dart'; +import 'plugin_models.dart'; +import 'plugin_package.dart'; +import 'plugin_signing.dart'; +import 'plugin_storage.dart'; + +class PluginStore { + PluginStore._(); + + static final PluginStore instance = PluginStore._(); + static const _stateKey = 'plugins_state_v1'; + static const _bundled = [ + 'assets/plugins/info', + 'assets/plugins/nekos', + 'assets/plugins/weather', + ]; + + final ValueNotifier> plugins = ValueNotifier(const []); + Directory? _root; + Map _state = {}; + + static bool isBundledId(String id) => id.startsWith('pw.qlyra.'); + + Future load() async { + final prefs = await SharedPreferences.getInstance(); + final rawState = prefs.getString(_stateKey); + if (rawState != null) { + try { + final decoded = jsonDecode(rawState); + if (decoded is Map) _state = Map.from(decoded); + } catch (_) {} + } + final base = await getApplicationSupportDirectory(); + _root = Directory(p.join(base.path, 'plugins${AppInstance.suffix}')); + await _root!.create(recursive: true); + await _recoverInterruptedInstalls(); + await refresh(); + } + + Future _recoverInterruptedInstalls() async { + final root = _root; + if (root == null) return; + await for (final entity in root.list()) { + if (entity is! Directory) continue; + final name = p.basename(entity.path); + if (!name.startsWith('.')) continue; + if (name.endsWith('.backup')) { + final pluginId = name.substring(1, name.length - '.backup'.length); + final target = Directory(p.join(root.path, pluginId)); + if (await target.exists()) { + await entity.delete(recursive: true); + } else { + await entity.rename(target.path); + } + } else { + await entity.delete(recursive: true); + } + } + } + + Future refresh() async { + final descriptors = []; + for (final assetPath in _bundled) { + try { + descriptors.add(await _loadBundled(assetPath)); + } catch (_) {} + } + final root = _root; + if (root != null && await root.exists()) { + await for (final entity in root.list()) { + if (entity is! Directory || p.basename(entity.path).startsWith('.')) { + continue; + } + try { + descriptors.add(await _loadInstalled(entity)); + } catch (_) {} + } + } + descriptors.sort((a, b) { + final origin = a.origin.index.compareTo(b.origin.index); + return origin != 0 ? origin : a.manifest.name.compareTo(b.manifest.name); + }); + plugins.value = List.unmodifiable(descriptors); + } + + Future _loadBundled(String assetPath) async { + final manifest = PluginManifest.decode( + await rootBundle.loadString('$assetPath/manifest.json'), + ); + return PluginDescriptor( + manifest: manifest, + origin: PluginOrigin.bundled, + enabled: _enabled(manifest.id, fallback: true), + grantedPermissions: manifest.permissions, + loadModules: () async => { + manifest.main: await rootBundle.loadString( + '$assetPath/${manifest.main}', + ), + }, + signatureStatus: PluginSignatureStatus.bundled, + signerFingerprint: 'KOMET:BUNDLED', + ); + } + + Future _loadInstalled(Directory directory) async { + final manifestFile = File(p.join(directory.path, 'manifest.json')); + final manifestBytes = await manifestFile.readAsBytes(); + final manifest = PluginManifest.decode(utf8.decode(manifestBytes)); + final state = _pluginState(manifest.id); + var signerFingerprint = state['signerFingerprint']?.toString(); + if (manifest.signature != null) { + final files = await _loadInstalledBytes(directory); + files['manifest.json'] = manifestBytes; + final verification = await PluginSigning.verify(manifest, files); + if (signerFingerprint != null && + verification.fingerprint != signerFingerprint) { + throw const FormatException( + 'Ключ подписи установленного плагина изменён', + ); + } + signerFingerprint = verification.fingerprint; + } else if (signerFingerprint != null) { + throw const FormatException('Подпись установленного плагина удалена'); + } + final granted = {}; + final rawGranted = state['granted']; + if (rawGranted is List) { + for (final raw in rawGranted.whereType()) { + final permission = PluginPermission.fromId(raw); + if (permission != null && manifest.permissions.contains(permission)) { + granted.add(permission); + } + } + } + return PluginDescriptor( + manifest: manifest, + origin: PluginOrigin.installed, + enabled: _enabled(manifest.id, fallback: true), + grantedPermissions: Set.unmodifiable(granted), + loadModules: () => _loadInstalledModules(directory), + signatureStatus: signerFingerprint == null + ? PluginSignatureStatus.unsigned + : PluginSignatureStatus.verified, + signerFingerprint: signerFingerprint, + ); + } + + Future> _loadInstalledModules(Directory directory) async { + final modules = {}; + await for (final entity in directory.list(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.js')) continue; + final relative = p + .relative(entity.path, from: directory.path) + .replaceAll('\\', '/'); + modules[relative] = await entity.readAsString(); + } + return modules; + } + + Future> _loadInstalledBytes( + Directory directory, + ) async { + final files = {}; + await for (final entity in directory.list(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.js')) continue; + final relative = p + .relative(entity.path, from: directory.path) + .replaceAll('\\', '/'); + files[relative] = await entity.readAsBytes(); + } + return files; + } + + Future install( + List bytes, { + required Set grantedPermissions, + Uri? sourceUrl, + }) async { + final package = PluginPackage.decode(bytes); + final verification = await PluginSigning.verify( + package.manifest, + package.files, + ); + if (isBundledId(package.manifest.id)) { + throw const FormatException('Этот id зарезервирован ИКАР'); + } + if (!package.manifest.permissions.containsAll(grantedPermissions)) { + throw const FormatException('Выданы неизвестные разрешения'); + } + final previousState = _pluginState(package.manifest.id); + final previousPublicKey = previousState['signerPublicKey']?.toString(); + final signerPublicKey = package.manifest.signature?.publicKey; + if (previousPublicKey != null && signerPublicKey != previousPublicKey) { + throw const FormatException( + 'Обновление должно быть подписано прежним ключом автора', + ); + } + final root = _root; + if (root == null) throw StateError('PluginStore не инициализирован'); + final target = Directory(p.join(root.path, package.manifest.id)); + final staging = Directory( + p.join( + root.path, + '.${package.manifest.id}.${DateTime.now().microsecondsSinceEpoch}', + ), + ); + final backup = Directory( + p.join(root.path, '.${package.manifest.id}.backup'), + ); + await staging.create(recursive: true); + try { + for (final entry in package.files.entries) { + final file = File(p.join(staging.path, entry.key)); + await file.parent.create(recursive: true); + await file.writeAsBytes(entry.value, flush: true); + } + if (await backup.exists()) await backup.delete(recursive: true); + if (await target.exists()) await target.rename(backup.path); + try { + await staging.rename(target.path); + } catch (_) { + if (await backup.exists()) await backup.rename(target.path); + rethrow; + } + if (await backup.exists()) await backup.delete(recursive: true); + _state[package.manifest.id] = { + 'enabled': true, + 'granted': grantedPermissions + .map((permission) => permission.id) + .toList(), + if (sourceUrl != null) 'sourceUrl': sourceUrl.toString(), + 'signerFingerprint': ?verification.fingerprint, + 'signerPublicKey': ?signerPublicKey, + }; + await _persistState(); + await refresh(); + return plugins.value.firstWhere( + (plugin) => plugin.manifest.id == package.manifest.id, + ); + } finally { + if (await staging.exists()) await staging.delete(recursive: true); + } + } + + Future setEnabled(String pluginId, bool enabled) async { + final state = _pluginState(pluginId); + state['enabled'] = enabled; + _state[pluginId] = state; + await _persistState(); + await refresh(); + } + + Future uninstall(String pluginId) async { + final plugin = plugins.value.firstWhere( + (item) => item.manifest.id == pluginId, + ); + if (plugin.origin == PluginOrigin.bundled) { + throw StateError('Встроенный плагин нельзя удалить'); + } + final root = _root; + if (root != null) { + final directory = Directory(p.join(root.path, pluginId)); + if (await directory.exists()) await directory.delete(recursive: true); + } + _state.remove(pluginId); + await PluginStorage(pluginId).clear(); + await _persistState(); + await refresh(); + } + + Uri? sourceUrl(String pluginId) { + final raw = _pluginState(pluginId)['sourceUrl']; + return raw is String ? Uri.tryParse(raw) : null; + } + + bool _enabled(String pluginId, {required bool fallback}) { + final value = _pluginState(pluginId)['enabled']; + return value is bool ? value : fallback; + } + + Map _pluginState(String pluginId) { + final raw = _state[pluginId]; + return raw is Map ? Map.from(raw) : {}; + } + + Future _persistState() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_stateKey, jsonEncode(_state)); + } +} diff --git a/lib/core/plugins/plugin_updater.dart b/lib/core/plugins/plugin_updater.dart new file mode 100644 index 0000000..7542efe --- /dev/null +++ b/lib/core/plugins/plugin_updater.dart @@ -0,0 +1,157 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; + +import '../../l10n/app_localizations.dart'; +import 'plugin_installer.dart'; +import 'plugin_models.dart'; +import 'plugin_permission_label.dart'; +import 'plugin_store.dart'; + +class PluginUpdater { + PluginUpdater({ + PluginInstaller installer = const PluginInstaller(), + PluginStore? store, + }) : _installer = installer, + _store = store ?? PluginStore.instance; + + final PluginInstaller _installer; + final PluginStore _store; + + Future check(PluginDescriptor plugin) async { + final manifestUrl = plugin.manifest.updateUrl; + if (manifestUrl == null) return null; + final client = HttpClient() + ..connectionTimeout = const Duration(seconds: 15); + try { + final request = await client.getUrl(manifestUrl); + request.headers.set(HttpHeaders.userAgentHeader, 'QlyraPluginUpdater/1'); + final response = await request.close().timeout( + const Duration(seconds: 15), + ); + if (response.statusCode != HttpStatus.ok) { + await response.drain(); + throw HttpException('HTTP ${response.statusCode}', uri: manifestUrl); + } + final decoded = jsonDecode( + await response.transform(const Utf8Decoder()).join(), + ); + if (decoded is! Map) { + throw const FormatException('Некорректный update manifest'); + } + final version = decoded['version']; + final packageUrl = decoded['packageUrl']; + final size = decoded['size']; + final digest = decoded['sha256']; + if (version is! String || + packageUrl is! String || + size is! int || + digest is! String) { + throw const FormatException('Некорректный update manifest'); + } + if (_compareVersions(version, plugin.manifest.version) <= 0) return null; + final uri = manifestUrl.resolve(packageUrl); + if (uri.scheme != 'https') { + throw const FormatException('packageUrl должен использовать HTTPS'); + } + return PluginUpdateInfo( + plugin: plugin, + version: version, + packageUrl: uri, + size: size, + sha256: digest.toLowerCase(), + ); + } finally { + client.close(force: true); + } + } + + Future apply( + PluginUpdateInfo update, + AppLocalizations l10n, + ) async { + final preview = await _installer.download(update.packageUrl); + if (preview.manifest.id != update.plugin.manifest.id || + preview.manifest.version != update.version) { + throw const FormatException('Обновление принадлежит другому плагину'); + } + if (update.size > 0 && preview.bytes.length != update.size) { + throw const FormatException('Размер обновления не совпадает'); + } + if (update.sha256.isNotEmpty && + sha256.convert(preview.bytes).toString() != update.sha256) { + throw const FormatException('SHA-256 обновления не совпадает'); + } + final missing = preview.manifest.permissions.difference( + update.plugin.grantedPermissions, + ); + if (missing.isNotEmpty) { + throw FormatException( + l10n.pluginUpdateNewPermissions( + missing.map((item) => item.label(l10n)).join(', '), + ), + ); + } + return _store.install( + preview.bytes, + grantedPermissions: update.plugin.grantedPermissions.intersection( + preview.manifest.permissions, + ), + sourceUrl: _store.sourceUrl(update.plugin.manifest.id), + ); + } +} + +int _compareVersions(String a, String b) { + final left = _ParsedVersion.parse(a); + final right = _ParsedVersion.parse(b); + for (var i = 0; i < 3; i++) { + final comparison = left.core[i].compareTo(right.core[i]); + if (comparison != 0) return comparison; + } + if (left.preRelease.isEmpty && right.preRelease.isNotEmpty) return 1; + if (left.preRelease.isNotEmpty && right.preRelease.isEmpty) return -1; + final length = left.preRelease.length > right.preRelease.length + ? left.preRelease.length + : right.preRelease.length; + for (var i = 0; i < length; i++) { + if (i >= left.preRelease.length) return -1; + if (i >= right.preRelease.length) return 1; + final leftPart = left.preRelease[i]; + final rightPart = right.preRelease[i]; + final leftNumber = int.tryParse(leftPart); + final rightNumber = int.tryParse(rightPart); + if (leftNumber != null && rightNumber != null) { + final comparison = leftNumber.compareTo(rightNumber); + if (comparison != 0) return comparison; + continue; + } + if (leftNumber != null) return -1; + if (rightNumber != null) return 1; + final comparison = leftPart.compareTo(rightPart); + if (comparison != 0) return comparison; + } + return 0; +} + +class _ParsedVersion { + const _ParsedVersion(this.core, this.preRelease); + + final List core; + final List preRelease; + + factory _ParsedVersion.parse(String value) { + final withoutBuild = value.split('+').first; + final separator = withoutBuild.indexOf('-'); + final coreSource = separator == -1 + ? withoutBuild + : withoutBuild.substring(0, separator); + final preRelease = separator == -1 + ? const [] + : withoutBuild.substring(separator + 1).split('.'); + final core = coreSource.split('.').map(int.parse).toList(); + if (core.length != 3) throw const FormatException('Некорректная версия'); + return _ParsedVersion(core, preRelease); + } +} diff --git a/lib/core/protocol/chat_cache_fingerprint.dart b/lib/core/protocol/chat_cache_fingerprint.dart new file mode 100644 index 0000000..f5d6d52 --- /dev/null +++ b/lib/core/protocol/chat_cache_fingerprint.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +// #***! отпечаток родной сборки, хэши подписи dex и so +class ChatCacheFingerprint { + static const String defaultArch = 'arm64-v8a'; + + static final Uint8List _signatureDigest = _hex( + '1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93', + ); + static final Uint8List _dexDigest = _hex( + '9affa687874d88ea80b298949f826bcf8c1bba36f24d48c44b8f54cdbf01c96f', + ); + // #***! so своя на каждую архитектуру, берём ту что уехала в хэндшейк + static final Map _soDigests = { + 'arm64-v8a': _hex( + '38e2e5de3d4a9010ea1053eb28573593683c5b2bb16b09914a8822476c8aab8c', + ), + 'armeabi-v7a': _hex( + 'f60cb2576885e0af5350c98fe88355a2f26008a372c998c193b6222f717e274f', + ), + 'x86': _hex( + '80bd04964e7d0492740113047ff20ebdf88ba6e6eb48d8765cbd5fe678283065', + ), + 'x86_64': _hex( + '96173e7c2d9449c1ab4474265b410f37ce45f560b0974460a75c7c6000ca22fd', + ), + }; + + // #***! три sha256 подряд, 96 байт серверу + static Uint8List compute( + int callsSeed, + String deviceId, { + String arch = defaultArch, + }) { + final seed = _int64BigEndian(callsSeed); + final device = Uint8List.fromList(utf8.encode(deviceId)); + final soDigest = _soDigests[arch] ?? _soDigests[defaultArch]!; + final result = BytesBuilder(); + result.add(_sha256(_signatureDigest, seed, device)); + result.add(_sha256(_dexDigest, seed, device)); + result.add(_sha256(soDigest, seed, device)); + return result.toBytes(); + } + + static List _sha256(Uint8List a, Uint8List b, Uint8List c) { + final builder = BytesBuilder() + ..add(a) + ..add(b) + ..add(c); + return sha256.convert(builder.toBytes()).bytes; + } + + // #***! seed звонков 8 байт big-endian + static Uint8List _int64BigEndian(int value) { + final data = ByteData(8)..setInt64(0, value, Endian.big); + return data.buffer.asUint8List(); + } + + // #***! хэши строкой, разворачиваем в байты + static Uint8List _hex(String hex) { + final out = Uint8List(hex.length ~/ 2); + for (var i = 0; i < out.length; i++) { + out[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16); + } + return out; + } +} diff --git a/lib/core/protocol/opcode_map.dart b/lib/core/protocol/opcode_map.dart new file mode 100644 index 0000000..29e3c51 --- /dev/null +++ b/lib/core/protocol/opcode_map.dart @@ -0,0 +1,429 @@ +/// All protocol operation codes. +/// +/// Naming follows the server-side convention. +/// Use [Opcode.name] to get a human-readable label for logging. +/// +/// файл писла нейронка (я че ебанутый чтоль чтобы вручную хуярить опкоды и их значения) +abstract class Opcode { + // ── Session ──────────────────────────────────────────────────────── + static const int ping = 1; // Пинг + static const int debug = 2; // Отладка / понг + static const int reconnect = 3; // Реконнект + static const int log = 5; // Аналитика / события + static const int sessionInit = 6; // Инициализация сессии (хэндшейк) + static const int contactsGet = 8; // Синхронизация списка контактов + + // ── Profile ──────────────────────────────────────────────────────── + static const int profile = 16; // Обновление профиля + + // ── Auth ─────────────────────────────────────────────────────────── + static const int authRequest = 17; // Запрос OTP-кода + static const int auth = 18; // Проверка OTP-кода + static const int login = 19; // Вход (загрузка чатов/контактов) + static const int logout = 20; // Выход из аккаунта + static const int sync = 21; // Синхронизация данных + static const int config = 22; // Настройки приватности / конфиг + static const int authConfirm = 23; // Завершение регистрации + + // ── Auth 2FA ─────────────────────────────────────────────────────── + static const int authLoginRestorePassword = 101; // Восстановление пароля + static const int auth2faDetails = 104; // Детали 2FA + static const int externalCallback = 105; // Внешний коллбэк + static const int phoneWebappShare = 106; + static const int authValidatePassword = 107; // Валидация пароля + static const int authValidateHint = 108; // Валидация подсказки пароля + static const int authVerifyEmail = 109; // Верификация email + static const int authCheckEmail = 110; // Проверка email + static const int authSet2fa = 111; // Установка 2FA + static const int authCreateTrack = 112; // Создание трека авторизации + static const int authCheckPassword = 113; // Проверка пароля при регистрации + static const int authLoginCheckPassword = 115; // Проверка пароля при входе + static const int authLoginProfileDelete = 116; // Удаление профиля при входе + + // ── Assets (стикеры) ─────────────────────────────────────────────── + static const int presetAvatars = 25; // Пресетные аватарки + static const int assetsGet = 26; // Получение стикерпаков + static const int assetsUpdate = 27; // Синхронизация стикеров + static const int assetsGetByIds = 28; // Стикерпаки по ID + static const int assetsAdd = 29; // Добавление стикерпака + static const int assetsRemove = 259; // Удаление стикерпака + static const int assetsMove = 260; // Перемещение стикерпака + static const int assetsListModify = 261; // Изменение списка стикерпаков + + // ── Contacts ─────────────────────────────────────────────────────── + static const int contactInfo = 32; // Информация о контакте + static const int contactAdd = 33; // Добавление контакта + static const int contactUpdate = 34; // Обновление контакта (блок и т.д.) + static const int contactPresence = 35; // Запрос присутствия по ID + static const int contactList = 36; // Список заблокированных + static const int contactSearch = 37; // Поиск контакта + static const int contactMutual = 38; // Общие контакты + static const int contactPhotos = 39; // Фото контакта + static const int contactSort = 40; // Сортировка контактов + static const int contactAddByPhone = 41; // Добавление контакта по номеру + static const int contactVerify = 42; // Верификация контакта + static const int removeContactPhoto = 43; // Удаление фото контакта + static const int contactInfoByPhone = 46; // Поиск контакта по номеру + + // ── Chats ────────────────────────────────────────────────────────── + static const int chatInfo = 48; // Информация о чате / создание группы + static const int chatHistory = 49; // История сообщений + static const int chatMark = 50; // Отметка прочитанным + static const int chatMedia = 51; // Медиа чата + static const int chatDelete = 52; // Удаление чата + static const int chatsList = 53; // Список чатов + static const int chatClear = 54; // Очистка истории чата + static const int chatUpdate = 55; // Обновление настроек чата + static const int chatCheckLink = 56; // Проверка ссылки чата + static const int chatJoin = 57; // Вступление в группу / канал + static const int chatLeave = 58; // Выход из чата + static const int chatMembers = 59; // Участники группы + static const int publicSearch = 60; // Глобальный поиск + static const int chatPersonalConfig = 61; // Персональные настройки чата + static const int chatCreate = 63; // Создание чата + + // ── Messages ─────────────────────────────────────────────────────── + static const int msgSend = 64; // Отправка сообщения + static const int msgTyping = 65; // Индикатор набора текста + static const int msgDelete = 66; // Удаление сообщения + static const int msgEdit = 67; // Редактирование сообщения + static const int chatSearch = 68; // Поиск по чату + static const int msgSharePreview = 70; // Превью ссылки + static const int msgGet = 71; // Получение сообщения по ID + static const int msgSearchTouch = 72; // Точечный поиск сообщений + static const int msgSearch = 73; // Поиск сообщений + static const int msgGetStat = 74; // Статистика сообщения + static const int chatSubscribe = 75; // Подписка на обновления чата + static const int msgDeleteRange = 92; // Удаление диапазона сообщений + + // ── Reactions ────────────────────────────────────────────────────── + static const int msgReaction = 178; // Отправка реакции + static const int msgCancelReaction = 179; // Отмена реакции + static const int msgGetReactions = 180; // Получение реакций + static const int msgGetDetailedReactions = 181; // Детальные реакции + static const int chatReactionsSettingsSet = 257; // Установка настроек реакций + static const int reactionsSettingsGetByChatId = 258; // Настройки реакций чата + + // ── Calls & Video ────────────────────────────────────────────────── + static const int videoChatStart = 76; // Начало видеочата + static const int chatMembersUpdate = 77; // Обновление участников / добавление + static const int videoChatStartActive = 78; // Инициация активного звонка + static const int videoChatHistory = 79; // История звонков + static const int videoChatDeleteHistory = + 164; // Удаление записей истории звонков + static const int videoChatCreateJoinLink = 84; // Ссылка для входа в видеочат + static const int videoChatJoinByLink = 166; // Вход в звонок по ссылке + static const int videoChatMembers = 195; // Участники видеочата + static const int getInboundCalls = 103; // Входящие звонки + + // ── Media & Files ────────────────────────────────────────────────── + static const int photoUpload = 80; // Загрузка фото + static const int stickerUpload = 81; // Загрузка стикера + static const int videoUpload = 82; // Загрузка видео + static const int videoPlay = 83; // URL видео + static const int chatPinSetVisibility = 86; // Видимость закрепов + static const int fileUpload = 87; // Загрузка файла + static const int fileDownload = 88; // Скачивание файла + static const int linkInfo = 89; // Информация по ссылке / вход в канал + static const int audioPlay = 301; // Воспроизведение аудио + + // ── Comments (комментарии к постам каналов) ──────────────────────── + // Загрузка/отправка/набор комментариев переиспользуют chatHistory (49), + // msgSend (64) и msgTyping (65) с добавленным полем postId. + static const int commentsInfo = + 91; // Кол-во комментариев к постам (totalCount) + + // ── Sessions ─────────────────────────────────────────────────────── + static const int sessionsInfo = 96; // Запрос активных сессий + static const int sessionsClose = 97; // Закрытие всех сессий + static const int phoneBindRequest = 98; // Запрос привязки телефона + static const int phoneBindConfirm = 99; // Подтверждение привязки телефона + + // ── Bots ─────────────────────────────────────────────────────────── + static const int chatComplain = 117; // Жалоба на чат + static const int msgSendCallback = 118; // Коллбэк бота + static const int suspendBot = 119; // Приостановка бота + static const int chatBotCommands = 144; // Команды бота + static const int botInfo = 145; // Информация о боте + + // ── Location ─────────────────────────────────────────────────────── + static const int locationStop = 124; // Остановка трансляции геолокации + + // ── Mentions ─────────────────────────────────────────────────────── + static const int getLastMentions = 127; // Последние упоминания + + // ── Stickers (creation) ──────────────────────────────────────────── + static const int stickerCreate = 193; // Создание стикера + static const int stickerSuggest = 194; // Предложение стикера + + // ── Notifications (push от сервера) ──────────────────────────────── + static const int notifMessage = 128; // Новое сообщение + static const int notifTyping = 129; // Индикатор набора + static const int notifMark = 130; // Прочитано + static const int notifContact = 131; // Обновление контакта + static const int notifPresence = 132; // Статус онлайн + static const int notifConfig = 134; // Обновление конфига + static const int notifChat = 135; // Обновление чата + static const int notifAttach = 136; // Загрузка файла завершена + static const int notifCallStart = 137; // Входящий звонок + static const int notifContactSort = 139; // Пересортировка контактов + static const int notifMsgDeleteRange = 140; // Удаление диапазона + static const int notifMsgDelete = 142; // Удаление сообщения + static const int notifCallbackAnswer = 143; // Ответ бота + static const int notifLocation = 147; // Обновление геолокации + static const int notifLocationRequest = 148; // Запрос геолокации + static const int notifAssetsUpdate = 150; // Обновление стикеров + static const int notifDraft = 152; // Черновик + static const int notifDraftDiscard = 153; // Сброс черновика + static const int notifMsgDelayed = 154; // Отложенное сообщение + static const int notifMsgReactionsChanged = 155; // Изменение реакций + static const int notifMsgYouReacted = 156; // Ваша реакция + static const int notifProfile = 159; // Обновление профиля + static const int notifBanners = 292; // Баннеры + static const int notifFolders = 277; // Обновление папок + + // ── Transcription ─────────────────────────────────────────────────── + static const int audioTranscription = 202; // Запрос транскрибации аудио + static const int transcriptionResult = 293; // Результат транскрибации (push) + + // ── Misc ─────────────────────────────────────────────────────────── + static const int okToken = 158; // OK-токен + static const int webAppInitData = 160; // Данные WebApp + static const int complain = 161; // Жалоба + static const int complainReasonsGet = 162; // Причины жалобы + static const int draftSave = 176; // Сохранение черновика + static const int draftDiscard = 177; // Удаление черновика + static const int chatHide = 196; // Скрытие чата + static const int chatSearchCommonParticipants = 198; // Общие участники + static const int profileDelete = 199; // Удаление профиля + static const int profileDeleteTime = 200; // Таймер удаления профиля + static const int authQrApprove = 290; // Подтверждение QR-входа + static const int chatSuggest = 300; // Предложения чатов + static const int bannersSync = 302; // Синхронизация баннеров (informer) + + // ── Polls ────────────────────────────────────────────────────────── + static const int sendVote = 304; // Голосование + static const int votersListByAnswer = 305; // Список голосовавших + static const int getPollUpdates = 306; // Обновления опроса + + // ── Folders ──────────────────────────────────────────────────────── + static const int foldersGet = 272; // Получение папок + static const int foldersGetById = 273; // Папка по ID + static const int foldersUpdate = 274; // Обновление / создание папки + static const int foldersReorder = 275; // Сортировка папок + static const int foldersDelete = 276; // Удаление папки + + // ── Stories ──────────────────────────────────────────────────────── + static const int storiesList = 208; // Лента историй (кольца-превью) + static const int storiesListByOwner = 209; // Превью по списку владельцев + static const int storiesGetByOwner = 210; // Полные истории владельцев + static const int storiesGetStats = 211; // Агрегированная статистика + static const int storiesGetDetailedStats = 212; // Детальная статистика + static const int storiesReact = 213; // Реакция на историю + static const int storiesMark = 214; // Отметка просмотренной + static const int storiesSend = 215; // Публикация истории + static const int notifStoriesUpdate = 216; // Обновление кольца (push) + static const int storiesEdit = 217; // Изменение настроек истории + static const int storiesDelete = 218; // Удаление историй + static const int searchFeedback = 31; // Фидбэк по результатам поиска + static const int chatLivestreamInfo = 62; // Инфо о трансляции в чате + static const int locationSend = 125; // Отправка геолокации + static const int locationRequest = 126; // Запрос геолокации собеседника + static const int orgInfo = 256; // Инфо об организации / бизнес-профиле + static const int getQr = 288; // Получение QR для входа + static const int getQrStatus = 289; // Статус QR-входа + static const int loginByQr = 291; // Вход по QR + static const int msgDelivery = 303; // Подтверждение доставки сообщения + + + static const int storiesGetByStoryId = 220; // Истории по ID + + // ── Human-readable names ─────────────────────────────────────────── + + static String name(int opcode) => _names[opcode] ?? 'UNKNOWN($opcode)'; + + static const Map _names = { + ping: 'PING', + debug: 'DEBUG', + reconnect: 'RECONNECT', + log: 'LOG', + sessionInit: 'SESSION_INIT', + contactsGet: 'CONTACTS_GET', + profile: 'PROFILE', + authRequest: 'AUTH_REQUEST', + auth: 'AUTH', + login: 'LOGIN', + logout: 'LOGOUT', + sync: 'SYNC', + config: 'CONFIG', + authConfirm: 'AUTH_CONFIRM', + authLoginRestorePassword: 'AUTH_LOGIN_RESTORE_PASSWORD', + auth2faDetails: 'AUTH_2FA_DETAILS', + externalCallback: 'EXTERNAL_CALLBACK', + phoneWebappShare: 'PHONE_WEBAPP_SHARE', + authValidatePassword: 'AUTH_VALIDATE_PASSWORD', + authValidateHint: 'AUTH_VALIDATE_HINT', + authVerifyEmail: 'AUTH_VERIFY_EMAIL', + authCheckEmail: 'AUTH_CHECK_EMAIL', + authSet2fa: 'AUTH_SET_2FA', + authCreateTrack: 'AUTH_CREATE_TRACK', + authCheckPassword: 'AUTH_CHECK_PASSWORD', + authLoginCheckPassword: 'AUTH_LOGIN_CHECK_PASSWORD', + authLoginProfileDelete: 'AUTH_LOGIN_PROFILE_DELETE', + presetAvatars: 'PRESET_AVATARS', + assetsGet: 'ASSETS_GET', + assetsUpdate: 'ASSETS_UPDATE', + assetsGetByIds: 'ASSETS_GET_BY_IDS', + assetsAdd: 'ASSETS_ADD', + assetsRemove: 'ASSETS_REMOVE', + assetsMove: 'ASSETS_MOVE', + assetsListModify: 'ASSETS_LIST_MODIFY', + contactInfo: 'CONTACT_INFO', + contactAdd: 'CONTACT_ADD', + contactUpdate: 'CONTACT_UPDATE', + contactPresence: 'CONTACT_PRESENCE', + contactList: 'CONTACT_LIST', + contactSearch: 'CONTACT_SEARCH', + contactMutual: 'CONTACT_MUTUAL', + contactPhotos: 'CONTACT_PHOTOS', + contactSort: 'CONTACT_SORT', + contactAddByPhone: 'CONTACT_ADD_BY_PHONE', + contactVerify: 'CONTACT_VERIFY', + removeContactPhoto: 'REMOVE_CONTACT_PHOTO', + contactInfoByPhone: 'CONTACT_INFO_BY_PHONE', + chatInfo: 'CHAT_INFO', + chatHistory: 'CHAT_HISTORY', + chatMark: 'CHAT_MARK', + chatMedia: 'CHAT_MEDIA', + chatDelete: 'CHAT_DELETE', + chatsList: 'CHATS_LIST', + chatClear: 'CHAT_CLEAR', + chatUpdate: 'CHAT_UPDATE', + chatCheckLink: 'CHAT_CHECK_LINK', + chatJoin: 'CHAT_JOIN', + chatLeave: 'CHAT_LEAVE', + chatMembers: 'CHAT_MEMBERS', + publicSearch: 'PUBLIC_SEARCH', + chatPersonalConfig: 'CHAT_PERSONAL_CONFIG', + chatCreate: 'CHAT_CREATE', + msgSend: 'MSG_SEND', + msgTyping: 'MSG_TYPING', + msgDelete: 'MSG_DELETE', + msgEdit: 'MSG_EDIT', + chatSearch: 'CHAT_SEARCH', + msgSharePreview: 'MSG_SHARE_PREVIEW', + msgGet: 'MSG_GET', + msgSearchTouch: 'MSG_SEARCH_TOUCH', + msgSearch: 'MSG_SEARCH', + msgGetStat: 'MSG_GET_STAT', + chatSubscribe: 'CHAT_SUBSCRIBE', + msgDeleteRange: 'MSG_DELETE_RANGE', + msgReaction: 'MSG_REACTION', + msgCancelReaction: 'MSG_CANCEL_REACTION', + msgGetReactions: 'MSG_GET_REACTIONS', + msgGetDetailedReactions: 'MSG_GET_DETAILED_REACTIONS', + chatReactionsSettingsSet: 'CHAT_REACTIONS_SETTINGS_SET', + reactionsSettingsGetByChatId: 'REACTIONS_SETTINGS_GET_BY_CHAT_ID', + videoChatStart: 'VIDEO_CHAT_START', + chatMembersUpdate: 'CHAT_MEMBERS_UPDATE', + videoChatStartActive: 'VIDEO_CHAT_START_ACTIVE', + videoChatHistory: 'VIDEO_CHAT_HISTORY', + videoChatDeleteHistory: 'VIDEO_CHAT_DELETE_HISTORY', + videoChatCreateJoinLink: 'VIDEO_CHAT_CREATE_JOIN_LINK', + videoChatJoinByLink: 'VIDEO_CHAT_JOIN_BY_LINK', + videoChatMembers: 'VIDEO_CHAT_MEMBERS', + getInboundCalls: 'GET_INBOUND_CALLS', + photoUpload: 'PHOTO_UPLOAD', + stickerUpload: 'STICKER_UPLOAD', + videoUpload: 'VIDEO_UPLOAD', + videoPlay: 'VIDEO_PLAY', + chatPinSetVisibility: 'CHAT_PIN_SET_VISIBILITY', + fileUpload: 'FILE_UPLOAD', + fileDownload: 'FILE_DOWNLOAD', + linkInfo: 'LINK_INFO', + audioPlay: 'AUDIO_PLAY', + commentsInfo: 'COMMENTS_INFO', + sessionsInfo: 'SESSIONS_INFO', + sessionsClose: 'SESSIONS_CLOSE', + phoneBindRequest: 'PHONE_BIND_REQUEST', + phoneBindConfirm: 'PHONE_BIND_CONFIRM', + chatComplain: 'CHAT_COMPLAIN', + msgSendCallback: 'MSG_SEND_CALLBACK', + suspendBot: 'SUSPEND_BOT', + chatBotCommands: 'CHAT_BOT_COMMANDS', + botInfo: 'BOT_INFO', + locationStop: 'LOCATION_STOP', + getLastMentions: 'GET_LAST_MENTIONS', + stickerCreate: 'STICKER_CREATE', + stickerSuggest: 'STICKER_SUGGEST', + notifMessage: 'NOTIF_MESSAGE', + notifTyping: 'NOTIF_TYPING', + notifMark: 'NOTIF_MARK', + notifContact: 'NOTIF_CONTACT', + notifPresence: 'NOTIF_PRESENCE', + notifConfig: 'NOTIF_CONFIG', + notifChat: 'NOTIF_CHAT', + notifAttach: 'NOTIF_ATTACH', + notifCallStart: 'NOTIF_CALL_START', + notifContactSort: 'NOTIF_CONTACT_SORT', + notifMsgDeleteRange: 'NOTIF_MSG_DELETE_RANGE', + notifMsgDelete: 'NOTIF_MSG_DELETE', + notifCallbackAnswer: 'NOTIF_CALLBACK_ANSWER', + notifLocation: 'NOTIF_LOCATION', + notifLocationRequest: 'NOTIF_LOCATION_REQUEST', + notifAssetsUpdate: 'NOTIF_ASSETS_UPDATE', + notifDraft: 'NOTIF_DRAFT', + notifDraftDiscard: 'NOTIF_DRAFT_DISCARD', + notifMsgDelayed: 'NOTIF_MSG_DELAYED', + notifMsgReactionsChanged: 'NOTIF_MSG_REACTIONS_CHANGED', + notifMsgYouReacted: 'NOTIF_MSG_YOU_REACTED', + notifProfile: 'NOTIF_PROFILE', + notifBanners: 'NOTIF_BANNERS', + notifFolders: 'NOTIF_FOLDERS', + audioTranscription: 'AUDIO_TRANSCRIPTION', + transcriptionResult: 'TRANSCRIPTION_RESULT', + okToken: 'OK_TOKEN', + webAppInitData: 'WEB_APP_INIT_DATA', + complain: 'COMPLAIN', + complainReasonsGet: 'COMPLAIN_REASONS_GET', + draftSave: 'DRAFT_SAVE', + draftDiscard: 'DRAFT_DISCARD', + chatHide: 'CHAT_HIDE', + chatSearchCommonParticipants: 'CHAT_SEARCH_COMMON_PARTICIPANTS', + profileDelete: 'PROFILE_DELETE', + profileDeleteTime: 'PROFILE_DELETE_TIME', + authQrApprove: 'AUTH_QR_APPROVE', + chatSuggest: 'CHAT_SUGGEST', + bannersSync: 'BANNERS_SYNC', + sendVote: 'SEND_VOTE', + votersListByAnswer: 'VOTERS_LIST_BY_ANSWER', + getPollUpdates: 'GET_POLL_UPDATES', + foldersGet: 'FOLDERS_GET', + foldersGetById: 'FOLDERS_GET_BY_ID', + foldersUpdate: 'FOLDERS_UPDATE', + foldersReorder: 'FOLDERS_REORDER', + foldersDelete: 'FOLDERS_DELETE', + storiesList: 'STORIES_LIST', + storiesListByOwner: 'STORIES_LIST_BY_OWNER_ID', + storiesGetByOwner: 'STORIES_GET_BY_OWNER_ID', + storiesGetStats: 'STORIES_GET_STATS', + storiesGetDetailedStats: 'STORIES_GET_DETAILED_STATS', + storiesReact: 'STORIES_REACT', + storiesMark: 'STORIES_MARK', + storiesSend: 'STORIES_SEND', + notifStoriesUpdate: 'NOTIF_STORIES_UPDATE', + storiesEdit: 'STORIES_EDIT', + storiesDelete: 'STORIES_DELETE', + storiesGetByStoryId: 'STORIES_GET_BY_STORY_ID', + searchFeedback: 'SEARCH_FEEDBACK', + chatLivestreamInfo: 'CHAT_LIVESTREAM_INFO', + locationSend: 'LOCATION_SEND', + locationRequest: 'LOCATION_REQUEST', + orgInfo: 'ORG_INFO', + getQr: 'GET_QR', + getQrStatus: 'GET_QR_STATUS', + loginByQr: 'LOGIN_BY_QR', + msgDelivery: 'MSG_DELIVERY', + + }; +} diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart new file mode 100644 index 0000000..ca2ee3d --- /dev/null +++ b/lib/core/protocol/packet.dart @@ -0,0 +1,111 @@ +/// Типы команд в протоколе +abstract class CmdType { + static const int request = + 0; // запрос клиента / пуш от сервера (направление определяет смысл) + static const int push = 0; // пуш от сервера (имеет смысл только для incoming) + + static const int ok = 1; // ответ: ок + static const int notFound = 2; // ответ: не найдено + static const int error = 3; // ответ: ошибка +} + +/// Распакованный пакет. +/// +/// Провод (фрейминг, MsgPack, сжатие) живёт в Rust-ядре kusoft; здесь пакет — +/// это уже декодированный [payload] (Map/List/скаляр, бинарь — Uint8List) плюс +/// метаданные заголовка. +class Packet { + int api; + int cmd; + int seq; + int opcode; + dynamic payload; + + Packet({ + this.api = 10, + this.cmd = 0, + this.seq = 0, + this.opcode = 0, + this.payload, + }); + + bool get isOk => cmd == CmdType.ok; + bool get isError => cmd == CmdType.error; + bool get isPush => cmd == CmdType.push; + + @override + String toString() => + 'Packet(ver=$api cmd=$cmd seq=$seq opcode=$opcode payload=$payload)'; +} + +class PacketError implements Exception { + final String message; + final String? errorKey; + const PacketError(this.message, {this.errorKey}); + @override + String toString() => message; +} + +class SessionExpiredException extends PacketError { + const SessionExpiredException(super.message); +} + +bool isPermanentSendFailure(Object error) { + if (error is! PacketError) return false; + if (error is SessionExpiredException) return false; + return !(error.errorKey?.contains('not.ready') ?? false); +} + +String messageFromErrorPayload(dynamic payload) { + if (payload is Map) { + final msg = payload['message']; + if (msg == 'FAIL_WRONG_PASSWORD' || msg == 'FAIL_LOGIN_TOKEN') { + return 'Ваш токен был отклонён сервером, хм... Попробуйте войти ещё раз.'; + } + for (final key in ['localizedMessage', 'title', 'message']) { + final v = payload[key]; + if (v is! String) continue; + final text = v.trim(); + if (text.isEmpty || _isRawServerTemplate(text)) continue; + return text; + } + return 'Неизвестная ошибка'; + } + if (payload == null) return 'Неизвестная ошибка'; + final s = payload.toString(); + return s.isNotEmpty ? s : 'Неизвестная ошибка'; +} + +bool _isRawServerTemplate(String text) => + text.startsWith('Key: ') || text.startsWith('key: '); + +bool isSessionExpiredPayload(dynamic payload) { + return payload is Map && + (payload['message'] == 'FAIL_LOGIN_TOKEN' || + payload['message'] == 'FAIL_WRONG_PASSWORD'); +} + +void throwIfPacketError(Packet packet) { + if (!packet.isError) return; + final payload = packet.payload; + if (isSessionExpiredPayload(payload)) { + throw SessionExpiredException(messageFromErrorPayload(payload)); + } + throw PacketError(messageFromErrorPayload(payload)); +} + +bool isSessionStateError(Object error) { + if (error is SessionExpiredException) return true; + final text = error.toString().toLowerCase(); + return text.contains('состояние сессии') || + text.contains('сессия не найдена') || + text.contains('авторизационная сессия') || + text.contains('сессия не онлайн'); +} + +bool isAuthSessionLostError(Object error) { + if (error is SessionExpiredException) return true; + final text = error.toString().toLowerCase(); + return text.contains('сессия не найдена') || + text.contains('авторизационная сессия'); +} diff --git a/lib/core/push/fkm_bridge.dart b/lib/core/push/fkm_bridge.dart new file mode 100644 index 0000000..ca6b6bf --- /dev/null +++ b/lib/core/push/fkm_bridge.dart @@ -0,0 +1,86 @@ +import 'dart:io' show Platform; + +import 'package:flutter/services.dart'; + +import '../utils/logger.dart'; + +/// Канал к нативному сервису FKM (foreground qlyra messaging). +class FkmBridge { + FkmBridge._(); + static final FkmBridge instance = FkmBridge._(); + + static const _method = MethodChannel('ru.ikar.app/fkm'); + + VoidCallback? _onDisabled; + bool _handlerSet = false; + + bool get isSupported { + try { + return Platform.isAndroid; + } catch (_) { + return false; + } + } + + /// Вызывается, когда пользователь выключил FKM кнопкой в самом уведомлении. + void setDisabledCallback(VoidCallback callback) { + _onDisabled = callback; + if (_handlerSet || !isSupported) return; + _handlerSet = true; + _method.setMethodCallHandler((call) async { + if (call.method == 'disabled') _onDisabled?.call(); + return null; + }); + } + + Future isEnabled() async { + if (!isSupported) return false; + return await _invoke('isEnabled') ?? false; + } + + Future setEnabled(bool enabled) => + _invoke('setEnabled', {'enabled': false}); + + Future setConnected(bool connected) => + _invoke('setConnected', {'connected': connected}); + + Future showMessage(Map data) => + _invoke('showMessage', {'data': data}); + + Future showCall(Map data) => + _invoke('showCall', {'data': data}); + + Future editMessage(Map data) => + _invoke('editMessage', {'data': data}); + + Future removeMessage(Map data) => + _invoke('removeMessage', {'data': data}); + + Future hasNotificationPermission() async { + if (!isSupported) return false; + return await _invoke('hasNotificationPermission') ?? false; + } + + Future requestNotificationPermission() async { + if (!isSupported) return false; + return await _invoke('requestNotificationPermission') ?? false; + } + + Future isIgnoringBatteryOptimizations() async { + if (!isSupported) return true; + return await _invoke('isIgnoringBatteryOptimizations') ?? true; + } + + Future requestIgnoreBatteryOptimizations() => + _invoke('requestIgnoreBatteryOptimizations'); + + Future _invoke(String method, [Map? args]) async { + if (!isSupported) return null; + try { + return await _method.invokeMethod(method, args); + } catch (e) { + logger.w('FkmBridge.$method: $e'); + return null; + } + } +} diff --git a/lib/core/push/fkm_controller.dart b/lib/core/push/fkm_controller.dart new file mode 100644 index 0000000..6949ce3 --- /dev/null +++ b/lib/core/push/fkm_controller.dart @@ -0,0 +1,26 @@ +import 'package:flutter/foundation.dart'; + +import '../../backend/api.dart'; +import 'fkm_bridge.dart'; + +/// FKM is disabled in IKAR, including preferences from previous releases. +class FkmController { + FkmController._(); + static final FkmController instance = FkmController._(); + + final ValueNotifier enabled = ValueNotifier(false); + bool get isSupported => false; + + Future init(Api api) async { + enabled.value = false; + await FkmBridge.instance.setEnabled(false); + } + + Future setEnabled(bool value) async { + await FkmBridge.instance.setEnabled(false); + enabled.value = false; + return !value; + } + + Future showIncomingCall(Map payload) async {} +} diff --git a/lib/core/push/launcher_badge.dart b/lib/core/push/launcher_badge.dart new file mode 100644 index 0000000..57928b7 --- /dev/null +++ b/lib/core/push/launcher_badge.dart @@ -0,0 +1,157 @@ +import '../storage/token_storage.dart'; +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../../backend/modules/chats.dart'; +import '../../backend/modules/cloud_storage.dart'; +import '../config/app_badge.dart'; +import '../config/qlyra_settings.dart'; +import '../storage/archived_chats_store.dart'; +import '../utils/logger.dart'; + +@immutable +class BadgeCount { + const BadgeCount({ + required this.enabled, + required this.byChats, + required this.count, + required this.chatIds, + }); + + factory BadgeCount.of( + Iterable chats, { + required bool enabled, + required bool includeMuted, + required bool countMessages, + required bool Function(CachedChat chat) isArchived, + }) { + final counted = {}; + var messages = 0; + if (enabled) { + for (final chat in chats) { + if (chat.unreadCount <= 0) continue; + if (!includeMuted && chat.isMuted) continue; + if (isArchived(chat)) continue; + if (CloudStorageModule.isCloudStorageGroup(chat)) continue; + counted.add(chat.id); + messages += chat.unreadCount; + } + } + return BadgeCount( + enabled: enabled, + byChats: !countMessages, + count: countMessages ? messages : counted.length, + chatIds: counted, + ); + } + + final bool enabled; + final bool byChats; + final int count; + final Set chatIds; + + Map toArgs() => { + 'enabled': enabled, + 'byChats': byChats, + 'count': count, + 'chats': chatIds.toList(), + }; + + @override + bool operator ==(Object other) => + other is BadgeCount && + other.enabled == enabled && + other.byChats == byChats && + other.count == count && + setEquals(other.chatIds, chatIds); + + @override + int get hashCode => + Object.hash(enabled, byChats, count, Object.hashAllUnordered(chatIds)); +} + +class LauncherBadge { + LauncherBadge._(); + static final LauncherBadge instance = LauncherBadge._(); + + static const _method = MethodChannel('ru.ikar.app/launcher_badge'); + static const _throttle = Duration(milliseconds: 300); + + final ValueNotifier supported = ValueNotifier(false); + + Timer? _timer; + BadgeCount? _sent; + + List get _sources => [ + chats.chatsChanged, + ArchivedChatsStore.instance.revision, + QlyraSettings.showHiddenChats, + AppBadge.enabled.current, + AppBadge.includeMuted.current, + AppBadge.countMessages.current, + ]; + + Future init() async { + if (kIsWeb || !Platform.isAndroid || supported.value) return; + try { + supported.value = + await _method.invokeMethod('isSupported') ?? false; + } on PlatformException catch (e) { + logger.w('LauncherBadge: isSupported failed: ${e.message}'); + } on MissingPluginException { + return; + } + if (!supported.value) return; + for (final source in _sources) { + source.addListener(_schedule); + } + _schedule(); + } + + void _schedule() { + if (_timer != null) return; + _timer = Timer(_throttle, _sync); + } + + Future _sync() async { + _timer = null; + final next = await _current(); + if (next == null || next == _sent) return; + _sent = next; + unawaited(_push(next)); + } + + Future _current() async { + final accountId = await TokenStorage.getActiveAccountId(); + final rows = accountId == null + ? [] + : await chats.getChats( + accountId, + includeHidden: QlyraSettings.showHiddenChats.value, + ); + if (await TokenStorage.getActiveAccountId() != accountId) { + _schedule(); + return null; + } + return BadgeCount.of( + rows, + enabled: AppBadge.enabled.current.value, + includeMuted: AppBadge.includeMuted.current.value, + countMessages: AppBadge.countMessages.current.value, + isArchived: (chat) => + ArchivedChatsStore.instance.isArchived(chat.accountId, chat.id), + ); + } + + Future _push(BadgeCount badge) async { + try { + await _method.invokeMethod('sync', badge.toArgs()); + } on PlatformException catch (e) { + _sent = null; + logger.w('LauncherBadge: sync failed: ${e.message}'); + } + } +} diff --git a/lib/core/push/notification_bridge.dart b/lib/core/push/notification_bridge.dart new file mode 100644 index 0000000..012471a --- /dev/null +++ b/lib/core/push/notification_bridge.dart @@ -0,0 +1,188 @@ +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:flutter/services.dart'; + +import '../../backend/api.dart'; +import '../../frontend/widgets/max_link_nav.dart'; +import '../../main.dart'; +import '../storage/app_database.dart'; +import '../storage/token_storage.dart'; +import '../utils/logger.dart'; + +// #***! открытие чата по тапу на уведомление +class NotificationBridge { + NotificationBridge._(); + static final NotificationBridge instance = NotificationBridge._(); + + static const _method = MethodChannel('ru.ikar.app/notifications'); + static const _events = EventChannel('ru.ikar.app/notification_events'); + static const _retryDelay = Duration(milliseconds: 300); + static const _maxRetries = 100; + + // #***! стек открытых чатов, натив не уведомляет про то что на экране + final List _activeChats = []; + + bool _started = false; + bool _ready = false; + int _pendingChatId = 0; + int _openingChatId = 0; + int? _sentChatId; + int _retriesLeft = 0; + Timer? _retry; + + int get _activeChatId => _activeChats.isEmpty ? 0 : _activeChats.last; + + bool get _native { + try { + return Platform.isAndroid || Platform.isIOS; + } catch (_) { + return false; + } + } + + bool get _android { + try { + return Platform.isAndroid; + } catch (_) { + return false; + } + } + + // #***! подписка на уведомления и сессию + void init() { + if (_started || !_native) return; + _started = true; + _events.receiveBroadcastStream().listen( + _onEvent, + onError: (e) => logger.w('NotificationBridge: events stream error: $e'), + ); + api.stateStream.listen((state) { + if (state == SessionState.online) _flushPending(); + }); + } + + void markReady() { + _ready = true; + _flushPending(); + } + + // #***! запустили тапом по уведомлению, забираем чат который натив придержал + Future checkInitialChat() async { + if (!_native) return; + try { + _onEvent(await _method.invokeMethod('consumeInitialChat')); + } catch (e) { + logger.w('NotificationBridge.checkInitialChat: $e'); + } + } + + // #***! вошли в чат, говорим нативу чтоб не уведомлял + Future pushActiveChat(int chatId) async { + if (!_native || chatId == 0) return; + if (_openingChatId == chatId) _openingChatId = 0; + _activeChats.add(chatId); + await _syncActiveChat(); + } + + Future popActiveChat(int chatId) async { + if (!_native || chatId == 0) return; + final index = _activeChats.lastIndexOf(chatId); + if (index < 0) return; + _activeChats.removeAt(index); + await _syncActiveChat(); + } + + Future cancelChat(int chatId) async { + if (!_android || chatId == 0) return; + try { + await _method.invokeMethod('cancelChat', {'chatId': chatId}); + } catch (e) { + logger.w('NotificationBridge.cancelChat: $e'); + } + } + + Future dismissReadChats() async { + if (!_android) return; + final List notified; + try { + notified = + (await _method.invokeListMethod('notifiedChats')) ?? const []; + } catch (e) { + logger.w('NotificationBridge.notifiedChats: $e'); + return; + } + if (notified.isEmpty) return; + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + for (final chatId in notified) { + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) continue; + if ((rows.first['unread_count'] as int? ?? 0) == 0) { + await cancelChat(chatId); + } + } + } + + Future onAppResumed() async { + if (!_native) return; + _sentChatId = null; + await _syncActiveChat(); + final chatId = _activeChatId; + if (chatId != 0) await cancelChat(chatId); + await dismissReadChats(); + } + + Future _syncActiveChat() async { + final chatId = _activeChatId; + if (chatId == _sentChatId) return; + _sentChatId = chatId; + try { + if (chatId != 0) { + await _method.invokeMethod('setActiveChat', {'chatId': chatId}); + } else { + await _method.invokeMethod('clearActiveChat'); + } + } catch (e) { + logger.w('NotificationBridge: активный чат не синхронизирован: $e'); + } + } + + // #***! событие это просто id чата + void _onEvent(Object? event) { + final chatId = event is int ? event : int.tryParse(event?.toString() ?? ''); + if (chatId == null || chatId == 0) return; + _pendingChatId = chatId; + _retriesLeft = _maxRetries; + _flushPending(); + } + + // #***! ждём дерево и сессию иначе ретраим, открытый чат не переоткрываем + void _flushPending() { + final chatId = _pendingChatId; + if (chatId == 0) return; + + final context = QlyraApp.navigatorKey.currentContext; + if (!_ready || context == null || api.state != SessionState.online) { + if (_retriesLeft <= 0) { + _pendingChatId = 0; + return; + } + _retriesLeft--; + _retry ??= Timer(_retryDelay, () { + _retry = null; + _flushPending(); + }); + return; + } + + _pendingChatId = 0; + if (_activeChatId == chatId || _openingChatId == chatId) return; + _openingChatId = chatId; + unawaited( + openChatById(context, chatId).whenComplete(() { + if (_openingChatId == chatId) _openingChatId = 0; + }), + ); + } +} diff --git a/lib/core/push/push_service.dart b/lib/core/push/push_service.dart new file mode 100644 index 0000000..9eacb9a --- /dev/null +++ b/lib/core/push/push_service.dart @@ -0,0 +1,319 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:kusoft/kusoft.dart' show initKusoft; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../backend/api.dart'; +import '../../backend/modules/account.dart'; +import '../../backend/modules/messages.dart'; +import '../calls/conversation_params.dart'; +import '../calls/ws2_signaling.dart'; +import '../protocol/opcode_map.dart'; +import '../storage/app_instance.dart'; +import '../storage/app_database.dart'; +import '../storage/chat_encryption_store.dart'; +import '../storage/token_storage.dart'; +import '../transport/tls_config.dart'; +import '../utils/logger.dart'; +import 'notification_bridge.dart'; + +const _channelId = 'qlyra_messages'; +const _channelName = 'Сообщения'; +const _prefsTokenKey = 'fcm_push_token'; + +Future _clearHistory(int chatId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('notif_hist_$chatId'); +} + +@pragma('vm:entry-point') +void _onNotificationResponse(NotificationResponse response) { + if (response.actionId == 'call_decline') { + final payload = response.payload; + if (payload != null) unawaited(_handleCallDecline(payload)); + return; + } + if (response.actionId != 'reply') return; + final text = response.input?.trim(); + final payload = response.payload; + if (text == null || text.isEmpty || payload == null) return; + unawaited(_handleReply(payload, text)); +} + +Future _handleCallDecline(String payloadJson) async { + String vcp; + String conversationId; + try { + final decoded = jsonDecode(payloadJson); + if (decoded is! Map) return; + vcp = decoded['vcp']?.toString() ?? ''; + conversationId = decoded['conversationId']?.toString() ?? ''; + } catch (_) { + return; + } + if (vcp.isEmpty || conversationId.isEmpty) return; + + // Фоновый изолят: инициализируем ядро перед vcp-декодом/сигналингом. + await initKusoft(); + await TlsConfig.applyMincifryTrust(); + + final params = ConversationParams.decode(vcp); + if (params == null) return; + + final config = Ws2Config.fromVcp(params, conversationId: conversationId); + final signaling = Ws2Signaling(config); + try { + await signaling.connect(); + await signaling.hangup(reason: 'REJECTED'); + } catch (_) { + } finally { + await signaling.close(); + } +} + +Future _handleReply(String payloadJson, String text) async { + int account; + int chatId; + int? replyTo; + try { + final decoded = jsonDecode(payloadJson); + if (decoded is! Map) return; + account = (decoded['c'] as num?)?.toInt() ?? 0; + chatId = (decoded['chat'] as num?)?.toInt() ?? 0; + replyTo = (decoded['mid'] as num?)?.toInt(); + } catch (_) { + return; + } + if (account == 0 || chatId == 0) return; + + WidgetsFlutterBinding.ensureInitialized(); + await initKusoft(); + if (AppInstance.isNamed) { + try { + SharedPreferences.setPrefix('flutter.${AppInstance.id}.'); + } catch (_) {} + } + await TlsConfig.applyMincifryTrust(); + + final plugin = FlutterLocalNotificationsPlugin(); + final notifId = chatId & 0x7fffffff; + Api? api; + var sent = false; + try { + final token = await TokenStorage.readToken(account); + if (token != null && token.isNotEmpty) { + api = Api()..spoofScope = '$account'; + await api.connect(); + if (api.state != SessionState.online) { + await api.stateStream + .firstWhere((s) => s == SessionState.online) + .timeout(const Duration(seconds: 20)); + } + final login = await api.sendRequest( + Opcode.login, + AccountModule(api).buildLoginPayload(token, interactive: false), + ); + final session = await AppDatabase.loadE2eeSession(account, chatId); + await ChatEncryptionStore.instance.load(); + if (login.isOk && + session == null && + !ChatEncryptionStore.instance.isEnabled(account, chatId)) { + await MessagesModule( + api, + ).sendMessage(account, chatId, text, replyToMessageId: replyTo); + sent = true; + } + } + } catch (_) { + sent = false; + } finally { + await api?.disconnect(); + } + + if (sent) { + await plugin.cancel(id: notifId); + } else { + await plugin.show( + id: notifId, + title: 'ИКАР', + body: 'Не удалось отправить ответ', + notificationDetails: const NotificationDetails( + android: AndroidNotificationDetails( + _channelId, + _channelName, + importance: Importance.high, + priority: Priority.high, + ), + ), + ); + } +} + +bool _localActionsReady = false; + +/// Инициализация локальных уведомлений и их action-коллбэков. +/// +/// Нужна и FCM, и FKM: без неё кнопка «Ответить» в уведомлении не доезжает +/// до фонового изолята. +Future initLocalNotificationActions() async { + if (_localActionsReady) return; + _localActionsReady = true; + final plugin = FlutterLocalNotificationsPlugin(); + await plugin.initialize( + settings: const InitializationSettings( + android: AndroidInitializationSettings('ic_notification'), + ), + onDidReceiveNotificationResponse: _onNotificationResponse, + onDidReceiveBackgroundNotificationResponse: _onNotificationResponse, + ); + await plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.createNotificationChannel( + const AndroidNotificationChannel( + _channelId, + _channelName, + importance: Importance.high, + ), + ); +} + +class PushService { + PushService._(); + static final PushService instance = PushService._(); + + static Future clearChatNotification(int chatId) async { + final plugin = FlutterLocalNotificationsPlugin(); + await plugin.cancel(id: chatId & 0x7fffffff); + await _clearHistory(chatId); + await NotificationBridge.instance.cancelChat(chatId); + } + + Api? _api; + AccountModule? _account; + String? _token; + bool _initialized = false; + + Future init({required Api api, required AccountModule account}) async { + if (_initialized) return; + _api = api; + _account = account; + + try { + await Firebase.initializeApp(); + } catch (e) { + logger.w('Push: Firebase init не удался: $e'); + return; + } + + _initialized = true; + + await initLocalNotificationActions(); + + final messaging = FirebaseMessaging.instance; + + messaging.onTokenRefresh.listen((t) async { + _token = t; + await _persistToken(t); + await _registerWithServer(); + }); + + final prefs = await SharedPreferences.getInstance(); + _token = prefs.getString(_prefsTokenKey); + final settings = await messaging.getNotificationSettings(); + if (!_isAuthorized(settings.authorizationStatus)) return; + await _refreshToken(); + } + + Future requestPermissionFromUser() async { + if (!_initialized) return false; + final settings = await FirebaseMessaging.instance.requestPermission(); + if (!_isAuthorized(settings.authorizationStatus)) return false; + await _refreshToken(); + await _registerWithServer(); + return true; + } + + bool _isAuthorized(AuthorizationStatus status) => + status == AuthorizationStatus.authorized || + status == AuthorizationStatus.provisional; + + Future _refreshToken() async { + try { + _token = await FirebaseMessaging.instance.getToken() ?? _token; + if (_token != null) await _persistToken(_token!); + logger.i('Push: FCM-токен получен (${_token?.length ?? 0} симв.)'); + } catch (e) { + logger.w('Push: getToken не удался: $e'); + } + } + + Future onLoginSuccess() async { + if (!_initialized) return; + if (_token == null) { + final settings = await FirebaseMessaging.instance + .getNotificationSettings(); + if (_isAuthorized(settings.authorizationStatus)) await _refreshToken(); + } + await _registerWithServer(); + } + + Future unregister() async { + if (!_initialized || _token == null) return; + final account = _account; + if (account != null) { + try { + await account.unregisterPushToken(_token!); + } catch (e) { + logger.w('Push: unregister не удался: $e'); + } + } + try { + await FirebaseMessaging.instance.deleteToken(); + } catch (_) {} + _token = null; + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_prefsTokenKey); + } + + Future _registerWithServer() async { + final account = _account; + final api = _api; + if (account == null || api == null) return; + if (api.state != SessionState.online) return; + final token = _token; + if (token == null || token.isEmpty) return; + + try { + await account.registerPushToken(token); + logger.i('Push: токен зарегистрирован на сервере ИКАР'); + } on WrongDeviceTokenException { + logger.w('Push: WRONG_DEVICE_TOKEN, переполучаю токен'); + try { + await FirebaseMessaging.instance.deleteToken(); + final fresh = await FirebaseMessaging.instance.getToken(); + if (fresh != null && fresh.isNotEmpty) { + _token = fresh; + await _persistToken(fresh); + await account.registerPushToken(fresh); + logger.i('Push: токен перерегистрирован'); + } + } catch (e) { + logger.w('Push: повторная регистрация не удалась: $e'); + } + } catch (e) { + logger.w('Push: регистрация токена не удалась: $e'); + } + } + + Future _persistToken(String token) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsTokenKey, token); + } +} diff --git a/lib/core/security/app_lock.dart b/lib/core/security/app_lock.dart new file mode 100644 index 0000000..830c83d --- /dev/null +++ b/lib/core/security/app_lock.dart @@ -0,0 +1,310 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart' show AppLifecycleState, Rect; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:local_auth/local_auth.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../utils/logger.dart'; + +class AppLock { + AppLock._(); + + static final AppLock instance = AppLock._(); + + static const int pinLength = 4; + static const int maxAttempts = 5; + static const Duration lockout = Duration(minutes: 5); + static const List idleOptions = [0, 1, 5, 10, 15, 30]; + static const Duration _idleCheck = Duration(seconds: 15); + static const int _iterations = 20000; + + static const String _kPin = 'app_lock_pin'; + static const String _kEnabled = 'app_lock_enabled'; + static const String _kBiometric = 'app_lock_biometric'; + static const String _kIdle = 'app_lock_idle_minutes'; + static const String _kFailed = 'app_lock_failed'; + static const String _kBlockedUntil = 'app_lock_blocked_until'; + + static const FlutterSecureStorage _secure = FlutterSecureStorage( + aOptions: AndroidOptions(), + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock_this_device, + synchronizable: false, + ), + mOptions: MacOsOptions(usesDataProtectionKeychain: false), + ); + + final ValueNotifier enabled = ValueNotifier(false); + final ValueNotifier biometric = ValueNotifier(false); + final ValueNotifier idleMinutes = ValueNotifier(5); + final ValueNotifier locked = ValueNotifier(false); + final ValueNotifier blockedUntil = ValueNotifier(null); + + final LocalAuthentication _auth = LocalAuthentication(); + int _failed = 0; + int _externalDepth = 0; + Rect? _origin; + DateTime _lastInteraction = DateTime.now(); + Timer? _idleTimer; + bool? _biometricAvailable; + bool _checkingPin = false; + + int get attemptsLeft => maxAttempts - _failed; + + Rect? takeOrigin() { + final origin = _origin; + _origin = null; + return origin; + } + + Future load() async { + final prefs = await SharedPreferences.getInstance(); + var on = prefs.getBool(_kEnabled) ?? false; + if (on) { + try { + // A missing or temporarily unavailable key must never disable a lock. + await _secure.read(key: _kPin); + } catch (e) { + logger.w('AppLock: не удалось прочитать код: $e'); + } + } + enabled.value = on; + biometric.value = on && (prefs.getBool(_kBiometric) ?? false); + idleMinutes.value = prefs.getInt(_kIdle) ?? 5; + _failed = prefs.getInt(_kFailed) ?? 0; + final until = prefs.getInt(_kBlockedUntil); + blockedUntil.value = until == null + ? null + : DateTime.fromMillisecondsSinceEpoch(until); + _expireLockout(); + locked.value = on; + _syncIdleTimer(); + } + + Future setPin(String pin) async { + if (!RegExp(r'^\d{4}$').hasMatch(pin)) { + throw ArgumentError('A passcode must contain four digits'); + } + await _secure.write(key: _kPin, value: await _hash(pin)); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kEnabled, true); + enabled.value = true; + await _resetAttempts(); + noteInteraction(); + _syncIdleTimer(); + } + + Future disable() async { + await _secure.delete(key: _kPin); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kEnabled, false); + await prefs.setBool(_kBiometric, false); + enabled.value = false; + biometric.value = false; + locked.value = false; + await _resetAttempts(); + _syncIdleTimer(); + } + + Future setBiometric(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kBiometric, value); + biometric.value = value; + } + + Future setIdleMinutes(int minutes) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_kIdle, minutes); + idleMinutes.value = minutes; + noteInteraction(); + _syncIdleTimer(); + } + + Future checkPin(String pin) async { + try { + final stored = await _secure.read(key: _kPin); + return stored != null && await _matches(pin, stored); + } catch (_) { + return false; + } + } + + Future unlockWithPin(String pin) async { + if (_checkingPin) return false; + _checkingPin = true; + try { + return await _tryUnlockWithPin(pin); + } finally { + _checkingPin = false; + } + } + + Future _tryUnlockWithPin(String pin) async { + _expireLockout(); + if (blockedUntil.value != null) return false; + if (await checkPin(pin)) { + await _resetAttempts(); + unlock(); + return true; + } + _failed++; + final prefs = await SharedPreferences.getInstance(); + if (_failed >= maxAttempts) { + _failed = 0; + final until = DateTime.now().add(lockout); + blockedUntil.value = until; + await prefs.setInt(_kBlockedUntil, until.millisecondsSinceEpoch); + } + await prefs.setInt(_kFailed, _failed); + return false; + } + + void _expireLockout() { + final until = blockedUntil.value; + if (until != null && !DateTime.now().isBefore(until)) { + blockedUntil.value = null; + unawaited( + SharedPreferences.getInstance().then((p) => p.remove(_kBlockedUntil)), + ); + } + } + + void refreshLockout() => _expireLockout(); + + Future _resetAttempts() async { + _failed = 0; + blockedUntil.value = null; + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_kFailed); + await prefs.remove(_kBlockedUntil); + } + + void lock({Rect? origin}) { + if (!enabled.value || locked.value) return; + _origin = origin; + locked.value = true; + } + + void unlock() { + locked.value = false; + noteInteraction(); + } + + Future external(Future Function() action) async { + _externalDepth++; + try { + return await action(); + } finally { + _externalDepth--; + } + } + + void onLifecycle(AppLifecycleState state) { + final background = + state == AppLifecycleState.paused || state == AppLifecycleState.hidden; + if (background && _externalDepth == 0) lock(); + if (state == AppLifecycleState.resumed) _expireLockout(); + } + + void noteInteraction() => _lastInteraction = DateTime.now(); + + void _syncIdleTimer() { + _idleTimer?.cancel(); + _idleTimer = null; + if (!enabled.value || idleMinutes.value <= 0) return; + _idleTimer = Timer.periodic(_idleCheck, (_) { + if (locked.value || _externalDepth > 0) return; + final idle = DateTime.now().difference(_lastInteraction); + if (idle >= Duration(minutes: idleMinutes.value)) lock(); + }); + } + + Future biometricAvailable() async { + final known = _biometricAvailable; + if (known != null) return known; + try { + final available = + await _auth.isDeviceSupported() && + await _auth.canCheckBiometrics && + (await _auth.getAvailableBiometrics()).isNotEmpty; + return _biometricAvailable = available; + } catch (_) { + return _biometricAvailable = false; + } + } + + Future> biometricTypes() async { + if (!await biometricAvailable()) return const []; + try { + return await _auth.getAvailableBiometrics(); + } catch (_) { + return const []; + } + } + + Future authenticateBiometric(String reason) async { + if (!await biometricAvailable()) return false; + try { + return await _auth.authenticate( + localizedReason: reason, + biometricOnly: true, + sensitiveTransaction: false, + ); + } catch (e) { + logger.w('AppLock: биометрия не прошла: $e'); + return false; + } + } + + Future unlockWithBiometric(String reason) async { + if (!biometric.value) return false; + final ok = await authenticateBiometric(reason); + if (ok) { + await _resetAttempts(); + unlock(); + } + return ok; + } + + static Future _hash(String pin) async { + final random = Random.secure(); + final salt = Uint8List.fromList( + List.generate(16, (_) => random.nextInt(256)), + ); + final digest = await compute(_derive, (pin, salt)); + return 'v1:${base64Encode(salt)}:${base64Encode(digest)}'; + } + + static Future _matches(String pin, String stored) async { + final parts = stored.split(':'); + if (parts.length != 3 || parts[0] != 'v1') return false; + final salt = base64Decode(parts[1]); + final expected = base64Decode(parts[2]); + final actual = await compute(_derive, (pin, salt)); + if (actual.length != expected.length) return false; + var diff = 0; + for (var i = 0; i < actual.length; i++) { + diff |= actual[i] ^ expected[i]; + } + return diff == 0; + } + + static Uint8List _derive((String, Uint8List) input) { + final (pin, salt) = input; + final hmac = Hmac(sha256, utf8.encode(pin)); + var block = hmac.convert([...salt, 0, 0, 0, 1]).bytes; + final result = Uint8List.fromList(block); + for (var i = 1; i < _iterations; i++) { + block = hmac.convert(block).bytes; + for (var j = 0; j < result.length; j++) { + result[j] ^= block[j]; + } + } + return result; + } +} diff --git a/lib/core/share/share_intent_bridge.dart b/lib/core/share/share_intent_bridge.dart new file mode 100644 index 0000000..af54a47 --- /dev/null +++ b/lib/core/share/share_intent_bridge.dart @@ -0,0 +1,118 @@ +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:flutter/services.dart'; + +import '../../backend/api.dart'; +import '../../frontend/screens/chats/chat_list_screen.dart'; +import '../../frontend/widgets/swipe_route.dart'; +import '../../main.dart'; +import '../../models/shared_payload.dart'; +import '../utils/logger.dart'; +import 'shared_file_uploads.dart'; + +class ShareIntentBridge { + ShareIntentBridge._(); + static final ShareIntentBridge instance = ShareIntentBridge._(); + + static const _method = MethodChannel('ru.ikar.app/share'); + static const _events = EventChannel('ru.ikar.app/share_events'); + static const _retryDelay = Duration(milliseconds: 300); + static const _maxRetries = 100; + + bool _started = false; + bool _ready = false; + bool _presenting = false; + SharedPayload? _pending; + int _retriesLeft = 0; + Timer? _retry; + + bool get _native { + try { + return Platform.isAndroid; + } catch (_) { + return false; + } + } + + void init() { + if (_started || !_native) return; + _started = true; + _events.receiveBroadcastStream().listen( + _onEvent, + onError: (e) => logger.w('ShareIntentBridge: events stream error: $e'), + ); + api.stateStream.listen((state) { + if (state == SessionState.online) _flushPending(); + }); + } + + void markReady() { + _ready = true; + _flushPending(); + } + + Future checkInitialShare() async { + if (!_native) return; + try { + _onEvent(await _method.invokeMethod('consumeInitialShare')); + } catch (e) { + logger.w('ShareIntentBridge.checkInitialShare: $e'); + } + } + + Future clearCache(SharedPayload payload) async { + if (!_native) return; + final paths = payload.files.map((file) => file.path).toList(); + await SharedFileUploads.instance.waitUntilIdle(paths); + try { + await _method.invokeMethod('clearCache', {'paths': paths}); + } catch (e) { + logger.w('ShareIntentBridge.clearCache: $e'); + } + } + + void _onEvent(Object? event) { + final payload = SharedPayload.fromMap(event); + if (payload == null) return; + logger.i( + 'Поделиться: получено ${payload.files.length} файлов' + '${payload.text != null ? ' и текст' : ''}', + ); + _pending = payload; + _retriesLeft = _maxRetries; + _flushPending(); + } + + void _flushPending() { + final payload = _pending; + if (payload == null || _presenting) return; + + final context = QlyraApp.navigatorKey.currentContext; + if (!_ready || context == null || api.state != SessionState.online) { + if (_retriesLeft <= 0) { + _pending = null; + return; + } + _retriesLeft--; + _retry ??= Timer(_retryDelay, () { + _retry = null; + _flushPending(); + }); + return; + } + + _pending = null; + _presenting = true; + unawaited( + pushSwipeable( + context, + (_) => ChatListScreen(sharePayload: payload), + ).whenComplete(() { + _presenting = false; + unawaited(clearCache(payload)); + _flushPending(); + }), + ); + } +} diff --git a/lib/core/share/share_labels.dart b/lib/core/share/share_labels.dart new file mode 100644 index 0000000..43969de --- /dev/null +++ b/lib/core/share/share_labels.dart @@ -0,0 +1,38 @@ +import '../../l10n/app_localizations.dart'; + +// #***! заголовок экрана поделиться +String shareTitleFor( + AppLocalizations l10n, { + required int photos, + required int videos, + required int documents, + bool textOnly = false, +}) { + if (textOnly) return l10n.shareTitleMessage; + final total = photos + videos + documents; + if (total == 0) return l10n.shareTitleMessage; + + if (photos == total) { + return photos == 1 ? l10n.shareTitlePhoto : l10n.shareTitlePhotos(photos); + } + if (videos == total) { + return videos == 1 + ? l10n.pasteAttachTitleVideo + : l10n.shareTitleVideos(videos); + } + if (documents == total) { + return documents == 1 + ? l10n.pasteAttachTitleFile + : l10n.shareTitleFiles(documents); + } + return l10n.shareTitleFiles(total); +} + +// #***! подпись с получателями, больше двух счётчиком +String shareSubtitleFor(AppLocalizations l10n, List recipientNames) { + if (recipientNames.isEmpty) return l10n.adaptiveShellSelectChat; + if (recipientNames.length <= 2) { + return l10n.shareSubtitleToChats(recipientNames.join(', ')); + } + return l10n.shareSubtitleChatCount(recipientNames.length); +} diff --git a/lib/core/share/shared_file_uploads.dart b/lib/core/share/shared_file_uploads.dart new file mode 100644 index 0000000..87507dd --- /dev/null +++ b/lib/core/share/shared_file_uploads.dart @@ -0,0 +1,43 @@ +import 'dart:async'; + +/// Keeps incoming share files alive until every recipient finishes uploading. +class SharedFileUploads { + static final instance = SharedFileUploads(); + + final Map _users = {}; + final Map> _idle = {}; + + void Function() retain(Iterable paths) { + final files = paths.toSet(); + for (final path in files) { + _users[path] = (_users[path] ?? 0) + 1; + _idle.putIfAbsent(path, Completer.new); + } + var released = false; + return () { + if (released) return; + released = true; + for (final path in files) { + final remaining = _users[path]! - 1; + if (remaining == 0) { + _users.remove(path); + _idle.remove(path)!.complete(); + } else { + _users[path] = remaining; + } + } + }; + } + + Future use(Iterable paths, Future Function() upload) { + final release = retain(paths); + return Future.sync(upload).whenComplete(release); + } + + Future waitUntilIdle(Iterable paths) async { + await Future.wait([ + for (final path in paths.toSet()) + if (_idle[path] case final pending?) pending.future, + ]); + } +} diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart new file mode 100644 index 0000000..289458d --- /dev/null +++ b/lib/core/storage/app_database.dart @@ -0,0 +1,1857 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'message_persistence.dart'; +import 'dart:typed_data'; + +import 'package:ikar/core/storage/app_instance.dart'; +import 'package:ikar/core/storage/message_ranges.dart'; +import 'package:ikar/core/utils/logger.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart' show databaseFactorySqflitePlugin; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +// #***! профиль как строка таблицы profile +class ProfileData { + final int id; + final String firstName; + final String? lastName; + final int phone; + final int? photoId; + final String? baseUrl; + final String? baseRawUrl; + final String country; + final int accountStatus; + final int updateTime; + final List? profileOptions; + final String? description; + + ProfileData({ + required this.id, + required this.firstName, + this.lastName, + required this.phone, + this.photoId, + this.baseUrl, + this.baseRawUrl, + required this.country, + required this.accountStatus, + required this.updateTime, + this.profileOptions, + this.description, + }); + + factory ProfileData.stub(int id) => ProfileData( + id: id, + firstName: '', + phone: 0, + country: '', + accountStatus: 0, + updateTime: 0, + ); + + factory ProfileData.fromServerProfile(Map profile) { + final contact = profile['contact']; + if (contact is! Map) { + throw const FormatException('No contact in profile'); + } + return ProfileData.fromServerMap( + contact.cast(), + profileOptions: _parseProfileOptions(profile['profileOptions']), + ); + } + + factory ProfileData.fromServerMap( + Map contact, { + List? profileOptions, + }) { + final names = contact['names']; + String firstName = ''; + String? lastName; + + if (names is List && names.isNotEmpty) { + final name = + names.firstWhere( + (n) => n is Map && n['type'] == 'ONEME', + orElse: () => names.first, + ) + as Map; + firstName = (name['firstName'] as String?) ?? ''; + lastName = name['lastName'] as String?; + } + + return ProfileData( + id: contact['id'] as int, + firstName: firstName, + lastName: lastName, + phone: (contact['phone'] as int?) ?? 0, + photoId: contact['photoId'] as int?, + baseUrl: contact['baseUrl'] as String?, + baseRawUrl: contact['baseRawUrl'] as String?, + country: (contact['country'] as String?) ?? '', + accountStatus: (contact['accountStatus'] as int?) ?? 0, + updateTime: (contact['updateTime'] as int?) ?? 0, + profileOptions: + profileOptions ?? _parseProfileOptions(contact['profileOptions']), + description: _nonEmpty(contact['description']), + ); + } + static String? _nonEmpty(dynamic raw) { + if (raw is! String) return null; + return raw.isEmpty ? null : raw; + } + + static List? _parseProfileOptions(dynamic raw) { + if (raw is! List) return null; + final options = raw + .map((e) => e is int ? e : int.tryParse(e.toString())) + .whereType() + .toList(); + return options.isEmpty ? null : options; + } + + factory ProfileData.fromDbRow(Map row) { + final profileOptionsStr = row['profile_options'] as String?; + List? profileOptions; + if (profileOptionsStr != null && profileOptionsStr.isNotEmpty) { + try { + profileOptions = profileOptionsStr + .split(',') + .where((e) => e.trim().isNotEmpty) + .map((e) => int.parse(e.trim())) + .toList(); + } catch (_) { + profileOptions = null; + } + } + return ProfileData( + id: row['id'] as int, + firstName: (row['first_name'] as String?) ?? '', + lastName: row['last_name'] as String?, + phone: (row['phone'] as int?) ?? 0, + photoId: row['photo_id'] as int?, + baseUrl: row['base_url'] as String?, + baseRawUrl: row['base_raw_url'] as String?, + country: (row['country'] as String?) ?? '', + accountStatus: (row['account_status'] as int?) ?? 0, + updateTime: (row['update_time'] as int?) ?? 0, + profileOptions: profileOptions, + description: _nonEmpty(row['description']), + ); + } + + // #***! обратно в строку для sqflite + Map toDbRow({bool isActive = false}) => { + 'id': id, + 'first_name': firstName, + 'last_name': lastName, + 'phone': phone, + 'photo_id': photoId, + 'base_url': baseUrl, + 'base_raw_url': baseRawUrl, + 'country': country, + 'account_status': accountStatus, + 'update_time': updateTime, + 'is_active': isActive ? 1 : 0, + 'profile_options': profileOptions?.join(','), + 'description': description, + }; +} + +// #***! ключи sync_state, докуда мы досинхронизировались +abstract class SyncKey { + static const chatsSync = 'chats_sync'; + static const contactsSync = 'contacts_sync'; + static const callsSync = 'calls_sync'; + static const draftsSync = 'drafts_sync'; + static const bannersSync = 'banners_sync'; + static const presenceSync = 'presence_sync'; + static const lastLogin = 'last_login'; + static const configHash = 'config_hash'; + static const chatCacheFingerprint = 'chat_cache_fingerprint'; + static const serverTime = 'server_time'; + static const loginInfo = 'login_info'; + static const serverConfigSeen = 'server_config_seen'; + static const profileInviteLink = 'profile_invite_link'; + static const welcomeStickerIds = 'welcome_sticker_ids'; +} + +// #***! вся локальная база, профили чаты контакты сообщения +class AppDatabase { + static Database? _db; + + static String? _mobileDbDir; + + // #***! зовётся один раз на старте до первого обращения + static Future init() async { + if (Platform.isAndroid || Platform.isIOS) { + _mobileDbDir = await databaseFactorySqflitePlugin.getDatabasesPath(); + } + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + } + + static Completer? _initCompleter; + + // #***! ленивое открытие с защитой от гонки чтоб базу не открыли дважды + static Future get _instance async { + if (_db != null) return _db!; + if (_initCompleter != null) return _initCompleter!.future; + _initCompleter = Completer(); + try { + _db = await _open(); + _initCompleter!.complete(_db!); + } catch (e) { + _initCompleter!.completeError(e); + _initCompleter = null; + rethrow; + } + return _db!; + } + + // #***! на десктопе и на iOS в support, на андроиде в системной папке баз. + // #***! на iOS getDatabasesPath это Documents, а он открыт файловым шерингом + static Future _databasesDir() async { + if (Platform.isLinux || + Platform.isWindows || + Platform.isMacOS || + Platform.isIOS) { + final dir = await getApplicationSupportDirectory(); + return dir.path; + } + return _mobileDbDir ??= await databaseFactorySqflitePlugin + .getDatabasesPath(); + } + + static String? _legacyExposedDb; + + // #***! WAL живёт в отдельных файлах, копировать надо все три + static Future _copyDbFiles(String from, String to) async { + for (final suffix in const ['', '-wal', '-shm']) { + final src = File('$from$suffix'); + if (await src.exists()) await src.copy('$to$suffix'); + } + } + + static Future _dropLegacyExposedDb() async { + final legacy = _legacyExposedDb; + if (legacy == null) return; + _legacyExposedDb = null; + for (final suffix in const ['', '-wal', '-shm']) { + try { + final file = File('$legacy$suffix'); + if (await file.exists()) await file.delete(); + } catch (e) { + logger.w('[db] не удалось убрать старую базу: $e'); + } + } + } + + // #***! разовый перенос со старого пути + static Future _migrateLegacyDb(String target) async { + if (Platform.isIOS) { + try { + final legacyDir = await databaseFactorySqflitePlugin.getDatabasesPath(); + final legacy = join(legacyDir, 'qlyra${AppInstance.suffix}.db'); + if (legacy == target || !await File(legacy).exists()) return; + if (!await File(target).exists()) { + await _copyDbFiles(legacy, target); + logger.i('[db] перенёс базу из Documents в Application Support'); + } + // #***! удаляем только после того, как новая база успешно откроется + _legacyExposedDb = legacy; + } catch (e) { + logger.w('ios db migration failed: $e'); + } + return; + } + if (AppInstance.isNamed) return; + if (!(Platform.isLinux || Platform.isWindows || Platform.isMacOS)) return; + try { + if (await File(target).exists()) return; + final legacy = File(join(await getDatabasesPath(), 'qlyra.db')); + if (legacy.path == target) return; + if (await legacy.exists()) { + await legacy.copy(target); + logger.i('[db] перенёс qlyra.db -> $target'); + } + } catch (e) { + logger.w('legacy db migration failed: $e'); + } + } + + // #***! версия 24, поднял версию дописывай миграцию ниже + static Future _open() async { + final dbPath = await _databasesDir(); + await Directory(dbPath).create(recursive: true); + final target = join(dbPath, 'qlyra${AppInstance.suffix}.db'); + await _migrateLegacyDb(target); + final opened = await openDatabase( + target, + // #***! каждый if oldVersion < N это шаг миграции, идут по порядку + version: 29, + onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), + onCreate: (db, _) => _createTables(db), + onUpgrade: (db, oldVersion, newVersion) async { + if (oldVersion < 2) { + await _addColumnIfMissing( + db, + 'profile', + 'is_active', + 'INTEGER NOT NULL DEFAULT 0', + ); + await db.execute('DROP TABLE IF EXISTS sync_state'); + await db.execute(_syncStateSchema); + } + if (oldVersion < 3) { + await db.execute(_chatsCacheSchema); + } + if (oldVersion < 4) { + await db.execute(_contactsSchema); + } + if (oldVersion < 5) { + await db.execute('DROP TABLE IF EXISTS chats_cache'); + await db.execute(_chatsCacheSchema); + } + if (oldVersion < 6) { + await db.execute(_messagesSchema); + } + if (oldVersion < 7) { + await _addColumnIfMissing(db, 'profile', 'profile_options', 'TEXT'); + } + if (oldVersion < 8) { + await _addColumnIfMissing(db, 'chats_cache', 'participants', 'TEXT'); + } + if (oldVersion < 9) { + await _addColumnIfMissing(db, 'contacts', 'options', 'TEXT'); + await _addColumnIfMissing(db, 'chats_cache', 'options', 'TEXT'); + } + if (oldVersion < 10) { + await _addColumnIfMissing(db, 'chats_cache', 'owner', 'INTEGER'); + await _addColumnIfMissing(db, 'chats_cache', 'admins', 'TEXT'); + } + if (oldVersion < 11) { + await _createIndexes(db); + } + if (oldVersion < 12) { + await _addColumnIfMissing( + db, + 'chats_cache', + 'last_msg_status', + 'TEXT', + ); + } + if (oldVersion < 13) { + await _addColumnIfMissing( + db, + 'messages', + 'deleted', + 'INTEGER NOT NULL DEFAULT 0', + ); + } + if (oldVersion < 14) { + await _addColumnIfMissing( + db, + 'chats_cache', + 'in_list', + 'INTEGER NOT NULL DEFAULT 1', + ); + } + if (oldVersion < 15) { + await _addColumnIfMissing(db, 'messages', 'edit_history', 'TEXT'); + } + if (oldVersion < 16) { + await _addColumnIfMissing( + db, + 'chats_cache', + 'last_msg_elements', + 'TEXT', + ); + } + if (oldVersion < 17) { + await db.execute(_chatParticipantsSchema); + await _createChatParticipantsIndex(db); + await _backfillChatParticipants(db); + } + if (oldVersion < 18) { + await _addColumnIfMissing( + db, + 'chats_cache', + 'pinned_msg_id', + 'INTEGER', + ); + await _addColumnIfMissing( + db, + 'chats_cache', + 'pinned_msg_text', + 'TEXT', + ); + await _addColumnIfMissing( + db, + 'chats_cache', + 'pinned_msg_time', + 'INTEGER', + ); + } + if (oldVersion < 19) { + await _addColumnIfMissing( + db, + 'chats_cache', + 'pinned_msg_is_preview', + 'INTEGER NOT NULL DEFAULT 0', + ); + } + if (oldVersion < 20) { + await _addColumnIfMissing( + db, + 'chats_cache', + 'last_mention_msg_id', + 'INTEGER', + ); + } + if (oldVersion < 21) { + await _addColumnIfMissing( + db, + 'chats_cache', + 'last_msg_preview', + 'TEXT', + ); + } + if (oldVersion < 22) { + await db.execute(_webAppStorageSchema); + await db.execute(_webAppBiometrySchema); + } + if (oldVersion < 23) { + await _addColumnIfMissing( + db, + 'contacts', + 'account_status', + 'INTEGER NOT NULL DEFAULT 0', + ); + } + if (oldVersion < 24) { + await _addColumnIfMissing(db, 'messages', 'text_sealed', 'BLOB'); + await _addColumnIfMissing( + db, + 'messages', + 'e2ee', + 'INTEGER NOT NULL DEFAULT 0', + ); + await db.execute(_e2eeSessionsSchema); + await db.execute(_e2eeOutgoingSchema); + } + if (oldVersion < 25) { + await _addColumnIfMissing(db, 'chats_cache', 'active_call', 'TEXT'); + } + if (oldVersion < 26) { + await _addColumnIfMissing(db, 'chats_cache', 'public_link', 'TEXT'); + } + if (oldVersion < 27) { + await _addColumnIfMissing(db, 'profile', 'description', 'TEXT'); + } + if (oldVersion < 28) { + await db.execute(_messageRangesSchema); + } + if (oldVersion < 29) { + await _addColumnIfMissing(db, 'messages', 'typing_ms', 'INTEGER'); + } + }, + ); + await _dropLegacyExposedDb(); + return opened; + } + + // #***! создание таблиц с нуля для свежей установки + static Future _createTables(Database db) async { + await db.execute(''' + CREATE TABLE profile ( + id INTEGER PRIMARY KEY, + first_name TEXT NOT NULL, + last_name TEXT, + phone INTEGER NOT NULL, + photo_id INTEGER, + base_url TEXT, + base_raw_url TEXT, + country TEXT NOT NULL DEFAULT '', + account_status INTEGER NOT NULL DEFAULT 0, + update_time INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 0, + profile_options TEXT, + description TEXT + ) + '''); + await db.execute(_syncStateSchema); + await db.execute(_chatsCacheSchema); + await db.execute(_contactsSchema); + await db.execute(_messagesSchema); + await db.execute(_chatParticipantsSchema); + await db.execute(_webAppStorageSchema); + await db.execute(_webAppBiometrySchema); + await db.execute(_e2eeSessionsSchema); + await db.execute(_e2eeOutgoingSchema); + await db.execute(_messageRangesSchema); + await _createIndexes(db); + await _createChatParticipantsIndex(db); + } + + // #***! в sqlite нет ADD COLUMN IF NOT EXISTS, делаем сами + static Future _addColumnIfMissing( + Database db, + String table, + String column, + String definition, + ) async { + final info = await db.rawQuery('PRAGMA table_info($table)'); + final exists = info.any((row) => row['name'] == column); + if (exists) return; + await db.execute('ALTER TABLE $table ADD COLUMN $column $definition'); + } + + // #***! индексы под частые выборки, без них список чатов тормозит + static Future _createIndexes(Database db) async { + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_messages_chat ON messages(account_id, chat_id, time DESC)', + ); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_chats_account ON chats_cache(account_id, last_event_time DESC)', + ); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_contacts_account ON contacts(account_id)', + ); + } + + static Future _createChatParticipantsIndex(Database db) async { + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_chat_participants_lookup ' + 'ON chat_participants(account_id, participant_id, chat_id)', + ); + } + + // #***! участники отдельной таблицей чтоб искать диалог по собеседнику + static List _participantIdsFromRaw(Object? raw) { + if (raw is! String || raw.isEmpty) return const []; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return const []; + final ids = []; + for (final key in decoded.keys) { + final id = key is int ? key : int.tryParse(key.toString()); + if (id != null) ids.add(id); + } + return ids; + } catch (_) { + return const []; + } + } + + // #***! дозаполняем участников для баз где таблицы ещё не было + static Future _backfillChatParticipants(Database db) async { + final chats = await db.query( + 'chats_cache', + columns: ['id', 'account_id', 'participants'], + where: "type = 'DIALOG'", + ); + final batch = db.batch(); + for (final chat in chats) { + final accountId = chat['account_id']; + final chatId = chat['id']; + if (accountId is! int || chatId is! int) continue; + for (final pid in _participantIdsFromRaw(chat['participants'])) { + batch.insert('chat_participants', { + 'account_id': accountId, + 'chat_id': chatId, + 'participant_id': pid, + }, conflictAlgorithm: ConflictAlgorithm.ignore); + } + } + await batch.commit(noResult: true); + } + + // #***! схемы таблиц строками, их же жуют onCreate и миграции + static const _contactsSchema = ''' + CREATE TABLE contacts ( + id INTEGER PRIMARY KEY, + account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + first_name TEXT NOT NULL, + last_name TEXT, + phone INTEGER NOT NULL, + photo_id INTEGER, + base_url TEXT, + base_raw_url TEXT, + update_time INTEGER NOT NULL DEFAULT 0, + options TEXT, + account_status INTEGER NOT NULL DEFAULT 0 + ) + '''; + + static const _syncStateSchema = ''' + CREATE TABLE sync_state ( + account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (account_id, key) + ) + '''; + + static const _chatsCacheSchema = ''' + CREATE TABLE chats_cache ( + id INTEGER NOT NULL, + account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + type TEXT NOT NULL, + title TEXT, + icon_url TEXT, + last_msg_id INTEGER, + last_msg_time INTEGER, + last_msg_text TEXT, + last_msg_elements TEXT, + last_msg_preview TEXT, + last_msg_sender INTEGER, + last_msg_status TEXT, + unread_count INTEGER NOT NULL DEFAULT 0, + last_event_time INTEGER NOT NULL DEFAULT 0, + cached_at INTEGER NOT NULL, + fav_index INTEGER, + dont_disturb_until INTEGER NOT NULL DEFAULT 0, + is_online INTEGER NOT NULL DEFAULT 0, + seen_time INTEGER NOT NULL DEFAULT 0, + participants TEXT NOT NULL DEFAULT "", + options TEXT, + owner INTEGER, + admins TEXT, + in_list INTEGER NOT NULL DEFAULT 1, + pinned_msg_id INTEGER, + pinned_msg_text TEXT, + pinned_msg_time INTEGER, + pinned_msg_is_preview INTEGER NOT NULL DEFAULT 0, + last_mention_msg_id INTEGER, + active_call TEXT, + public_link TEXT, + PRIMARY KEY (id, account_id) + ) + '''; + + static const _chatParticipantsSchema = ''' + CREATE TABLE chat_participants ( + account_id INTEGER NOT NULL, + chat_id INTEGER NOT NULL, + participant_id INTEGER NOT NULL, + PRIMARY KEY (account_id, chat_id, participant_id), + FOREIGN KEY (chat_id, account_id) + REFERENCES chats_cache (id, account_id) ON DELETE CASCADE + ) + '''; + + static const _messagesSchema = ''' + CREATE TABLE messages ( + id TEXT NOT NULL, + account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + chat_id INTEGER NOT NULL, + sender_id INTEGER NOT NULL, + text TEXT, + time INTEGER NOT NULL, + status TEXT, + payload TEXT, + deleted INTEGER NOT NULL DEFAULT 0, + edit_history TEXT, + text_sealed BLOB, + e2ee INTEGER NOT NULL DEFAULT 0, + typing_ms INTEGER, + PRIMARY KEY (id, account_id), + FOREIGN KEY (chat_id, account_id) REFERENCES chats_cache (id, account_id) ON DELETE CASCADE + ) + '''; + + static const _messageRangesSchema = ''' + CREATE TABLE IF NOT EXISTS message_ranges ( + account_id INTEGER NOT NULL, + chat_id INTEGER NOT NULL, + start_time INTEGER NOT NULL, + end_time INTEGER NOT NULL, + PRIMARY KEY (account_id, chat_id, start_time), + FOREIGN KEY (chat_id, account_id) REFERENCES chats_cache (id, account_id) ON DELETE CASCADE + ) + '''; + + static const _e2eeSessionsSchema = ''' + CREATE TABLE e2ee_sessions ( + account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + chat_id INTEGER NOT NULL, + peer_id INTEGER NOT NULL, + phase TEXT NOT NULL, + state BLOB, + peer_public BLOB, + verified INTEGER NOT NULL DEFAULT 0, + offer_text TEXT, + offer_message_id TEXT, + updated INTEGER NOT NULL, + PRIMARY KEY (account_id, chat_id) + ) + '''; + + static const _webAppStorageSchema = ''' + CREATE TABLE webapp_storage ( + account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + bot_id INTEGER NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (account_id, bot_id, key) + ) + '''; + + static const _webAppBiometrySchema = ''' + CREATE TABLE webapp_biometry ( + account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + bot_id INTEGER NOT NULL, + access_requested INTEGER NOT NULL DEFAULT 0, + access_granted INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (account_id, bot_id) + ) + '''; + + // #***! дальше операции с данными + static Future saveProfile( + ProfileData profile, { + bool isActive = true, + }) async { + final db = await _instance; + final row = profile.toDbRow(isActive: isActive); + final cols = row.keys.toList(); + final placeholders = List.filled(cols.length, '?').join(', '); + final updates = cols + .where((c) => c != 'id') + .map((c) => '$c = excluded.$c') + .join(', '); + await db.rawInsert( + 'INSERT INTO profile (${cols.join(', ')}) VALUES ($placeholders) ' + 'ON CONFLICT(id) DO UPDATE SET $updates', + cols.map((c) => row[c]).toList(), + ); + } + + static Future loadProfile(int accountId) async { + final db = await _instance; + final rows = await db.query( + 'profile', + where: 'id = ?', + whereArgs: [accountId], + limit: 1, + ); + if (rows.isEmpty) return null; + return ProfileData.fromDbRow(rows.first); + } + + static Future> loadAllProfiles() async { + final db = await _instance; + final rows = await db.query('profile', orderBy: 'is_active DESC, id ASC'); + return rows.map(ProfileData.fromDbRow).toList(); + } + + static Future loadActiveProfile() async { + final db = await _instance; + final rows = await db.query('profile', where: 'is_active = 1', limit: 1); + if (rows.isEmpty) return null; + return ProfileData.fromDbRow(rows.first); + } + + static Future setActiveAccount(int accountId) async { + final db = await _instance; + await db.transaction((txn) async { + await txn.update('profile', {'is_active': 0}); + await txn.update( + 'profile', + {'is_active': 1}, + where: 'id = ?', + whereArgs: [accountId], + ); + }); + } + + static Future deleteAccount(int accountId) async { + final db = await _instance; + await db.delete('profile', where: 'id = ?', whereArgs: [accountId]); + } + + static Future setSyncValue( + int accountId, + String key, + String value, + ) async { + final db = await _instance; + await db.insert('sync_state', { + 'account_id': accountId, + 'key': key, + 'value': value, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + static Future getSyncValue(int accountId, String key) async { + final db = await _instance; + final rows = await db.query( + 'sync_state', + where: 'account_id = ? AND key = ?', + whereArgs: [accountId, key], + limit: 1, + ); + if (rows.isEmpty) return null; + return rows.first['value'] as String; + } + + static Future setWelcomeStickerIds(int accountId, List ids) => + setSyncValue(accountId, SyncKey.welcomeStickerIds, ids.join(',')); + + static Future> getWelcomeStickerIds(int accountId) async { + final raw = await getSyncValue(accountId, SyncKey.welcomeStickerIds); + if (raw == null || raw.isEmpty) return const []; + return raw.split(',').map(int.tryParse).whereType().toList(); + } + + static Future> getAllSyncValues(int accountId) async { + final db = await _instance; + final rows = await db.query( + 'sync_state', + where: 'account_id = ?', + whereArgs: [accountId], + ); + return { + for (final row in rows) row['key'] as String: row['value'] as String, + }; + } + + static Future saveE2eeSession(Map row) async { + final db = await _instance; + await db.insert( + 'e2ee_sessions', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + static const _e2eeOutgoingSchema = ''' + CREATE TABLE e2ee_outgoing ( + account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + chat_id INTEGER NOT NULL, + wire_text TEXT NOT NULL, + sealed BLOB NOT NULL, + e2ee INTEGER NOT NULL, + PRIMARY KEY (account_id, chat_id, wire_text) + ) + '''; + + static Future saveE2eeOutgoing( + int accountId, + int chatId, + String wire, + Uint8List sealed, + int e2ee, + ) async { + final db = await _instance; + await db.insert('e2ee_outgoing', { + 'account_id': accountId, + 'chat_id': chatId, + 'wire_text': wire, + 'sealed': sealed, + 'e2ee': e2ee, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + static Future?> loadE2eeOutgoing( + int accountId, + int chatId, + String wire, + ) async { + final db = await _instance; + final rows = await db.query( + 'e2ee_outgoing', + where: 'account_id = ? AND chat_id = ? AND wire_text = ?', + whereArgs: [accountId, chatId, wire], + limit: 1, + ); + return rows.isEmpty ? null : rows.first; + } + + static Future?> loadE2eeSession( + int accountId, + int chatId, + ) async { + final db = await _instance; + final rows = await db.query( + 'e2ee_sessions', + where: 'account_id = ? AND chat_id = ?', + whereArgs: [accountId, chatId], + limit: 1, + ); + if (rows.isEmpty) return null; + return rows.first; + } + + static Future>> loadE2eeSessions( + int accountId, + ) async { + final db = await _instance; + return db.query( + 'e2ee_sessions', + where: 'account_id = ?', + whereArgs: [accountId], + ); + } + + static Future deleteE2eeSession(int accountId, int chatId) async { + final db = await _instance; + await db.delete( + 'e2ee_sessions', + where: 'account_id = ? AND chat_id = ?', + whereArgs: [accountId, chatId], + ); + } + + static Future updateMessageSealed( + int accountId, + int chatId, + String messageId, { + required Uint8List? sealed, + required int e2ee, + }) async { + final db = await _instance; + await db.update( + 'messages', + {'text_sealed': sealed, 'e2ee': e2ee}, + where: 'account_id = ? AND chat_id = ? AND id = ?', + whereArgs: [accountId, chatId, messageId], + ); + } + + /// Avoid overwriting edits, deletion state or sealed text during media I/O. + static Future updateMessageMediaPayload( + int accountId, + int chatId, + String messageId, + String originalPayload, + String payload, + ) async { + final db = await _instance; + await db.update( + 'messages', + {'payload': payload}, + where: 'account_id = ? AND chat_id = ? AND id = ? AND payload = ?', + whereArgs: [accountId, chatId, messageId, originalPayload], + ); + } + + static Future saveWebAppValue( + int accountId, + int botId, + String key, + String value, + ) async { + final db = await _instance; + await db.insert('webapp_storage', { + 'account_id': accountId, + 'bot_id': botId, + 'key': key, + 'value': value, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + static Future getWebAppValue( + int accountId, + int botId, + String key, + ) async { + final db = await _instance; + final rows = await db.query( + 'webapp_storage', + where: 'account_id = ? AND bot_id = ? AND key = ?', + whereArgs: [accountId, botId, key], + limit: 1, + ); + if (rows.isEmpty) return null; + return rows.first['value'] as String; + } + + static Future removeWebAppValue( + int accountId, + int botId, + String key, + ) async { + final db = await _instance; + await db.delete( + 'webapp_storage', + where: 'account_id = ? AND bot_id = ? AND key = ?', + whereArgs: [accountId, botId, key], + ); + } + + static Future clearWebAppValues(int accountId, int botId) async { + final db = await _instance; + await db.delete( + 'webapp_storage', + where: 'account_id = ? AND bot_id = ?', + whereArgs: [accountId, botId], + ); + } + + static Future countWebAppValues(int accountId, int botId) async { + final db = await _instance; + final rows = await db.rawQuery( + 'SELECT COUNT(*) AS total FROM webapp_storage ' + 'WHERE account_id = ? AND bot_id = ?', + [accountId, botId], + ); + if (rows.isEmpty) return 0; + return (rows.first['total'] as num?)?.toInt() ?? 0; + } + + static Future<(bool, bool)> getWebAppBiometryAccess( + int accountId, + int botId, + ) async { + final db = await _instance; + final rows = await db.query( + 'webapp_biometry', + where: 'account_id = ? AND bot_id = ?', + whereArgs: [accountId, botId], + limit: 1, + ); + if (rows.isEmpty) return (false, false); + final row = rows.first; + return ( + (row['access_requested'] as int? ?? 0) != 0, + (row['access_granted'] as int? ?? 0) != 0, + ); + } + + static Future setWebAppBiometryAccess( + int accountId, + int botId, { + required bool requested, + required bool granted, + }) async { + final db = await _instance; + await db.insert('webapp_biometry', { + 'account_id': accountId, + 'bot_id': botId, + 'access_requested': requested ? 1 : 0, + 'access_granted': granted ? 1 : 0, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + static Future savePrivacyConfig( + int accountId, + String jsonConfig, + ) async { + final db = await _instance; + await db.insert('sync_state', { + 'account_id': accountId, + 'key': 'privacy_config', + 'value': jsonConfig, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + static Future getPrivacyConfig(int accountId) async { + final db = await _instance; + final rows = await db.query( + 'sync_state', + where: 'account_id = ? AND key = ?', + whereArgs: [accountId, 'privacy_config'], + limit: 1, + ); + if (rows.isEmpty) return null; + return rows.first['value'] as String; + } + + static Future saveLoginInfo(int accountId, String jsonInfo) async { + final db = await _instance; + await db.insert('sync_state', { + 'account_id': accountId, + 'key': SyncKey.loginInfo, + 'value': jsonInfo, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + static Future getLoginInfo(int accountId) async { + return getSyncValue(accountId, SyncKey.loginInfo); + } + + static Future close() async { + await _db?.close(); + _db = null; + _initCompleter = null; + } + + // Chats cache + + // #***! чаты пачкой в одной транзакции, по одному было бы на порядок медленнее + static Future saveChats(List> rows) async { + if (rows.isEmpty) return; + try { + final db = await _instance; + final cols = rows.first.keys.toList(); + final placeholders = List.filled(cols.length, '?').join(', '); + final updates = cols + .where((c) => c != 'id' && c != 'account_id') + .map((c) => '$c = excluded.$c') + .join(', '); + final sql = + 'INSERT INTO chats_cache (${cols.join(', ')}) ' + 'VALUES ($placeholders) ' + 'ON CONFLICT(id, account_id) DO UPDATE SET $updates'; + await db.transaction((txn) async { + final batch = txn.batch(); + for (final row in rows) { + batch.rawInsert(sql, cols.map((c) => row[c]).toList()); + } + await batch.commit(noResult: true); + for (final row in rows) { + if (!row.containsKey('participants')) continue; + if (row['type'] != 'DIALOG') continue; + final accountId = row['account_id']; + final chatId = row['id']; + if (accountId is! int || chatId is! int) continue; + await txn.delete( + 'chat_participants', + where: 'account_id = ? AND chat_id = ?', + whereArgs: [accountId, chatId], + ); + for (final pid in _participantIdsFromRaw(row['participants'])) { + await txn.insert('chat_participants', { + 'account_id': accountId, + 'chat_id': chatId, + 'participant_id': pid, + }, conflictAlgorithm: ConflictAlgorithm.ignore); + } + } + }); + } catch (e) { + logger.e("Ошибка при сохранении чата: $e"); + } + } + + // #***! чиним имена отправителей после неполной синхры + static Future repairLastMessageSenders(int accountId) async { + try { + final db = await _instance; + await db.rawUpdate( + 'UPDATE chats_cache SET last_msg_sender = (' + ' SELECT m.sender_id FROM messages m' + ' WHERE m.account_id = chats_cache.account_id' + ' AND m.chat_id = chats_cache.id' + ' AND m.id = CAST(chats_cache.last_msg_id AS TEXT)' + ') ' + 'WHERE account_id = ? AND last_msg_sender IS NULL ' + 'AND last_msg_id IS NOT NULL', + [accountId], + ); + } catch (e) { + logger.w('Не удалось восстановить отправителей последних сообщений: $e'); + } + } + + static Future>> loadChat( + int accountId, + int chatId, + ) async { + final db = await _instance; + return db.query( + 'chats_cache', + where: 'account_id = ? AND id = ?', + whereArgs: [accountId, chatId], + orderBy: 'last_event_time DESC', + ); + } + + // #***! в списке значит активный и не скрытый + static bool chatRowIsInList(Map row) { + final value = row['in_list']; + return value is! int || value != 0; + } + + static Future isChatInList(int accountId, int chatId) async { + final rows = await loadChat(accountId, chatId); + return rows.isNotEmpty && chatRowIsInList(rows.first); + } + + static Future updateChatColumns( + List<({int accountId, int chatId, Map values})> updates, + ) async { + if (updates.isEmpty) return; + try { + final db = await _instance; + final batch = db.batch(); + for (final update in updates) { + batch.update( + 'chats_cache', + update.values, + where: 'account_id = ? AND id = ?', + whereArgs: [update.accountId, update.chatId], + ); + } + await batch.commit(noResult: true); + } catch (e) { + logger.e('Ошибка при обновлении чата: $e'); + } + } + + static Future setChatListState( + int accountId, + int chatId, + int listState, + ) async { + final db = await _instance; + await db.update( + 'chats_cache', + {'in_list': listState}, + where: 'account_id = ? AND id = ?', + whereArgs: [accountId, chatId], + ); + } + + static Future>> loadChats( + int accountId, { + bool includeHidden = false, + }) async { + final db = await _instance; + return db.query( + 'chats_cache', + where: includeHidden + ? 'account_id = ? AND in_list IN (1, 2)' + : 'account_id = ? AND in_list = 1', + whereArgs: [accountId], + orderBy: 'last_event_time DESC', + ); + } + + // #***! общий счётчик непрочитанных для бейджа + static Future sumUnread( + int accountId, { + int? excludeChatId, + Set? excludeChatIds, + }) async { + final db = await _instance; + final buffer = StringBuffer('account_id = ? AND in_list = 1'); + final args = [accountId]; + if (excludeChatId != null) { + buffer.write(' AND id != ?'); + args.add(excludeChatId); + } + if (excludeChatIds != null && excludeChatIds.isNotEmpty) { + final placeholders = List.filled(excludeChatIds.length, '?').join(', '); + buffer.write(' AND id NOT IN ($placeholders)'); + args.addAll(excludeChatIds); + } + final result = await db.rawQuery( + 'SELECT COALESCE(SUM(unread_count), 0) AS total ' + 'FROM chats_cache WHERE $buffer', + args, + ); + return (result.first['total'] as int?) ?? 0; + } + + // #***! поиск диалога по собеседнику, ради этого и таблица участников + static Future findDialogChatByParticipant( + int accountId, + int contactId, + ) async { + final db = await _instance; + final rows = await db.rawQuery( + 'SELECT p.chat_id AS id FROM chat_participants p ' + 'JOIN chats_cache c ON c.id = p.chat_id AND c.account_id = p.account_id ' + "WHERE p.account_id = ? AND p.participant_id = ? AND c.type = 'DIALOG' " + 'LIMIT 1', + [accountId, contactId], + ); + if (rows.isEmpty) return null; + return rows.first['id'] as int?; + } + + static Future>> loadDialogChats( + int accountId, + ) async { + final db = await _instance; + return db.query( + 'chats_cache', + where: "account_id = ? AND type = 'DIALOG'", + whereArgs: [accountId], + ); + } + + // #***! экранируем LIKE иначе поиск по % вернёт всё + static String _escapeLike(String value) => value + .replaceAll('\\', '\\\\') + .replaceAll('%', '\\%') + .replaceAll('_', '\\_'); + + static Future>> searchContacts( + int accountId, + String query, { + int limit = 30, + }) async { + final term = query.trim(); + if (term.isEmpty) return const []; + final db = await _instance; + final like = '%${_escapeLike(term)}%'; + return db.query( + 'contacts', + where: + 'account_id = ? AND ' + "(first_name LIKE ? ESCAPE '\\' OR last_name LIKE ? ESCAPE '\\' " + "OR CAST(phone AS TEXT) LIKE ? ESCAPE '\\')", + whereArgs: [accountId, like, like, like], + orderBy: 'first_name ASC, last_name ASC', + limit: limit, + ); + } + + static Future>> searchChatsByTitle( + int accountId, + String query, { + int limit = 30, + }) async { + final term = query.trim(); + if (term.isEmpty) return const []; + final db = await _instance; + final like = '%${_escapeLike(term)}%'; + return db.query( + 'chats_cache', + where: "account_id = ? AND title LIKE ? ESCAPE '\\'", + whereArgs: [accountId, like], + orderBy: 'last_event_time DESC', + limit: limit, + ); + } + + static Future>> loadChatsByIds( + int accountId, + List ids, + ) async { + if (ids.isEmpty) return const []; + final db = await _instance; + final placeholders = List.filled(ids.length, '?').join(','); + return db.query( + 'chats_cache', + where: 'account_id = ? AND id IN ($placeholders)', + whereArgs: [accountId, ...ids], + ); + } + + static Future deleteChat(int chatId, int accountId) async { + final db = await _instance; + await db.delete( + 'chats_cache', + where: 'id = ? AND account_id = ?', + whereArgs: [chatId, accountId], + ); + } + + static Future clearChatsCache(int accountId) async { + final db = await _instance; + await db.delete( + 'chats_cache', + where: 'account_id = ?', + whereArgs: [accountId], + ); + } + + // #***! контакты пачкой как и чаты + static Future saveContacts(List> rows) async { + final db = await _instance; + final batch = db.batch(); + for (final row in rows) { + batch.insert( + 'contacts', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + } + + static Future>> loadContacts( + int accountId, { + bool includeDeleted = false, + }) async { + final db = await _instance; + return db.query( + 'contacts', + where: includeDeleted + ? 'account_id = ?' + : 'account_id = ? AND account_status = 0', + whereArgs: [accountId], + ); + } + + static Future> loadContactIds(int accountId) async { + final db = await _instance; + final rows = await db.query( + 'contacts', + columns: ['id'], + where: 'account_id = ?', + whereArgs: [accountId], + ); + return [for (final r in rows) r['id'] as int]; + } + + static Future?> loadContact( + int accountId, + int id, + ) async { + final db = await _instance; + final rows = await db.query( + 'contacts', + where: 'account_id = ? AND id = ?', + whereArgs: [accountId, id], + limit: 1, + ); + return rows.isEmpty ? null : rows.first; + } + + static Future deleteContact(int accountId, int id) async { + final db = await _instance; + await db.delete( + 'contacts', + where: 'account_id = ? AND id = ?', + whereArgs: [accountId, id], + ); + } + + static Future deleteContacts(int accountId, List ids) async { + if (ids.isEmpty) return; + final db = await _instance; + await db.transaction((txn) async { + final batch = txn.batch(); + for (final id in ids) { + batch.delete( + 'contacts', + where: 'account_id = ? AND id = ?', + whereArgs: [accountId, id], + ); + } + await batch.commit(noResult: true); + }); + } + + static Future replaceMessage( + Map row, { + String? removeId, + }) async { + await MessagePersistence.replace(await _instance, row, removeId: removeId); + } + + static Future saveMessages(List> rows) async { + final db = await _instance; + await db.transaction((txn) async { + final batch = txn.batch(); + for (final row in rows) { + batch.insert( + 'messages', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + }); + } + + /// A first outgoing message can precede the server's first chat card. + /// Keep its local parent and message in one transaction so foreign keys stay + /// enabled and an interrupted write cannot leave half an outgoing item. + static Future saveOutgoingMessage( + Map row, { + bool createDialog = false, + String? dialogTitle, + String? dialogIconUrl, + String? removeId, + }) async { + final db = await _instance; + await db.transaction((txn) async { + if (createDialog) { + await _insertDraftDialog( + txn, + row['account_id'] as int, + row['chat_id'] as int, + title: dialogTitle, + iconUrl: dialogIconUrl, + ); + } + await txn.insert( + 'messages', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + if (removeId != null && removeId != row['id']) { + await txn.delete( + 'messages', + where: 'account_id = ? AND chat_id = ? AND id = ?', + whereArgs: [row['account_id'], row['chat_id'], removeId], + ); + } + }); + } + + static Future ensureDialogCached( + int accountId, + int chatId, { + String? title, + String? iconUrl, + }) async { + final db = await _instance; + await db.transaction( + (txn) => _insertDraftDialog( + txn, + accountId, + chatId, + title: title, + iconUrl: iconUrl, + ), + ); + } + + static Future _insertDraftDialog( + DatabaseExecutor txn, + int accountId, + int chatId, { + String? title, + String? iconUrl, + }) async { + final existing = await txn.query( + 'chats_cache', + columns: ['id'], + where: 'account_id = ? AND id = ?', + whereArgs: [accountId, chatId], + limit: 1, + ); + if (existing.isNotEmpty) return; + final peerId = accountId ^ chatId; + if (accountId <= 0 || peerId <= 0) { + throw ArgumentError('An account and dialog peer are required'); + } + final participants = {accountId: 0, peerId: 0}; + await txn.insert('chats_cache', { + 'id': chatId, + 'account_id': accountId, + 'type': 'DIALOG', + 'title': title, + 'icon_url': iconUrl, + 'cached_at': DateTime.now().millisecondsSinceEpoch, + 'participants': jsonEncode( + participants.map((id, marker) => MapEntry(id.toString(), marker)), + ), + }, conflictAlgorithm: ConflictAlgorithm.ignore); + for (final participantId in participants.keys) { + await txn.insert('chat_participants', { + 'account_id': accountId, + 'chat_id': chatId, + 'participant_id': participantId, + }, conflictAlgorithm: ConflictAlgorithm.ignore); + } + } + + static Future updateSendingMessageStatus( + int accountId, + int chatId, + String messageId, + String status, + ) async { + final db = await _instance; + final changed = await db.update( + 'messages', + {'status': status}, + where: + 'account_id = ? AND chat_id = ? AND id = ? ' + "AND status = 'sending' AND deleted = 0", + whereArgs: [accountId, chatId, messageId], + ); + return changed != 0; + } + + static Future replacePendingMessage( + Map row, { + required String pendingId, + }) async => MessagePersistence.replacePending( + await _instance, + row, + pendingId: pendingId, + ); + + // #***! дальше выборки истории, с конца до сообщения между и вокруг + static Future>> loadMessages( + int accountId, + int chatId, { + int? limit, + int? offset, + bool onlyVisible = false, + }) async { + final db = await _instance; + return db.query( + 'messages', + where: onlyVisible + ? 'account_id = ? AND chat_id = ? AND deleted = 0' + : 'account_id = ? AND chat_id = ?', + whereArgs: [accountId, chatId], + orderBy: 'time DESC', + limit: limit, + offset: offset, + ); + } + + static Future>> loadMessagesBefore( + int accountId, + int chatId, { + required int beforeTime, + int limit = 30, + bool onlyVisible = false, + }) async { + final db = await _instance; + return db.query( + 'messages', + where: onlyVisible + ? 'account_id = ? AND chat_id = ? AND deleted = 0 AND time < ?' + : 'account_id = ? AND chat_id = ? AND time < ?', + whereArgs: [accountId, chatId, beforeTime], + orderBy: 'time DESC', + limit: limit, + ); + } + + static Future>> loadMessagesBetween( + int accountId, + int chatId, { + required int afterTime, + required int beforeTime, + int limit = 60, + bool onlyVisible = false, + }) async { + final db = await _instance; + return db.query( + 'messages', + where: onlyVisible + ? 'account_id = ? AND chat_id = ? AND deleted = 0 ' + 'AND time > ? AND time < ?' + : 'account_id = ? AND chat_id = ? AND time > ? AND time < ?', + whereArgs: [accountId, chatId, afterTime, beforeTime], + orderBy: 'time ASC', + limit: limit, + ); + } + + // #***! вокруг нужно для перехода по ответу, грузим окно с обеих сторон + static Future>> loadMessagesAround( + int accountId, + int chatId, { + required int centerTime, + int before = 40, + int after = 20, + bool onlyVisible = false, + }) async { + final db = await _instance; + final base = onlyVisible + ? 'account_id = ? AND chat_id = ? AND deleted = 0' + : 'account_id = ? AND chat_id = ?'; + final older = await db.query( + 'messages', + where: '$base AND time <= ?', + whereArgs: [accountId, chatId, centerTime], + orderBy: 'time DESC', + limit: before, + ); + final newer = await db.query( + 'messages', + where: '$base AND time > ?', + whereArgs: [accountId, chatId, centerTime], + orderBy: 'time ASC', + limit: after, + ); + return [...newer.reversed, ...older]; + } + + // #***! удалённое не стираем а помечаем, с настройкой его ещё можно глянуть + static Future markMessageDeleted( + int accountId, + int chatId, + String messageId, + ) async { + final db = await _instance; + await db.update( + 'messages', + {'deleted': 1}, + where: 'account_id = ? AND chat_id = ? AND id = ?', + whereArgs: [accountId, chatId, messageId], + ); + } + + static Future markMessagesDeleted( + int accountId, + int chatId, + List messageIds, + ) async { + if (messageIds.isEmpty) return; + final db = await _instance; + await db.transaction((txn) async { + final batch = txn.batch(); + for (final id in messageIds) { + batch.update( + 'messages', + {'deleted': 1}, + where: 'account_id = ? AND chat_id = ? AND id = ?', + whereArgs: [accountId, chatId, id], + ); + } + await batch.commit(noResult: true); + }); + } + + static Future clearMessages(int accountId, int chatId) async { + final db = await _instance; + await db.transaction((txn) async { + await txn.delete( + 'messages', + where: 'account_id = ? AND chat_id = ?', + whereArgs: [accountId, chatId], + ); + await txn.delete( + 'message_ranges', + where: 'account_id = ? AND chat_id = ?', + whereArgs: [accountId, chatId], + ); + }); + } + + static Future loadMessageRanges( + int accountId, + int chatId, + ) async { + final db = await _instance; + final rows = await db.query( + 'message_ranges', + columns: ['start_time', 'end_time'], + where: 'account_id = ? AND chat_id = ?', + whereArgs: [accountId, chatId], + ); + return MessageRanges([ + for (final row in rows) + MessageRange(row['start_time'] as int, row['end_time'] as int), + ]); + } + + static Future addMessageRange( + int accountId, + int chatId, + MessageRange range, + ) async { + final db = await _instance; + await db.transaction((txn) async { + final rows = await txn.query( + 'message_ranges', + columns: ['start_time', 'end_time'], + where: 'account_id = ? AND chat_id = ?', + whereArgs: [accountId, chatId], + ); + final merged = MessageRanges.merge([ + for (final row in rows) + MessageRange(row['start_time'] as int, row['end_time'] as int), + ], range); + await txn.delete( + 'message_ranges', + where: 'account_id = ? AND chat_id = ?', + whereArgs: [accountId, chatId], + ); + final batch = txn.batch(); + for (final r in merged) { + batch.insert('message_ranges', { + 'account_id': accountId, + 'chat_id': chatId, + 'start_time': r.start, + 'end_time': r.end, + }); + } + await batch.commit(noResult: true); + }); + } + + static Future extendMessageRange( + int accountId, + int chatId, { + required int previousLastTime, + required int time, + }) async { + if (time < previousLastTime) return; + final db = await _instance; + await db.rawUpdate( + 'UPDATE message_ranges SET end_time = ? ' + 'WHERE account_id = ? AND chat_id = ? ' + 'AND start_time <= ? AND end_time >= ? AND end_time < ?', + [time, accountId, chatId, previousLastTime, previousLastTime, time], + ); + } + + static Future>> loadMessagesByIds( + int accountId, + int chatId, + List messageIds, + ) async { + if (messageIds.isEmpty) return const []; + final db = await _instance; + final placeholders = List.filled(messageIds.length, '?').join(','); + return db.query( + 'messages', + where: 'account_id = ? AND chat_id = ? AND id IN ($placeholders)', + whereArgs: [accountId, chatId, ...messageIds], + ); + } + + static Future?> loadMessage( + int accountId, + int chatId, + String messageId, + ) async { + final db = await _instance; + final rows = await db.query( + 'messages', + where: 'account_id = ? AND chat_id = ? AND id = ?', + whereArgs: [accountId, chatId, messageId], + limit: 1, + ); + if (rows.isEmpty) return null; + return rows.first; + } + + static Future deleteSyntheticMessages( + int accountId, + int chatId, { + required String prefix, + }) async { + if (prefix != 'sim_') throw ArgumentError.value(prefix, 'prefix'); + final db = await _instance; + await db.delete( + 'messages', + where: 'account_id = ? AND chat_id = ? AND substr(id, 1, 4) = ?', + whereArgs: [accountId, chatId, prefix], + ); + } + + static Future deleteMessage( + int accountId, + int chatId, + String messageId, + ) async { + final db = await _instance; + await db.delete( + 'messages', + where: 'account_id = ? AND chat_id = ? AND id = ?', + whereArgs: [accountId, chatId, messageId], + ); + } + + // #***! неотправленные, их подхватит outbox при коннекте + static Future>> loadPendingMessages( + int accountId, + ) async { + final db = await _instance; + return db.query( + 'messages', + where: 'account_id = ? AND status = ? AND deleted = 0', + whereArgs: [accountId, 'pending'], + orderBy: 'time ASC', + ); + } +} diff --git a/lib/core/storage/app_instance.dart b/lib/core/storage/app_instance.dart new file mode 100644 index 0000000..0057a26 --- /dev/null +++ b/lib/core/storage/app_instance.dart @@ -0,0 +1,9 @@ +class AppInstance { + AppInstance._(); + + static const String id = String.fromEnvironment('QLYRA_INSTANCE'); + + static bool get isNamed => id.isNotEmpty; + + static String get suffix => isNamed ? '_$id' : ''; +} diff --git a/lib/core/storage/archived_chats_store.dart b/lib/core/storage/archived_chats_store.dart new file mode 100644 index 0000000..bea8c44 --- /dev/null +++ b/lib/core/storage/archived_chats_store.dart @@ -0,0 +1,30 @@ +import 'per_chat_json_store.dart'; + +class ArchivedChatsStore extends PerChatJsonStore { + ArchivedChatsStore._() + : super( + prefsKey: 'archived_chats', + fromJson: (raw) => raw == true ? true : null, + toJson: (value) => value, + ); + + static final ArchivedChatsStore instance = ArchivedChatsStore._(); + + bool isArchived(int accountId, int chatId) => read(accountId, chatId) == true; + + Future setArchived(int accountId, int chatId, bool archived) => + write(accountId, chatId, archived ? true : null); + + Set archivedChatIds(int accountId) { + if (accountId == 0) return const {}; + final prefix = '$accountId/'; + final ids = {}; + for (final entry in allEntries) { + if (entry.value != true) continue; + if (!entry.key.startsWith(prefix)) continue; + final id = int.tryParse(entry.key.substring(prefix.length)); + if (id != null) ids.add(id); + } + return ids; + } +} diff --git a/lib/core/storage/chat_activity_store.dart b/lib/core/storage/chat_activity_store.dart new file mode 100644 index 0000000..de57967 --- /dev/null +++ b/lib/core/storage/chat_activity_store.dart @@ -0,0 +1,136 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; + +enum ChatActivity { typing, sticker } + +extension ChatActivityLabel on ChatActivity { + String get label => switch (this) { + ChatActivity.typing => 'Печатает...', + ChatActivity.sticker => 'Выбирает стикер...', + }; +} + +ChatActivity chatActivityFromType(dynamic type) => + type == 'STICKER' ? ChatActivity.sticker : ChatActivity.typing; + +class ChatActivitySnapshot { + const ChatActivitySnapshot({required this.activity, required this.userIds}); + + final ChatActivity activity; + final List userIds; + + String get label => activity.label; + + @override + bool operator ==(Object other) => + other is ChatActivitySnapshot && + other.activity == activity && + listEquals(other.userIds, userIds); + + @override + int get hashCode => Object.hash(activity, Object.hashAll(userIds)); +} + +class ChatActivityStore { + ChatActivityStore._({int Function()? now}) : _now = now ?? _wallClock; + + @visibleForTesting + ChatActivityStore.withClock(int Function() now) : this._(now: now); + + static final ChatActivityStore instance = ChatActivityStore._(); + + static const Duration _ttl = Duration(seconds: 6); + static const Duration maxTypingTime = Duration(minutes: 15); + + static int _wallClock() => DateTime.now().millisecondsSinceEpoch; + + final int Function() _now; + final Map> _typingSince = {}; + final Map> _users = {}; + final Map> _timers = {}; + final Map> _notifiers = {}; + + ValueListenable listenable(int chatId) => + _notifiers.putIfAbsent( + chatId, + () => ValueNotifier(_current(chatId)), + ); + + ChatActivitySnapshot? snapshot(int chatId) => _current(chatId); + + ChatActivity? activity(int chatId) => _current(chatId)?.activity; + + void mark(int chatId, int userId, ChatActivity activity) { + final timers = _timers.putIfAbsent(chatId, () => {}); + timers[userId]?.cancel(); + timers[userId] = Timer(_ttl, () => _remove(chatId, userId)); + _users.putIfAbsent(chatId, () => {})[userId] = activity; + if (activity == ChatActivity.typing) { + _typingSince + .putIfAbsent(chatId, () => {}) + .putIfAbsent(userId, _now); + } else { + _forgetTypingStart(chatId, userId); + } + _sync(chatId); + } + + int? takeTypingTime(int chatId, int userId) { + final since = _forgetTypingStart(chatId, userId); + if (since == null) return null; + final elapsed = _now() - since; + if (elapsed > maxTypingTime.inMilliseconds) return null; + return math.max(0, elapsed); + } + + int? _forgetTypingStart(int chatId, int userId) { + final starts = _typingSince[chatId]; + final since = starts?.remove(userId); + if (starts != null && starts.isEmpty) _typingSince.remove(chatId); + return since; + } + + void clearUser(int chatId, int userId) => _remove(chatId, userId); + + void clearChat(int chatId) { + final timers = _timers.remove(chatId); + if (timers != null) { + for (final timer in timers.values) { + timer.cancel(); + } + } + _users.remove(chatId); + _typingSince.remove(chatId); + _sync(chatId); + } + + void _remove(int chatId, int userId) { + _timers[chatId]?.remove(userId)?.cancel(); + _forgetTypingStart(chatId, userId); + final users = _users[chatId]; + if (users != null) { + users.remove(userId); + if (users.isEmpty) _users.remove(chatId); + } + _sync(chatId); + } + + ChatActivitySnapshot? _current(int chatId) { + final users = _users[chatId]; + if (users == null || users.isEmpty) return null; + final leading = users.values.contains(ChatActivity.typing) + ? ChatActivity.typing + : ChatActivity.sticker; + final ids = []; + users.forEach((userId, activity) { + if (activity == leading) ids.add(userId); + }); + return ChatActivitySnapshot(activity: leading, userIds: ids); + } + + void _sync(int chatId) { + _notifiers[chatId]?.value = _current(chatId); + } +} diff --git a/lib/core/storage/chat_encryption_store.dart b/lib/core/storage/chat_encryption_store.dart new file mode 100644 index 0000000..3d751de --- /dev/null +++ b/lib/core/storage/chat_encryption_store.dart @@ -0,0 +1,38 @@ +import 'per_chat_json_store.dart'; +import 'token_storage.dart'; + +class ChatEncryptionStore extends PerChatJsonStore { + ChatEncryptionStore._() + : super( + prefsKey: 'chat_encryption', + fromJson: (raw) => raw == true ? true : null, + toJson: (value) => value, + ); + + static final ChatEncryptionStore instance = ChatEncryptionStore._(); + + static const String _keyPrefix = 'chat_encryption_key'; + + bool isEnabled(int accountId, int chatId) => read(accountId, chatId) == true; + + Future setEnabled(int accountId, int chatId, bool enabled) => + write(accountId, chatId, enabled ? true : null); + + Future readKey(int accountId, int chatId) async { + if (accountId == 0) return null; + return TokenStorage.readSecure(_secureKey(accountId, chatId)); + } + + Future writeKey(int accountId, int chatId, String key) async { + if (accountId == 0) return; + await TokenStorage.writeSecure(_secureKey(accountId, chatId), key); + } + + Future deleteKey(int accountId, int chatId) async { + if (accountId == 0) return; + await TokenStorage.deleteSecure(_secureKey(accountId, chatId)); + } + + String _secureKey(int accountId, int chatId) => + '${_keyPrefix}_${accountId}_$chatId'; +} diff --git a/lib/core/storage/chat_members_store.dart b/lib/core/storage/chat_members_store.dart new file mode 100644 index 0000000..f4d569a --- /dev/null +++ b/lib/core/storage/chat_members_store.dart @@ -0,0 +1,45 @@ +import 'package:flutter/foundation.dart'; + +class ChatMembersStore { + ChatMembersStore._(); + + static final ChatMembersStore instance = ChatMembersStore._(); + + final Map _counts = {}; + final Map> _notifiers = {}; + + ValueListenable listenable(int chatId) => _notifiers.putIfAbsent( + chatId, + () => ValueNotifier(_counts[chatId]), + ); + + int? count(int chatId) => _counts[chatId]; + + void setCount(int chatId, int? count) { + if (count == null || count < 0) return; + if (_counts[chatId] == count) return; + _counts[chatId] = count; + _notifiers[chatId]?.value = count; + } + + void adjust(int chatId, int delta) { + final current = _counts[chatId]; + if (current == null || delta == 0) return; + final next = current + delta; + setCount(chatId, next < 0 ? 0 : next); + } + + void applyChatPayload(Object? chat) { + if (chat is! Map) return; + final id = chat['id']; + final count = chat['participantsCount']; + if (id is int && count is int) setCount(id, count); + } + + void clear() { + _counts.clear(); + for (final notifier in _notifiers.values) { + notifier.value = null; + } + } +} diff --git a/lib/core/storage/chat_wallpaper_store.dart b/lib/core/storage/chat_wallpaper_store.dart new file mode 100644 index 0000000..c5a7c48 --- /dev/null +++ b/lib/core/storage/chat_wallpaper_store.dart @@ -0,0 +1,227 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' show Color; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../utils/logger.dart'; +import 'per_chat_json_store.dart'; + +// #***! chatId 0 это обои по умолчанию +const int kGlobalWallpaperChatId = 0; + +// #***! обои, своя картинка, встроенная тема или свой градиент +enum ChatWallpaperKind { image, theme, gradient } + +// #***! правки картинки, затемнение блюр параллакс сдвиг +@immutable +class WallpaperImageSettings { + final double dim; + final bool blur; + final bool motion; + final double offsetX; + + const WallpaperImageSettings({ + this.dim = 0, + this.blur = false, + this.motion = false, + this.offsetX = 0, + }); +} + +// #***! один объект на все виды, у темы/градиента настройки картинки нулевые +@immutable +class ChatWallpaper { + final ChatWallpaperKind kind; + final String? imagePath; + final String? themeId; + final List? gradientColors; + final bool gradientAnimated; + final double gradientRotation; + final double dim; + final bool blur; + final bool motion; + final double offsetX; + + const ChatWallpaper.image( + String path, { + this.dim = 0, + this.blur = false, + this.motion = false, + this.offsetX = 0, + }) : kind = ChatWallpaperKind.image, + imagePath = path, + themeId = null, + gradientColors = null, + gradientAnimated = true, + gradientRotation = 0; + + const ChatWallpaper.theme(String id) + : kind = ChatWallpaperKind.theme, + imagePath = null, + themeId = id, + gradientColors = null, + gradientAnimated = true, + gradientRotation = 0, + dim = 0, + blur = false, + motion = false, + offsetX = 0; + + const ChatWallpaper.gradient( + List colors, { + this.gradientAnimated = false, + this.gradientRotation = 0, + }) : kind = ChatWallpaperKind.gradient, + imagePath = null, + themeId = null, + gradientColors = colors, + dim = 0, + blur = false, + motion = false, + offsetX = 0; + + bool get isImage => kind == ChatWallpaperKind.image; + bool get isGradient => kind == ChatWallpaperKind.gradient; + + // #***! в джейсон или путь с настройками, или id темы, или свои цвета + Map _toJson() { + if (isImage) { + return { + 'path': imagePath, + 'dim': dim, + 'blur': blur, + 'motion': motion, + 'offsetX': offsetX, + }; + } + if (isGradient) { + return { + 'colors': gradientColors!.map((c) => c.toARGB32()).toList(), + 'animated': gradientAnimated, + 'rotation': gradientRotation, + }; + } + return {'theme': themeId}; + } + + static ChatWallpaper? _fromJson(Object? raw) { + if (raw is! Map) return null; + final path = raw['path']; + if (path is String && path.isNotEmpty) { + return ChatWallpaper.image( + path, + dim: (raw['dim'] as num?)?.toDouble() ?? 0, + blur: raw['blur'] == true, + motion: raw['motion'] == true, + offsetX: (raw['offsetX'] as num?)?.toDouble() ?? 0, + ); + } + final colors = raw['colors']; + if (colors is List && colors.isNotEmpty) { + return ChatWallpaper.gradient( + colors.whereType().map((v) => Color(v.toInt())).toList(), + gradientAnimated: raw['animated'] == true, + gradientRotation: (raw['rotation'] as num?)?.toDouble() ?? 0, + ); + } + final theme = raw['theme']; + if (theme is String && theme.isNotEmpty) return ChatWallpaper.theme(theme); + return null; + } +} + +// #***! обои по чатам поверх общего хранилища +class ChatWallpaperStore extends PerChatJsonStore { + @visibleForTesting + ChatWallpaperStore.forTesting() : this._(); + + ChatWallpaperStore._() + : super( + prefsKey: 'chat_wallpapers', + fromJson: ChatWallpaper._fromJson, + toJson: (value) => value._toJson(), + ); + + static final ChatWallpaperStore instance = ChatWallpaperStore._(); + + static const String _dirName = 'chat_wallpapers'; + + ChatWallpaper? get(int accountId, int chatId) => read(accountId, chatId); + + // #***! картинку копируем к себе, исходник из галереи может исчезнуть + Future setImage( + int accountId, + int chatId, + Uint8List bytes, { + WallpaperImageSettings settings = const WallpaperImageSettings(), + }) async { + if (accountId == 0) return null; + final dir = await getApplicationDocumentsDirectory(); + final wpDir = Directory('${dir.path}/$_dirName'); + if (!await wpDir.exists()) await wpDir.create(recursive: true); + // #***! время в имени файла, иначе флаттер отдаст старую из кэша + final stamp = DateTime.now().millisecondsSinceEpoch; + final file = File('${wpDir.path}/${accountId}_${chatId}_$stamp.img'); + await file.writeAsBytes(bytes, flush: true); + final wallpaper = ChatWallpaper.image( + file.path, + dim: settings.dim, + blur: settings.blur, + motion: settings.motion, + offsetX: settings.offsetX, + ); + await write(accountId, chatId, wallpaper); + return wallpaper; + } + + Future setTheme( + int accountId, + int chatId, + String themeId, + ) async { + final wallpaper = ChatWallpaper.theme(themeId); + await write(accountId, chatId, wallpaper); + return wallpaper; + } + + Future setGradient( + int accountId, + int chatId, + List colors, { + bool animated = false, + double rotation = 0, + }) async { + final wallpaper = ChatWallpaper.gradient( + colors, + gradientAnimated: animated, + gradientRotation: rotation, + ); + await write(accountId, chatId, wallpaper); + return wallpaper; + } + + Future clear(int accountId, int chatId) => + write(accountId, chatId, null); + + // #***! старый файл удаляем иначе обои копятся на диске + @override + void onBeforeWrite(String key, ChatWallpaper? previous, ChatWallpaper? next) { + if (previous != null && + previous.isImage && + previous.imagePath != next?.imagePath) { + unawaited(_deleteImage(previous.imagePath)); + } + } + + Future _deleteImage(String? path) async { + if (path == null) return; + try { + final file = File(path); + if (await file.exists()) await file.delete(); + } catch (e) { + logger.w('wallpaper image delete failed: $e'); + } + } +} diff --git a/lib/core/storage/device_identity.dart b/lib/core/storage/device_identity.dart new file mode 100644 index 0000000..26a919e --- /dev/null +++ b/lib/core/storage/device_identity.dart @@ -0,0 +1,34 @@ +import 'dart:math'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../utils/ids.dart'; + +abstract class DeviceIdentity { + static const String _instanceIdKey = 'mt_instance_id'; + static const String _deviceIdKey = 'device_id_local'; + + static final Random _rng = Random.secure(); + static int? _clientSessionId; + + static int get clientSessionId => + _clientSessionId ??= _rng.nextInt(0x7FFFFFFF) + 1; + + static Future instanceId() async { + final prefs = await SharedPreferences.getInstance(); + final existing = prefs.getString(_instanceIdKey); + if (existing != null && existing.isNotEmpty) return existing; + final generated = uuidV4(); + await prefs.setString(_instanceIdKey, generated); + return generated; + } + + static Future deviceId() async { + final prefs = await SharedPreferences.getInstance(); + final existing = prefs.getString(_deviceIdKey); + if (existing != null && existing.isNotEmpty) return existing; + final generated = randomHex(8); + await prefs.setString(_deviceIdKey, generated); + return generated; + } +} diff --git a/lib/core/storage/draft_store.dart b/lib/core/storage/draft_store.dart new file mode 100644 index 0000000..e1dc9f6 --- /dev/null +++ b/lib/core/storage/draft_store.dart @@ -0,0 +1,28 @@ +import 'per_chat_json_store.dart'; + +class DraftStore extends PerChatJsonStore { + DraftStore._() + : super( + prefsKey: 'chat_drafts', + fromJson: (raw) => raw is String ? raw : null, + toJson: (value) => value, + ); + + static final DraftStore instance = DraftStore._(); + + String? get(int accountId, int chatId) => read(accountId, chatId); + + Future set(int accountId, int chatId, String text) async { + if (accountId == 0) return; + final current = read(accountId, chatId); + if (text.trim().isEmpty) { + if (current == null) return; + await write(accountId, chatId, null); + } else { + if (current == text) return; + await write(accountId, chatId, text); + } + } + + Future clear(int accountId, int chatId) => set(accountId, chatId, ''); +} diff --git a/lib/core/storage/ikar_device_key.dart b/lib/core/storage/ikar_device_key.dart new file mode 100644 index 0000000..2fc26b6 --- /dev/null +++ b/lib/core/storage/ikar_device_key.dart @@ -0,0 +1,22 @@ +import 'package:flutter/services.dart'; + +/// The private signing key stays in Android Keystore. +abstract final class IkarDeviceKey { + static const _channel = MethodChannel('ru.ikar.app/device_auth'); + static Future> identity(String scope) async { + final value = await _channel.invokeMapMethod('identity', { + 'scope': scope, + }); + if (value == null) throw StateError('Не удалось создать ключ ИКАР'); + return value; + } + + static Future sign(String challenge, String scope) async { + final value = await _channel.invokeMethod('sign', { + 'scope': scope, + 'challenge': challenge, + }); + if (value == null) throw StateError('Не удалось подтвердить вход'); + return value; + } +} diff --git a/lib/core/storage/local_contact_avatars.dart b/lib/core/storage/local_contact_avatars.dart new file mode 100644 index 0000000..ee528c5 --- /dev/null +++ b/lib/core/storage/local_contact_avatars.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class LocalContactAvatars { + LocalContactAvatars._(); + + static final LocalContactAvatars instance = LocalContactAvatars._(); + + static const _prefsKey = 'local_contact_avatars'; + static const _dirName = 'contact_avatars'; + + final ValueNotifier revision = ValueNotifier(0); + final Map _paths = {}; + bool _loaded = false; + + Future load() async { + if (_loaded) return; + _loaded = true; + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_prefsKey); + if (raw == null) return; + final Object? decoded; + try { + decoded = jsonDecode(raw); + } on FormatException { + return; + } + if (decoded is! Map) return; + for (final MapEntry(:key, :value) in decoded.entries) { + final userId = int.tryParse('$key'); + if (userId == null || value is! String) continue; + if (await File(value).exists()) _paths[userId] = value; + } + } + + bool has(int userId) => _paths.containsKey(userId); + + ImageProvider? imageFor(int userId) { + final path = _paths[userId]; + return path == null ? null : FileImage(File(path)); + } + + Future set(int userId, Uint8List jpeg) async { + final root = await getApplicationDocumentsDirectory(); + final dir = Directory('${root.path}/$_dirName'); + await dir.create(recursive: true); + final stamp = DateTime.now().millisecondsSinceEpoch; + final file = File('${dir.path}/${userId}_$stamp.jpg'); + await file.writeAsBytes(jpeg, flush: true); + await _replace(userId, file.path); + } + + Future clear(int userId) => _replace(userId, null); + + Future _replace(int userId, String? path) async { + final previous = _paths[userId]; + if (previous == path) return; + if (path == null) { + _paths.remove(userId); + } else { + _paths[userId] = path; + } + revision.value++; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _prefsKey, + jsonEncode({for (final e in _paths.entries) '${e.key}': e.value}), + ); + if (previous == null) return; + try { + await File(previous).delete(); + } on FileSystemException { + return; + } + } +} diff --git a/lib/core/storage/message_persistence.dart b/lib/core/storage/message_persistence.dart new file mode 100644 index 0000000..1813b87 --- /dev/null +++ b/lib/core/storage/message_persistence.dart @@ -0,0 +1,53 @@ +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +class MessagePersistence { + static Future replace( + Database db, + Map row, { + String? removeId, + }) => db.transaction((txn) async { + await txn.insert( + 'messages', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + if (removeId != null && removeId != row['id']) { + await txn.delete( + 'messages', + where: 'account_id = ? AND chat_id = ? AND id = ?', + whereArgs: [row['account_id'], row['chat_id'], removeId], + ); + } + }); + + /// Updates only an item still queued by this account. Deletion wins over a + /// late network response, including a retry or a successful server receipt. + static Future replacePending( + Database db, + Map row, { + required String pendingId, + }) async { + return db.transaction((txn) async { + final args = [row['account_id'], row['chat_id'], pendingId]; + const where = + "account_id = ? AND chat_id = ? AND id = ? " + "AND status = 'pending' AND deleted = 0"; + final existing = await txn.query( + 'messages', + columns: ['id'], + where: where, + whereArgs: args, + ); + if (existing.isEmpty) return false; + await txn.insert( + 'messages', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + if (row['id'] != pendingId) { + await txn.delete('messages', where: where, whereArgs: args); + } + return true; + }); + } +} diff --git a/lib/core/storage/message_ranges.dart b/lib/core/storage/message_ranges.dart new file mode 100644 index 0000000..07ec847 --- /dev/null +++ b/lib/core/storage/message_ranges.dart @@ -0,0 +1,178 @@ +import 'dart:math' as math; + +class MessageRange { + const MessageRange(this.start, this.end); + + final int start; + final int end; + + bool contains(int time) => start <= time && time <= end; + + bool touches(MessageRange other) => start <= other.end && other.start <= end; + + MessageRange union(MessageRange other) => + MessageRange(math.min(start, other.start), math.max(end, other.end)); + + @override + bool operator ==(Object other) => + other is MessageRange && other.start == start && other.end == end; + + @override + int get hashCode => Object.hash(start, end); + + @override + String toString() => 'MessageRange($start, $end)'; +} + +class MessageRanges { + MessageRanges(Iterable ranges) + : ranges = List.unmodifiable( + [...ranges]..sort((a, b) => a.start.compareTo(b.start)), + ); + + static final MessageRanges none = MessageRanges(const []); + + final List ranges; + + bool get isEmpty => ranges.isEmpty; + + MessageRange? at(int time) { + for (final range in ranges) { + if (range.contains(time)) return range; + } + return null; + } + + MessageRange? get newest => ranges.isEmpty ? null : ranges.last; + + static List merge( + List existing, + MessageRange added, + ) { + var merged = added; + final kept = []; + for (final range in existing) { + if (range.touches(merged)) { + merged = merged.union(range); + } else { + kept.add(range); + } + } + return [...kept, merged]..sort((a, b) => a.start.compareTo(b.start)); + } + + static MessageRange? coverageOfFetch({ + required int? fromTime, + required int forward, + required int backward, + required List times, + }) { + if (times.isEmpty && fromTime == null) return null; + final from = fromTime; + final oldest = times.isEmpty ? from! : times.reduce(math.min); + final newest = times.isEmpty ? from! : times.reduce(math.max); + + var start = oldest; + if (backward > 0) { + final older = from == null + ? times.length + : times.where((t) => t <= from).length; + if (older < backward) start = 0; + } else if (from != null) { + start = math.min(oldest, from); + } + + var end = newest; + if (forward <= 0 && from != null) end = math.max(newest, from); + + return MessageRange(start, end); + } + + List clipOlder( + List olderDesc, + int edgeTime, + int Function(T) timeOf, + ) { + final range = at(edgeTime); + if (range == null) return const []; + return [ + for (final item in olderDesc) + if (timeOf(item) >= range.start) item, + ]; + } + + List clipNewer(List newerAsc, int edgeTime, int Function(T) timeOf) { + final range = at(edgeTime); + if (range == null) return const []; + return [ + for (final item in newerAsc) + if (timeOf(item) <= range.end) item, + ]; + } + + bool coversOlderPage({ + required int edgeTime, + required List pageTimes, + required int limit, + }) { + final range = at(edgeTime); + if (range == null) return false; + if (pageTimes.any((t) => t < range.start)) return false; + return pageTimes.length >= limit || range.start == 0; + } + + bool coversNewerPage({ + required int edgeTime, + required List pageTimes, + required int limit, + required int? newestKnownTime, + }) { + final range = at(edgeTime); + if (range == null) return false; + if (pageTimes.any((t) => t > range.end)) return false; + if (pageTimes.length >= limit) return true; + return newestKnownTime != null && range.end >= newestKnownTime; + } + + bool coversWindow({ + required int centerTime, + required List windowTimes, + required int before, + required int after, + required int? newestKnownTime, + }) { + final range = at(centerTime); + if (range == null) return false; + if (windowTimes.any((t) => !range.contains(t))) return false; + final older = windowTimes.where((t) => t <= centerTime).length; + final newer = windowTimes.length - older; + final olderComplete = older >= before || range.start == 0; + final newerComplete = + newer >= after || + (newestKnownTime != null && range.end >= newestKnownTime); + return olderComplete && newerComplete; + } + + List clipAround(List items, int centerTime, int Function(T) timeOf) { + final range = at(centerTime); + if (range == null) return items; + return [ + for (final item in items) + if (range.contains(timeOf(item))) item, + ]; + } + + List clipLatest(List latest, int Function(T) timeOf) { + final range = newest; + if (range == null) return latest; + return [ + for (final item in latest) + if (timeOf(item) >= range.start) item, + ]; + } + + bool coversNewest(int newestTime) { + final range = newest; + return range != null && range.end >= newestTime; + } +} diff --git a/lib/core/storage/per_chat_json_store.dart b/lib/core/storage/per_chat_json_store.dart new file mode 100644 index 0000000..7f375fc --- /dev/null +++ b/lib/core/storage/per_chat_json_store.dart @@ -0,0 +1,73 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +abstract class PerChatJsonStore { + PerChatJsonStore({ + required String prefsKey, + required T? Function(Object? raw) fromJson, + required Object? Function(T value) toJson, + }) : _prefsKey = prefsKey, + _fromJson = fromJson, + _toJson = toJson; + + final String _prefsKey; + final T? Function(Object? raw) _fromJson; + final Object? Function(T value) _toJson; + + final Map _values = {}; + final ValueNotifier revision = ValueNotifier(0); + bool _loaded = false; + + String _buildKey(int accountId, int chatId) => '$accountId/$chatId'; + + @protected + Iterable> get allEntries => _values.entries; + + @protected + void onBeforeWrite(String key, T? previous, T? next) {} + + Future load() async { + if (_loaded) return; + _loaded = true; + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_prefsKey); + if (raw == null) return; + try { + final map = jsonDecode(raw); + if (map is Map) { + map.forEach((k, v) { + if (k is! String) return; + final value = _fromJson(v); + if (value != null) _values[k] = value; + }); + } + } catch (_) {} + } + + @protected + T? read(int accountId, int chatId) { + if (accountId == 0) return null; + return _values[_buildKey(accountId, chatId)]; + } + + @protected + Future write(int accountId, int chatId, T? value) async { + if (accountId == 0) return; + final key = _buildKey(accountId, chatId); + final previous = _values[key]; + onBeforeWrite(key, previous, value); + if (value == null) { + if (previous == null) return; + _values.remove(key); + } else { + _values[key] = value; + } + revision.value++; + final prefs = await SharedPreferences.getInstance(); + final serializable = {}; + _values.forEach((k, v) => serializable[k] = _toJson(v)); + await prefs.setString(_prefsKey, jsonEncode(serializable)); + } +} diff --git a/lib/core/storage/profile_deletion_store.dart b/lib/core/storage/profile_deletion_store.dart new file mode 100644 index 0000000..a0f7efb --- /dev/null +++ b/lib/core/storage/profile_deletion_store.dart @@ -0,0 +1,24 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +// #***! когда запланировано удаление профиля +abstract final class ProfileDeletionStore { + static String _key(int accountId) => 'profile_delete_at_$accountId'; + + static Future scheduledAt(int accountId) async { + final prefs = await SharedPreferences.getInstance(); + final millis = prefs.getInt(_key(accountId)) ?? 0; + if (millis <= 0) return null; + return DateTime.fromMillisecondsSinceEpoch(millis); + } + + static Future save(int accountId, int millis) async { + final prefs = await SharedPreferences.getInstance(); + if (millis > 0) { + await prefs.setInt(_key(accountId), millis); + } else { + await prefs.remove(_key(accountId)); + } + } + + static Future clear(int accountId) => save(accountId, 0); +} diff --git a/lib/core/storage/spoofing_service.dart b/lib/core/storage/spoofing_service.dart new file mode 100644 index 0000000..6645e9c --- /dev/null +++ b/lib/core/storage/spoofing_service.dart @@ -0,0 +1,264 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../config/device_presets.dart'; +import '../config/build_profile.dart'; +import '../../models/spoof_profile.dart'; +import 'token_storage.dart'; +import '../utils/ids.dart'; +import '../utils/logger.dart'; + +class SpoofingService { + static const String hardcodedAppVersion = '26.31.0'; + static const int hardcodedBuildNumber = 6822; + static const String pendingScope = 'pending'; + + static const String _legacyEnabledKey = 'spoofing_enabled'; + static const List _legacyKeys = [ + 'spoofing_enabled', + 'spoof_devicename', + 'spoof_osversion', + 'spoof_screen', + 'spoof_timezone', + 'spoof_locale', + 'spoof_devicelocale', + 'spoof_deviceid', + 'spoof_devicetype', + 'spoof_arch', + 'spoof_appversion', + 'spoof_buildnumber', + 'spoof_pushdevicetype', + 'spoof_instanceid', + 'spoof_clientsessionid', + 'spoof_useragent', + ]; + + static final Random _rng = Random.secure(); + + static String _profileKey(String scope) => 'spoof_profile_$scope'; + + static Future activeScope() async { + final id = await TokenStorage.getActiveAccountId(); + return id?.toString() ?? pendingScope; + } + + static Future loadProfile(String scope) async { + final prefs = await SharedPreferences.getInstance(); + return _read(prefs, scope); + } + + static Future saveProfile(String scope, SpoofProfile profile) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_profileKey(scope), jsonEncode(profile.toJson())); + } + + static Future clearAccountSpoof(int accountId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_profileKey('$accountId')); + } + + static Future commitPendingSpoof(int accountId) async { + final prefs = await SharedPreferences.getInstance(); + final pending = await _read(prefs, pendingScope); + if (pending == null) return; + await prefs.setString( + _profileKey('$accountId'), + jsonEncode(pending.toJson()), + ); + await prefs.remove(_profileKey(pendingScope)); + } + + static Future prepareNewAccountSpoof( + List existingAccountIds, + ) async { + final prefs = await SharedPreferences.getInstance(); + + final used = {}; + for (final id in existingAccountIds) { + final existing = await _read(prefs, '$id'); + if (existing != null && existing.deviceName.isNotEmpty) { + used.add(existing.deviceName); + } + } + + bool isAndroid(DevicePreset p) => p.deviceType == 'ANDROID'; + final fresh = devicePresets + .where((p) => isAndroid(p) && !used.contains(p.deviceName)) + .toList(); + final pool = fresh.isNotEmpty + ? fresh + : devicePresets.where(isAndroid).toList(); + final preset = pool[_rng.nextInt(pool.length)]; + final shortLocale = preset.locale.split(RegExp(r'[-_]')).first; + + final profile = SpoofProfile( + enabled: true, + deviceName: preset.deviceName, + osVersion: preset.osVersion, + screen: preset.screen, + timezone: preset.timezone, + locale: shortLocale, + deviceLocale: shortLocale, + deviceId: _hex(8), + deviceType: preset.deviceType, + arch: 'arm64-v8a', + appVersion: hardcodedAppVersion, + buildNumber: hardcodedBuildNumber, + pushDeviceType: 'GCM', + instanceId: uuidV4(), + clientSessionId: _rng.nextInt(0x7FFFFFFF) + 1, + userAgent: preset.userAgent, + ); + + await prefs.setString( + _profileKey(pendingScope), + jsonEncode(profile.toJson()), + ); + return profile; + } + + static Future?> getSpoofedSessionData({ + String? scope, + }) async { + if (!BuildProfile.spoofUi) return null; + final prefs = await SharedPreferences.getInstance(); + final profile = await _read(prefs, scope ?? await activeScope()); + if (profile == null || !profile.enabled) return null; + + return { + 'device_name': profile.deviceName, + 'os_version': profile.osVersion, + 'screen': profile.screen, + 'timezone': profile.timezone, + 'locale': profile.locale, + 'device_locale': profile.deviceLocale, + 'device_id': profile.deviceId, + 'device_type': profile.deviceType, + 'app_version': profile.appVersion.isEmpty + ? hardcodedAppVersion + : profile.appVersion, + 'arch': profile.arch.isEmpty ? 'arm64-v8a' : profile.arch, + 'build_number': profile.buildNumber == 0 + ? hardcodedBuildNumber + : profile.buildNumber, + 'instance_id': profile.instanceId, + 'client_session_id': profile.clientSessionId, + 'push_device_type': profile.pushDeviceType, + 'user_agent': profile.userAgent, + }; + } + + static Future getWebViewUserAgent() async { + final prefs = await SharedPreferences.getInstance(); + final profile = await _read(prefs, await activeScope()); + if (profile == null || !profile.enabled) return null; + + if (profile.userAgent.isNotEmpty) return profile.userAgent; + for (final preset in devicePresets) { + if (preset.deviceName == profile.deviceName) return preset.userAgent; + } + return _deriveUserAgent(profile); + } + + static String _deriveUserAgent(SpoofProfile profile) { + final deviceType = profile.deviceType.isEmpty + ? 'ANDROID' + : profile.deviceType; + final osVersion = profile.osVersion; + final model = profile.deviceName.isEmpty ? 'K' : profile.deviceName; + + if (deviceType == 'IOS' || deviceType == 'iOS') { + final version = osVersion + .replaceAll(RegExp(r'[^0-9.]'), '') + .replaceAll('.', '_'); + return 'Mozilla/5.0 (iPhone; CPU iPhone OS ' + '${version.isEmpty ? '17_0' : version} like Mac OS X) ' + 'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 ' + 'Mobile/15E148 Safari/604.1'; + } + + final android = osVersion.isEmpty ? 'Android 14' : osVersion; + return 'Mozilla/5.0 (Linux; $android; $model) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36'; + } + + static Future _read( + SharedPreferences prefs, + String scope, + ) async { + final raw = prefs.getString(_profileKey(scope)); + if (raw != null && raw.isNotEmpty) { + try { + final profile = SpoofProfile.fromJson( + jsonDecode(raw) as Map, + ); + return await _migrateVersion(prefs, scope, profile); + } catch (e) { + logger.w('spoof profile read failed: $e'); + } + } + if (scope != pendingScope) { + return _migrateLegacy(prefs, scope); + } + return null; + } + + static Future _migrateVersion( + SharedPreferences prefs, + String scope, + SpoofProfile profile, + ) async { + if (profile.appVersion == hardcodedAppVersion && + profile.buildNumber == hardcodedBuildNumber) { + return profile; + } + final migrated = profile.copyWith( + appVersion: hardcodedAppVersion, + buildNumber: hardcodedBuildNumber, + ); + await prefs.setString(_profileKey(scope), jsonEncode(migrated.toJson())); + return migrated; + } + + static Future _migrateLegacy( + SharedPreferences prefs, + String scope, + ) async { + if (!(prefs.getBool(_legacyEnabledKey) ?? false)) return null; + + final profile = SpoofProfile( + enabled: true, + deviceName: prefs.getString('spoof_devicename') ?? '', + osVersion: prefs.getString('spoof_osversion') ?? '', + screen: prefs.getString('spoof_screen') ?? '', + timezone: prefs.getString('spoof_timezone') ?? '', + locale: prefs.getString('spoof_locale') ?? '', + deviceLocale: prefs.getString('spoof_devicelocale') ?? '', + deviceId: prefs.getString('spoof_deviceid') ?? '', + deviceType: prefs.getString('spoof_devicetype') ?? 'ANDROID', + arch: prefs.getString('spoof_arch') ?? 'arm64-v8a', + appVersion: prefs.getString('spoof_appversion') ?? hardcodedAppVersion, + buildNumber: prefs.getInt('spoof_buildnumber') ?? hardcodedBuildNumber, + pushDeviceType: prefs.getString('spoof_pushdevicetype') ?? 'GCM', + instanceId: prefs.getString('spoof_instanceid') ?? '', + clientSessionId: prefs.getInt('spoof_clientsessionid'), + userAgent: prefs.getString('spoof_useragent') ?? '', + ); + + await prefs.setString(_profileKey(scope), jsonEncode(profile.toJson())); + for (final key in _legacyKeys) { + await prefs.remove(key); + } + return profile; + } + + static String _hex(int bytes) { + final sb = StringBuffer(); + for (var i = 0; i < bytes; i++) { + sb.write(_rng.nextInt(256).toRadixString(16).padLeft(2, '0')); + } + return sb.toString(); + } +} diff --git a/lib/core/storage/startup_account.dart b/lib/core/storage/startup_account.dart new file mode 100644 index 0000000..22b3d8b --- /dev/null +++ b/lib/core/storage/startup_account.dart @@ -0,0 +1,17 @@ +/// Resolves the local account without waiting for a network connection. +Future resolveStartupAccount({ + required Future Function() readActiveId, + required Future Function(int) readToken, + required Future> Function() readProfileIds, + required Future Function(int) activate, +}) async { + final activeId = await readActiveId(); + if (activeId != null && await readToken(activeId) != null) return activeId; + for (final id in await readProfileIds()) { + if (id == activeId) continue; + if (await readToken(id) == null) continue; + await activate(id); + return id; + } + return null; +} diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart new file mode 100644 index 0000000..8dedf0d --- /dev/null +++ b/lib/core/storage/token_storage.dart @@ -0,0 +1,116 @@ +import 'package:flutter/services.dart' show PlatformException; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class TokenStorage { + static const _tokenPrefix = 'auth_token_'; + static const _activeAccountKey = 'active_account_id'; + + static const _secure = FlutterSecureStorage( + // Keep the existing Android token-store options across this feature port. + // ignore: deprecated_member_use + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock_this_device, + synchronizable: false, + ), + mOptions: MacOsOptions(usesDataProtectionKeychain: false), + ); + + static const int _duplicateKeychainItem = -25299; + + static bool _isDuplicateItem(PlatformException error) => + error.details == _duplicateKeychainItem || + (error.message?.contains('$_duplicateKeychainItem') ?? false); + + static Future _write(String key, String value) async { + try { + await _secure.write(key: key, value: value); + } on PlatformException catch (e) { + if (!_isDuplicateItem(e)) rethrow; + await _secure.delete(key: key); + await _secure.write(key: key, value: value); + } + } + + static Future writeSecure(String key, String value) => + _write(key, value); + + static Future readSecure(String key) async { + return _secure.read(key: key); + } + + static Future deleteSecure(String key) async { + await _secure.delete(key: key); + } + + static Future> secureKeysWithPrefix(String prefix) async { + final all = await _secure.readAll(); + return all.keys.where((key) => key.startsWith(prefix)).toList(); + } + + static Future saveToken(String token, int accountId) => + _write('$_tokenPrefix$accountId', token); + + static Future readToken(int accountId) async { + final key = '$_tokenPrefix$accountId'; + final secured = await _secure.read(key: key); + if (secured != null) return secured; + + final prefs = await SharedPreferences.getInstance(); + final legacy = prefs.getString(key); + if (legacy != null) { + await _write(key, legacy); + await prefs.remove(key); + return legacy; + } + return null; + } + + static Future tryReadToken(int accountId) async { + try { + return await readToken(accountId); + } on PlatformException { + return null; + } + } + + static Future deleteToken(int accountId) async { + final key = '$_tokenPrefix$accountId'; + await _secure.delete(key: key); + await _secure.delete(key: 'ikar_refresh_$accountId'); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(key); + } + + static Future setActiveAccount(int accountId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_activeAccountKey, accountId.toString()); + } + + static Future clearActiveAccount() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_activeAccountKey); + } + + static Future getActiveAccountId() async { + final prefs = await SharedPreferences.getInstance(); + final val = prefs.getString(_activeAccountKey); + return val != null ? int.tryParse(val) : null; + } + + static Future readActiveToken() async { + final id = await getActiveAccountId(); + if (id == null) return null; + return await readToken(id); + } + + static Future deleteAccount(int accountId) async { + await deleteToken(accountId); + final activeId = await getActiveAccountId(); + if (activeId == accountId) { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_activeAccountKey); + } + } +} diff --git a/lib/core/storage/webapp_storage.dart b/lib/core/storage/webapp_storage.dart new file mode 100644 index 0000000..ec40832 --- /dev/null +++ b/lib/core/storage/webapp_storage.dart @@ -0,0 +1,123 @@ +import 'app_database.dart'; +import 'token_storage.dart'; + +enum WebAppStorageBackend { device, secure } + +class WebAppStorage { + static const int deviceKeyLimit = 512; + static const int secureKeyLimit = 128; + + static String _securePrefix(int accountId, int botId) => + 'webapp_ss_${accountId}_${botId}_'; + + static String _secureKey(int accountId, int botId, String key) => + '${_securePrefix(accountId, botId)}$key'; + + static String _biometryTokenKey(int accountId, int botId) => + 'webapp_bio_${accountId}_$botId'; + + static Future read( + int accountId, + int botId, + WebAppStorageBackend backend, + String key, + ) { + if (backend == WebAppStorageBackend.secure) { + return TokenStorage.readSecure(_secureKey(accountId, botId, key)); + } + return AppDatabase.getWebAppValue(accountId, botId, key); + } + + static Future save( + int accountId, + int botId, + WebAppStorageBackend backend, + String key, + String value, + ) async { + if (await read(accountId, botId, backend, key) == null && + await _count(accountId, botId, backend) >= _limit(backend)) { + return false; + } + if (backend == WebAppStorageBackend.secure) { + await TokenStorage.writeSecure(_secureKey(accountId, botId, key), value); + } else { + await AppDatabase.saveWebAppValue(accountId, botId, key, value); + } + return true; + } + + static Future remove( + int accountId, + int botId, + WebAppStorageBackend backend, + String key, + ) async { + if (backend == WebAppStorageBackend.secure) { + await TokenStorage.deleteSecure(_secureKey(accountId, botId, key)); + return; + } + await AppDatabase.removeWebAppValue(accountId, botId, key); + } + + static Future clear( + int accountId, + int botId, + WebAppStorageBackend backend, + ) async { + if (backend == WebAppStorageBackend.secure) { + final keys = await TokenStorage.secureKeysWithPrefix( + _securePrefix(accountId, botId), + ); + for (final key in keys) { + await TokenStorage.deleteSecure(key); + } + return; + } + await AppDatabase.clearWebAppValues(accountId, botId); + } + + static Future biometryToken(int accountId, int botId) => + TokenStorage.readSecure(_biometryTokenKey(accountId, botId)); + + static Future saveBiometryToken( + int accountId, + int botId, + String token, + ) => TokenStorage.writeSecure(_biometryTokenKey(accountId, botId), token); + + static Future removeBiometryToken(int accountId, int botId) => + TokenStorage.deleteSecure(_biometryTokenKey(accountId, botId)); + + static Future<(bool, bool)> biometryAccess(int accountId, int botId) => + AppDatabase.getWebAppBiometryAccess(accountId, botId); + + static Future setBiometryAccess( + int accountId, + int botId, { + required bool requested, + required bool granted, + }) => AppDatabase.setWebAppBiometryAccess( + accountId, + botId, + requested: requested, + granted: granted, + ); + + static int _limit(WebAppStorageBackend backend) => + backend == WebAppStorageBackend.secure ? secureKeyLimit : deviceKeyLimit; + + static Future _count( + int accountId, + int botId, + WebAppStorageBackend backend, + ) async { + if (backend == WebAppStorageBackend.secure) { + final keys = await TokenStorage.secureKeysWithPrefix( + _securePrefix(accountId, botId), + ); + return keys.length; + } + return AppDatabase.countWebAppValues(accountId, botId); + } +} diff --git a/lib/core/transport/dispatcher.dart b/lib/core/transport/dispatcher.dart new file mode 100644 index 0000000..13ce1d8 --- /dev/null +++ b/lib/core/transport/dispatcher.dart @@ -0,0 +1,171 @@ +import 'dart:async'; + +import '../protocol/packet.dart'; +import '../protocol/opcode_map.dart'; +import '../utils/log_redact.dart'; +import '../utils/logger.dart'; + +typedef PacketHandler = void Function(Packet packet); + +class _PendingRequest { + _PendingRequest(this.completer, this.sentAt); + + final Completer completer; + final DateTime sentAt; +} + +/// Роутер входящих пакетов. +/// +/// Ответы на запросы матчатся по seq (через [registerPending]), +/// пуши — по opcode (через [registerHandler]). +class PacketDispatcher { + final Map _pendingRequests = {}; + final Map _pushHandlers = {}; + + final _pushController = StreamController.broadcast(); + final _errorController = StreamController.broadcast(); + + /// Стрим всех входящих пушей (cmd == 1) + Stream get pushStream => _pushController.stream; + + Stream get errorStream => _errorController.stream; + + static String? _serverErrorText(dynamic payload) { + if (payload is! Map) return null; + for (final key in ['localizedMessage', 'title']) { + final v = payload[key]; + if (v is String && v.trim().isNotEmpty) return v.trim(); + } + return null; + } + + Timer? _cleanupTimer; + + PacketDispatcher() { + _cleanupTimer = Timer.periodic( + const Duration(seconds: 10), + (_) => _cleanupStaleRequests(), + ); + } + + /// Регистрирует ожидание ответа — future завершится когда + /// придёт пакет с совпадающим seq. + Future registerPending(int seq) { + final existing = _pendingRequests[seq]; + if (existing != null && !existing.completer.isCompleted) { + existing.completer.completeError( + StateError('seq=$seq переиспользован до получения ответа'), + ); + } + final completer = Completer(); + _pendingRequests[seq] = _PendingRequest(completer, DateTime.now()); + return completer.future; + } + + /// Вешает обработчик на пуши с конкретным опкодом. + void registerHandler(int opcode, PacketHandler handler) { + _pushHandlers[opcode] = handler; + } + + void unregisterHandler(int opcode) { + _pushHandlers.remove(opcode); + } + + void dispatch(Packet packet) { + final tag = Opcode.name(packet.opcode); + + if (packet.cmd == CmdType.ok || + packet.cmd == CmdType.error || + packet.cmd == CmdType.notFound) { + final payloadLog = packet.opcode == Opcode.login + ? '<скрыто: ответ login>' + : payloadForLog(packet.payload); + logger.i( + '<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: $payloadLog}', + ); + + if (packet.isError) { + final isSessionExpired = + packet.payload is Map && + packet.payload['message'] == 'FAIL_LOGIN_TOKEN'; + final serverText = _serverErrorText(packet.payload); + if (serverText != null && !isSessionExpired) { + _errorController.add(serverText); + } + } + + final pending = _pendingRequests.remove(packet.seq); + final completer = pending?.completer; + + if (completer == null) { + if (packet.opcode != Opcode.ping) { + logger.w('Нет ожидающего запроса для seq=${packet.seq} [$tag]'); + } + return; + } + + if (packet.isError) { + final message = messageFromErrorPayload(packet.payload); + final errorKey = packet.payload is Map + ? packet.payload['error']?.toString() + : null; + if (packet.payload is Map && + packet.payload['message'] == 'FAIL_LOGIN_TOKEN') { + completer.completeError(SessionExpiredException(message)); + } else { + completer.completeError(PacketError(message, errorKey: errorKey)); + } + } else { + completer.complete(packet); + } + } else if (packet.isPush) { + logger.i( + '<= push {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${payloadForLog(packet.payload)}}', + ); + final handler = _pushHandlers[packet.opcode]; + if (handler != null) { + try { + handler(packet); + } catch (e) { + logger.w('$tag handler failed: $e'); + } + } + _pushController.add(packet); + } + } + + /// Чистит зависшие запросы старше 30 секунд + void _cleanupStaleRequests() { + final now = DateTime.now(); + final staleKeys = []; + + _pendingRequests.forEach((seq, pending) { + if (now.difference(pending.sentAt).inSeconds > 30) staleKeys.add(seq); + }); + + for (final seq in staleKeys) { + final pending = _pendingRequests.remove(seq); + final completer = pending?.completer; + if (completer != null && !completer.isCompleted) { + completer.completeError(TimeoutException('Таймаут запроса seq=$seq')); + } + } + } + + /// Обрывает все ожидающие запросы (при дисконнекте) + void clearPending() { + for (final entry in _pendingRequests.entries) { + if (!entry.value.completer.isCompleted) { + entry.value.completer.completeError(StateError('Соединение закрыто')); + } + } + _pendingRequests.clear(); + } + + void dispose() { + _cleanupTimer?.cancel(); + clearPending(); + _pushController.close(); + _errorController.close(); + } +} diff --git a/lib/core/transport/ikar_network.dart b/lib/core/transport/ikar_network.dart new file mode 100644 index 0000000..30ec13d --- /dev/null +++ b/lib/core/transport/ikar_network.dart @@ -0,0 +1,79 @@ +import 'dart:async'; +import 'dart:io'; + +abstract final class IkarNetwork { + // TLS still validates ikar.kusoft.xyz when DNS bootstrap is needed. + static const bootstrapAddress = String.fromEnvironment( + 'IKAR_BOOTSTRAP_IP', + defaultValue: '195.216.241.165', + ); + static HttpClient client() => configure(HttpClient()); + static InternetAddress? _cachedAddress; + static DateTime? _cachedUntil; + static Future _resolve(String host) async { + if (host != 'ikar.kusoft.xyz') + return (await InternetAddress.lookup(host)).first; + if (_cachedAddress != null && DateTime.now().isBefore(_cachedUntil!)) + return _cachedAddress!; + try { + _cachedAddress = (await InternetAddress.lookup( + host, + ).timeout(const Duration(seconds: 3))).first; + } on SocketException { + _cachedAddress = InternetAddress(bootstrapAddress); + } on TimeoutException { + _cachedAddress = InternetAddress(bootstrapAddress); + } + _cachedUntil = DateTime.now().add(const Duration(minutes: 5)); + return _cachedAddress!; + } + + static HttpClient configure(HttpClient client) { + client.connectionTimeout = const Duration(seconds: 15); + client.connectionFactory = (uri, proxyHost, proxyPort) async { + final host = proxyHost ?? uri.host; + final port = + proxyPort ?? + (uri.port == 0 ? (uri.scheme == 'https' ? 443 : 80) : uri.port); + ConnectionTask task; + try { + final address = await _resolve(host); + task = await Socket.startConnect(address, port); + } on SocketException { + if (proxyHost == null && uri.host == 'ikar.kusoft.xyz') + task = await Socket.startConnect(bootstrapAddress, port); + else + rethrow; + } + if (uri.scheme == 'https' && proxyHost == null) { + return ConnectionTask.fromSocket( + task.socket.then( + (socket) => SecureSocket.secure(socket, host: uri.host), + ), + task.cancel, + ); + } + return task; + }; + return client; + } + + static Future connectWebSocket( + String url, { + Map? headers, + }) { + var uri = Uri.parse(url); + if (!uri.hasPort) uri = uri.replace(port: uri.scheme == 'wss' ? 443 : 80); + return WebSocket.connect( + uri.toString(), + headers: headers, + customClient: client(), + ); + } +} + +class IkarHttpOverrides extends HttpOverrides { + @override + HttpClient createHttpClient(SecurityContext? context) => + IkarNetwork.configure(super.createHttpClient(context)); +} diff --git a/lib/core/transport/tls_config.dart b/lib/core/transport/tls_config.dart new file mode 100644 index 0000000..e1b401d --- /dev/null +++ b/lib/core/transport/tls_config.dart @@ -0,0 +1,29 @@ +import 'package:kusoft/kusoft.dart' show setTrustMincifryCa; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../config/config.dart'; +import '../config/build_profile.dart'; + +abstract class TlsConfig { + static const String prefKey = 'dev_tls_insecure'; + + static Future applyMincifryTrust() async { + final prefs = await SharedPreferences.getInstance(); + setTrustMincifryCa( + enabled: + prefs.getBool(ServerConfig.prefTrustMincifryKey) ?? + ServerConfig.defaultTrustMincifryCa, + ); + } + + static Future isInsecureAllowed() async { + if (!BuildProfile.insecureTransport) return false; + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(prefKey) ?? false; + } + + static Future setInsecureAllowed(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(prefKey, value); + } +} diff --git a/lib/core/transport/traffic_monitor.dart b/lib/core/transport/traffic_monitor.dart new file mode 100644 index 0000000..9bcce11 --- /dev/null +++ b/lib/core/transport/traffic_monitor.dart @@ -0,0 +1,236 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../protocol/opcode_map.dart'; +import '../protocol/packet.dart'; + +enum TrafficDirection { outgoing, incoming, event } + +class TrafficEntry { + final TrafficDirection direction; + final DateTime time; + final String label; + final int? opcode; + final int? seq; + final int? cmd; + final dynamic payload; + final int? byteSize; + final String? detail; + + TrafficEntry({ + required this.direction, + required this.time, + required this.label, + this.opcode, + this.seq, + this.cmd, + this.payload, + this.byteSize, + this.detail, + }); + + String get prettyPayload => prettyJson(payload); +} + +String prettyJson(dynamic value) { + if (value == null) return 'null'; + try { + return const JsonEncoder.withIndent(' ').convert(_sanitize(value)); + } catch (_) { + return value.toString(); + } +} + +const _redacted = '***'; + +const _sensitiveExportFields = { + 'token', + 'accesstoken', + 'refreshtoken', + 'authtoken', + 'password', + 'secret', + 'phone', + 'phonenumber', + 'email', + 'msisdn', + 'otp', + 'smscode', + 'verifycode', + 'pin', + 'qrlink', + 'webappdata', + 'deviceid', + 'instanceid', + 'mt_instanceid', + 'text', + 'caption', +}; + +bool _isSensitiveExportKey(Object? key) { + if (key is! String) return false; + return _sensitiveExportFields.contains(key.toLowerCase()); +} + +dynamic _redactForExport(dynamic value) { + if (value is Map) { + final out = {}; + value.forEach((k, v) { + out[k] = _isSensitiveExportKey(k) ? _redacted : _redactForExport(v); + }); + return out; + } + if (value is List) return value.map(_redactForExport).toList(); + return value; +} + +dynamic _sanitize(dynamic value) { + if (value is Map) { + final out = {}; + value.forEach((k, v) => out[k.toString()] = _sanitize(v)); + return out; + } + if (value is Uint8List) return ''; + if (value is List) return value.map(_sanitize).toList(); + if (value is num || value is bool || value is String) return value; + return value.toString(); +} + +/// Перехватчик сокет-трафика для меню разработчика. +/// +/// Захват включается только пока открыт экран монитора ([enabled]), +/// поэтому в обычной работе хуки в sender/dispatcher/connection почти +/// бесплатны (один ранний выход по флагу). +class TrafficMonitor extends ChangeNotifier { + TrafficMonitor._(); + static final TrafficMonitor instance = TrafficMonitor._(); + + static const int _maxEntries = 1000; + static const String _prefKey = 'dev_traffic_capture'; + static const bool _defaultEnabled = false; + + final List _entries = []; + String? _activeEndpoint; + + final ValueNotifier captureEnabled = ValueNotifier(_defaultEnabled); + + bool get enabled => captureEnabled.value; + + List get entries => List.unmodifiable(_entries); + String? get activeEndpoint => _activeEndpoint; + + Future load() async { + final prefs = await SharedPreferences.getInstance(); + captureEnabled.value = prefs.getBool(_prefKey) ?? _defaultEnabled; + } + + Future setEnabled(bool value) async { + if (captureEnabled.value != value) { + captureEnabled.value = value; + notifyListeners(); + } + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefKey, value); + } + + void clear() { + _entries.clear(); + notifyListeners(); + } + + /// Сериализует захваченный трафик для экспорта. + /// Чувствительные поля payload (токены, телефоны, коды и т.п.) + /// маскируются через [redactForLog] — файлом можно делиться. + String buildExport({String? appVersion}) { + final data = { + 'tool': 'ИКАР traffic monitor', + 'appVersion': ?appVersion, + 'exportedAt': DateTime.now().toIso8601String(), + 'endpoint': _activeEndpoint, + 'entryCount': _entries.length, + 'sensitiveDataRedacted': true, + 'entries': _entries.map(_entryToJson).toList(), + }; + return const JsonEncoder.withIndent(' ').convert(data); + } + + Map _entryToJson(TrafficEntry e) { + return { + 'time': e.time.toIso8601String(), + 'direction': e.direction.name, + 'label': e.label, + if (e.opcode != null) 'opcode': e.opcode, + if (e.seq != null) 'seq': e.seq, + if (e.cmd != null) 'cmd': e.cmd, + if (e.byteSize != null) 'bytes': e.byteSize, + if (e.detail != null) 'detail': e.detail, + if (e.payload != null) 'payload': _sanitize(_redactForExport(e.payload)), + }; + } + + void recordOutgoing(int opcode, dynamic payload, int seq, int byteSize) { + if (!enabled) return; + _add( + TrafficEntry( + direction: TrafficDirection.outgoing, + time: DateTime.now(), + label: Opcode.name(opcode), + opcode: opcode, + seq: seq, + cmd: CmdType.request, + payload: payload, + byteSize: byteSize, + ), + ); + } + + void recordIncoming(Packet packet, int byteSize) { + if (!enabled) return; + _add( + TrafficEntry( + direction: TrafficDirection.incoming, + time: DateTime.now(), + label: Opcode.name(packet.opcode), + opcode: packet.opcode, + seq: packet.seq, + cmd: packet.cmd, + payload: packet.payload, + byteSize: byteSize, + ), + ); + } + + void recordEvent(String label, {String? detail, String? endpoint}) { + if (endpoint != null) _activeEndpoint = endpoint; + if (!enabled) return; + _add( + TrafficEntry( + direction: TrafficDirection.event, + time: DateTime.now(), + label: label, + detail: detail, + ), + ); + } + + void _add(TrafficEntry entry) { + _entries.add(entry); + if (_entries.length > _maxEntries) { + _entries.removeRange(0, _entries.length - _maxEntries); + } + _scheduleNotify(); + } + + bool _notifyScheduled = false; + + void _scheduleNotify() { + if (_notifyScheduled) return; + _notifyScheduled = true; + Future.microtask(() { + _notifyScheduled = false; + notifyListeners(); + }); + } +} diff --git a/lib/core/transport/vpn_bypass.dart b/lib/core/transport/vpn_bypass.dart new file mode 100644 index 0000000..671ffdf --- /dev/null +++ b/lib/core/transport/vpn_bypass.dart @@ -0,0 +1,148 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../utils/logger.dart'; + +class VpnBypassResult { + final bool enabled; + final bool tunDetected; + final bool bound; + final String? boundInterface; + final String? transport; + final String? reason; + + const VpnBypassResult({ + required this.enabled, + this.tunDetected = false, + this.bound = false, + this.boundInterface, + this.transport, + this.reason, + }); + + @override + String toString() => + 'VpnBypassResult(enabled: $enabled, tun: $tunDetected, bound: $bound, ' + 'iface: $boundInterface, transport: $transport, reason: $reason)'; +} + +/// При активном VPN (tun-интерфейс) привязывает процесс к не-VPN сети +/// (wlan*/rmnet*). Только Android, по умолчанию выключено. +class VpnBypassService { + VpnBypassService._(); + static final VpnBypassService instance = VpnBypassService._(); + + static const String prefKey = 'dev_vpn_bypass'; + + static const MethodChannel _channel = MethodChannel( + 'ru.ikar.app/vpn_bypass', + ); + + bool _bound = false; + + final _eventController = StreamController.broadcast(); + + /// Эмитит результат каждой попытки обхода (для уведомления в UI). + Stream get events => _eventController.stream; + + bool get _supported => Platform.isAndroid; + + Future isEnabled() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(prefKey) ?? false; + } + + Future setEnabled(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(prefKey, value); + } + + /// true — обход включён, платформа поддерживается и активен VPN. + Future shouldArm() async { + if (!_supported) return false; + if (!await isEnabled()) return false; + return _isVpnActive(); + } + + /// Привязывает процесс к non-VPN сети (wlan*/rmnet*). + Future bind() async { + VpnBypassResult result; + try { + final res = await _channel.invokeMapMethod( + 'bindToNonVpnNetwork', + ); + final bound = res?['bound'] == true; + _bound = bound; + result = VpnBypassResult( + enabled: true, + tunDetected: true, + bound: bound, + boundInterface: res?['interface'] as String?, + transport: res?['transport'] as String?, + reason: res?['reason'] as String?, + ); + } on PlatformException catch (e) { + logger.e('VPN bypass: ошибка платформы: ${e.message}'); + result = VpnBypassResult( + enabled: true, + tunDetected: true, + reason: e.code, + ); + } on MissingPluginException { + result = const VpnBypassResult( + enabled: true, + tunDetected: true, + reason: 'no_plugin', + ); + } + if (result.bound) { + logger.i( + 'VPN bypass: привязано к ${result.boundInterface} ' + '(${result.transport})', + ); + } else { + logger.w('VPN bypass: обойти не удалось (${result.reason})'); + } + _eventController.add(result); + return result; + } + + Future _isVpnActive() async { + try { + final res = await _channel.invokeMapMethod( + 'detectInterfaces', + ); + if (res != null) { + if (res['hasTun'] == true || res['hasVpn'] == true) return true; + if (res.containsKey('hasTun')) return false; + } + } catch (_) {} + try { + final ifaces = await NetworkInterface.list( + includeLoopback: false, + includeLinkLocal: true, + ); + return ifaces.any((i) { + final n = i.name.toLowerCase(); + return n.startsWith('tun') || + n.startsWith('ppp') || + n.startsWith('ipsec') || + n.startsWith('wg'); + }); + } catch (_) { + return false; + } + } + + /// Возвращает маршрутизацию процесса к системной (через VPN, если он есть). + Future restoreDefault() async { + if (!_bound) return; + try { + await _channel.invokeMethod('unbindNetwork'); + } catch (_) {} + _bound = false; + } +} diff --git a/lib/core/utils/app_foreground.dart b/lib/core/utils/app_foreground.dart new file mode 100644 index 0000000..d679c5a --- /dev/null +++ b/lib/core/utils/app_foreground.dart @@ -0,0 +1,12 @@ +import 'package:flutter/foundation.dart'; + +// #***! на переднем плане или нет, в фоне экономим +/// Признак «приложение на переднем плане» для подсистем, которые обязаны +/// экономить в фоне: реконнект, пинг ядра, запись отладочного лога. +abstract class AppForeground { + static final ValueNotifier notifier = ValueNotifier(true); + + static bool get value => notifier.value; + + static void update({required bool foreground}) => notifier.value = foreground; +} diff --git a/lib/core/utils/bubble_radius.dart b/lib/core/utils/bubble_radius.dart new file mode 100644 index 0000000..7c45ca3 --- /dev/null +++ b/lib/core/utils/bubble_radius.dart @@ -0,0 +1,81 @@ +import 'package:flutter/widgets.dart'; + +import '../config/app_bubble_behavior.dart'; +import '../config/app_bubble_shape.dart'; + +const double kBubbleBigRadius = 20; +const double kBubbleSmallRadius = 4; + +const Radius _big = Radius.circular(kBubbleBigRadius); +const Radius _small = Radius.circular(kBubbleSmallRadius); + +BorderRadius computeBubbleRadius({ + required bool isMe, + required bool isTop, + required bool isBottom, + required BubbleStyle style, + required BubbleBehavior behavior, + bool hasPhotoWithCaption = false, + bool hasMultiplePhotosNoCaption = false, +}) { + final isSingle = isTop && isBottom; + + if (hasPhotoWithCaption && (isTop || isBottom)) { + return BorderRadius.only( + topLeft: _big, + topRight: _big, + bottomLeft: isMe ? _big : _small, + bottomRight: _small, + ); + } + + if (hasMultiplePhotosNoCaption && isBottom) { + return BorderRadius.only( + topLeft: isMe ? _big : _small, + topRight: _small, + bottomLeft: isMe ? _big : _small, + bottomRight: isMe ? _small : _big, + ); + } + + final base = style == BubbleStyle.desktop ? _small : _big; + Radius tl = base, tr = base, bl = base, br = base; + + if (behavior == BubbleBehavior.immutable || isSingle) { + return BorderRadius.only( + topLeft: tl, + topRight: tr, + bottomLeft: bl, + bottomRight: br, + ); + } + + if (isTop) { + if (isMe) { + br = _small; + } else { + bl = _small; + } + } else if (isBottom) { + if (isMe) { + tr = _small; + } else { + tl = _small; + } + } else { + if (isMe) { + tr = _small; + br = _small; + } else { + tl = _small; + bl = _small; + } + } + + return BorderRadius.only( + topLeft: tl, + topRight: tr, + bottomLeft: bl, + bottomRight: br, + ); +} diff --git a/lib/core/utils/channel_comments.dart b/lib/core/utils/channel_comments.dart new file mode 100644 index 0000000..11986a2 --- /dev/null +++ b/lib/core/utils/channel_comments.dart @@ -0,0 +1,16 @@ +/// Posts asked for in one comment-count request. +const int kCommentsInfoBatchSize = 50; + +/// Splits post ids into comment-count requests of at most [size] posts, +/// so one long history does not turn into a single oversized request. +List> commentsInfoBatches( + List postIds, { + int size = kCommentsInfoBatchSize, +}) { + final batches = >[]; + for (var i = 0; i < postIds.length; i += size) { + final end = i + size < postIds.length ? i + size : postIds.length; + batches.add(postIds.sublist(i, end)); + } + return batches; +} diff --git a/lib/core/utils/debouncer.dart b/lib/core/utils/debouncer.dart new file mode 100644 index 0000000..976b7bb --- /dev/null +++ b/lib/core/utils/debouncer.dart @@ -0,0 +1,20 @@ +import 'dart:async'; + +class Debouncer { + Debouncer(this.duration); + + final Duration duration; + Timer? _timer; + + void run(void Function() action) { + _timer?.cancel(); + _timer = Timer(duration, action); + } + + void cancel() { + _timer?.cancel(); + _timer = null; + } + + void dispose() => cancel(); +} diff --git a/lib/core/utils/debug_session_log.dart b/lib/core/utils/debug_session_log.dart new file mode 100644 index 0000000..959e900 --- /dev/null +++ b/lib/core/utils/debug_session_log.dart @@ -0,0 +1,593 @@ +import '../config/qlyra_settings.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../protocol/opcode_map.dart'; +import 'app_foreground.dart'; +import 'format.dart'; +import 'log_redact.dart'; + +// #***! файл в архиве экспорта +class DebugExportFile { + final String name; + final String content; + + DebugExportFile(this.name, this.content); +} + +// #***! запрос с ответом, сматчены по seq +class _LogEntry { + final int opcode; + final int seq; + final DateTime requestTime; + final dynamic request; + DateTime? responseTime; + int? cmd; + dynamic response; + String? error; + + _LogEntry({ + required this.opcode, + required this.seq, + required this.requestTime, + required this.request, + }); + + static _LogEntry fromJson(Map data) { + final entry = _LogEntry( + opcode: (data['opcode'] as num?)?.toInt() ?? 0, + seq: (data['seq'] as num?)?.toInt() ?? 0, + requestTime: + DateTime.tryParse(data['requestTime']?.toString() ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0), + request: data['request'], + ); + final rt = data['responseTime']?.toString(); + if (rt != null) entry.responseTime = DateTime.tryParse(rt); + entry.cmd = (data['cmd'] as num?)?.toInt(); + entry.response = data['response']; + entry.error = data['error']?.toString(); + return entry; + } + + bool get pending => responseTime == null && error == null; + + Map requestRecord() => { + 'k': 'q', + 'op': opcode, + 'seq': seq, + 't': requestTime.toIso8601String(), + 'p': request, + }; + + Map responseRecord() => { + 'k': 'r', + 'seq': seq, + 'cmd': cmd, + 't': responseTime?.toIso8601String(), + 'p': response, + }; + + Map errorRecord() => { + 'k': 'e', + 'seq': seq, + 't': responseTime?.toIso8601String(), + 'err': error, + }; + + List> records() => [ + requestRecord(), + if (error != null) + errorRecord() + else if (responseTime != null) + responseRecord(), + ]; + + static _LogEntry fromRequestRecord(Map record) => _LogEntry( + opcode: (record['op'] as num?)?.toInt() ?? 0, + seq: (record['seq'] as num?)?.toInt() ?? 0, + requestTime: _recordTime(record), + request: record['p'], + ); + + void applyResponseRecord(Map record) { + responseTime = _recordTime(record); + cmd = (record['cmd'] as num?)?.toInt(); + response = record['p']; + } + + void applyErrorRecord(Map record) { + responseTime = _recordTime(record); + error = record['err']?.toString() ?? ''; + } + + static DateTime _recordTime(Map record) => + DateTime.tryParse(record['t']?.toString() ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0); +} + +_LogEntry? _lastPending(List<_LogEntry> entries, int seq) { + for (var i = entries.length - 1; i >= 0; i--) { + final entry = entries[i]; + if (entry.seq == seq && entry.pending) return entry; + } + return null; +} + +// #***! одна сессия, запросы плюс строки лога +class _SessionData { + final DateTime startedAt; + final List<_LogEntry> entries; + final bool truncated; + final List logLines; + final bool logsTruncated; + + _SessionData({ + required this.startedAt, + required this.entries, + this.truncated = false, + this.logLines = const [], + this.logsTruncated = false, + }); + + static _SessionData fromJson(Map data) { + final list = data['entries']; + final entries = <_LogEntry>[]; + if (list is List) { + for (final e in list) { + if (e is Map) entries.add(_LogEntry.fromJson(e)); + } + } + final rawLogs = data['logs']; + final logLines = []; + if (rawLogs is List) { + for (final l in rawLogs) { + logLines.add(l.toString()); + } + } + return _SessionData( + startedAt: + DateTime.tryParse(data['startedAt']?.toString() ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0), + entries: entries, + truncated: data['truncated'] == true, + logLines: logLines, + logsTruncated: data['logsTruncated'] == true, + ); + } + + static _SessionData fromRecords(DateTime startedAt, String content) { + final entries = <_LogEntry>[]; + final logLines = []; + var truncated = false; + var logsTruncated = false; + for (final line in const LineSplitter().convert(content)) { + if (line.isEmpty) continue; + final Object? record; + try { + record = jsonDecode(line); + } catch (_) { + continue; + } + if (record is! Map) continue; + final seq = (record['seq'] as num?)?.toInt() ?? 0; + switch (record['k']) { + case 's': + truncated = record['truncated'] == true; + logsTruncated = record['logsTruncated'] == true; + case 'q': + entries.add(_LogEntry.fromRequestRecord(record)); + case 'r': + _lastPending(entries, seq)?.applyResponseRecord(record); + case 'e': + _lastPending(entries, seq)?.applyErrorRecord(record); + case 'l': + logLines.add(record['v']?.toString() ?? ''); + } + } + const maxEntries = DebugSessionLog._maxEntriesPerSession; + if (entries.length > maxEntries) { + entries.removeRange(0, entries.length - maxEntries); + truncated = true; + } + const maxLogLines = DebugSessionLog._maxLogLinesPerSession; + if (logLines.length > maxLogLines) { + logLines.removeRange(0, logLines.length - maxLogLines); + logsTruncated = true; + } + return _SessionData( + startedAt: startedAt, + entries: entries, + truncated: truncated, + logLines: logLines, + logsTruncated: logsTruncated, + ); + } +} + +// #***! отладочный лог, пишется на диск и выгружается архивом из дев меню +class DebugSessionLog { + DebugSessionLog._(); + static final DebugSessionLog instance = DebugSessionLog._(); + + // #***! старше суток и лишние сверх 30 удаляем + static const Duration _retention = Duration(hours: 24); + static const int _maxStoredSessions = 30; + // #***! потолки на сессию чтоб файл не рос бесконечно + static const int _maxEntriesPerSession = 2000; + static const int _maxLogLinesPerSession = 5000; + // #***! в фоне пишем раз в минуту а не в три секунды, батарея + static const Duration _flushDebounce = Duration(seconds: 3); + static const Duration _backgroundFlushDebounce = Duration(seconds: 60); + static const int _compactAfterChars = 16 * 1024 * 1024; + static const int _maxPendingRecords = 10000; + static const String _extension = '.jsonl'; + static const String _legacyExtension = '.json'; + + static final RegExp _ansiEscape = RegExp(r'\x1B\[[0-9;]*m'); + + Directory? _dir; + File? _currentFile; + DateTime? _currentStart; + final List<_LogEntry> _entries = []; + final List _logLines = []; + final List> _pending = []; + bool _truncated = false; + bool _logsTruncated = false; + bool _initialized = false; + bool _compact = false; + int _writtenChars = 0; + Future _writes = Future.value(); + Timer? _flushTimer; + + // #***! каталог сессий и ротация + Future init() async { + if (_initialized) return; + _initialized = true; + _currentStart = DateTime.now(); + try { + final base = await getApplicationSupportDirectory(); + final dir = Directory('${base.path}/debug_sessions'); + if (!await dir.exists()) await dir.create(recursive: true); + _dir = dir; + await _rotate(); + _currentFile = File( + '${dir.path}/session_${_currentStart!.millisecondsSinceEpoch}$_extension', + ); + if (_pending.isNotEmpty || _compact) _scheduleFlush(); + } catch (_) { + _dir = null; + _currentFile = null; + } + } + + // #***! чистим ANSI цвета иначе в файле мусор + void recordLogLine(String line) { + if (!QlyraSettings.recordDebugLogs.value) return; + final clean = line.replaceAll(_ansiEscape, ''); + _logLines.add(clean); + if (_logLines.length > _maxLogLinesPerSession) { + _logLines.removeRange(0, _logLines.length - _maxLogLinesPerSession); + _logsTruncated = true; + } + _append({'k': 'l', 'v': clean}); + } + + // #***! исходящий запрос, payload чистим от секретов + void recordRequest(int opcode, int seq, dynamic payload) { + if (!QlyraSettings.recordDebugLogs.value) return; + final entry = _LogEntry( + opcode: opcode, + seq: seq, + requestTime: DateTime.now(), + request: redactForLog(payload), + ); + _entries.add(entry); + if (_entries.length > _maxEntriesPerSession) { + _entries.removeRange(0, _entries.length - _maxEntriesPerSession); + _truncated = true; + } + _append(entry.requestRecord()); + } + + // #***! ответ подшиваем к запросу по seq + void recordResponse(int seq, int cmd, dynamic payload) { + if (!QlyraSettings.recordDebugLogs.value) return; + final entry = _lastPending(_entries, seq); + if (entry == null) return; + entry.responseTime = DateTime.now(); + entry.cmd = cmd; + entry.response = redactForLog(payload); + _append(entry.responseRecord()); + } + + // #***! ошибку тоже подшиваем + void recordError(int seq, Object error) { + if (!QlyraSettings.recordDebugLogs.value) return; + final entry = _lastPending(_entries, seq); + if (entry == null) return; + entry.responseTime = DateTime.now(); + entry.error = error.toString(); + _append(entry.errorRecord()); + } + + void _append(Map record) { + _pending.add(record); + if (_pending.length > _maxPendingRecords) { + _pending.clear(); + _compact = true; + } + _scheduleFlush(); + } + + // #***! пишем с задержкой иначе файл переписывается на каждый пакет + void _scheduleFlush() { + if (_currentFile == null) return; + _flushTimer ??= Timer( + AppForeground.value ? _flushDebounce : _backgroundFlushDebounce, + () { + _flushTimer = null; + _flush(); + }, + ); + } + + Future _flush() => _writes = _writes.then((_) => _writePending()); + + Future _writePending() async { + final file = _currentFile; + if (file == null) return; + final compact = _compact || _writtenChars > _compactAfterChars; + if (!compact && _pending.isEmpty) return; + final records = compact ? _snapshotRecords() : List.of(_pending); + _pending.clear(); + _compact = false; + final buffer = StringBuffer(); + for (final record in records) { + buffer.writeln(_encodeRecord(record)); + } + final data = buffer.toString(); + try { + await file.writeAsString( + data, + mode: compact ? FileMode.write : FileMode.append, + ); + _writtenChars = compact ? data.length : _writtenChars + data.length; + } catch (_) { + _compact = true; + } + } + + List> _snapshotRecords() => [ + {'k': 's', 'truncated': _truncated, 'logsTruncated': _logsTruncated}, + for (final entry in _entries) ...entry.records(), + for (final line in _logLines) {'k': 'l', 'v': line}, + ]; + + static String _encodeRecord(Map record) { + try { + return jsonEncode(record); + } catch (_) { + return jsonEncode(_jsonSafe(record)); + } + } + + static Object? _jsonSafe(Object? value) { + if (value is Map) { + return { + for (final entry in value.entries) + entry.key.toString(): _jsonSafe(entry.value), + }; + } + if (value is List) return value.map(_jsonSafe).toList(); + if (value is double && !value.isFinite) return value.toString(); + if (value == null || value is num || value is bool || value is String) { + return value; + } + return value.toString(); + } + + Future flushNow() { + _flushTimer?.cancel(); + _flushTimer = null; + return _flush(); + } + + // #***! ротация, удаляем протухшие и лишние + Future _rotate() async { + final files = await _sessionFiles(); + final cutoff = DateTime.now().subtract(_retention).millisecondsSinceEpoch; + final stale = []; + final fresh = []; + for (final file in files) { + (_startMillis(file) < cutoff ? stale : fresh).add(file); + } + const keep = _maxStoredSessions - 1; + if (fresh.length > keep) { + stale.addAll(fresh.take(fresh.length - keep)); + } + for (final file in stale) { + try { + await file.delete(); + } catch (_) {} + } + } + + Future> _sessionFiles() async { + final dir = _dir; + if (dir == null) return []; + final entries = await dir.list().toList(); + final files = entries.whereType().where((f) { + final name = f.uri.pathSegments.last; + return name.startsWith('session_') && + (name.endsWith(_extension) || name.endsWith(_legacyExtension)); + }).toList(); + files.sort((a, b) => _startMillis(a).compareTo(_startMillis(b))); + return files; + } + + int _startMillis(File file) { + final name = file.uri.pathSegments.last; + final dot = name.indexOf('.'); + final digits = name.substring( + 'session_'.length, + dot < 0 ? name.length : dot, + ); + return int.tryParse(digits) ?? 0; + } + + Future<_SessionData?> _readSession(File file) async { + final content = await file.readAsString(); + if (file.path.endsWith(_legacyExtension)) { + final decoded = jsonDecode(content); + return decoded is Map ? _SessionData.fromJson(decoded) : null; + } + return _SessionData.fromRecords( + DateTime.fromMillisecondsSinceEpoch(_startMillis(file)), + content, + ); + } + + Future?> buildExportFiles({String? endpoint}) async { + final cutoff = DateTime.now().subtract(_retention); + final sessions = <_SessionData>[]; + final dir = _dir; + if (dir != null) { + for (final file in await _sessionFiles()) { + if (_currentFile != null && p.equals(file.path, _currentFile!.path)) { + continue; + } + try { + final session = await _readSession(file); + if (session != null && !session.startedAt.isBefore(cutoff)) { + sessions.add(session); + } + } catch (_) {} + } + } + sessions.add( + _SessionData( + startedAt: _currentStart ?? DateTime.now(), + entries: List.of(_entries), + truncated: _truncated, + logLines: List.of(_logLines), + logsTruncated: _logsTruncated, + ), + ); + sessions.sort((a, b) => a.startedAt.compareTo(b.startedAt)); + final totalEntries = sessions.fold( + 0, + (sum, s) => sum + s.entries.length, + ); + final totalLogs = sessions.fold( + 0, + (sum, s) => sum + s.logLines.length, + ); + if (totalEntries == 0 && totalLogs == 0) return null; + + final info = StringBuffer(); + info.writeln('ИКАР — отладочный лог'); + if (endpoint != null) info.writeln('Сервер: $endpoint'); + info.writeln('Экспортирован: ${DateTime.now().toIso8601String()}'); + info.writeln('Период: последние ${_retention.inHours} часа'); + info.writeln('Заходов в приложение: ${sessions.length}'); + info.writeln('Всего запросов: $totalEntries'); + info.writeln('Всего строк лога: $totalLogs'); + info.writeln('Скрыто: токен полностью, номер кроме первых 3 символов'); + + final files = [DebugExportFile('info.txt', '$info')]; + for (var s = 0; s < sessions.length; s++) { + final session = sessions[s]; + final name = + 'session_${(s + 1).toString().padLeft(2, '0')}_' + '${formatFileStamp(session.startedAt)}.txt'; + files.add(DebugExportFile(name, _buildSessionText(s + 1, session))); + } + return files; + } + + // #***! сессия в читаемый текст + String _buildSessionText(int index, _SessionData session) { + final buffer = StringBuffer(); + buffer.writeln('=================================================='); + buffer.writeln('ЗАХОД #$index — ${session.startedAt.toIso8601String()}'); + buffer.writeln( + 'запросов: ${session.entries.length}' + '${session.truncated ? ' (обрезано до $_maxEntriesPerSession)' : ''}' + ' · строк лога: ${session.logLines.length}' + '${session.logsTruncated ? ' (обрезано до $_maxLogLinesPerSession)' : ''}', + ); + buffer.writeln('=================================================='); + buffer.writeln(); + + buffer.writeln('----- ЛОГИ ПРИЛОЖЕНИЯ -----'); + if (session.logLines.isEmpty) { + buffer.writeln('(пусто)'); + } else { + for (final line in session.logLines) { + buffer.writeln(line); + } + } + buffer.writeln(); + + buffer.writeln('----- ЗАПРОСЫ -----'); + if (session.entries.isEmpty) { + buffer.writeln('(пусто)'); + } else { + for (var i = 0; i < session.entries.length; i++) { + _writeEntry(buffer, i + 1, session.entries[i]); + } + } + return buffer.toString(); + } + + void _writeEntry(StringBuffer buffer, int index, _LogEntry e) { + buffer.writeln('----- запрос #$index -----'); + buffer.writeln('время: ${e.requestTime.toIso8601String()}'); + buffer.writeln('opcode: ${e.opcode} (${Opcode.name(e.opcode)})'); + buffer.writeln('seq: ${e.seq}'); + buffer.writeln('=> payload:'); + buffer.writeln(_pretty(e.request)); + if (e.error != null) { + buffer.writeln('<= ошибка: ${e.error}'); + } else if (e.responseTime != null) { + buffer.writeln( + '<= ответ: ${e.responseTime!.toIso8601String()} ' + 'cmd ${e.cmd} (${_cmdName(e.cmd)})', + ); + buffer.writeln('payload:'); + buffer.writeln(_pretty(e.response)); + } else { + buffer.writeln('<= (ответ не получен)'); + } + buffer.writeln(); + } + + String _pretty(dynamic value) { + try { + return const JsonEncoder.withIndent(' ').convert(value); + } catch (_) { + return value.toString(); + } + } +} + +// #***! числовой cmd в имя +String _cmdName(int? cmd) { + switch (cmd) { + case 0: + return 'request'; + case 1: + return 'ok'; + case 2: + return 'notFound'; + case 3: + return 'error'; + default: + return 'cmd$cmd'; + } +} diff --git a/lib/core/utils/device_locale.dart b/lib/core/utils/device_locale.dart new file mode 100644 index 0000000..cab121a --- /dev/null +++ b/lib/core/utils/device_locale.dart @@ -0,0 +1,27 @@ +import 'dart:io'; + +// #***! язык по дефолту если системный не определили +const String defaultLanguageCode = 'ru'; + +final RegExp _languageSubtag = RegExp(r'^[a-z]{2,3}$'); + +// #***! из ru_RU.UTF-8 достаём только ru +String languageCodeOf(String localeName) { + final subtag = localeName + .split(RegExp(r'[.@]')) + .first + .split(RegExp(r'[-_]')) + .first + .toLowerCase(); + return _languageSubtag.hasMatch(subtag) ? subtag : defaultLanguageCode; +} + +// #***! на линуксе локаль не читаем, она там часто мусорная +String deviceLanguageCode() { + if (Platform.isLinux) return defaultLanguageCode; + try { + return languageCodeOf(Platform.localeName); + } catch (_) { + return defaultLanguageCode; + } +} diff --git a/lib/core/utils/download_history.dart b/lib/core/utils/download_history.dart new file mode 100644 index 0000000..18da940 --- /dev/null +++ b/lib/core/utils/download_history.dart @@ -0,0 +1,339 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../storage/app_instance.dart'; +import 'media_cache.dart'; + +enum DownloadKind { photo, video, gif, audio, file } + +DownloadKind downloadKindForName( + String name, { + DownloadKind fallback = DownloadKind.file, +}) { + final extension = p.extension(name).toLowerCase(); + if (extension == '.gif') return DownloadKind.gif; + if (const { + '.mp4', + '.mkv', + '.mov', + '.webm', + '.avi', + '.m4v', + }.contains(extension)) { + return DownloadKind.video; + } + if (const { + '.mp3', + '.ogg', + '.opus', + '.wav', + '.m4a', + '.flac', + }.contains(extension)) { + return DownloadKind.audio; + } + if (const { + '.jpg', + '.jpeg', + '.png', + '.webp', + '.heic', + '.bmp', + }.contains(extension)) { + return DownloadKind.photo; + } + return fallback; +} + +class DownloadMetadata { + final String cacheName; + final String name; + final DownloadKind kind; + final String sourceName; + final String? thumbnailUrl; + final int expectedSize; + final int? chatId; + final String? messageId; + final int? messageTime; + + const DownloadMetadata({ + required this.cacheName, + this.name = '', + required this.kind, + this.sourceName = '', + this.thumbnailUrl, + this.expectedSize = 0, + this.chatId, + this.messageId, + this.messageTime, + }); +} + +class DownloadRecord { + final String cacheName; + final String name; + final DownloadKind kind; + final String sourceName; + final String? thumbnailUrl; + final int size; + final int downloadedAt; + final int? chatId; + final String? messageId; + final int? messageTime; + + const DownloadRecord({ + required this.cacheName, + required this.name, + required this.kind, + required this.sourceName, + required this.thumbnailUrl, + required this.size, + required this.downloadedAt, + required this.chatId, + required this.messageId, + required this.messageTime, + }); + + factory DownloadRecord.fromJson(Map json) { + final rawKind = json['kind']?.toString(); + final kind = DownloadKind.values.firstWhere( + (value) => value.name == rawKind, + orElse: () => DownloadKind.file, + ); + return DownloadRecord( + cacheName: json['cacheName']?.toString() ?? '', + name: json['name']?.toString() ?? '', + kind: kind, + sourceName: json['sourceName']?.toString() ?? '', + thumbnailUrl: json['thumbnailUrl']?.toString(), + size: (json['size'] as num?)?.toInt() ?? 0, + downloadedAt: (json['downloadedAt'] as num?)?.toInt() ?? 0, + chatId: switch (json['chatId']) { + final num value => value.toInt(), + final String value => int.tryParse(value), + _ => null, + }, + messageId: json['messageId']?.toString(), + messageTime: switch (json['messageTime']) { + final num value => value.toInt(), + final String value => int.tryParse(value), + _ => null, + }, + ); + } + + Map toJson() => { + 'cacheName': cacheName, + 'name': name, + 'kind': kind.name, + 'sourceName': sourceName, + 'thumbnailUrl': thumbnailUrl, + 'size': size, + 'downloadedAt': downloadedAt, + 'chatId': chatId, + 'messageId': messageId, + 'messageTime': messageTime, + }; + + DownloadRecord withSize(int value) => DownloadRecord( + cacheName: cacheName, + name: name, + kind: kind, + sourceName: sourceName, + thumbnailUrl: thumbnailUrl, + size: value, + downloadedAt: downloadedAt, + chatId: chatId, + messageId: messageId, + messageTime: messageTime, + ); +} + +class DownloadHistory { + static const int maxEntries = 200; + static final ValueNotifier> records = ValueNotifier( + const [], + ); + + static Future? _loading; + static Future _mutations = Future.value(); + + static String get _key => 'recent_downloads_v1${AppInstance.suffix}'; + + @visibleForTesting + static void resetForTesting() { + _loading = null; + _mutations = Future.value(); + records.value = const []; + } + + static Future load() => _loading ??= _load(); + + static Future refresh() => _enqueue(() async { + await load(); + final available = await _available(records.value); + if (listEquals(available, records.value)) return; + records.value = List.unmodifiable(available); + await _save(); + }); + + static Future record(DownloadMetadata metadata, File file) => + _enqueue(() async { + await load(); + if (!await file.exists()) return; + final actualSize = await file.length(); + final entry = DownloadRecord( + cacheName: metadata.cacheName, + name: metadata.name, + kind: metadata.kind, + sourceName: metadata.sourceName, + thumbnailUrl: metadata.thumbnailUrl, + size: actualSize > 0 ? actualSize : metadata.expectedSize, + downloadedAt: DateTime.now().millisecondsSinceEpoch, + chatId: metadata.chatId, + messageId: metadata.messageId, + messageTime: metadata.messageTime, + ); + final next = [ + entry, + ...records.value.where((item) => item.cacheName != entry.cacheName), + ]; + if (next.length > maxEntries) next.removeRange(maxEntries, next.length); + records.value = List.unmodifiable(next); + await _save(); + }); + + static Future remove(String cacheName) => _enqueue(() async { + await load(); + final next = records.value + .where((item) => item.cacheName != cacheName) + .toList(growable: false); + if (next.length == records.value.length) return; + records.value = List.unmodifiable(next); + await _save(); + }); + + static Future clear() => _enqueue(() async { + await load(); + records.value = const []; + await _save(); + }); + + static Future fileFor( + DownloadRecord record, { + bool touch = true, + }) async { + if (touch) return MediaCache.existing(record.cacheName); + final file = await MediaCache.fileFor(record.cacheName); + return await file.exists() && await file.length() > 0 ? file : null; + } + + static Future _load() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key); + List loaded; + if (raw == null) { + loaded = await _migrateCache(); + } else { + loaded = _decode(raw); + } + loaded = await _available(loaded); + loaded.sort((a, b) => b.downloadedAt.compareTo(a.downloadedAt)); + if (loaded.length > maxEntries) { + loaded = loaded.sublist(0, maxEntries); + } + records.value = List.unmodifiable(loaded); + await _save(); + } + + static List _decode(String raw) { + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return const []; + return decoded + .whereType() + .map( + (item) => DownloadRecord.fromJson(Map.from(item)), + ) + .where((item) => item.cacheName.isNotEmpty) + .toList(); + } catch (_) { + return const []; + } + } + + static Future> _available( + List source, + ) async { + final available = []; + for (final record in source) { + final file = await fileFor(record, touch: false); + if (file == null) continue; + final size = await file.length(); + available.add(size == record.size ? record : record.withSize(size)); + } + return available; + } + + static Future> _migrateCache() async { + final files = await MediaCache.files(); + final migrated = []; + for (final file in files) { + final cacheName = p.basename(file.path); + if (cacheName.startsWith('avatar_') || + cacheName.startsWith('decrypted_') || + cacheName.startsWith(MediaCache.keptPrefix) || + cacheName.startsWith('download_thumb_')) { + continue; + } + final stat = await file.stat(); + final kind = cacheName.startsWith('photo_') + ? DownloadKind.photo + : cacheName.startsWith('video_') + ? DownloadKind.video + : downloadKindForName(cacheName); + migrated.add( + DownloadRecord( + cacheName: cacheName, + name: kind == DownloadKind.file ? _fileName(cacheName) : '', + kind: kind, + sourceName: '', + thumbnailUrl: null, + size: stat.size, + downloadedAt: stat.modified.millisecondsSinceEpoch, + chatId: null, + messageId: null, + messageTime: null, + ), + ); + } + return migrated; + } + + static String _fileName(String cacheName) { + final separator = cacheName.indexOf('_'); + if (separator <= 0) return cacheName; + final prefix = cacheName.substring(0, separator); + return int.tryParse(prefix) == null + ? cacheName + : cacheName.substring(separator + 1); + } + + static Future _save() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _key, + jsonEncode(records.value.map((item) => item.toJson()).toList()), + ); + } + + static Future _enqueue(Future Function() operation) { + final next = _mutations.then((_) => operation()); + _mutations = next.catchError((_) {}); + return next; + } +} diff --git a/lib/core/utils/download_progress.dart b/lib/core/utils/download_progress.dart new file mode 100644 index 0000000..5af4219 --- /dev/null +++ b/lib/core/utils/download_progress.dart @@ -0,0 +1,15 @@ +import 'package:flutter/foundation.dart'; + +/// Прогресс активных загрузок вложений, ключ — имя в кэше. +/// +/// Значение: `null` — не загружается; `0..1` — доля загруженного. +class MediaDownloadProgress { + static final Map> _notifiers = {}; + + static ValueNotifier notifier(String key) => + _notifiers.putIfAbsent(key, () => ValueNotifier(null)); + + static void set(String key, double? value) { + notifier(key).value = value; + } +} diff --git a/lib/core/utils/emoji_keyword_index.dart b/lib/core/utils/emoji_keyword_index.dart new file mode 100644 index 0000000..d0b868a --- /dev/null +++ b/lib/core/utils/emoji_keyword_index.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; + +class _Entry { + final String emoji; + final List tokens; + + const _Entry(this.emoji, this.tokens); +} + +class EmojiKeywordIndex { + EmojiKeywordIndex._(); + + static final EmojiKeywordIndex instance = EmojiKeywordIndex._(); + + static const _variationSelector = '️'; + static final _wordPattern = RegExp(r'[0-9a-zа-яё\-]{2,}', unicode: true); + + final List<_Entry> _entries = []; + final Set _emojiKeys = {}; + Future? _loading; + + Future ensureLoaded() => _loading ??= _load(); + + Future _load() async { + final raw = await rootBundle.loadString('assets/emoji_keywords.json'); + final map = json.decode(raw) as Map; + map.forEach((emoji, words) { + _entries.add(_Entry(emoji, (words as String).split(' '))); + _emojiKeys.add(emoji); + }); + } + + List get all => List.unmodifiable(_entries.map((e) => e.emoji)); + + List search(String query) { + final targets = resolve(query); + if (targets.isEmpty) return const []; + return [ + for (final entry in _entries) + if (targets.contains(entry.emoji)) entry.emoji, + ]; + } + + static String normalize(String emoji) => + emoji.replaceAll(_variationSelector, ''); + + Set resolve(String query) { + final q = query.trim().toLowerCase(); + if (q.isEmpty) return const {}; + + final targets = {}; + final normalized = normalize(q); + for (final key in _emojiKeys) { + if (normalized.contains(key)) targets.add(key); + } + + final words = _wordPattern + .allMatches(q) + .map((m) => m.group(0)!) + .toList(growable: false); + if (words.isEmpty) return targets; + + for (final entry in _entries) { + for (final word in words) { + if (_hasPrefix(entry.tokens, word)) { + targets.add(entry.emoji); + break; + } + } + } + return targets; + } + + static bool _hasPrefix(List tokens, String prefix) { + for (final token in tokens) { + if (token.startsWith(prefix)) return true; + } + return false; + } +} diff --git a/lib/core/utils/file_download.dart b/lib/core/utils/file_download.dart new file mode 100644 index 0000000..a1f2592 --- /dev/null +++ b/lib/core/utils/file_download.dart @@ -0,0 +1,123 @@ +import 'package:open_filex/open_filex.dart'; + +import 'download_history.dart'; +import 'media_cache.dart'; + +// #***! итог скачивания, путь или ошибка +class FileDownloadResult { + final bool ok; + final String? path; + final String? error; + final bool noAppToOpen; + final bool downloaded; + + const FileDownloadResult({ + required this.ok, + this.path, + this.error, + this.noAppToOpen = false, + this.downloaded = false, + }); +} + +// #***! открыть файл, скачав если надо +/// Открывает файл из кэша, скачивая его при отсутствии. +/// +/// [cacheName] — стабильное имя в кэше (например, `_имя.ext`). +/// [resolveUrl] вызывается лениво — только если файла ещё нет в кэше, +/// чтобы не дёргать сервер за временной ссылкой повторно. +/// [onReady] вызывается как только файл лежит на диске — до открытия во +/// внешнем приложении, которое может не возвращать управление, пока его не +/// закроют. Без этого индикатор загрузки висел бы всё это время. +/// [downloadOnlyIfMissing] оставляет впервые скачанный файл в чате; +/// повторное нажатие открывает уже готовую копию. +Future openCachedFile( + String cacheName, + Future Function() resolveUrl, { + void Function(double progress)? onProgress, + void Function()? onReady, + DownloadMetadata? download, + bool downloadOnlyIfMissing = false, + Future Function(String path)? openFile, +}) async { + final result = await ensureCachedFile( + cacheName, + resolveUrl, + onProgress: onProgress, + onReady: onReady, + download: download, + ); + if (!result.ok || result.path == null) return result; + if (downloadOnlyIfMissing && result.downloaded) return result; + try { + final opened = await (openFile ?? OpenFilex.open)(result.path!); + return FileDownloadResult( + ok: opened.type == ResultType.done, + path: result.path, + error: opened.type == ResultType.done ? null : opened.message, + noAppToOpen: opened.type == ResultType.noAppToOpen, + downloaded: result.downloaded, + ); + } catch (e) { + return FileDownloadResult( + ok: false, + path: result.path, + error: e.toString(), + downloaded: result.downloaded, + ); + } +} + +// #***! скачивание в кэш, onReady зовётся как только файл на диске +Future ensureCachedFile( + String cacheName, + Future Function() resolveUrl, { + void Function(double progress)? onProgress, + void Function()? onReady, + DownloadMetadata? download, +}) async { + // #***! ready зовётся из разных веток, защита от повтора + var readyFired = false; + void ready() { + if (readyFired) return; + readyFired = true; + onReady?.call(); + } + + try { + var file = await MediaCache.existing(cacheName); + final downloaded = file == null; + + if (file == null) { + final url = await resolveUrl(); + if (url == null || url.isEmpty) { + ready(); + return const FileDownloadResult(ok: false, error: 'нет ссылки'); + } + file = await MediaCache.getOrDownload( + cacheName, + url, + onProgress: onProgress, + ); + if (file == null) { + ready(); + return const FileDownloadResult(ok: false, error: 'ошибка загрузки'); + } + } + + ready(); + if (download != null) { + try { + await DownloadHistory.record(download, file); + } catch (_) {} + } + return FileDownloadResult( + ok: true, + path: file.path, + downloaded: downloaded, + ); + } catch (e) { + ready(); + return FileDownloadResult(ok: false, error: e.toString()); + } +} diff --git a/lib/core/utils/format.dart b/lib/core/utils/format.dart new file mode 100644 index 0000000..25e878c --- /dev/null +++ b/lib/core/utils/format.dart @@ -0,0 +1,203 @@ +library; + +import '../../l10n/app_localizations.dart'; + +// #***! русские месяцы, intl ради трёх букв тянуть не хочется +const List _ruMonthsShort = [ + 'янв', + 'фев', + 'мар', + 'апр', + 'мая', + 'июн', + 'июл', + 'авг', + 'сен', + 'окт', + 'ноя', + 'дек', +]; + +const List _enMonthsShort = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +const List _ruWeekdaysShort = [ + 'пн', + 'вт', + 'ср', + 'чт', + 'пт', + 'сб', + 'вс', +]; + +const List _enWeekdaysShort = [ + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + 'Sun', +]; + +bool _isRussian(AppLocalizations l10n) => l10n.localeName == 'ru'; + +String _formatMonthShort(AppLocalizations l10n, int month) => + (_isRussian(l10n) ? _ruMonthsShort : _enMonthsShort)[month - 1]; + +String formatWeekdayShort(AppLocalizations l10n, int weekday) => + (_isRussian(l10n) ? _ruWeekdaysShort : _enWeekdaysShort)[weekday - 1]; + +// #***! ведущий ноль +String pad2(int n) => n.toString().padLeft(2, '0'); + +// #***! таймер голосового с десятыми +String formatVoiceElapsed(int ms) { + final totalSec = ms ~/ 1000; + final m = totalSec ~/ 60; + final s = pad2(totalSec % 60); + final ds = (ms % 1000) ~/ 100; + return '$m:$s,$ds'; +} + +final RegExp _phoneNonDigits = RegExp(r'[^0-9]'); + +// #***! размер по человечески +String formatBytes(AppLocalizations l10n, int bytes) { + if (bytes < 1024) return l10n.formatBytesB('$bytes'); + if (bytes < 1024 * 1024) { + return l10n.formatBytesKb((bytes / 1024).toStringAsFixed(1)); + } + if (bytes < 1024 * 1024 * 1024) { + return l10n.formatBytesMb((bytes / (1024 * 1024)).toStringAsFixed(1)); + } + return l10n.formatBytesGb((bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)); +} + +String formatDurationMmSs(Duration d, {bool padMinutes = false}) { + final m = d.inMinutes; + return '${padMinutes ? pad2(m) : m}:${pad2(d.inSeconds % 60)}'; +} + +String formatSecondsMmSs(int seconds, {bool padMinutes = false}) => + formatDurationMmSs(Duration(seconds: seconds), padMinutes: padMinutes); + +String formatDurationClock(Duration d) { + final s = d.inSeconds; + final sec = pad2(s % 60); + final m = s ~/ 60; + if (m >= 60) return '${m ~/ 60}:${pad2(m % 60)}:$sec'; + return '$m:$sec'; +} + +String formatApproxDuration(AppLocalizations l10n, int ms) { + final tenths = (ms / 100).round(); + if (tenths < 600) { + return l10n.formatApproxSeconds('${tenths ~/ 10}', '${tenths % 10}'); + } + final hundredths = (ms / 600).round(); + return l10n.formatApproxMinutes( + '${hundredths ~/ 100}', + pad2(hundredths % 100), + ); +} + +String formatFileStamp(DateTime t) => + '${t.year}${pad2(t.month)}${pad2(t.day)}_' + '${pad2(t.hour)}${pad2(t.minute)}${pad2(t.second)}'; + +String formatClock(DateTime dt, {bool withSeconds = false}) => withSeconds + ? '${pad2(dt.hour)}:${pad2(dt.minute)}:${pad2(dt.second)}' + : '${pad2(dt.hour)}:${pad2(dt.minute)}'; + +String formatDayMonth(AppLocalizations l10n, DateTime dt) => + '${dt.day} ${_formatMonthShort(l10n, dt.month)}'; + +String formatDateWords(AppLocalizations l10n, DateTime dt) => + '${formatDayMonth(l10n, dt)} ${dt.year}'; + +String formatDateNumeric(DateTime dt) => + '${pad2(dt.day)}.${pad2(dt.month)}.${dt.year}'; + +String formatDateTimeNumeric(DateTime dt) => + '${formatDateNumeric(dt)} ${formatClock(dt)}'; + +String formatDateTimeWords(AppLocalizations l10n, DateTime dt) => + '${formatDateWords(l10n, dt)}, ${formatClock(dt)}'; + +// #***! был недавно и подобное для шапки чата +String formatLastSeen(AppLocalizations l10n, int secondsSinceEpoch) { + final dt = DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch * 1000); + final diff = DateTime.now().difference(dt); + if (diff.inMinutes < 2) return l10n.lastSeenJustNow; + if (diff.inMinutes < 60) return l10n.lastSeenMinutesAgo(diff.inMinutes); + if (diff.inHours < 24) return l10n.lastSeenHoursAgo(diff.inHours); + if (diff.inDays < 7) return l10n.lastSeenDaysAgo(diff.inDays); + return l10n.settingsTabLastSeen(formatDateWords(l10n, dt)); +} + +String? formatPhone(dynamic raw) { + String? digits; + if (raw is int && raw > 0) { + digits = raw.toString(); + } else if (raw is String && raw.isNotEmpty && raw != '***') { + digits = raw.replaceAll(_phoneNonDigits, ''); + if (digits.isEmpty) return null; + } + if (digits == null) return null; + if (digits.length == 11 && digits.startsWith('7')) { + return '+${digits[0]} (${digits.substring(1, 4)}) ' + '${digits.substring(4, 7)}-${digits.substring(7, 9)}-${digits.substring(9)}'; + } + return '+$digits'; +} + +String? formatGender(AppLocalizations l10n, dynamic raw) { + if (raw is! int) return null; + if (raw == 1) return l10n.genderMale; + if (raw == 2) return l10n.genderFemale; + return null; +} + +const List kRuMonthsShort = [ + 'янв', + 'фев', + 'мар', + 'апр', + 'мая', + 'июн', + 'июл', + 'авг', + 'сен', + 'окт', + 'ноя', + 'дек', +]; + +String pluralRu(int n, String one, String few, String many) { + final mod100 = n % 100; + if (mod100 >= 11 && mod100 <= 14) return many; + switch (n % 10) { + case 1: + return one; + case 2: + case 3: + case 4: + return few; + default: + return many; + } +} diff --git a/lib/core/utils/haptics.dart b/lib/core/utils/haptics.dart new file mode 100644 index 0000000..7e771f7 --- /dev/null +++ b/lib/core/utils/haptics.dart @@ -0,0 +1,83 @@ +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Centralized tactile feedback for ИКАР. +/// +/// Wraps Flutter's [HapticFeedback] so the whole app speaks one tactile +/// "language": the same gesture always feels the same. Composite patterns +/// chain impacts with short delays to produce richer, more memorable +/// sensations than a single buzz. +/// +/// Every call is best-effort and silent on failure — a device without a +/// vibrator (or with system haptics disabled) must never crash the UI. +class Haptics { + Haptics._(); + + static const String _prefKey = 'haptics_enabled'; + + /// Master switch. Silences every haptic app-wide when `false`. + /// Controlled by the user via Settings; persisted across launches. + static bool enabled = true; + + /// Restores the saved preference. Call once during app startup, + /// before the first frame. Defaults to enabled when never set. + static Future load() async { + try { + final prefs = await SharedPreferences.getInstance(); + enabled = prefs.getBool(_prefKey) ?? true; + } catch (_) { + enabled = true; + } + } + + /// Updates the master switch and persists it. + static Future setEnabled(bool value) async { + enabled = value; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefKey, value); + } catch (_) { + // Persistence is best-effort; the in-memory switch still applies. + } + } + + static Future _fire(Future Function() effect) async { + if (!enabled) return; + try { + await effect(); + } catch (_) { + // Intentionally swallowed: haptics are a nicety, never a hard dependency. + } + } + + /// A crisp, light tick — taps, toggles, opening panels. + static Future tap() => _fire(HapticFeedback.lightImpact); + + /// A firmer press — confirmations, entering a mode. + static Future medium() => _fire(HapticFeedback.mediumImpact); + + /// A strong thud — destructive or weighty actions. + static Future heavy() => _fire(HapticFeedback.heavyImpact); + + /// The subtle detent of moving between discrete options — tabs, selection. + static Future selection() => _fire(HapticFeedback.selectionClick); + + /// Message sent: a quick, instant tick (the "whoosh"). + static Future send() => tap(); + + /// A two-beat rising pulse — success, completion, "it landed". + static Future success() async { + if (!enabled) return; + await _fire(HapticFeedback.lightImpact); + await Future.delayed(const Duration(milliseconds: 90)); + await _fire(HapticFeedback.mediumImpact); + } + + /// A double thud — errors, rejected or failed actions. + static Future error() async { + if (!enabled) return; + await _fire(HapticFeedback.heavyImpact); + await Future.delayed(const Duration(milliseconds: 120)); + await _fire(HapticFeedback.heavyImpact); + } +} diff --git a/lib/core/utils/ids.dart b/lib/core/utils/ids.dart new file mode 100644 index 0000000..a5c8ced --- /dev/null +++ b/lib/core/utils/ids.dart @@ -0,0 +1,20 @@ +import 'dart:math'; + +final Random _rng = Random.secure(); + +String randomHex(int bytes) { + final sb = StringBuffer(); + for (var i = 0; i < bytes; i++) { + sb.write(_rng.nextInt(256).toRadixString(16).padLeft(2, '0')); + } + return sb.toString(); +} + +String uuidV4() { + final b = List.generate(16, (_) => _rng.nextInt(256)); + b[6] = (b[6] & 0x0f) | 0x40; + b[8] = (b[8] & 0x3f) | 0x80; + String h(int i) => b[i].toRadixString(16).padLeft(2, '0'); + return '${h(0)}${h(1)}${h(2)}${h(3)}-${h(4)}${h(5)}-${h(6)}${h(7)}-' + '${h(8)}${h(9)}-${h(10)}${h(11)}${h(12)}${h(13)}${h(14)}${h(15)}'; +} diff --git a/lib/core/utils/image_format.dart b/lib/core/utils/image_format.dart new file mode 100644 index 0000000..64cfc19 --- /dev/null +++ b/lib/core/utils/image_format.dart @@ -0,0 +1,183 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:image/image.dart' as img; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +enum ImageByteFormat { jpeg, png, gif, webp, bmp, heic, unknown } + +const Map _extensionByFormat = { + ImageByteFormat.jpeg: '.jpg', + ImageByteFormat.png: '.png', + ImageByteFormat.gif: '.gif', + ImageByteFormat.webp: '.webp', + ImageByteFormat.bmp: '.bmp', + ImageByteFormat.heic: '.heic', +}; + +const Set _heicBrands = { + 'heic', + 'heix', + 'heim', + 'heis', + 'hevc', + 'hevx', + 'hevm', + 'hevs', + 'mif1', + 'msf1', +}; + +const int _webpAnimationFlag = 0x02; + +const int _transcodeJpegQuality = 95; + +const int _headerBytes = 32; + +bool _startsWith(Uint8List bytes, int offset, List signature) { + if (bytes.length < offset + signature.length) return false; + for (var i = 0; i < signature.length; i++) { + if (bytes[offset + i] != signature[i]) return false; + } + return true; +} + +String _fourcc(Uint8List bytes, int offset, [int length = 4]) { + if (bytes.length < offset + length) return ''; + return String.fromCharCodes(bytes, offset, offset + length); +} + +ImageByteFormat sniffImageFormat(Uint8List bytes) { + if (_startsWith(bytes, 0, const [0xFF, 0xD8, 0xFF])) { + return ImageByteFormat.jpeg; + } + if (_startsWith(bytes, 0, const [ + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + ])) { + return ImageByteFormat.png; + } + if (_fourcc(bytes, 0) == 'GIF8') return ImageByteFormat.gif; + if (_fourcc(bytes, 0) == 'RIFF' && _fourcc(bytes, 8) == 'WEBP') { + return ImageByteFormat.webp; + } + if (_fourcc(bytes, 0, 2) == 'BM') return ImageByteFormat.bmp; + if (_fourcc(bytes, 4) == 'ftyp' && _heicBrands.contains(_fourcc(bytes, 8))) { + return ImageByteFormat.heic; + } + return ImageByteFormat.unknown; +} + +bool isAnimatedWebp(Uint8List bytes) { + if (sniffImageFormat(bytes) != ImageByteFormat.webp) return false; + if (_fourcc(bytes, 12) != 'VP8X' || bytes.length < 21) return false; + return (bytes[20] & _webpAnimationFlag) != 0; +} + +String? extensionForImageFormat(ImageByteFormat format) => + _extensionByFormat[format]; + +String withImageExtension(String name, String extension) { + if (extension.isEmpty) return name; + return '${p.basenameWithoutExtension(name)}$extension'; +} + +class SaveReadyImage { + final File file; + final String extension; + final bool temporary; + + const SaveReadyImage({ + required this.file, + required this.extension, + required this.temporary, + }); + + Future discard() async { + if (!temporary) return; + try { + await file.delete(); + await file.parent.delete(); + } catch (_) {} + } +} + +Future prepareImageForSave(File source) async { + final head = await _readHeader(source); + if (head == null) return null; + + final format = sniffImageFormat(head); + final asIs = SaveReadyImage( + file: source, + extension: + extensionForImageFormat(format) ?? + p.extension(source.path).toLowerCase(), + temporary: false, + ); + if (format != ImageByteFormat.webp || isAnimatedWebp(head)) return asIs; + + Uint8List bytes; + try { + bytes = await source.readAsBytes(); + } catch (_) { + return asIs; + } + + final converted = await compute(_transcodeWebp, bytes); + if (converted == null) return asIs; + + final (data, extension) = converted; + try { + final directory = await (await getTemporaryDirectory()).createTemp( + 'qlyra_image_save_', + ); + final target = File( + p.join( + directory.path, + '${p.basenameWithoutExtension(source.path)}_save$extension', + ), + ); + await target.writeAsBytes(data, flush: true); + return SaveReadyImage(file: target, extension: extension, temporary: true); + } catch (_) { + return asIs; + } +} + +Future _readHeader(File source) async { + RandomAccessFile? handle; + try { + handle = await source.open(); + return await handle.read(_headerBytes); + } catch (_) { + return null; + } finally { + await handle?.close(); + } +} + +(Uint8List, String)? _transcodeWebp(Uint8List bytes) { + final decoded = img.decodeWebP(bytes); + if (decoded == null) return null; + if (!_isOpaque(decoded)) return (img.encodePng(decoded), '.png'); + return (img.encodeJpg(decoded, quality: _transcodeJpegQuality), '.jpg'); +} + +bool _isOpaque(img.Image image) { + final data = image.data; + if (data is! img.ImageDataUint8 || data.numChannels != 4) { + return !image.hasAlpha; + } + final raw = data.toUint8List(); + for (var i = 3; i < raw.length; i += 4) { + if (raw[i] != 255) return false; + } + return true; +} diff --git a/lib/core/utils/image_utils.dart b/lib/core/utils/image_utils.dart new file mode 100644 index 0000000..ce5947b --- /dev/null +++ b/lib/core/utils/image_utils.dart @@ -0,0 +1,119 @@ +import 'dart:io'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:image/image.dart' as img; + +// #***! аватарка до 1024 px и примерно 900 КБ +const int _avatarMaxDimension = 1024; +const int _avatarTargetBytes = 900 * 1024; + +// #***! обои ужимаем под экран, иначе полный кадр декодится при каждом открытии чата. +// 1920 покрывает физическое разрешение большинства экранов по длинной стороне +const int _wallpaperMaxDimension = 1920; +const int _wallpaperQuality = 92; + +/// Maximum accepted size for a user-picked avatar before compression. +const int kMaxAvatarBytes = 8 * 1024 * 1024; + +/// Maximum accepted size for a user-picked chat wallpaper before compression. +const int kMaxWallpaperBytes = 16 * 1024 * 1024; + +// #***! сжимаем в изоляте иначе кадры пропадают, файл читаем тоже там: +// главный изолят не должен держать полный кадр +Future compressAvatarFile(String path) => + compute(_encodeAvatarFile, path); + +Future squareAvatarFile(String path) => + compute(_encodeSquareAvatarFile, path); + +Future compressWallpaperFile(String path) => + compute(_encodeWallpaperFile, path); + +Future encodeRgbaToJpeg(Uint8List rgba, int width, int height) => + compute(_encodeRgba, (rgba, width, height)); + +Uint8List? _encodeRgba((Uint8List, int, int) args) { + final (rgba, width, height) = args; + if (width <= 0 || height <= 0) return null; + final image = img.Image.fromBytes( + width: width, + height: height, + bytes: rgba.buffer, + numChannels: 4, + order: img.ChannelOrder.rgba, + ); + return img.encodeJpg(image, quality: 90); +} + +img.Image? _decodeFitted(Uint8List input, int maxDimension) { + final decoded = img.decodeImage(input); + if (decoded == null) return null; + final oriented = img.bakeOrientation(decoded); + if (oriented.width <= maxDimension && oriented.height <= maxDimension) { + return oriented; + } + return img.copyResize( + oriented, + width: oriented.width >= oriented.height ? maxDimension : null, + height: oriented.height > oriented.width ? maxDimension : null, + interpolation: img.Interpolation.average, + ); +} + +Uint8List? _encodeAvatarFile(String path) => + _encodeAvatar(File(path).readAsBytesSync()); + +const int _squareAvatarDimension = 512; + +Uint8List? _encodeSquareAvatarFile(String path) => + encodeSquareAvatar(File(path).readAsBytesSync()); + +@visibleForTesting +Uint8List? encodeSquareAvatar(Uint8List input) { + final decoded = img.decodeImage(input); + if (decoded == null) return null; + final oriented = img.bakeOrientation(decoded); + final side = math.min(oriented.width, oriented.height); + final square = img.copyCrop( + oriented, + x: (oriented.width - side) ~/ 2, + y: (oriented.height - side) ~/ 2, + width: side, + height: side, + ); + final fitted = side > _squareAvatarDimension + ? img.copyResize( + square, + width: _squareAvatarDimension, + height: _squareAvatarDimension, + interpolation: img.Interpolation.average, + ) + : square; + return img.encodeJpg(fitted, quality: 88); +} + +Uint8List? _encodeWallpaperFile(String path) { + final image = _decodeFitted( + File(path).readAsBytesSync(), + _wallpaperMaxDimension, + ); + if (image == null) return null; + return img.encodeJpg(image, quality: _wallpaperQuality); +} + +// #***! качество снижаем шагами пока не влезем +Uint8List? _encodeAvatar(Uint8List input) { + final image = _decodeFitted(input, _avatarMaxDimension); + if (image == null) return null; + var quality = 88; + var out = img.encodeJpg(image, quality: quality); + while (out.lengthInBytes > _avatarTargetBytes && quality > 35) { + quality -= 12; + out = img.encodeJpg(image, quality: quality); + } + return out; +} + +Future compressAvatar(Uint8List input) => + compute(_encodeAvatar, input); diff --git a/lib/core/utils/ip_lookup_service.dart b/lib/core/utils/ip_lookup_service.dart new file mode 100644 index 0000000..ebe6956 --- /dev/null +++ b/lib/core/utils/ip_lookup_service.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; +import 'dart:io'; +import '../config/build_profile.dart'; + +class IpLookupDetails { + final String city; + final String country; + final String isp; + final String network; + final bool mobile; + final bool proxy; + final String timezone; + + const IpLookupDetails({ + required this.city, + required this.country, + required this.isp, + required this.network, + required this.mobile, + required this.proxy, + required this.timezone, + }); + + factory IpLookupDetails.fromIpWhoIs(Map data) { + if (data['success'] != true) { + throw FormatException(data['message']?.toString() ?? 'IP lookup failed'); + } + final connection = data['connection'] is Map + ? (data['connection'] as Map).cast() + : const {}; + final security = data['security'] is Map + ? (data['security'] as Map).cast() + : const {}; + final timezoneData = data['timezone'] is Map + ? (data['timezone'] as Map).cast() + : const {}; + final asn = connection['asn']; + final organization = connection['org']?.toString() ?? ''; + final network = [ + if (asn != null) 'AS$asn', + if (organization.isNotEmpty) organization, + ].join(' '); + return IpLookupDetails( + city: data['city']?.toString() ?? '', + country: data['country']?.toString() ?? '', + isp: connection['isp']?.toString() ?? organization, + network: network, + mobile: security['mobile'] == true, + proxy: security['proxy'] == true, + timezone: timezoneData['id']?.toString() ?? '', + ); + } +} + +abstract class IpLookupService { + static const String providerName = 'ipwho.is'; + static const Duration _timeout = Duration(seconds: 8); + + static Future lookup(String ip) async { + if (!BuildProfile.ipGeoLookup) { + throw StateError('IP lookup is unavailable in this build'); + } + final uri = Uri.https(providerName, '/$ip'); + final client = HttpClient()..connectionTimeout = _timeout; + try { + final request = await client.getUrl(uri); + request.headers.set(HttpHeaders.userAgentHeader, 'QlyraIpLookup'); + final response = await request.close().timeout(_timeout); + if (response.statusCode != HttpStatus.ok) { + await response.drain(); + throw HttpException('HTTP ${response.statusCode}', uri: uri); + } + final body = await response + .transform(const Utf8Decoder()) + .join() + .timeout(_timeout); + final decoded = jsonDecode(body); + if (decoded is! Map) { + throw const FormatException('Invalid IP lookup response'); + } + return IpLookupDetails.fromIpWhoIs(decoded.cast()); + } finally { + client.close(force: true); + } + } +} diff --git a/lib/core/utils/link_opener.dart b/lib/core/utils/link_opener.dart new file mode 100644 index 0000000..7b3cb8a --- /dev/null +++ b/lib/core/utils/link_opener.dart @@ -0,0 +1,82 @@ +import 'dart:io' show Platform; + +import 'package:flutter/widgets.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../links/deep_link_service.dart'; + +import '../../frontend/widgets/custom_notification.dart'; +import '../../frontend/widgets/max_link_handler.dart'; + +const Set _webViewSchemes = { + 'http', + 'https', + 'about', + 'data', + 'blob', + 'javascript', + 'file', +}; + +bool leavesWebView(String? scheme) { + if (scheme == null || scheme.isEmpty) return false; + return !_webViewSchemes.contains(scheme.toLowerCase()); +} + +const Set _appSchemes = {'qlyra', 'max'}; + +Future openExternalUrl(BuildContext context, String url) async { + final appUri = Uri.tryParse(url.trim()); + if (appUri != null && _appSchemes.contains(appUri.scheme.toLowerCase())) { + DeepLinkService.instance.handle(appUri); + return; + } + + if (await tryHandleMaxLink(context, url)) return; + if (!context.mounted) return; + + final uri = Uri.tryParse(url); + if (uri == null) { + showCustomNotification(context, 'Некорректная ссылка'); + return; + } + final ok = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!ok && context.mounted) { + showCustomNotification(context, 'Не удалось открыть ссылку'); + } +} + +Future openLocationOnMap( + BuildContext context, + double latitude, + double longitude, { + double? zoom, +}) async { + final z = (zoom ?? 15).round(); + for (final uri in _nativeMapUris(latitude, longitude, z)) { + try { + if (!await canLaunchUrl(uri)) continue; + if (await launchUrl(uri, mode: LaunchMode.externalApplication)) return; + } catch (_) { + continue; + } + } + if (!context.mounted) return; + await openExternalUrl( + context, + 'https://yandex.ru/maps/?pt=$longitude,$latitude&z=$z&l=map', + ); +} + +List _nativeMapUris(double latitude, double longitude, int zoom) { + if (Platform.isIOS) { + return [ + Uri.parse( + 'yandexmaps://maps.yandex.ru/' + '?ll=$longitude,$latitude&z=$zoom&pt=$longitude,$latitude', + ), + Uri.parse('maps://?ll=$latitude,$longitude&q=$latitude,$longitude'), + ]; + } + return [Uri.parse('geo:$latitude,$longitude?z=$zoom')]; +} diff --git a/lib/core/utils/log_redact.dart b/lib/core/utils/log_redact.dart new file mode 100644 index 0000000..1b9a373 --- /dev/null +++ b/lib/core/utils/log_redact.dart @@ -0,0 +1,68 @@ +import 'package:flutter/foundation.dart'; + +const _redacted = '***'; + +const _sensitiveSubstrings = ['password', 'token', 'secret', 'auth']; + +const _sensitiveExact = { + 'code', + 'verifycode', + 'smscode', + 'otp', + 'hint', + 'pin', + 'qrlink', + 'text', + 'deviceid', + 'mt_instanceid', + 'instanceid', + 'webappdata', +}; + +bool _isSensitiveKey(Object? key) { + if (key is! String) return false; + final k = key.toLowerCase(); + if (_sensitiveExact.contains(k)) return true; + for (final s in _sensitiveSubstrings) { + if (k.contains(s)) return true; + } + return false; +} + +bool _isPhoneKey(Object? key) { + if (key is! String) return false; + final k = key.toLowerCase(); + return k.contains('phone') || k == 'msisdn'; +} + +String _maskPhone(dynamic value) { + final text = value?.toString() ?? ''; + if (text.length <= 3) return text; + return '${text.substring(0, 3)}***'; +} + +dynamic redactForLog(dynamic value) { + if (value is Map) { + final out = {}; + value.forEach((k, v) { + if (_isPhoneKey(k)) { + out[k] = _maskPhone(v); + } else { + out[k] = _isSensitiveKey(k) ? _redacted : redactForLog(v); + } + }); + return out; + } + if (value is Uint8List) { + return ''; + } + if (value is List) { + return value.map(redactForLog).toList(); + } + return value; +} + +Object? payloadForLog(dynamic value) { + if (kReleaseMode) return '