Merge pull request #77 from KometTeam/feature/FullStack
Feature/full stack
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
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/komet_crypto/rust
|
||||
@@ -41,6 +41,11 @@ jobs:
|
||||
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
|
||||
|
||||
|
||||
@@ -41,6 +41,11 @@ jobs:
|
||||
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
|
||||
|
||||
|
||||
@@ -37,6 +37,11 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -74,6 +79,9 @@ jobs:
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>get-task-allow</key><true/>
|
||||
<key>application-identifier</key><string>ru.komet.app</string>
|
||||
<key>keychain-access-groups</key>
|
||||
<array><string>ru.komet.app</string></array>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
@@ -42,6 +42,11 @@ jobs:
|
||||
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
|
||||
|
||||
|
||||
@@ -37,6 +37,11 @@ jobs:
|
||||
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
|
||||
|
||||
|
||||
@@ -37,6 +37,11 @@ jobs:
|
||||
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
|
||||
|
||||
|
||||
@@ -29,6 +29,11 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -66,3 +71,32 @@ jobs:
|
||||
|
||||
- name: Build Android APK
|
||||
run: flutter build apk --release --flavor komet
|
||||
|
||||
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: Build iOS (no codesign)
|
||||
run: |
|
||||
flutter config --no-enable-swift-package-manager
|
||||
flutter build ios --release --no-codesign
|
||||
|
||||
@@ -28,6 +28,11 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -66,7 +71,7 @@ jobs:
|
||||
- name: Build Android APK
|
||||
run: flutter build apk --release --flavor komet
|
||||
|
||||
web-linux:
|
||||
linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -85,12 +90,14 @@ jobs:
|
||||
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 Web
|
||||
run: flutter build web --release
|
||||
|
||||
- name: Build Linux
|
||||
run: flutter build linux --release
|
||||
|
||||
@@ -108,6 +115,11 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -128,6 +140,11 @@ jobs:
|
||||
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
|
||||
|
||||
|
||||
@@ -42,6 +42,11 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -110,6 +115,10 @@ jobs:
|
||||
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
|
||||
@@ -142,6 +151,10 @@ jobs:
|
||||
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
|
||||
@@ -169,6 +182,10 @@ jobs:
|
||||
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
|
||||
@@ -199,6 +216,10 @@ jobs:
|
||||
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)
|
||||
@@ -225,6 +246,9 @@ jobs:
|
||||
<dict>
|
||||
<key>platform-application</key><true/>
|
||||
<key>get-task-allow</key><true/>
|
||||
<key>application-identifier</key><string>ru.komet.app</string>
|
||||
<key>keychain-access-groups</key>
|
||||
<array><string>ru.komet.app</string></array>
|
||||
<key>com.apple.private.security.no-container</key><true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -37,6 +37,11 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -105,6 +110,10 @@ jobs:
|
||||
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
|
||||
@@ -137,6 +146,10 @@ jobs:
|
||||
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
|
||||
@@ -164,6 +177,10 @@ jobs:
|
||||
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
|
||||
@@ -192,6 +209,10 @@ jobs:
|
||||
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)
|
||||
@@ -212,6 +233,9 @@ jobs:
|
||||
<dict>
|
||||
<key>platform-application</key><true/>
|
||||
<key>get-task-allow</key><true/>
|
||||
<key>application-identifier</key><string>ru.komet.app</string>
|
||||
<key>keychain-access-groups</key>
|
||||
<array><string>ru.komet.app</string></array>
|
||||
<key>com.apple.private.security.no-container</key><true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -148,3 +148,9 @@ maxmint/
|
||||
maxtun/
|
||||
turnprobe/
|
||||
test/live_server_probe_test.dart
|
||||
|
||||
# local kolibri development: point the plugin at third_party/kolibri and the
|
||||
# Rust core at third_party/kolibri/kolibri-net instead of the published ones
|
||||
pubspec_overrides.yaml
|
||||
.cargo/
|
||||
third_party/kolibri/
|
||||
|
||||
@@ -3,3 +3,6 @@
|
||||
Лучше качество чем количество
|
||||
Когда при исправления какой то ошибки/добавление новой возникает ситуация 50/50 где можно выбрать починить сейчас но костылём, или чинить долго, упорно, может даже вообще не починить и переписать пол приложения - выбирай долго и упорно.
|
||||
ВМЕСТО СНЕКБАРОВ ИСПОЛЬЗУЙ НАШИ КАСТОМНЫЕ УВЕДОМЛЕНИЕ showCustomNotification(context, 'текст')
|
||||
Never leave real data in test files, including existing message contents or real IDs captured from requests. Use synthetic fixtures instead.
|
||||
Не пытайся собирать APK/AAB (flutter build apk, flutter build appbundle, gradle assemble) — сборку запускает пользователь, тебе достаточно flutter analyze
|
||||
Если делаешь кнопку, где иконка переключается перечёркнутая/неперечёркнутая (вспышка, микрофон, звук, уведомления) — она должна анимироваться lottie-иконкой, а не подменяться мгновенно: добавь спеку в SLASH_SPECS в tool/make_morph_icons.py (fill=1.0, если кнопка рисует Icon(..., fill: 1)), прогони python3 tool/make_morph_icons.py и выводи через LottieSlashIcon. Ассеты в assets/lottie/ руками не правь — только через генератор.
|
||||
|
||||
@@ -29,6 +29,8 @@ flutter build windows --release
|
||||
|
||||
Android builds require **Java 17**. Gradle memory is configured to `-Xmx4096m`.
|
||||
|
||||
**Never run APK/AAB builds yourself** (`flutter build apk`, `flutter build appbundle`, gradle assemble tasks) — they are slow and the user builds them. Verify changes with `flutter analyze`; the build commands above are documentation only.
|
||||
|
||||
## Build Flavors
|
||||
|
||||
| Flavor | App ID | Notes |
|
||||
@@ -68,6 +70,21 @@ Incoming packets: transport → dispatcher → backend module → state → UI r
|
||||
- **Use `showCustomNotification(context, 'text')`** for all user-facing notifications — never use SnackBars.
|
||||
- When a fix can be done quickly with a hack or properly with a rewrite, **choose the proper rewrite**.
|
||||
- Quality over quantity.
|
||||
- **Never leave real data in test files**, including existing message contents or real IDs captured from requests. Use synthetic fixtures instead.
|
||||
- **A button whose icon toggles between plain and slashed** (flash on/off, mic muted, sound, notifications) **must animate with a Lottie icon** — never swap two `Icon`s instantly. See *Animated icons* below.
|
||||
|
||||
## Animated icons
|
||||
|
||||
Everything in `assets/lottie/` is generated from the Material Symbols font by `tool/make_morph_icons.py` (stdlib-only Python, no deps). Never hand-edit the JSON — add a spec and re-run `python3 tool/make_morph_icons.py`.
|
||||
|
||||
| Kind | Spec list | Widget |
|
||||
|------|-----------|--------|
|
||||
| Morph between two glyphs | `SPECS` | `ComposerMorphIcon` |
|
||||
| Plain ↔ slashed toggle | `SLASH_SPECS` | `LottieSlashIcon` |
|
||||
|
||||
A slash spec takes the plain and slashed codepoints; the generator lays both glyphs out as static layers and sweeps a mask across the diagonal, so the slash looks drawn on top of the icon. Pass `fill=1.0` when the button renders `Icon(..., fill: 1)` — contours are then taken from the `FILL=1` instance of the variable font.
|
||||
|
||||
`LottieSlashIcon` plays the asset forward when `slashed` turns true and backward when it turns false, so a single asset covers both directions. The older `AnimatedSlashIcon` (clip wipe over two glyphs) stays where it is already used; new buttons use the Lottie one.
|
||||
|
||||
## Localization
|
||||
|
||||
|
||||
@@ -7,6 +7,15 @@
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- android/**
|
||||
- ios/**
|
||||
- web/**
|
||||
- windows/**
|
||||
- macos/**
|
||||
- linux/**
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
@@ -23,6 +27,7 @@
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
|
||||
<uses-permission android:name="android.permission.NFC"/>
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||
<uses-feature android:name="android.hardware.nfc.hce" android:required="false"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation"/>
|
||||
@@ -64,6 +69,15 @@
|
||||
<data android:scheme="http" android:host="max.ru"/>
|
||||
<data android:scheme="http" android:host="www.max.ru"/>
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="https" android:host="komet.pw"/>
|
||||
<data android:scheme="https" android:host="www.komet.pw"/>
|
||||
<data android:path="/export-logs"/>
|
||||
<data android:path="/export-logs/"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
@@ -71,6 +85,23 @@
|
||||
<data android:scheme="komet"/>
|
||||
<data android:scheme="max"/>
|
||||
</intent-filter>
|
||||
<intent-filter android:label="@string/share_target_label">
|
||||
<action android:name="android.intent.action.SEND"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="text/*"/>
|
||||
<data android:mimeType="image/*"/>
|
||||
<data android:mimeType="video/*"/>
|
||||
<data android:mimeType="audio/*"/>
|
||||
<data android:mimeType="application/*"/>
|
||||
</intent-filter>
|
||||
<intent-filter android:label="@string/share_target_label">
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="image/*"/>
|
||||
<data android:mimeType="video/*"/>
|
||||
<data android:mimeType="audio/*"/>
|
||||
<data android:mimeType="application/*"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity-alias
|
||||
android:name="ru.komet.app.MinimalIcon"
|
||||
@@ -113,8 +144,26 @@
|
||||
tools:node="remove" />
|
||||
<service
|
||||
android:name=".CallForegroundService"
|
||||
android:foregroundServiceType="microphone"
|
||||
android:foregroundServiceType="microphone|mediaProjection"
|
||||
android:exported="false" />
|
||||
<service
|
||||
android:name=".FkmService"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:exported="false">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Maintains the app's own persistent connection to the messaging server so that message notifications are delivered on builds without Google Play Services" />
|
||||
</service>
|
||||
<receiver
|
||||
android:name=".FkmDisableReceiver"
|
||||
android:exported="false" />
|
||||
<receiver
|
||||
android:name=".FkmBootReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver
|
||||
android:name=".CallActionReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
@@ -8,7 +8,9 @@ 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
|
||||
@@ -20,8 +22,34 @@ class CallForegroundService : Service() {
|
||||
companion object {
|
||||
const val ACTION_START = "ru.komet.app.CALL_ONGOING_START"
|
||||
const val ACTION_STOP = "ru.komet.app.CALL_ONGOING_STOP"
|
||||
const val ACTION_SCREEN_SHARE = "ru.komet.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("KometFcm", "screen share FGS update failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun start(ctx: Context, caller: String) {
|
||||
CallState.inCall = true
|
||||
val intent = Intent(ctx, CallForegroundService::class.java).apply {
|
||||
@@ -41,6 +69,13 @@ class CallForegroundService : Service() {
|
||||
|
||||
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 {
|
||||
@@ -54,17 +89,30 @@ class CallForegroundService : Service() {
|
||||
|
||||
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 -> {
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
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) ?: "Звонок"
|
||||
@@ -103,15 +151,16 @@ class CallForegroundService : Service() {
|
||||
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
ONGOING_ID, notif,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
|
||||
)
|
||||
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("KometFcm", "startForeground(mic) failed: ${e.message}")
|
||||
Log.w("KometFcm", "startForeground failed: ${e.message}")
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package ru.komet.app
|
||||
|
||||
import android.content.Intent
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
|
||||
object ChatNotifications {
|
||||
const val EXTRA_CHAT = "komet_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 {
|
||||
val id = intent?.getLongExtra(EXTRA_CHAT, 0L) ?: 0L
|
||||
return if (id > 0L) id else 0L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package ru.komet.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.komet.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)
|
||||
val ch = MethodChannel(engine.dartExecutor.binaryMessenger, NAME)
|
||||
channel = ch
|
||||
ch.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"isEnabled" -> result.success(FkmState.enabled)
|
||||
|
||||
"setEnabled" -> {
|
||||
val enabled = call.argument<Boolean>("enabled") ?: false
|
||||
FkmState.applyEnabled(ctx, enabled)
|
||||
if (enabled) FkmService.start(ctx) else FkmService.stop(ctx)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
"setConnected" -> {
|
||||
FkmState.connected = call.argument<Boolean>("connected") ?: false
|
||||
FkmService.refresh(ctx)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
"showMessage" -> result.success(deliver(ctx, call, "showMessage"))
|
||||
|
||||
"showCall" -> result.success(deliver(ctx, call, "showCall"))
|
||||
|
||||
"editMessage" -> result.success(
|
||||
update(ctx, call, "editMessage") { notifier, data ->
|
||||
notifier.editMessage(data)
|
||||
},
|
||||
)
|
||||
|
||||
"removeMessage" -> result.success(
|
||||
update(ctx, call, "removeMessage") { notifier, data ->
|
||||
notifier.removeMessage(data)
|
||||
},
|
||||
)
|
||||
|
||||
"hasNotificationPermission" ->
|
||||
result.success(NotificationManagerCompat.from(ctx).areNotificationsEnabled())
|
||||
|
||||
"requestNotificationPermission" -> {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
NotificationManagerCompat.from(ctx).areNotificationsEnabled()
|
||||
) {
|
||||
result.success(
|
||||
NotificationManagerCompat.from(ctx).areNotificationsEnabled(),
|
||||
)
|
||||
} else {
|
||||
permResult?.success(false)
|
||||
permResult = result
|
||||
ActivityCompat.requestPermissions(
|
||||
activity,
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
NOTIF_PERMS_REQUEST,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"isIgnoringBatteryOptimizations" -> {
|
||||
val power = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
result.success(power.isIgnoringBatteryOptimizations(ctx.packageName))
|
||||
}
|
||||
|
||||
"requestIgnoreBatteryOptimizations" -> {
|
||||
try {
|
||||
activity.startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||
Uri.parse("package:${ctx.packageName}"),
|
||||
),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w("Fkm", "battery settings failed: ${e.message}")
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Отрисовка тянет аватарку по сети — только не на главном потоке.
|
||||
private fun deliver(ctx: Context, call: MethodCall, tag: String): Boolean {
|
||||
val data = call.argument<Map<String, String>>("data") ?: return false
|
||||
worker.execute {
|
||||
try {
|
||||
KometNotifier(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: (KometNotifier, Map<String, String>) -> Unit,
|
||||
): Boolean {
|
||||
val data = call.argument<Map<String, String>>("data") ?: return false
|
||||
worker.execute {
|
||||
try {
|
||||
action(KometNotifier(ctx), data)
|
||||
} catch (e: Exception) {
|
||||
Log.w("Fkm", "$tag failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun detach() {
|
||||
channel?.setMethodCallHandler(null)
|
||||
channel = null
|
||||
permResult?.success(false)
|
||||
permResult = null
|
||||
}
|
||||
|
||||
fun onPermissionResult(grantResults: IntArray) {
|
||||
val pending = permResult ?: return
|
||||
permResult = null
|
||||
pending.success(
|
||||
grantResults.isNotEmpty() &&
|
||||
grantResults.all { it == PackageManager.PERMISSION_GRANTED },
|
||||
)
|
||||
}
|
||||
|
||||
fun notifyDisabled() {
|
||||
main.post {
|
||||
try {
|
||||
channel?.invokeMethod("disabled", null)
|
||||
} catch (e: Exception) {
|
||||
Log.w("Fkm", "notifyDisabled failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package ru.komet.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 PREFS = "komet_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) {
|
||||
val p = prefs(ctx)
|
||||
enabled = p.getBoolean(KEY_ENABLED, false)
|
||||
delivered = p.getInt(KEY_DELIVERED, 0)
|
||||
}
|
||||
|
||||
fun applyEnabled(ctx: Context, value: Boolean) {
|
||||
enabled = value
|
||||
// Счётчик обнуляем только на выключении, чтобы перезапуск приложения
|
||||
// не сбрасывал накопленное.
|
||||
if (!value) {
|
||||
delivered = 0
|
||||
connected = false
|
||||
}
|
||||
prefs(ctx).edit()
|
||||
.putBoolean(KEY_ENABLED, value)
|
||||
.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 = "komet_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,
|
||||
Intent(ctx, MainActivity::class.java)
|
||||
.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) {
|
||||
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))
|
||||
} 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.komet.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)
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,13 @@ 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
|
||||
@@ -29,6 +31,12 @@ class KometFcmService : FirebaseMessagingService() {
|
||||
val data = message.data
|
||||
Log.d("KometFcm", "onMessageReceived type=${data["type"]} keys=${data.keys}")
|
||||
if (data.isEmpty()) return
|
||||
val type = data["type"]
|
||||
FkmState.restore(applicationContext)
|
||||
if (FkmState.enabled && type != "InboundCall" && type != "CallFinished") {
|
||||
Log.d("KometFcm", "message push dropped: FKM handles messages")
|
||||
return
|
||||
}
|
||||
KometNotifier(applicationContext).handle(data)
|
||||
}
|
||||
}
|
||||
@@ -50,7 +58,18 @@ class KometNotifier(private val ctx: Context) {
|
||||
private const val ACCENT = 0xFF7C6BF0.toInt()
|
||||
}
|
||||
|
||||
private data class Hist(val text: String, val key: String, val name: String, val ts: Long)
|
||||
private data class Hist(
|
||||
val text: String,
|
||||
val key: String,
|
||||
val name: String,
|
||||
val ts: Long,
|
||||
val mid: String,
|
||||
val deleted: Boolean,
|
||||
)
|
||||
|
||||
// Что нужно для перерисовки чата, когда нового пуша нет: правка и удаление
|
||||
// приходят без заголовка, аккаунта и признака группы.
|
||||
private data class ChatMeta(val title: String, val account: Int, val group: Boolean)
|
||||
|
||||
fun handle(data: Map<String, String>) {
|
||||
when (data["type"]) {
|
||||
@@ -72,23 +91,107 @@ class KometNotifier(private val ctx: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifIdOf(chatId: Long): Int = (chatId and 0x7fffffff).toInt()
|
||||
|
||||
private fun showMessage(data: Map<String, String>) {
|
||||
val chatId = data["mc"]?.toLongOrNull() ?: return
|
||||
val notifId = notifIdOf(chatId)
|
||||
if (ChatNotifications.isDisplayed(chatId)) {
|
||||
manager().cancel(notifId)
|
||||
clearChat(chatId)
|
||||
syncSummary(notifId, null)
|
||||
return
|
||||
}
|
||||
val senderId = data["suid"] ?: ""
|
||||
val senderName = data["userName"] ?: data["title"] ?: "MAX"
|
||||
val chatTitle = data["title"] ?: senderName
|
||||
val text = data["msg"] ?: data["body"] ?: data["text"] ?: "Новое сообщение"
|
||||
val ts = data["ctime"]?.toLongOrNull() ?: data["ttime"]?.toLongOrNull()
|
||||
?: System.currentTimeMillis()
|
||||
val isGroup = chatTitle != senderName
|
||||
val notifId = (chatId and 0x7fffffff).toInt()
|
||||
|
||||
ensureChannel()
|
||||
|
||||
val active = activeIds()
|
||||
if (!active.contains(notifId)) clearHistory(chatId)
|
||||
val history = appendHistory(chatId, Hist(text, senderId, senderName, ts))
|
||||
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 editMessage(data: Map<String, String>) {
|
||||
val chatId = data["mc"]?.toLongOrNull() ?: return
|
||||
val mid = data["msgid"]?.takeIf { it.isNotEmpty() } ?: return
|
||||
val text = data["msg"]?.takeIf { it.isNotEmpty() } ?: return
|
||||
|
||||
val history = loadHistory(chatId)
|
||||
val index = history.indexOfLast { it.mid == mid }
|
||||
if (index < 0) return
|
||||
val old = history[index]
|
||||
if (old.deleted || old.text == text) return
|
||||
|
||||
val updated = history.toMutableList()
|
||||
updated[index] = old.copy(text = text)
|
||||
saveHistory(chatId, updated)
|
||||
|
||||
val notifId = notifIdOf(chatId)
|
||||
if (!activeIds().contains(notifId)) return
|
||||
val meta = loadMeta(chatId) ?: return
|
||||
render(chatId, notifId, meta, updated, alertOnce = true)
|
||||
syncSummary(notifId, updated.last())
|
||||
}
|
||||
|
||||
// Сообщение удалили. keep — включено «показывать удалённые сообщения»:
|
||||
// тогда строка остаётся в шторке, но зачёркнутой и с пометкой.
|
||||
fun removeMessage(data: Map<String, String>) {
|
||||
val chatId = data["mc"]?.toLongOrNull() ?: return
|
||||
val mid = data["msgid"]?.takeIf { it.isNotEmpty() } ?: return
|
||||
val keep = data["keep"] == "true"
|
||||
|
||||
val history = loadHistory(chatId)
|
||||
val index = history.indexOfLast { it.mid == mid }
|
||||
if (index < 0) return
|
||||
if (keep && history[index].deleted) return
|
||||
|
||||
val updated = history.toMutableList()
|
||||
if (keep) {
|
||||
updated[index] = updated[index].copy(deleted = true)
|
||||
} else {
|
||||
updated.removeAt(index)
|
||||
}
|
||||
|
||||
val notifId = notifIdOf(chatId)
|
||||
if (updated.isEmpty()) {
|
||||
clearChat(chatId)
|
||||
manager().cancel(notifId)
|
||||
syncSummary(notifId, null)
|
||||
return
|
||||
}
|
||||
|
||||
saveHistory(chatId, updated)
|
||||
if (!activeIds().contains(notifId)) return
|
||||
val meta = loadMeta(chatId) ?: return
|
||||
render(chatId, notifId, meta, updated, alertOnce = true)
|
||||
syncSummary(notifId, updated.last())
|
||||
}
|
||||
|
||||
private fun render(
|
||||
chatId: Long,
|
||||
notifId: Int,
|
||||
meta: ChatMeta,
|
||||
history: List<Hist>,
|
||||
alertOnce: Boolean,
|
||||
) {
|
||||
if (history.isEmpty()) return
|
||||
ensureChannel()
|
||||
|
||||
val newest = history.last()
|
||||
val avatarCache = HashMap<String, Bitmap>()
|
||||
val personCache = HashMap<String, Person>()
|
||||
fun avatarFor(key: String, name: String): Bitmap =
|
||||
@@ -102,17 +205,16 @@ class KometNotifier(private val ctx: Context) {
|
||||
.build()
|
||||
}
|
||||
|
||||
val senderPerson = personFor(senderId, senderName)
|
||||
val shortcutId = "chat_$chatId"
|
||||
publishShortcut(shortcutId, chatId, chatTitle, senderPerson)
|
||||
publishShortcut(shortcutId, chatId, meta.title, personFor(newest.key, newest.name))
|
||||
|
||||
val style = NotificationCompat.MessagingStyle(Person.Builder().setName("Вы").build())
|
||||
if (isGroup) {
|
||||
style.conversationTitle = chatTitle
|
||||
if (meta.group) {
|
||||
style.conversationTitle = meta.title
|
||||
style.isGroupConversation = true
|
||||
}
|
||||
for (h in history) {
|
||||
style.addMessage(h.text, h.ts, personFor(h.key, h.name))
|
||||
style.addMessage(displayText(h), h.ts, personFor(h.key, h.name))
|
||||
}
|
||||
|
||||
val builder = NotificationCompat.Builder(ctx, CHANNEL_ID)
|
||||
@@ -121,26 +223,38 @@ class KometNotifier(private val ctx: Context) {
|
||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||
.setAutoCancel(true)
|
||||
.setGroup(GROUP_KEY)
|
||||
.setWhen(ts)
|
||||
.setWhen(newest.ts)
|
||||
.setShowWhen(true)
|
||||
.setOnlyAlertOnce(alertOnce)
|
||||
.setContentIntent(openIntent(notifId, chatId))
|
||||
.setShortcutId(shortcutId)
|
||||
.setLocusId(LocusIdCompat(shortcutId))
|
||||
.setStyle(style)
|
||||
.setLargeIcon(avatarFor(senderId, senderName))
|
||||
.setLargeIcon(avatarFor(newest.key, newest.name))
|
||||
|
||||
val account = data["c"]?.toIntOrNull() ?: 0
|
||||
if (account != 0) {
|
||||
builder.addAction(replyAction(notifId, account, chatId, data["msgid"]?.toLongOrNull()))
|
||||
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())
|
||||
updateSummary(notifId, chatId, senderName, text, ts, active)
|
||||
}
|
||||
|
||||
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,
|
||||
chatId: Long,
|
||||
senderName: String,
|
||||
text: String,
|
||||
ts: Long,
|
||||
@@ -153,27 +267,60 @@ class KometNotifier(private val ctx: Context) {
|
||||
val k = keys.next()
|
||||
val id = k.toIntOrNull() ?: continue
|
||||
if (id == notifId) continue
|
||||
if (activeBefore.contains(id)) kept.put(k, reg.getJSONObject(k))
|
||||
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))
|
||||
}
|
||||
|
||||
if (kept.length() < 2) {
|
||||
manager().cancel(SUMMARY_ID)
|
||||
return
|
||||
// `newest` == null — уведомление чата ушло из шторки.
|
||||
private fun syncSummary(notifId: Int, newest: Hist?) {
|
||||
val active = activeIds()
|
||||
val reg = loadRegistry()
|
||||
val kept = JSONObject()
|
||||
val keys = reg.keys()
|
||||
while (keys.hasNext()) {
|
||||
val k = keys.next()
|
||||
val id = k.toIntOrNull() ?: continue
|
||||
if (id == notifId) continue
|
||||
val entry = reg.optJSONObject(k) ?: continue
|
||||
if (active.contains(id)) kept.put(k, entry)
|
||||
}
|
||||
if (newest != null && active.contains(notifId)) {
|
||||
kept.put(
|
||||
notifId.toString(),
|
||||
JSONObject()
|
||||
.put("n", newest.name)
|
||||
.put("t", plainText(newest))
|
||||
.put("ts", newest.ts),
|
||||
)
|
||||
}
|
||||
saveRegistry(kept)
|
||||
publishSummary(entriesOf(kept))
|
||||
}
|
||||
|
||||
private fun entriesOf(reg: JSONObject): List<Triple<String, String, Long>> {
|
||||
val entries = ArrayList<Triple<String, String, Long>>()
|
||||
val kk = kept.keys()
|
||||
while (kk.hasNext()) {
|
||||
val k = kk.next()
|
||||
val o = kept.getJSONObject(k)
|
||||
val keys = reg.keys()
|
||||
while (keys.hasNext()) {
|
||||
val o = reg.optJSONObject(keys.next()) ?: continue
|
||||
entries.add(Triple(o.optString("n"), o.optString("t"), o.optLong("ts")))
|
||||
}
|
||||
entries.sortByDescending { it.third }
|
||||
return entries
|
||||
}
|
||||
|
||||
private fun publishSummary(entries: List<Triple<String, String, Long>>) {
|
||||
if (entries.size < 2) {
|
||||
manager().cancel(SUMMARY_ID)
|
||||
return
|
||||
}
|
||||
val newest = entries.first()
|
||||
|
||||
val inbox = NotificationCompat.InboxStyle()
|
||||
for (e in entries.take(6)) inbox.addLine(boldLine(e.first, e.second))
|
||||
@@ -185,11 +332,12 @@ class KometNotifier(private val ctx: Context) {
|
||||
.setGroup(GROUP_KEY)
|
||||
.setGroupSummary(true)
|
||||
.setAutoCancel(true)
|
||||
.setWhen(ts)
|
||||
.setWhen(newest.third)
|
||||
.setShowWhen(true)
|
||||
.setNumber(entries.size)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
.setContentTitle("Komet")
|
||||
.setContentText(boldLine(senderName, text))
|
||||
.setContentText(boldLine(newest.first, newest.second))
|
||||
.setStyle(inbox)
|
||||
.build()
|
||||
manager().notify(SUMMARY_ID, summary)
|
||||
@@ -239,17 +387,15 @@ class KometNotifier(private val ctx: Context) {
|
||||
|
||||
private fun publishShortcut(id: String, chatId: Long, title: String, person: Person) {
|
||||
try {
|
||||
val intent = (ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)
|
||||
?: Intent(Intent.ACTION_VIEW)).apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
putExtra("komet_chat", chatId)
|
||||
}
|
||||
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) {
|
||||
@@ -257,12 +403,26 @@ class KometNotifier(private val ctx: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun openIntent(notifId: Int, chatId: Long): PendingIntent? {
|
||||
val launch = ctx.packageManager.getLaunchIntentForPackage(ctx.packageName) ?: return null
|
||||
launch.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
launch.putExtra("komet_chat", chatId)
|
||||
private fun chatIntent(chatId: Long): Intent {
|
||||
val launcher = ctx.packageManager
|
||||
.getLaunchIntentForPackage(ctx.packageName)?.component
|
||||
val intent = if (launcher != null) {
|
||||
Intent().setComponent(launcher)
|
||||
} else {
|
||||
Intent(ctx, MainActivity::class.java)
|
||||
}
|
||||
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, launch, flags)
|
||||
return PendingIntent.getActivity(ctx, notifId, chatIntent(chatId), flags)
|
||||
}
|
||||
|
||||
private fun activeIds(): Set<Int> = try {
|
||||
@@ -273,30 +433,74 @@ class KometNotifier(private val ctx: Context) {
|
||||
|
||||
private fun pushPrefs() = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
private fun appendHistory(chatId: Long, item: Hist): List<Hist> {
|
||||
val prefs = pushPrefs()
|
||||
val key = "hist_$chatId"
|
||||
private fun loadHistory(chatId: Long): List<Hist> {
|
||||
val arr = try {
|
||||
JSONArray(prefs.getString(key, "[]"))
|
||||
JSONArray(pushPrefs().getString("hist_$chatId", "[]"))
|
||||
} catch (e: Exception) {
|
||||
JSONArray()
|
||||
}
|
||||
arr.put(
|
||||
JSONObject().put("t", item.text).put("k", item.key)
|
||||
.put("n", item.name).put("ts", item.ts),
|
||||
)
|
||||
while (arr.length() > HISTORY_LIMIT) arr.remove(0)
|
||||
prefs.edit().putString(key, arr.toString()).apply()
|
||||
val out = ArrayList<Hist>(arr.length())
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.getJSONObject(i)
|
||||
out.add(Hist(o.optString("t"), o.optString("k"), o.optString("n"), o.optLong("ts")))
|
||||
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 clearHistory(chatId: Long) {
|
||||
pushPrefs().edit().remove("hist_$chatId").apply()
|
||||
private fun saveHistory(chatId: Long, items: List<Hist>) {
|
||||
val arr = JSONArray()
|
||||
for (h in items) {
|
||||
arr.put(
|
||||
JSONObject()
|
||||
.put("t", h.text)
|
||||
.put("k", h.key)
|
||||
.put("n", h.name)
|
||||
.put("ts", h.ts)
|
||||
.put("m", h.mid)
|
||||
.put("d", h.deleted),
|
||||
)
|
||||
}
|
||||
pushPrefs().edit().putString("hist_$chatId", arr.toString()).apply()
|
||||
}
|
||||
|
||||
private fun appendHistory(chatId: Long, item: Hist): List<Hist> {
|
||||
val items = ArrayList(loadHistory(chatId))
|
||||
items.add(item)
|
||||
while (items.size > HISTORY_LIMIT) items.removeAt(0)
|
||||
saveHistory(chatId, items)
|
||||
return items
|
||||
}
|
||||
|
||||
private fun clearChat(chatId: Long) {
|
||||
pushPrefs().edit().remove("hist_$chatId").remove("meta_$chatId").apply()
|
||||
}
|
||||
|
||||
private fun saveMeta(chatId: Long, meta: ChatMeta) {
|
||||
val json = JSONObject()
|
||||
.put("t", meta.title)
|
||||
.put("a", meta.account)
|
||||
.put("g", meta.group)
|
||||
.toString()
|
||||
pushPrefs().edit().putString("meta_$chatId", json).apply()
|
||||
}
|
||||
|
||||
private fun loadMeta(chatId: Long): ChatMeta? {
|
||||
val raw = pushPrefs().getString("meta_$chatId", null) ?: return null
|
||||
return try {
|
||||
val o = JSONObject(raw)
|
||||
ChatMeta(o.optString("t"), o.optInt("a"), o.optBoolean("g"))
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadRegistry(): JSONObject = try {
|
||||
|
||||
@@ -27,6 +27,7 @@ import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.FlutterEngineCache
|
||||
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
|
||||
@@ -75,14 +76,24 @@ class MainActivity : FlutterActivity() {
|
||||
@Volatile private var exchangingEmitted = false
|
||||
|
||||
private var pendingCall: Map<String, Any?>? = null
|
||||
private var pendingChat: Long = 0L
|
||||
private var pendingShare: Map<String, Any?>? = null
|
||||
private var pendingShareTask: java.util.concurrent.Future<Map<String, Any?>?>? = 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 CALL_ENGINE_ID = "komet_call_engine"
|
||||
const val KEEP_ENGINE_ID = "komet_keep_engine"
|
||||
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
|
||||
@@ -189,37 +200,46 @@ class MainActivity : FlutterActivity() {
|
||||
"ru.komet.app/upload_service",
|
||||
).setMethodCallHandler { call, result ->
|
||||
val ctx = this
|
||||
when (call.method) {
|
||||
"start" -> {
|
||||
val filename = call.argument<String>("filename") ?: "Файл"
|
||||
val intent = Intent(ctx, UploadForegroundService::class.java).apply {
|
||||
action = UploadForegroundService.ACTION_START
|
||||
putExtra(UploadForegroundService.EXTRA_FILENAME, filename)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
fun uploadIntent(call: MethodCall, action: String) =
|
||||
Intent(ctx, UploadForegroundService::class.java).apply {
|
||||
this.action = action
|
||||
putExtra(UploadForegroundService.EXTRA_TITLE, call.argument<String>("title"))
|
||||
putExtra(UploadForegroundService.EXTRA_BODY, call.argument<String>("body") ?: "")
|
||||
putExtra(UploadForegroundService.EXTRA_PROGRESS, call.argument<Int>("progress") ?: 0)
|
||||
putExtra(
|
||||
UploadForegroundService.EXTRA_INDETERMINATE,
|
||||
call.argument<Boolean>("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" -> {
|
||||
val filename = call.argument<String>("filename") ?: "Файл"
|
||||
val progress = call.argument<Int>("progress") ?: 0
|
||||
val speed = call.argument<Long>("speed") ?: 0L
|
||||
val intent = Intent(ctx, UploadForegroundService::class.java).apply {
|
||||
action = UploadForegroundService.ACTION_UPDATE
|
||||
putExtra(UploadForegroundService.EXTRA_FILENAME, filename)
|
||||
putExtra(UploadForegroundService.EXTRA_PROGRESS, progress)
|
||||
putExtra(UploadForegroundService.EXTRA_SPEED, speed)
|
||||
}
|
||||
startService(intent)
|
||||
launch(uploadIntent(call, UploadForegroundService.ACTION_UPDATE), false)
|
||||
result.success(null)
|
||||
}
|
||||
"stop" -> {
|
||||
startService(Intent(ctx, UploadForegroundService::class.java).apply {
|
||||
action = UploadForegroundService.ACTION_STOP
|
||||
})
|
||||
launch(
|
||||
Intent(ctx, UploadForegroundService::class.java).apply {
|
||||
action = UploadForegroundService.ACTION_STOP
|
||||
},
|
||||
false,
|
||||
)
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
@@ -231,15 +251,29 @@ class MainActivity : FlutterActivity() {
|
||||
"ru.komet.app/video_note",
|
||||
).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"permission" -> requestNotePermissions(result)
|
||||
"init" -> {
|
||||
val front = call.argument<Boolean>("front") ?: true
|
||||
val rec = VideoNoteRecorder(applicationContext, flutterEngine.renderer)
|
||||
val size = call.argument<Int>("size") ?: 480
|
||||
val fps = call.argument<Int>("fps") ?: 30
|
||||
val rec = VideoNoteRecorder(
|
||||
applicationContext,
|
||||
flutterEngine.renderer,
|
||||
size,
|
||||
fps,
|
||||
)
|
||||
noteRecorder?.dispose()
|
||||
noteRecorder = rec
|
||||
rec.init(front, result)
|
||||
}
|
||||
"start" -> noteRecorder?.start(result)
|
||||
?: result.error("NOT_READY", "recorder not initialized", null)
|
||||
"switch" -> noteRecorder?.switchCamera(result)
|
||||
"torch" -> noteRecorder?.setTorch(
|
||||
call.argument<Boolean>("on") ?: false,
|
||||
result,
|
||||
)
|
||||
?: result.error("NOT_READY", "recorder not initialized", null)
|
||||
"stop" -> noteRecorder?.stop(result)
|
||||
?: result.error("NOT_READY", "recorder not initialized", null)
|
||||
"dispose" -> {
|
||||
@@ -266,6 +300,32 @@ class MainActivity : FlutterActivity() {
|
||||
cropSquare(input, output, size, result)
|
||||
}
|
||||
}
|
||||
"probe" -> {
|
||||
val input = call.argument<String>("input")
|
||||
if (input == null) {
|
||||
result.error("BAD_ARGS", "input required", null)
|
||||
} else {
|
||||
probeVideo(input, result)
|
||||
}
|
||||
}
|
||||
"frames" -> {
|
||||
val input = call.argument<String>("input")
|
||||
val times = call.argument<List<Int>>("times")
|
||||
if (input == null || times == null) {
|
||||
result.error("BAD_ARGS", "input/times required", null)
|
||||
} else {
|
||||
videoFrames(
|
||||
input,
|
||||
times,
|
||||
call.argument<Int>("size") ?: 256,
|
||||
call.argument<Boolean>("precise") == true,
|
||||
result,
|
||||
)
|
||||
}
|
||||
}
|
||||
"edit" -> editVideo(call, result)
|
||||
"editProgress" -> editVideoProgress(result)
|
||||
"editCancel" -> editVideoCancel(result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
@@ -287,6 +347,25 @@ class MainActivity : FlutterActivity() {
|
||||
CallForegroundService.start(applicationContext, caller)
|
||||
result.success(null)
|
||||
}
|
||||
"ensureOngoing" -> {
|
||||
val caller = call.argument<String>("caller") ?: "Звонок"
|
||||
CallForegroundService.start(applicationContext, caller)
|
||||
result.success(null)
|
||||
}
|
||||
"setScreenShare" -> {
|
||||
val enabled = call.argument<Boolean>("enabled") ?: false
|
||||
val caller = call.argument<String>("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)
|
||||
@@ -323,6 +402,19 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
"ru.komet.app/screen",
|
||||
).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"setKeepAwake" -> {
|
||||
setKeepAwake(call.argument<Boolean>("enabled") == true)
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
EventChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
"ru.komet.app/calls_events",
|
||||
@@ -335,12 +427,101 @@ class MainActivity : FlutterActivity() {
|
||||
CallEvents.sink = null
|
||||
}
|
||||
})
|
||||
|
||||
MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
"ru.komet.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<Any>("chatId"))
|
||||
result.success(null)
|
||||
}
|
||||
"clearActiveChat" -> {
|
||||
ChatNotifications.activeChatId = 0L
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
EventChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
"ru.komet.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.komet.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" -> {
|
||||
ShareIntake.clearCache(applicationContext)
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
EventChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
"ru.komet.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)
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -350,11 +531,56 @@ class MainActivity : FlutterActivity() {
|
||||
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 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<Map<String, Any?>?> {
|
||||
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<String, Any?>("data" to json, "action" to action)
|
||||
val sink = CallEvents.sink
|
||||
@@ -373,8 +599,7 @@ class MainActivity : FlutterActivity() {
|
||||
@Suppress("DEPRECATION")
|
||||
window.addFlags(
|
||||
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
|
||||
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON,
|
||||
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON,
|
||||
)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
@@ -391,12 +616,24 @@ class MainActivity : FlutterActivity() {
|
||||
@Suppress("DEPRECATION")
|
||||
window.clearFlags(
|
||||
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
|
||||
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON,
|
||||
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 заполняет квадрат и обрезает
|
||||
// лишнее по бокам.
|
||||
@@ -462,6 +699,31 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
@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<Int>,
|
||||
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<String, Any> {
|
||||
val adapter = nfcAdapter
|
||||
return mapOf(
|
||||
@@ -566,12 +828,46 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private var notePermResult: MethodChannel.Result? = null
|
||||
|
||||
private fun isGranted(permission: String): Boolean =
|
||||
ContextCompat.checkSelfPermission(this, permission) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private fun notePermissionState(): Map<String, Boolean> = 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<out String>,
|
||||
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() &&
|
||||
@@ -641,30 +937,36 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// Движок переживает смерть активити, пока идёт звонок или включён FKM:
|
||||
// в обоих случаях в фоне должно жить то же соединение, что и в UI.
|
||||
private fun keepEngineAlive(): Boolean = CallState.inCall || FkmState.enabled
|
||||
|
||||
override fun provideFlutterEngine(context: Context): FlutterEngine? {
|
||||
val cache = FlutterEngineCache.getInstance()
|
||||
val cached = cache.get(CALL_ENGINE_ID)
|
||||
val cached = cache.get(KEEP_ENGINE_ID)
|
||||
if (cached != null) {
|
||||
if (CallState.inCall) return cached
|
||||
cache.remove(CALL_ENGINE_ID)
|
||||
if (keepEngineAlive()) return cached
|
||||
cache.remove(KEEP_ENGINE_ID)
|
||||
cached.destroy()
|
||||
}
|
||||
return super.provideFlutterEngine(context)
|
||||
}
|
||||
|
||||
override fun shouldDestroyEngineWithHost(): Boolean = !CallState.inCall
|
||||
override fun shouldDestroyEngineWithHost(): Boolean = !keepEngineAlive()
|
||||
|
||||
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
if (!CallState.inCall) {
|
||||
FlutterEngineCache.getInstance().remove(CALL_ENGINE_ID)
|
||||
if (!keepEngineAlive()) {
|
||||
FlutterEngineCache.getInstance().remove(KEEP_ENGINE_ID)
|
||||
FkmChannel.detach()
|
||||
}
|
||||
super.cleanUpFlutterEngine(flutterEngine)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (CallState.inCall && isFinishing) {
|
||||
Log.d("KometFcm", "task removed during call, caching engine")
|
||||
flutterEngine?.let { FlutterEngineCache.getInstance().put(CALL_ENGINE_ID, it) }
|
||||
shareExecutor.shutdown()
|
||||
if (keepEngineAlive() && isFinishing) {
|
||||
Log.d("KometFcm", "task removed, caching engine (call=${CallState.inCall} fkm=${FkmState.enabled})")
|
||||
flutterEngine?.let { FlutterEngineCache.getInstance().put(KEEP_ENGINE_ID, it) }
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
@@ -672,6 +974,7 @@ class MainActivity : FlutterActivity() {
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
AppState.resumed = true
|
||||
stashChatOpen(intent, emit = true)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package ru.komet.app
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
object ShareIntake {
|
||||
|
||||
private const val TAG = "ShareIntake"
|
||||
private const val CACHE_DIR = "shared_in"
|
||||
private const val MAX_FILES = 30
|
||||
|
||||
private val seq = AtomicLong(0L)
|
||||
|
||||
@Volatile
|
||||
var sink: EventChannel.EventSink? = null
|
||||
|
||||
fun isShare(intent: Intent?): Boolean {
|
||||
val action = intent?.action ?: return false
|
||||
return action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE
|
||||
}
|
||||
|
||||
class Snapshot(
|
||||
val uris: List<Uri>,
|
||||
val text: String?,
|
||||
val subject: String?,
|
||||
val intentType: String?,
|
||||
)
|
||||
|
||||
fun snapshot(intent: Intent): Snapshot? {
|
||||
val uris = collectUris(intent).take(MAX_FILES)
|
||||
val text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()
|
||||
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
|
||||
if (uris.isEmpty() && text.isNullOrBlank()) return null
|
||||
return Snapshot(uris, text, subject, intent.type)
|
||||
}
|
||||
|
||||
fun materialize(context: Context, snapshot: Snapshot): Map<String, Any?>? {
|
||||
val files = ArrayList<Map<String, Any?>>()
|
||||
for (uri in snapshot.uris) {
|
||||
val copied = copyToCache(context, uri, snapshot.intentType)
|
||||
if (copied != null) files.add(copied)
|
||||
}
|
||||
|
||||
if (files.isEmpty() && snapshot.text.isNullOrBlank()) return null
|
||||
|
||||
return mapOf(
|
||||
"files" to files,
|
||||
"text" to snapshot.text,
|
||||
"subject" to snapshot.subject,
|
||||
)
|
||||
}
|
||||
|
||||
fun clearCache(context: Context) {
|
||||
try {
|
||||
val dir = File(context.cacheDir, CACHE_DIR)
|
||||
if (!dir.isDirectory) return
|
||||
dir.listFiles()?.forEach { it.delete() }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "cache cleanup failed: $e")
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectUris(intent: Intent): List<Uri> {
|
||||
if (intent.action == Intent.ACTION_SEND_MULTIPLE) {
|
||||
val list = if (android.os.Build.VERSION.SDK_INT >= 33) {
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
}
|
||||
return list?.filterNotNull() ?: emptyList()
|
||||
}
|
||||
val single = if (android.os.Build.VERSION.SDK_INT >= 33) {
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
}
|
||||
return if (single != null) listOf(single) else emptyList()
|
||||
}
|
||||
|
||||
private fun copyToCache(context: Context, uri: Uri, intentType: String?): Map<String, Any?>? {
|
||||
val resolver = context.contentResolver
|
||||
val mime = resolveMime(resolver, uri, intentType)
|
||||
val displayName = queryDisplayName(resolver, uri) ?: fallbackName(uri, mime)
|
||||
|
||||
return try {
|
||||
val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() }
|
||||
val target = File(dir, "${System.currentTimeMillis()}_${seq.incrementAndGet()}_${sanitize(displayName)}")
|
||||
resolver.openInputStream(uri).use { input ->
|
||||
if (input == null) return null
|
||||
target.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
if (target.length() <= 0L) {
|
||||
target.delete()
|
||||
return null
|
||||
}
|
||||
mapOf(
|
||||
"path" to target.absolutePath,
|
||||
"name" to displayName,
|
||||
"mime" to mime,
|
||||
"size" to target.length(),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "cannot read $uri: $e")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveMime(resolver: ContentResolver, uri: Uri, intentType: String?): String {
|
||||
val fromResolver = resolver.getType(uri)
|
||||
if (!fromResolver.isNullOrBlank() && fromResolver != "*/*") return fromResolver
|
||||
val ext = MimeTypeMap.getFileExtensionFromUrl(uri.toString())
|
||||
if (!ext.isNullOrBlank()) {
|
||||
val guessed = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.lowercase())
|
||||
if (!guessed.isNullOrBlank()) return guessed
|
||||
}
|
||||
if (!intentType.isNullOrBlank() && intentType != "*/*") return intentType
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
private fun queryDisplayName(resolver: ContentResolver, uri: Uri): String? {
|
||||
if (uri.scheme == ContentResolver.SCHEME_FILE) return uri.lastPathSegment
|
||||
return try {
|
||||
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
||||
val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (index >= 0 && cursor.moveToFirst()) cursor.getString(index) else null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "display name for $uri: $e")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun fallbackName(uri: Uri, mime: String): String {
|
||||
val last = uri.lastPathSegment?.substringAfterLast('/')
|
||||
if (!last.isNullOrBlank() && last.contains('.')) return last
|
||||
val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "bin"
|
||||
return "shared_${System.currentTimeMillis()}.$ext"
|
||||
}
|
||||
|
||||
private fun sanitize(name: String): String {
|
||||
val cleaned = name.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||
return if (cleaned.length <= 64) cleaned else cleaned.takeLast(64)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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() {
|
||||
@@ -15,11 +16,14 @@ class UploadForegroundService : Service() {
|
||||
const val ACTION_START = "ru.komet.app.UPLOAD_START"
|
||||
const val ACTION_UPDATE = "ru.komet.app.UPLOAD_UPDATE"
|
||||
const val ACTION_STOP = "ru.komet.app.UPLOAD_STOP"
|
||||
const val EXTRA_FILENAME = "filename"
|
||||
const val EXTRA_PROGRESS = "progress" // 0-100
|
||||
const val EXTRA_SPEED = "speed" // bytes/sec (Long)
|
||||
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() {
|
||||
@@ -29,67 +33,96 @@ class UploadForegroundService : Service() {
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START -> {
|
||||
val filename = intent.getStringExtra(EXTRA_FILENAME) ?: "Файл"
|
||||
val notification = buildNotification(filename, 0, 0, indeterminate = true)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
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 {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
goForeground(notification)
|
||||
}
|
||||
}
|
||||
ACTION_UPDATE -> {
|
||||
val filename = intent.getStringExtra(EXTRA_FILENAME) ?: "Файл"
|
||||
val progress = intent.getIntExtra(EXTRA_PROGRESS, 0)
|
||||
val speed = intent.getLongExtra(EXTRA_SPEED, 0L)
|
||||
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.notify(NOTIFICATION_ID, buildNotification(filename, progress, speed))
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
ACTION_STOP -> stopEverything()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Загрузка файлов",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply { setShowBadge(false) }
|
||||
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager)
|
||||
.createNotificationChannel(channel)
|
||||
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(
|
||||
filename: String,
|
||||
title: String,
|
||||
body: String,
|
||||
progress: Int,
|
||||
speedBps: Long,
|
||||
indeterminate: Boolean = false
|
||||
indeterminate: Boolean,
|
||||
): Notification {
|
||||
val body = when {
|
||||
indeterminate -> "Подготовка..."
|
||||
speedBps > 0 -> "$progress% · ${formatSpeed(speedBps)}"
|
||||
else -> "$progress%"
|
||||
}
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
val open = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java)
|
||||
.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(filename)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setContentIntent(open)
|
||||
.setProgress(100, progress, indeterminate)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSilent(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun formatSpeed(bps: Long): String = when {
|
||||
bps < 1_024L -> "$bps Б/с"
|
||||
bps < 1_048_576L -> "${bps / 1024} КБ/с"
|
||||
else -> "${"%.1f".format(bps / 1_048_576.0)} МБ/с"
|
||||
.setOnlyAlertOnce(true)
|
||||
.setDefaults(0)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
builder.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
package ru.komet.app
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.media3.common.Effect
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.effect.BitmapOverlay
|
||||
import androidx.media3.effect.Crop
|
||||
import androidx.media3.effect.OverlayEffect
|
||||
import androidx.media3.effect.Presentation
|
||||
import androidx.media3.effect.RgbMatrix
|
||||
import androidx.media3.effect.ScaleAndRotateTransformation
|
||||
import androidx.media3.effect.TextureOverlay
|
||||
import androidx.media3.transformer.Composition
|
||||
import androidx.media3.transformer.DefaultEncoderFactory
|
||||
import androidx.media3.transformer.EditedMediaItem
|
||||
import androidx.media3.transformer.Effects
|
||||
import androidx.media3.transformer.ExportException
|
||||
import androidx.media3.transformer.ExportResult
|
||||
import androidx.media3.transformer.ProgressHolder
|
||||
import androidx.media3.transformer.Transformer
|
||||
import androidx.media3.transformer.VideoEncoderSettings
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
|
||||
@UnstableApi
|
||||
object VideoEditor {
|
||||
private const val LOG_TAG = "VideoEditor"
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val progressHolder = ProgressHolder()
|
||||
|
||||
private var transformer: Transformer? = null
|
||||
private var overlayBitmap: Bitmap? = null
|
||||
private var pending: MethodChannel.Result? = null
|
||||
|
||||
private class ColorMatrixEffect(private val values: FloatArray) : RgbMatrix {
|
||||
override fun getMatrix(presentationTimeUs: Long, useHdr: Boolean) = values
|
||||
}
|
||||
|
||||
fun probe(input: String, result: MethodChannel.Result) {
|
||||
Thread {
|
||||
val data = try {
|
||||
readInfo(input)
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "probe failed: ${e.message}")
|
||||
null
|
||||
}
|
||||
main.post { result.success(data) }
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun readInfo(input: String): Map<String, Any?>? {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
try {
|
||||
retriever.setDataSource(input)
|
||||
val rotation = retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
|
||||
?.toIntOrNull() ?: 0
|
||||
val rawWidth = retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
|
||||
?.toIntOrNull() ?: 0
|
||||
val rawHeight = retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
|
||||
?.toIntOrNull() ?: 0
|
||||
val duration = retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
|
||||
?.toLongOrNull() ?: 0L
|
||||
if (rawWidth <= 0 || rawHeight <= 0) return null
|
||||
val swap = rotation % 180 != 0
|
||||
val track = readTrackInfo(input)
|
||||
return mapOf(
|
||||
"width" to if (swap) rawHeight else rawWidth,
|
||||
"height" to if (swap) rawWidth else rawHeight,
|
||||
"durationMs" to duration,
|
||||
"fps" to track.first,
|
||||
"hasAudio" to track.second,
|
||||
)
|
||||
} finally {
|
||||
retriever.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun readTrackInfo(input: String): Pair<Double, Boolean> {
|
||||
val extractor = MediaExtractor()
|
||||
var fps = 30.0
|
||||
var hasAudio = false
|
||||
try {
|
||||
extractor.setDataSource(input)
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
|
||||
if (mime.startsWith("audio/")) hasAudio = true
|
||||
if (mime.startsWith("video/") &&
|
||||
format.containsKey(MediaFormat.KEY_FRAME_RATE)
|
||||
) {
|
||||
fps = try {
|
||||
format.getInteger(MediaFormat.KEY_FRAME_RATE).toDouble()
|
||||
} catch (_: ClassCastException) {
|
||||
format.getFloat(MediaFormat.KEY_FRAME_RATE).toDouble()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "track info failed: ${e.message}")
|
||||
} finally {
|
||||
extractor.release()
|
||||
}
|
||||
return Pair(if (fps > 0) fps else 30.0, hasAudio)
|
||||
}
|
||||
|
||||
fun frames(
|
||||
input: String,
|
||||
times: List<Int>,
|
||||
size: Int,
|
||||
precise: Boolean,
|
||||
result: MethodChannel.Result,
|
||||
) {
|
||||
Thread {
|
||||
val out = ArrayList<ByteArray?>(times.size)
|
||||
val retriever = MediaMetadataRetriever()
|
||||
try {
|
||||
retriever.setDataSource(input)
|
||||
for (ms in times) {
|
||||
out.add(grabFrame(retriever, ms.toLong(), size, precise))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "frames failed: ${e.message}")
|
||||
while (out.size < times.size) out.add(null)
|
||||
} finally {
|
||||
retriever.release()
|
||||
}
|
||||
main.post { result.success(out) }
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun grabFrame(
|
||||
retriever: MediaMetadataRetriever,
|
||||
timeMs: Long,
|
||||
size: Int,
|
||||
precise: Boolean,
|
||||
): ByteArray? {
|
||||
val us = timeMs * 1000L
|
||||
val option = if (precise) {
|
||||
MediaMetadataRetriever.OPTION_CLOSEST
|
||||
} else {
|
||||
MediaMetadataRetriever.OPTION_CLOSEST_SYNC
|
||||
}
|
||||
val bitmap = (
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
retriever.getScaledFrameAtTime(us, option, size, size)
|
||||
} else {
|
||||
retriever.getFrameAtTime(us, option)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "frame at $timeMs failed: ${e.message}")
|
||||
null
|
||||
}
|
||||
) ?: return null
|
||||
return try {
|
||||
val stream = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 82, stream)
|
||||
stream.toByteArray()
|
||||
} finally {
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
fun edit(context: Context, call: MethodCall, result: MethodChannel.Result) {
|
||||
val input = call.argument<String>("input")
|
||||
val output = call.argument<String>("output")
|
||||
if (input == null || output == null) {
|
||||
result.error("BAD_ARGS", "input/output required", null)
|
||||
return
|
||||
}
|
||||
release()
|
||||
pending = result
|
||||
try {
|
||||
val effects = buildEffects(call)
|
||||
val item = MediaItem.Builder()
|
||||
.setUri(Uri.fromFile(File(input)))
|
||||
.setClippingConfiguration(buildClipping(call))
|
||||
.build()
|
||||
val edited = EditedMediaItem.Builder(item)
|
||||
.setRemoveAudio(call.argument<Boolean>("removeAudio") == true)
|
||||
.setEffects(Effects(emptyList(), effects))
|
||||
.build()
|
||||
|
||||
val builder = Transformer.Builder(context)
|
||||
.addListener(object : Transformer.Listener {
|
||||
override fun onCompleted(
|
||||
composition: Composition,
|
||||
exportResult: ExportResult,
|
||||
) = finish(true)
|
||||
|
||||
override fun onError(
|
||||
composition: Composition,
|
||||
exportResult: ExportResult,
|
||||
exportException: ExportException,
|
||||
) {
|
||||
Log.w(LOG_TAG, "export failed: ${exportException.message}")
|
||||
finish(false)
|
||||
}
|
||||
})
|
||||
val bitrate = call.argument<Int>("bitrate")
|
||||
if (bitrate != null && bitrate > 0) {
|
||||
builder.setEncoderFactory(
|
||||
DefaultEncoderFactory.Builder(context)
|
||||
.setRequestedVideoEncoderSettings(
|
||||
VideoEncoderSettings.Builder()
|
||||
.setBitrate(bitrate)
|
||||
.setBitrateMode(
|
||||
MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR,
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
val transformer = builder.build()
|
||||
this.transformer = transformer
|
||||
transformer.start(edited, output)
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "export start failed: ${e.message}")
|
||||
finish(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun finish(ok: Boolean) {
|
||||
val result = pending
|
||||
pending = null
|
||||
release()
|
||||
result?.success(ok)
|
||||
}
|
||||
|
||||
private fun buildClipping(call: MethodCall): MediaItem.ClippingConfiguration {
|
||||
val builder = MediaItem.ClippingConfiguration.Builder()
|
||||
val start = call.argument<Number>("startMs")?.toLong()
|
||||
val end = call.argument<Number>("endMs")?.toLong()
|
||||
if (start != null && start > 0) builder.setStartPositionMs(start)
|
||||
if (end != null && end > 0) builder.setEndPositionMs(end)
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun buildEffects(call: MethodCall): List<Effect> {
|
||||
val effects = mutableListOf<Effect>()
|
||||
val rotation = call.argument<Number>("rotationDegrees")?.toFloat() ?: 0f
|
||||
val flipH = call.argument<Boolean>("flipH") == true
|
||||
if (flipH || kotlin.math.abs(rotation) > 0.01f) {
|
||||
effects.add(
|
||||
ScaleAndRotateTransformation.Builder()
|
||||
.setScale(if (flipH) -1f else 1f, 1f)
|
||||
.setRotationDegrees(rotation)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
val crop = call.argument<List<Double>>("crop")
|
||||
if (crop != null && crop.size == 4) {
|
||||
effects.add(
|
||||
Crop(
|
||||
crop[0].toFloat(),
|
||||
crop[1].toFloat(),
|
||||
crop[2].toFloat(),
|
||||
crop[3].toFloat(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val width = call.argument<Int>("outWidth") ?: 0
|
||||
val height = call.argument<Int>("outHeight") ?: 0
|
||||
if (width > 0 && height > 0) {
|
||||
effects.add(
|
||||
Presentation.createForWidthAndHeight(
|
||||
width,
|
||||
height,
|
||||
Presentation.LAYOUT_SCALE_TO_FIT_WITH_CROP,
|
||||
),
|
||||
)
|
||||
}
|
||||
val matrix = call.argument<List<Double>>("rgbMatrix")
|
||||
if (matrix != null && matrix.size == 16) {
|
||||
effects.add(
|
||||
ColorMatrixEffect(FloatArray(16) { matrix[it].toFloat() }),
|
||||
)
|
||||
}
|
||||
val overlay = call.argument<String>("overlay")
|
||||
if (overlay != null) {
|
||||
val bitmap = BitmapFactory.decodeFile(overlay)
|
||||
if (bitmap != null) {
|
||||
overlayBitmap = bitmap
|
||||
effects.add(
|
||||
OverlayEffect(
|
||||
listOf<TextureOverlay>(
|
||||
BitmapOverlay.createStaticBitmapOverlay(bitmap),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
return effects
|
||||
}
|
||||
|
||||
fun progress(result: MethodChannel.Result) {
|
||||
val active = transformer
|
||||
if (active == null) {
|
||||
result.success(-1)
|
||||
return
|
||||
}
|
||||
val state = try {
|
||||
active.getProgress(progressHolder)
|
||||
} catch (_: IllegalStateException) {
|
||||
result.success(-1)
|
||||
return
|
||||
}
|
||||
result.success(
|
||||
if (state == Transformer.PROGRESS_STATE_AVAILABLE) {
|
||||
progressHolder.progress
|
||||
} else {
|
||||
-1
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun cancel(result: MethodChannel.Result) {
|
||||
try {
|
||||
transformer?.cancel()
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "cancel failed: ${e.message}")
|
||||
}
|
||||
finish(false)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun release() {
|
||||
transformer = null
|
||||
overlayBitmap?.recycle()
|
||||
overlayBitmap = null
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ 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
|
||||
@@ -35,22 +36,35 @@ import java.nio.FloatBuffer
|
||||
|
||||
// Нативная запись видео-кружка через GL-конвейер: камера выдаёт стандартный
|
||||
// кадр в SurfaceTexture (OES), шейдер кропает по центру в квадрат и рендерит
|
||||
// одновременно в превью (Flutter Texture) и в MediaRecorder (480×480, H.264,
|
||||
// одновременно в превью (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 = 480
|
||||
private val bitrate = 1_024_000
|
||||
private val fps = 30
|
||||
private val edge = requestedEdge.coerceIn(240, 1080)
|
||||
private val fps = requestedFps.coerceIn(24, 60)
|
||||
// 1 Мбит/с — базовый битрейт официального клиента для 480×480@30;
|
||||
// масштабируем по площади кадра и частоте.
|
||||
private val bitrate =
|
||||
(1_024_000L * edge * edge / (480L * 480L) * fps / 30L).toInt()
|
||||
|
||||
private var cameraId = ""
|
||||
private var lensFacing = CameraCharacteristics.LENS_FACING_FRONT
|
||||
private var sensorOrientation = 270
|
||||
private var camSize = Size(1280, 720)
|
||||
private var fpsRange: Range<Int>? = null
|
||||
private var hasOis = false
|
||||
private var hasEis = false
|
||||
private var hasFlash = false
|
||||
private var torchOn = false
|
||||
private var previewRequest: CaptureRequest.Builder? = null
|
||||
|
||||
private var cameraDevice: CameraDevice? = null
|
||||
private var session: CameraCaptureSession? = null
|
||||
@@ -95,23 +109,44 @@ class VideoNoteRecorder(
|
||||
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
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Поддерживаемый камерой размер вывода (для SurfaceTexture), близкий к 720p.
|
||||
// Поддерживаемый камерой размер вывода (для 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 Size(1280, 720)
|
||||
var best = sizes.firstOrNull() ?: Size(1280, 720)
|
||||
?: 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 - 1280) + kotlin.math.abs(shortSide - 720)
|
||||
val score = kotlin.math.abs(longSide - targetLong) +
|
||||
kotlin.math.abs(shortSide - targetShort)
|
||||
if (score < bestScore) {
|
||||
bestScore = score
|
||||
best = s
|
||||
@@ -120,12 +155,28 @@ class VideoNoteRecorder(
|
||||
return best
|
||||
}
|
||||
|
||||
// Диапазон AE под запрошенный fps: предпочитаем фиксированный [fps, fps],
|
||||
// иначе самый узкий диапазон, включающий fps; если 60 недоступно —
|
||||
// максимально быстрый из имеющихся.
|
||||
private fun pickFpsRange(ranges: Array<Range<Int>>?): Range<Int>? {
|
||||
if (ranges == null || ranges.isEmpty()) return null
|
||||
val covering = ranges.filter { it.lower <= fps && fps <= it.upper }
|
||||
if (covering.isNotEmpty()) {
|
||||
return covering.minByOrNull { (it.upper - it.lower) * 1000 + (fps - it.lower) }
|
||||
}
|
||||
return ranges.maxByOrNull { it.upper * 1000 - (it.upper - it.lower) }
|
||||
}
|
||||
|
||||
fun init(facingFront: Boolean, rawResult: MethodChannel.Result) {
|
||||
val result = OnceResult(rawResult)
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
result.error("NO_PERMISSION", "camera permission required", null)
|
||||
result.error("NO_CAMERA_PERMISSION", "camera permission required", null)
|
||||
return
|
||||
}
|
||||
if (!hasMicPermission()) {
|
||||
result.error("NO_MIC_PERMISSION", "microphone permission required", null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -213,7 +264,7 @@ class VideoNoteRecorder(
|
||||
recordWindow?.let { w ->
|
||||
w.makeCurrent()
|
||||
GLES20.glViewport(0, 0, edge, edge)
|
||||
prog.draw(oesTexId, stMatrix, camSize, lensFacing, false)
|
||||
prog.draw(oesTexId, stMatrix, camSize, lensFacing, true)
|
||||
w.setPresentationTime(System.nanoTime())
|
||||
w.swap()
|
||||
}
|
||||
@@ -252,9 +303,37 @@ class VideoNoteRecorder(
|
||||
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")
|
||||
result.success(mapOf("textureId" to textureId, "size" to edge))
|
||||
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)
|
||||
@@ -283,7 +362,7 @@ class VideoNoteRecorder(
|
||||
try {
|
||||
rec.setVideoEncodingProfileLevel(
|
||||
android.media.MediaCodecInfo.CodecProfileLevel.AVCProfileHigh,
|
||||
android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel3,
|
||||
avcLevel(),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(tag, "profile level: ${e.message}")
|
||||
@@ -295,10 +374,101 @@ class VideoNoteRecorder(
|
||||
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
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<resources>
|
||||
<string name="upload_channel_name">Отправка медиа</string>
|
||||
<string name="fkm_channel_name">Сервис уведомлений</string>
|
||||
<string name="fkm_channel_description">Держит фоновое соединение с сервером</string>
|
||||
<string name="fkm_title">Komet · сервис уведомлений</string>
|
||||
<string name="fkm_status_active">Соединение активно</string>
|
||||
<string name="fkm_status_inactive">Соединение не активно</string>
|
||||
<string name="fkm_status_line">%1$s · принято %2$d</string>
|
||||
<string name="fkm_explain">Это уведомление держит фоновое соединение с сервером, чтобы сообщения приходили без гугловых пушей. Убрать его можно, выключив FKM — кнопкой ниже или в Настройки → Уведомления → FKM.</string>
|
||||
<string name="fkm_disable">Выключить</string>
|
||||
<string name="notif_deleted_prefix">Удалено:</string>
|
||||
<string name="share_target_label">Отправить в чат</string>
|
||||
</resources>
|
||||
@@ -1,4 +1,15 @@
|
||||
<resources>
|
||||
<string name="nfc_service_description">Komet contact exchange</string>
|
||||
<string name="nfc_aid_group_description">Komet contact exchange</string>
|
||||
<string name="upload_channel_name">Sending media</string>
|
||||
<string name="fkm_channel_name">Notification service</string>
|
||||
<string name="fkm_channel_description">Keeps the background connection to the server alive</string>
|
||||
<string name="fkm_title">Komet · notification service</string>
|
||||
<string name="fkm_status_active">Connection active</string>
|
||||
<string name="fkm_status_inactive">Connection inactive</string>
|
||||
<string name="fkm_status_line">%1$s · %2$d delivered</string>
|
||||
<string name="fkm_explain">This notification is what keeps a background connection to the server, so messages arrive without Google push. To get rid of it, turn FKM off — with the button below, or in Settings → Notifications → FKM.</string>
|
||||
<string name="fkm_disable">Turn off</string>
|
||||
<string name="notif_deleted_prefix">Deleted:</string>
|
||||
<string name="share_target_label">Send to a chat</string>
|
||||
</resources>
|
||||
|
||||
Binary file not shown.
@@ -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":[]}
|
||||
@@ -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":[]}
|
||||
@@ -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":[]}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+90
@@ -69,8 +69,98 @@ target 'Runner' do
|
||||
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 = 'KOMET_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
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
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 /* KometVideo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE550001 /* KometVideo.swift */; };
|
||||
AA11BB22CC33DD44EE550102 /* KometVideoNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE550002 /* KometVideoNote.swift */; };
|
||||
AA11BB22CC33DD44EE550103 /* KometNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE550003 /* KometNotifications.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 */; };
|
||||
@@ -56,6 +59,10 @@
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
AA11BB22CC33DD44EE550001 /* KometVideo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KometVideo.swift; sourceTree = "<group>"; };
|
||||
AA11BB22CC33DD44EE550002 /* KometVideoNote.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KometVideoNote.swift; sourceTree = "<group>"; };
|
||||
AA11BB22CC33DD44EE550003 /* KometNotifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KometNotifications.swift; sourceTree = "<group>"; };
|
||||
AA11BB22CC33DD44EE550004 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
@@ -157,6 +164,10 @@
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
AA11BB22CC33DD44EE550001 /* KometVideo.swift */,
|
||||
AA11BB22CC33DD44EE550002 /* KometVideoNote.swift */,
|
||||
AA11BB22CC33DD44EE550003 /* KometNotifications.swift */,
|
||||
AA11BB22CC33DD44EE550004 /* Runner.entitlements */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
@@ -396,6 +407,9 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
AA11BB22CC33DD44EE550101 /* KometVideo.swift in Sources */,
|
||||
AA11BB22CC33DD44EE550102 /* KometVideoNote.swift in Sources */,
|
||||
AA11BB22CC33DD44EE550103 /* KometNotifications.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -495,6 +509,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
@@ -677,6 +692,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
@@ -699,6 +715,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
|
||||
+161
-34
@@ -1,50 +1,177 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
final class KometStreamHandler: 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: [KometStreamHandler] = []
|
||||
private var videoNote: KometVideoNote?
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
KometNotifications.shared.start()
|
||||
|
||||
let controller = window?.rootViewController as? FlutterViewController
|
||||
if let messenger = controller?.binaryMessenger {
|
||||
let channel = FlutterMethodChannel(
|
||||
name: "ru.komet.app/app_icon",
|
||||
binaryMessenger: messenger
|
||||
)
|
||||
channel.setMethodCallHandler { (call, result) in
|
||||
guard call.method == "setAppIcon" else {
|
||||
result(FlutterMethodNotImplemented)
|
||||
return
|
||||
}
|
||||
let args = call.arguments as? [String: Any]
|
||||
let name = args?["name"] as? String
|
||||
let iconName: String? = (name == "DefaultIcon") ? nil : name
|
||||
if !UIApplication.shared.supportsAlternateIcons {
|
||||
result(FlutterError(
|
||||
code: "UNSUPPORTED",
|
||||
message: "Alternate icons are not supported",
|
||||
details: nil
|
||||
))
|
||||
return
|
||||
}
|
||||
UIApplication.shared.setAlternateIconName(iconName) { error in
|
||||
if let error = error {
|
||||
result(FlutterError(
|
||||
code: "APPLY_FAILED",
|
||||
message: error.localizedDescription,
|
||||
details: nil
|
||||
))
|
||||
} else {
|
||||
result(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let controller = window?.rootViewController as? FlutterViewController {
|
||||
let messenger = controller.binaryMessenger
|
||||
registerAppIcon(messenger)
|
||||
registerVideo(messenger)
|
||||
registerVideoNote(messenger)
|
||||
registerNotifications(messenger)
|
||||
registerScreen(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 = KometStreamHandler(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.komet.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.komet.app/video", messenger) { call, result in
|
||||
KometVideo.shared.handle(call, result: result)
|
||||
}
|
||||
}
|
||||
|
||||
private func registerVideoNote(_ messenger: FlutterBinaryMessenger) {
|
||||
guard let textures = registrar(forPlugin: "KometVideoNote")?.textures() else { return }
|
||||
|
||||
method("ru.komet.app/video_note", messenger) { [weak self] call, result in
|
||||
guard let self = self else { return }
|
||||
switch call.method {
|
||||
case "permission":
|
||||
KometVideoNote.requestPermission(result)
|
||||
case "init":
|
||||
let args = call.arguments as? [String: Any] ?? [:]
|
||||
self.videoNote?.dispose()
|
||||
let recorder = KometVideoNote(registry: textures)
|
||||
self.videoNote = recorder
|
||||
recorder.initialize(
|
||||
front: (args["front"] as? NSNumber)?.boolValue ?? true,
|
||||
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: (KometVideoNote) -> 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.komet.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 registerNotifications(_ messenger: FlutterBinaryMessenger) {
|
||||
method("ru.komet.app/notifications", messenger) { call, result in
|
||||
KometNotifications.shared.handle(call, result: result)
|
||||
}
|
||||
events("ru.komet.app/notification_events", messenger) { sink in
|
||||
KometNotifications.shared.attach(sink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@
|
||||
<string>Доступ к галерее нужен, чтобы отправлять фото и видео в чатах.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>Доступ к галерее нужен, чтобы сохранять полученные фото и видео.</string>
|
||||
<key>NSContactsUsageDescription</key>
|
||||
<string>Доступ к контактам нужен, чтобы показывать имена собеседников так, как они записаны в вашей телефонной книге.</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -82,5 +84,31 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>ru</string>
|
||||
<string>en</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>tel</string>
|
||||
<string>telprompt</string>
|
||||
<string>sms</string>
|
||||
<string>mailto</string>
|
||||
<string>maps</string>
|
||||
<string>comgooglemaps</string>
|
||||
<string>yandexmaps</string>
|
||||
<string>yandexnavi</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
final class KometNotifications: NSObject {
|
||||
static let shared = KometNotifications()
|
||||
|
||||
private static let chatKeys = ["komet_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 KometNotifications: 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()
|
||||
}
|
||||
}
|
||||
@@ -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 KometVideo {
|
||||
static let shared = KometVideo()
|
||||
|
||||
private let queue = DispatchQueue(label: "ru.komet.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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import AVFoundation
|
||||
import CoreImage
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
final class KometVideoNoteTexture: NSObject, FlutterTexture {
|
||||
private let lock = NSLock()
|
||||
private var latest: CVPixelBuffer?
|
||||
|
||||
func push(_ buffer: CVPixelBuffer) {
|
||||
lock.lock()
|
||||
latest = buffer
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func copyPixelBuffer() -> Unmanaged<CVPixelBuffer>? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard let buffer = latest else { return nil }
|
||||
return Unmanaged.passRetained(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
final class KometVideoNote: NSObject {
|
||||
private let registry: FlutterTextureRegistry
|
||||
private let queue = DispatchQueue(label: "ru.komet.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 = KometVideoNoteTexture()
|
||||
|
||||
private var textureId: Int64 = 0
|
||||
private var deviceInput: AVCaptureDeviceInput?
|
||||
private var audioInput: AVCaptureDeviceInput?
|
||||
private var position: AVCaptureDevice.Position = .front
|
||||
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, edge: Int, fps: Int, result: @escaping FlutterResult) {
|
||||
self.position = front ? .front : .back
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
guard let device = AVCaptureDevice.default(
|
||||
.builtInWideAngleCamera, for: .video, position: position)
|
||||
?? AVCaptureDevice.default(for: .video) else {
|
||||
throw NSError(domain: "KometVideoNote", code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "no camera found"])
|
||||
}
|
||||
let input = try AVCaptureDeviceInput(device: device)
|
||||
guard session.canAddInput(input) else {
|
||||
throw NSError(domain: "KometVideoNote", 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(
|
||||
"komet_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: "KometVideoNote", 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: "KometVideoNote", 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 KometVideoNote: 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)ru.komet.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
+466
-303
@@ -1,53 +1,66 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||
import 'package:kolibri/kolibri.dart';
|
||||
import 'package:timezone/data/latest_all.dart' as tz;
|
||||
|
||||
import '../core/cache/self_presence.dart';
|
||||
import '../core/config/config.dart';
|
||||
import '../core/config/countries.dart';
|
||||
import '../core/config/komet_settings.dart';
|
||||
import '../core/config/proxy_config.dart';
|
||||
import '../core/protocol/opcode_map.dart';
|
||||
import '../core/protocol/packet.dart';
|
||||
import '../core/storage/device_identity.dart';
|
||||
import '../core/storage/spoofing_service.dart';
|
||||
import '../core/transport/connection.dart';
|
||||
import '../core/transport/dispatcher.dart';
|
||||
import '../core/transport/receiver.dart';
|
||||
import '../core/transport/sender.dart';
|
||||
import '../core/transport/tls_config.dart';
|
||||
import '../core/transport/traffic_monitor.dart';
|
||||
import '../core/transport/vpn_bypass.dart';
|
||||
import '../core/utils/debug_session_log.dart';
|
||||
import '../core/utils/logger.dart';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||
import 'package:timezone/data/latest_all.dart' as tz;
|
||||
import 'dart:io';
|
||||
|
||||
enum SessionState { disconnected, connecting, connected, online }
|
||||
|
||||
/// Клиент API.
|
||||
///
|
||||
/// Подключение, хэндшейк, пинг, реконнект.
|
||||
/// Тонкий адаптер над Rust-ядром [KolibriSession] (пакет kolibri): подключение,
|
||||
/// хэндшейк, пинг и реконнект живут в ядре, здесь — оркестрация жизненного цикла
|
||||
/// и сохранение прежнего интерфейса для модулей (Packet/пуши/стримы).
|
||||
class Api {
|
||||
final Connection _connection = Connection();
|
||||
final PacketReceiver _receiver = PacketReceiver();
|
||||
final PacketSender _sender = PacketSender();
|
||||
KolibriSession? _session;
|
||||
|
||||
/// Роутер пушей (ответы на запросы ядро матчит само, диспетчер держим только
|
||||
/// ради registerHandler/pushStream).
|
||||
final PacketDispatcher _dispatcher = PacketDispatcher();
|
||||
StreamSubscription<(int, Map<String, dynamic>)>? _pushSub;
|
||||
StreamSubscription<WireLogEvent>? _wireLogSub;
|
||||
|
||||
SessionState _sessionState = SessionState.disconnected;
|
||||
final _stateController = StreamController<SessionState>.broadcast();
|
||||
final _sessionExpiredController =
|
||||
StreamController<SessionExpiredException>.broadcast();
|
||||
final _handshakeSuccessController = StreamController<String>.broadcast();
|
||||
Map<dynamic, dynamic>? _userAgent;
|
||||
final _errorController = StreamController<String>.broadcast();
|
||||
|
||||
Map<dynamic, dynamic>? _userAgent;
|
||||
Map<dynamic, dynamic>? get userAgent => _userAgent;
|
||||
|
||||
int? _callsSeed;
|
||||
String? _deviceId;
|
||||
String? _callsDevice;
|
||||
String? _callsOsVersion;
|
||||
|
||||
int? get callsSeed => _callsSeed;
|
||||
String? get deviceId => _deviceId;
|
||||
String? get callsDevice => _callsDevice;
|
||||
String? get callsOsVersion => _callsOsVersion;
|
||||
|
||||
/// Сырой доступ к сессии ядра — для медиа-загрузок (data-plane).
|
||||
KolibriSession? get session => _session;
|
||||
|
||||
String? spoofScope;
|
||||
|
||||
@@ -63,28 +76,25 @@ class Api {
|
||||
_sessionExpiredController.stream;
|
||||
Stream<String> get handshakeSuccessStream =>
|
||||
_handshakeSuccessController.stream;
|
||||
Stream<String> get errorStream => _dispatcher.errorStream;
|
||||
Stream<String> get errorStream => _errorController.stream;
|
||||
SessionState get state => _sessionState;
|
||||
|
||||
StreamSubscription<Uint8List>? _dataSubscription;
|
||||
StreamSubscription<SocketState>? _socketStateSubscription;
|
||||
Timer? _pingTimer;
|
||||
Timer? _livenessTimer;
|
||||
Timer? _reconnectTimer;
|
||||
Timer? _connectWatchdog;
|
||||
int _connectGen = 0;
|
||||
int _reconnectAttempts = 0;
|
||||
bool _autoReconnect = false;
|
||||
int _sessionEpoch = 0;
|
||||
bool? _lastInteractive;
|
||||
|
||||
static const Duration _connectWatchdogTimeout = Duration(seconds: 75);
|
||||
static const Duration _shouldArmTimeout = Duration(seconds: 5);
|
||||
static const Duration _endpointTimeout = Duration(seconds: 5);
|
||||
static const Duration _livenessInterval = Duration(seconds: 5);
|
||||
|
||||
int get sessionEpoch => _sessionEpoch;
|
||||
|
||||
/// Залипает на время сессии: VPN-путь не сработал — идём мимо туннеля.
|
||||
bool _bypassActive = false;
|
||||
|
||||
// Публичное API
|
||||
|
||||
/// Подключается к серверу, шлёт хэндшейк, запускает пинг.
|
||||
@@ -100,32 +110,18 @@ class Api {
|
||||
_armConnectWatchdog(gen);
|
||||
|
||||
try {
|
||||
_dataSubscription = _connection.dataStream.listen(_onDataReceived);
|
||||
_socketStateSubscription = _connection.stateStream.listen((socketState) {
|
||||
if (socketState == SocketState.disconnected &&
|
||||
_sessionState != SessionState.disconnected) {
|
||||
_onDisconnected();
|
||||
}
|
||||
});
|
||||
|
||||
bool bypassArmed;
|
||||
bool useBypass;
|
||||
try {
|
||||
bypassArmed = await VpnBypassService.instance.shouldArm().timeout(
|
||||
useBypass = await VpnBypassService.instance.shouldArm().timeout(
|
||||
_shouldArmTimeout,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.w('connect: shouldArm завис/упал ($e) — без обхода VPN');
|
||||
bypassArmed = false;
|
||||
useBypass = false;
|
||||
}
|
||||
if (gen != _connectGen) return;
|
||||
|
||||
if (!bypassArmed) _bypassActive = false;
|
||||
final useBypass = _bypassActive && bypassArmed;
|
||||
final attemptTimeout = bypassArmed && !useBypass
|
||||
? const Duration(seconds: 8)
|
||||
: null;
|
||||
|
||||
({String host, int port}) endpoint;
|
||||
({String host, int port, bool trustMincifryCa}) endpoint;
|
||||
try {
|
||||
endpoint = await ServerConfig.loadEndpoint().timeout(_endpointTimeout);
|
||||
} catch (e) {
|
||||
@@ -133,84 +129,75 @@ class Api {
|
||||
endpoint = (
|
||||
host: ServerConfig.defaultHost,
|
||||
port: ServerConfig.defaultPort,
|
||||
trustMincifryCa: ServerConfig.defaultTrustMincifryCa,
|
||||
);
|
||||
}
|
||||
if (gen != _connectGen) return;
|
||||
|
||||
setTrustMincifryCa(enabled: endpoint.trustMincifryCa);
|
||||
|
||||
final (session, wireLog) = await _buildSessionOptions(endpoint);
|
||||
if (gen != _connectGen) return;
|
||||
|
||||
logger.i(
|
||||
'connect: endpoint ${endpoint.host}:${endpoint.port}, bypass=$useBypass',
|
||||
);
|
||||
try {
|
||||
await _connection.connect(
|
||||
endpoint.host,
|
||||
endpoint.port,
|
||||
bypassVpn: useBypass,
|
||||
timeout: attemptTimeout,
|
||||
);
|
||||
} catch (e) {
|
||||
if (gen != _connectGen) return;
|
||||
await _handleConnectFailure(
|
||||
e,
|
||||
phase: 'Не удалось подключиться',
|
||||
bypassArmed: bypassArmed,
|
||||
useBypass: useBypass,
|
||||
bypassWhy: 'подключение не удалось',
|
||||
);
|
||||
return;
|
||||
|
||||
if (useBypass) {
|
||||
try {
|
||||
await VpnBypassService.instance.bind();
|
||||
} catch (e) {
|
||||
logger.w('connect: VPN bind не удался ($e)');
|
||||
}
|
||||
}
|
||||
if (gen != _connectGen) return;
|
||||
|
||||
_session = session;
|
||||
// Подписываемся на wire-лог ядра ДО connect(), чтобы поймать пакеты
|
||||
// SESSION_INIT-хендшейка (иначе они уходят до listen и теряются).
|
||||
_wireLogSub = wireLog.listen(_onWireLog);
|
||||
_pushSub = session.pushesMap().listen(_onPush);
|
||||
TrafficMonitor.instance.recordEvent(
|
||||
'connect',
|
||||
endpoint: '${endpoint.host}:${endpoint.port}',
|
||||
);
|
||||
|
||||
_setSessionState(SessionState.connected);
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
HandshakeInfo info;
|
||||
try {
|
||||
logger.i('connect: сокет готов, отправляю хэндшейк');
|
||||
final response = await sendHandshake();
|
||||
if (gen != _connectGen) return;
|
||||
if (response.isOk) {
|
||||
_callsSeed = response.payload['callsSeed'] as int?;
|
||||
_registrationCountries = _parseRegistrationCountries(
|
||||
response.payload,
|
||||
);
|
||||
_sessionState = SessionState.online;
|
||||
_sessionEpoch++;
|
||||
_cancelConnectWatchdog();
|
||||
_startPinging();
|
||||
logger.i('Сессия онлайн, хэндшейк ок');
|
||||
if (_onReconnectCallback != null) {
|
||||
try {
|
||||
await _onReconnectCallback!();
|
||||
} catch (e) {
|
||||
logger.w('Авто-логин при хэндшейке не удался: $e');
|
||||
}
|
||||
}
|
||||
if (_sessionState == SessionState.online) {
|
||||
_stateController.add(SessionState.online);
|
||||
_handshakeSuccessController.add(
|
||||
response.payload['device_name'] as String? ?? 'Unknown',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.e('Хэндшейк отклонён: ${response.payload}');
|
||||
await _handleConnectFailure(
|
||||
StateError('хэндшейк отклонён сервером'),
|
||||
phase: 'Хэндшейк отклонён',
|
||||
bypassArmed: bypassArmed,
|
||||
useBypass: useBypass,
|
||||
bypassWhy: 'хэндшейк отклонён',
|
||||
disconnectSocket: true,
|
||||
);
|
||||
}
|
||||
info = await session.connect();
|
||||
} catch (e) {
|
||||
if (gen != _connectGen) return;
|
||||
await _handleConnectFailure(
|
||||
e,
|
||||
phase: 'Ошибка хэндшейка',
|
||||
bypassArmed: bypassArmed,
|
||||
useBypass: useBypass,
|
||||
bypassWhy: 'хэндшейк не прошёл',
|
||||
disconnectSocket: true,
|
||||
);
|
||||
await _handleConnectFailure(e, phase: 'Ошибка хэндшейка');
|
||||
return;
|
||||
} finally {
|
||||
if (useBypass) {
|
||||
try {
|
||||
await VpnBypassService.instance.restoreDefault();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
if (gen != _connectGen) return;
|
||||
|
||||
_callsSeed = info.callsSeed?.toInt();
|
||||
_registrationCountries = _parseRegistrationCountries(info.payloadMap);
|
||||
_sessionState = SessionState.online;
|
||||
_sessionEpoch++;
|
||||
_cancelConnectWatchdog();
|
||||
_startLiveness();
|
||||
logger.i('Сессия онлайн, хэндшейк ок');
|
||||
if (_onReconnectCallback != null) {
|
||||
try {
|
||||
await _onReconnectCallback!();
|
||||
} catch (e) {
|
||||
logger.w('Авто-логин при хэндшейке не удался: $e');
|
||||
}
|
||||
}
|
||||
if (_sessionState == SessionState.online) {
|
||||
_stateController.add(SessionState.online);
|
||||
_handshakeSuccessController.add(info.deviceName ?? 'Unknown');
|
||||
}
|
||||
} catch (e, st) {
|
||||
logger.e('connect: непредвиденная ошибка: $e\n$st');
|
||||
@@ -244,9 +231,6 @@ class Api {
|
||||
_connectGen++;
|
||||
_cancelConnectWatchdog();
|
||||
_cleanup();
|
||||
try {
|
||||
await _connection.disconnect();
|
||||
} catch (_) {}
|
||||
_setSessionState(SessionState.disconnected);
|
||||
if (_autoReconnect) _scheduleReconnect();
|
||||
}
|
||||
@@ -254,37 +238,22 @@ class Api {
|
||||
Future<void> _handleConnectFailure(
|
||||
Object error, {
|
||||
required String phase,
|
||||
required bool bypassArmed,
|
||||
required bool useBypass,
|
||||
required String bypassWhy,
|
||||
bool disconnectSocket = false,
|
||||
}) async {
|
||||
logger.e('$phase: $error');
|
||||
_cancelConnectWatchdog();
|
||||
if (_sessionState != SessionState.disconnected) {
|
||||
_cleanup();
|
||||
if (disconnectSocket) await _connection.disconnect();
|
||||
_setSessionState(SessionState.disconnected);
|
||||
_armBypassIfPossible(bypassArmed, useBypass, bypassWhy);
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void _armBypassIfPossible(bool armed, bool alreadyBypassing, String why) {
|
||||
if (armed && !alreadyBypassing && !_bypassActive) {
|
||||
_bypassActive = true;
|
||||
logger.w('VPN bypass: $why — следующая попытка мимо VPN');
|
||||
}
|
||||
}
|
||||
|
||||
/// Отключается без автореконнекта.
|
||||
Future<void> disconnect() async {
|
||||
_autoReconnect = false;
|
||||
_bypassActive = false;
|
||||
_connectGen++;
|
||||
_reconnectTimer?.cancel();
|
||||
_cleanup();
|
||||
await _connection.disconnect();
|
||||
_setSessionState(SessionState.disconnected);
|
||||
}
|
||||
|
||||
@@ -303,145 +272,49 @@ class Api {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Packet> sendHandshake() async {
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
|
||||
String deviceType = 'ANDROID';
|
||||
String osVersion = '';
|
||||
String deviceName = 'Unknown';
|
||||
String architecture = 'arm64';
|
||||
String appVersion = SpoofingService.hardcodedAppVersion;
|
||||
int buildNumber = SpoofingService.hardcodedBuildNumber;
|
||||
String screen = '420dpi 420dpi 1080x2340';
|
||||
|
||||
if (!_tzInitialized) {
|
||||
tz.initializeTimeZones();
|
||||
_tzInitialized = true;
|
||||
}
|
||||
final timeZoneName = await FlutterTimezone.getLocalTimezone();
|
||||
String timezone = timeZoneName.identifier;
|
||||
String locale = 'ru';
|
||||
String deviceLocale = Platform.localeName.substring(0, 2);
|
||||
String deviceId = await DeviceIdentity.deviceId();
|
||||
String pushDeviceType = 'GCM';
|
||||
String instanceId = await DeviceIdentity.instanceId();
|
||||
int clientSessionId = DeviceIdentity.clientSessionId;
|
||||
|
||||
if (Platform.isLinux) {
|
||||
final linuxInfo = await deviceInfo.linuxInfo;
|
||||
osVersion = linuxInfo.name;
|
||||
architecture = _archFromPlatformVersion();
|
||||
} else if (Platform.isIOS) {
|
||||
final iosInfo = await deviceInfo.iosInfo;
|
||||
osVersion = iosInfo.systemVersion;
|
||||
deviceName = iosInfo.utsname.machine;
|
||||
} else if (Platform.isAndroid) {
|
||||
final androidInfo = await deviceInfo.androidInfo;
|
||||
osVersion = 'Android ${androidInfo.version.release}';
|
||||
deviceName = '${androidInfo.manufacturer} ${androidInfo.model}';
|
||||
architecture = androidInfo.supportedAbis.first;
|
||||
} else if (Platform.isWindows) {
|
||||
final windowsInfo = await deviceInfo.windowsInfo;
|
||||
osVersion = windowsInfo.productName;
|
||||
architecture = _archFromPlatformVersion();
|
||||
}
|
||||
|
||||
final spoofed = await SpoofingService.getSpoofedSessionData(
|
||||
scope: spoofScope,
|
||||
);
|
||||
if (spoofed != null) {
|
||||
final sDeviceType = spoofed['device_type'] as String?;
|
||||
if (sDeviceType != null && sDeviceType != 'IOS') deviceType = sDeviceType;
|
||||
final sDeviceName = spoofed['device_name'] as String?;
|
||||
if (sDeviceName != null && sDeviceName.isNotEmpty) {
|
||||
deviceName = sDeviceName;
|
||||
}
|
||||
final sOsVersion = spoofed['os_version'] as String?;
|
||||
if (sOsVersion != null && sOsVersion.isNotEmpty) osVersion = sOsVersion;
|
||||
final sScreen = spoofed['screen'] as String?;
|
||||
if (sScreen != null && sScreen.isNotEmpty) screen = sScreen;
|
||||
final sTimezone = spoofed['timezone'] as String?;
|
||||
if (sTimezone != null && sTimezone.isNotEmpty) timezone = sTimezone;
|
||||
final sLocale = spoofed['locale'] as String?;
|
||||
if (sLocale != null && sLocale.isNotEmpty) {
|
||||
locale = sLocale;
|
||||
deviceLocale = sLocale.split(RegExp(r'[-_]')).first;
|
||||
}
|
||||
final sDeviceLocale = spoofed['device_locale'] as String?;
|
||||
if (sDeviceLocale != null && sDeviceLocale.isNotEmpty) {
|
||||
deviceLocale = sDeviceLocale;
|
||||
}
|
||||
final sDeviceId = spoofed['device_id'] as String?;
|
||||
if (sDeviceId != null && sDeviceId.isNotEmpty) deviceId = sDeviceId;
|
||||
appVersion = (spoofed['app_version'] as String?) ?? appVersion;
|
||||
architecture = (spoofed['arch'] as String?) ?? architecture;
|
||||
final sBuild = spoofed['build_number'];
|
||||
if (sBuild is int) {
|
||||
buildNumber = sBuild;
|
||||
} else if (sBuild is String) {
|
||||
buildNumber = int.tryParse(sBuild) ?? buildNumber;
|
||||
}
|
||||
final sPushType = spoofed['push_device_type'] as String?;
|
||||
if (sPushType != null && sPushType.isNotEmpty) pushDeviceType = sPushType;
|
||||
final sInstanceId = spoofed['instance_id'] as String?;
|
||||
if (sInstanceId != null && sInstanceId.isNotEmpty) {
|
||||
instanceId = sInstanceId;
|
||||
}
|
||||
final sClientSession = spoofed['client_session_id'];
|
||||
if (sClientSession is int) clientSessionId = sClientSession;
|
||||
}
|
||||
|
||||
_userAgent = {
|
||||
'deviceType': deviceType,
|
||||
'appVersion': appVersion,
|
||||
'osVersion': osVersion,
|
||||
'timezone': timezone,
|
||||
'screen': screen,
|
||||
'pushDeviceType': pushDeviceType,
|
||||
'arch': architecture,
|
||||
'locale': locale,
|
||||
'buildNumber': buildNumber,
|
||||
'deviceName': deviceName,
|
||||
'deviceLocale': deviceLocale,
|
||||
};
|
||||
|
||||
_deviceId = deviceId;
|
||||
|
||||
final payload = <dynamic, dynamic>{
|
||||
'mt_instanceid': instanceId,
|
||||
'userAgent': _userAgent,
|
||||
'clientSessionId': clientSessionId,
|
||||
'deviceId': deviceId,
|
||||
};
|
||||
|
||||
return sendRequest(Opcode.sessionInit, payload);
|
||||
}
|
||||
|
||||
/// Отправляет запрос и ждёт ответ от сервера.
|
||||
Future<Packet> sendRequest(int opcode, Map<dynamic, dynamic> payload) {
|
||||
final seq = _sender.send(_connection, opcode, payload);
|
||||
DebugSessionLog.instance.recordRequest(opcode, seq, payload);
|
||||
return _dispatcher
|
||||
.registerPending(seq)
|
||||
Future<Packet> sendRequest(
|
||||
int opcode,
|
||||
Map<dynamic, dynamic> payload, {
|
||||
bool silent = false,
|
||||
}) async {
|
||||
final session = _session;
|
||||
if (session == null) {
|
||||
throw StateError('Нет соединения (${Opcode.name(opcode)})');
|
||||
}
|
||||
// Лог запроса/ответа ведётся из wire-лога ядра (_onWireLog) по настоящему
|
||||
// проводному seq, поэтому здесь ничего не пишем.
|
||||
final KolibriResponse resp = await session
|
||||
.requestMapFull(opcode, Map<String, dynamic>.from(payload))
|
||||
.timeout(
|
||||
ServerConfig.requestTimeout,
|
||||
onTimeout: () =>
|
||||
throw TimeoutException('${Opcode.name(opcode)} таймаут'),
|
||||
)
|
||||
.then(
|
||||
(packet) {
|
||||
DebugSessionLog.instance.recordResponse(
|
||||
seq,
|
||||
packet.cmd,
|
||||
packet.payload,
|
||||
);
|
||||
return packet;
|
||||
},
|
||||
onError: (Object e, StackTrace st) {
|
||||
DebugSessionLog.instance.recordError(seq, e);
|
||||
Error.throwWithStackTrace(e, st);
|
||||
},
|
||||
);
|
||||
|
||||
final packet = Packet(
|
||||
cmd: resp.cmd,
|
||||
opcode: resp.opcode,
|
||||
payload: resp.payload,
|
||||
);
|
||||
|
||||
if (packet.isError) {
|
||||
if (isSessionExpiredPayload(packet.payload)) {
|
||||
final ex = SessionExpiredException(
|
||||
messageFromErrorPayload(packet.payload),
|
||||
);
|
||||
_sessionExpiredController.add(ex);
|
||||
throw ex;
|
||||
}
|
||||
final text = _serverErrorText(packet.payload);
|
||||
if (text != null && !silent) _errorController.add(text);
|
||||
final err = PacketError(
|
||||
messageFromErrorPayload(packet.payload),
|
||||
errorKey: resp.errorKey,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
return packet;
|
||||
}
|
||||
|
||||
Future<Map<dynamic, dynamic>?> sendRequestMap(
|
||||
@@ -485,14 +358,290 @@ class Api {
|
||||
_reconnectTimer?.cancel();
|
||||
_cleanup();
|
||||
_dispatcher.dispose();
|
||||
await _connection.dispose();
|
||||
await _stateController.close();
|
||||
await _sessionExpiredController.close();
|
||||
await _handshakeSuccessController.close();
|
||||
await _errorController.close();
|
||||
}
|
||||
|
||||
// Внутрянка
|
||||
|
||||
/// Строит устройство-поля и создаёт сессию ядра. Заодно заполняет
|
||||
/// [_userAgent] и [_deviceId] для геттеров.
|
||||
Future<(KolibriSession, Stream<WireLogEvent>)> _buildSessionOptions(
|
||||
({String host, int port, bool trustMincifryCa}) endpoint,
|
||||
) async {
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
|
||||
String deviceType = 'ANDROID';
|
||||
String osVersion = '';
|
||||
String deviceName = 'Unknown';
|
||||
String architecture = 'arm64-v8a';
|
||||
String appVersion = SpoofingService.hardcodedAppVersion;
|
||||
int buildNumber = SpoofingService.hardcodedBuildNumber;
|
||||
String screen = '420dpi 420dpi 1080x2340';
|
||||
|
||||
if (!_tzInitialized) {
|
||||
tz.initializeTimeZones();
|
||||
_tzInitialized = true;
|
||||
}
|
||||
final timeZoneName = await FlutterTimezone.getLocalTimezone();
|
||||
String timezone = timeZoneName.identifier;
|
||||
String locale = 'ru';
|
||||
String deviceLocale = Platform.localeName.substring(0, 2);
|
||||
String deviceId = await DeviceIdentity.deviceId();
|
||||
String pushDeviceType = 'GCM';
|
||||
String instanceId = await DeviceIdentity.instanceId();
|
||||
int clientSessionId = DeviceIdentity.clientSessionId;
|
||||
|
||||
String? androidManufacturer;
|
||||
String? androidModel;
|
||||
int? androidSdkInt;
|
||||
|
||||
if (Platform.isLinux) {
|
||||
final linuxInfo = await deviceInfo.linuxInfo;
|
||||
osVersion = linuxInfo.name;
|
||||
} else if (Platform.isIOS) {
|
||||
final iosInfo = await deviceInfo.iosInfo;
|
||||
osVersion = iosInfo.systemVersion;
|
||||
deviceName = iosInfo.utsname.machine;
|
||||
} else if (Platform.isAndroid) {
|
||||
final androidInfo = await deviceInfo.androidInfo;
|
||||
osVersion = 'Android ${androidInfo.version.release}';
|
||||
deviceName = '${androidInfo.manufacturer} ${androidInfo.model}';
|
||||
androidManufacturer = androidInfo.manufacturer;
|
||||
androidModel = androidInfo.model;
|
||||
androidSdkInt = androidInfo.version.sdkInt;
|
||||
} else if (Platform.isWindows) {
|
||||
final windowsInfo = await deviceInfo.windowsInfo;
|
||||
osVersion = windowsInfo.productName;
|
||||
}
|
||||
|
||||
String? spoofUserAgent;
|
||||
final spoofed = await SpoofingService.getSpoofedSessionData(
|
||||
scope: spoofScope,
|
||||
);
|
||||
if (spoofed != null) {
|
||||
spoofUserAgent = spoofed['user_agent'] as String?;
|
||||
final sDeviceType = spoofed['device_type'] as String?;
|
||||
if (sDeviceType != null && sDeviceType != 'IOS') deviceType = sDeviceType;
|
||||
final sDeviceName = spoofed['device_name'] as String?;
|
||||
if (sDeviceName != null && sDeviceName.isNotEmpty) {
|
||||
deviceName = sDeviceName;
|
||||
}
|
||||
final sOsVersion = spoofed['os_version'] as String?;
|
||||
if (sOsVersion != null && sOsVersion.isNotEmpty) osVersion = sOsVersion;
|
||||
final sScreen = spoofed['screen'] as String?;
|
||||
if (sScreen != null && sScreen.isNotEmpty) screen = sScreen;
|
||||
final sTimezone = spoofed['timezone'] as String?;
|
||||
if (sTimezone != null && sTimezone.isNotEmpty) timezone = sTimezone;
|
||||
final sLocale = spoofed['locale'] as String?;
|
||||
if (sLocale != null && sLocale.isNotEmpty) {
|
||||
locale = sLocale;
|
||||
deviceLocale = sLocale.split(RegExp(r'[-_]')).first;
|
||||
}
|
||||
final sDeviceLocale = spoofed['device_locale'] as String?;
|
||||
if (sDeviceLocale != null && sDeviceLocale.isNotEmpty) {
|
||||
deviceLocale = sDeviceLocale;
|
||||
}
|
||||
final sDeviceId = spoofed['device_id'] as String?;
|
||||
if (sDeviceId != null && sDeviceId.isNotEmpty) deviceId = sDeviceId;
|
||||
appVersion = (spoofed['app_version'] as String?) ?? appVersion;
|
||||
architecture = (spoofed['arch'] as String?) ?? architecture;
|
||||
final sBuild = spoofed['build_number'];
|
||||
if (sBuild is int) {
|
||||
buildNumber = sBuild;
|
||||
} else if (sBuild is String) {
|
||||
buildNumber = int.tryParse(sBuild) ?? buildNumber;
|
||||
}
|
||||
final sPushType = spoofed['push_device_type'] as String?;
|
||||
if (sPushType != null && sPushType.isNotEmpty) pushDeviceType = sPushType;
|
||||
final sInstanceId = spoofed['instance_id'] as String?;
|
||||
if (sInstanceId != null && sInstanceId.isNotEmpty) {
|
||||
instanceId = sInstanceId;
|
||||
}
|
||||
final sClientSession = spoofed['client_session_id'];
|
||||
if (sClientSession is int) clientSessionId = sClientSession;
|
||||
}
|
||||
|
||||
_callsDevice = _resolveCallsDevice(
|
||||
spoofed: spoofed != null,
|
||||
deviceName: deviceName,
|
||||
spoofUserAgent: spoofUserAgent,
|
||||
manufacturer: androidManufacturer,
|
||||
model: androidModel,
|
||||
);
|
||||
_callsOsVersion = _resolveCallsOsVersion(
|
||||
spoofed: spoofed != null,
|
||||
osVersion: osVersion,
|
||||
sdkInt: androidSdkInt,
|
||||
);
|
||||
|
||||
_userAgent = {
|
||||
'deviceType': deviceType,
|
||||
'appVersion': appVersion,
|
||||
'osVersion': osVersion,
|
||||
'timezone': timezone,
|
||||
'screen': screen,
|
||||
'pushDeviceType': pushDeviceType,
|
||||
'arch': architecture,
|
||||
'locale': locale,
|
||||
'buildNumber': buildNumber,
|
||||
'deviceName': deviceName,
|
||||
'deviceLocale': deviceLocale,
|
||||
};
|
||||
_deviceId = deviceId;
|
||||
|
||||
final insecureTls = await TlsConfig.isInsecureAllowed();
|
||||
final proxy = await _buildProxyUrl();
|
||||
|
||||
return openSessionWithWireLog(
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
deviceId: deviceId,
|
||||
instanceId: instanceId,
|
||||
appVersion: appVersion,
|
||||
buildNumber: buildNumber,
|
||||
deviceType: deviceType,
|
||||
osVersion: osVersion,
|
||||
timezone: timezone,
|
||||
screen: screen,
|
||||
pushDeviceType: pushDeviceType,
|
||||
arch: architecture,
|
||||
locale: locale,
|
||||
deviceName: deviceName,
|
||||
deviceLocale: deviceLocale,
|
||||
clientSessionId: clientSessionId,
|
||||
pingIntervalSecs: ServerConfig.pingInterval.inSeconds,
|
||||
pingInteractive: !KometSettings.ghostMode.value,
|
||||
autoReconnect: false,
|
||||
insecureTls: insecureTls,
|
||||
proxy: proxy,
|
||||
);
|
||||
}
|
||||
|
||||
static String? _resolveCallsDevice({
|
||||
required bool spoofed,
|
||||
required String deviceName,
|
||||
String? spoofUserAgent,
|
||||
String? manufacturer,
|
||||
String? model,
|
||||
}) {
|
||||
if (!spoofed &&
|
||||
manufacturer != null &&
|
||||
manufacturer.isNotEmpty &&
|
||||
model != null &&
|
||||
model.isNotEmpty) {
|
||||
return '$manufacturer/$model';
|
||||
}
|
||||
final parts = deviceName.trim().split(RegExp(r'\s+'))
|
||||
..removeWhere((p) => p.isEmpty);
|
||||
if (parts.isEmpty) return null;
|
||||
final fallbackModel = parts.length > 1
|
||||
? parts.sublist(1).join(' ')
|
||||
: parts.first;
|
||||
return '${parts.first}/'
|
||||
'${_modelFromUserAgent(spoofUserAgent) ?? fallbackModel}';
|
||||
}
|
||||
|
||||
static String? _modelFromUserAgent(String? userAgent) {
|
||||
if (userAgent == null || userAgent.isEmpty) return null;
|
||||
final match = RegExp(r'Android\s+[\d.]+;\s*([^;)]+)').firstMatch(userAgent);
|
||||
final model = match
|
||||
?.group(1)
|
||||
?.replaceFirst(RegExp(r'\s+Build/.*$'), '')
|
||||
.trim();
|
||||
return model == null || model.isEmpty ? null : model;
|
||||
}
|
||||
|
||||
static String _resolveCallsOsVersion({
|
||||
required bool spoofed,
|
||||
required String osVersion,
|
||||
int? sdkInt,
|
||||
}) {
|
||||
if (!spoofed && sdkInt != null && sdkInt > 0) return '$sdkInt';
|
||||
final release = RegExp(
|
||||
r'^Android\s+(\d+)',
|
||||
).firstMatch(osVersion.trim())?.group(1);
|
||||
return '${_androidSdkForRelease(int.tryParse(release ?? ''))}';
|
||||
}
|
||||
|
||||
static int _androidSdkForRelease(int? release) => switch (release) {
|
||||
null => 34,
|
||||
<= 9 => 28,
|
||||
10 => 29,
|
||||
11 => 30,
|
||||
12 => 31,
|
||||
13 => 33,
|
||||
14 => 34,
|
||||
15 => 35,
|
||||
_ => 36,
|
||||
};
|
||||
|
||||
static Future<String?> _buildProxyUrl() async {
|
||||
final p = await ProxyConfig.load();
|
||||
if (!p.isEnabled) return null;
|
||||
final scheme = p.type == ProxyType.socks5 ? 'socks5h' : 'http';
|
||||
final auth = p.hasCredentials
|
||||
? '${Uri.encodeComponent(p.username!)}:'
|
||||
'${Uri.encodeComponent(p.password!)}@'
|
||||
: '';
|
||||
return '$scheme://$auth${p.host}:${p.port}';
|
||||
}
|
||||
|
||||
void _onPush((int, Map<String, dynamic>) event) {
|
||||
final packet = Packet(
|
||||
cmd: CmdType.push,
|
||||
opcode: event.$1,
|
||||
payload: event.$2,
|
||||
);
|
||||
// Учёт трафика пушей ведётся из wire-лога ядра (_onWireLog).
|
||||
_dispatcher.dispatch(packet);
|
||||
}
|
||||
|
||||
/// Единый источник лога трафика: ядро отдаёт сюда каждый пакет обеих сторон —
|
||||
/// включая SESSION_INIT-хендшейк и пинги — с настоящим проводным seq. Раньше
|
||||
/// лог вёлся вручную из [sendRequest] по локальному счётчику, из-за чего
|
||||
/// хендшейк/пинги в дамп не попадали, а seq был смещён относительно провода.
|
||||
void _onWireLog(WireLogEvent e) {
|
||||
final payload = _decodeWireJson(e.json);
|
||||
final cmd = _wireCmdCode(e.cmd);
|
||||
if (e.direction == 'out') {
|
||||
DebugSessionLog.instance.recordRequest(e.opcode, e.seq, payload);
|
||||
TrafficMonitor.instance.recordOutgoing(e.opcode, payload, e.seq, 0);
|
||||
return;
|
||||
}
|
||||
// Входящие: ответы матчатся по seq, пуши идут только в монитор трафика.
|
||||
if (e.cmd != 'push') {
|
||||
DebugSessionLog.instance.recordResponse(e.seq, cmd, payload);
|
||||
}
|
||||
TrafficMonitor.instance.recordIncoming(
|
||||
Packet(cmd: cmd, seq: e.seq, opcode: e.opcode, payload: payload),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
static int _wireCmdCode(String cmd) {
|
||||
switch (cmd) {
|
||||
case 'ok':
|
||||
return CmdType.ok;
|
||||
case 'not_found':
|
||||
return CmdType.notFound;
|
||||
case 'error':
|
||||
return CmdType.error;
|
||||
default:
|
||||
return CmdType.request; // 'request' и 'push'
|
||||
}
|
||||
}
|
||||
|
||||
static dynamic _decodeWireJson(String json) {
|
||||
try {
|
||||
return jsonDecode(json);
|
||||
} catch (_) {
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
void _setSessionState(SessionState state) {
|
||||
if (_sessionState == state) return;
|
||||
_sessionState = state;
|
||||
@@ -500,35 +649,6 @@ class Api {
|
||||
logger.i('Сессия: ${state.name}');
|
||||
}
|
||||
|
||||
Future<void> _onDataReceived(Uint8List data) async {
|
||||
final List<Uint8List> rawPackets;
|
||||
try {
|
||||
rawPackets = _receiver.feed(data);
|
||||
} on ReceiverOverflowException catch (e) {
|
||||
logger.e('$e — форсируем реконнект');
|
||||
if (_sessionState != SessionState.disconnected) {
|
||||
unawaited(_forceReconnect());
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (final raw in rawPackets) {
|
||||
final Packet packet;
|
||||
try {
|
||||
packet = await unpackPacket(raw);
|
||||
} catch (e) {
|
||||
logger.e('PacketReceiver: ошибка распаковки: $e');
|
||||
continue;
|
||||
}
|
||||
TrafficMonitor.instance.recordIncoming(packet, raw.length);
|
||||
if (packet.isError && isSessionExpiredPayload(packet.payload)) {
|
||||
_sessionExpiredController.add(
|
||||
SessionExpiredException(messageFromErrorPayload(packet.payload)),
|
||||
);
|
||||
}
|
||||
_dispatcher.dispatch(packet);
|
||||
}
|
||||
}
|
||||
|
||||
void _onDisconnected() {
|
||||
_connectGen++;
|
||||
_cleanup();
|
||||
@@ -536,13 +656,18 @@ class Api {
|
||||
if (_autoReconnect) _scheduleReconnect();
|
||||
}
|
||||
|
||||
/// Пробный запрос-пинг: если не ответил — форсируем реконнект.
|
||||
Future<void> _probeLiveness() async {
|
||||
if (_sessionState != SessionState.online) return;
|
||||
final session = _session;
|
||||
if (session == null) return;
|
||||
final epoch = _sessionEpoch;
|
||||
try {
|
||||
await sendRequest(Opcode.ping, {
|
||||
'interactive': !KometSettings.ghostMode.value,
|
||||
}).timeout(const Duration(seconds: 6));
|
||||
await session
|
||||
.requestMapFull(Opcode.ping, {
|
||||
'interactive': !KometSettings.ghostMode.value,
|
||||
})
|
||||
.timeout(const Duration(seconds: 6));
|
||||
} catch (_) {
|
||||
if (_sessionEpoch != epoch || _sessionState != SessionState.online) {
|
||||
return;
|
||||
@@ -555,7 +680,6 @@ class Api {
|
||||
Future<void> _forceReconnect() async {
|
||||
_connectGen++;
|
||||
_cleanup();
|
||||
await _connection.disconnect();
|
||||
_reconnectAttempts = 0;
|
||||
_reconnectTimer?.cancel();
|
||||
_setSessionState(SessionState.disconnected);
|
||||
@@ -564,12 +688,20 @@ class Api {
|
||||
|
||||
void _cleanup() {
|
||||
_cancelConnectWatchdog();
|
||||
_pingTimer?.cancel();
|
||||
_dataSubscription?.cancel();
|
||||
_socketStateSubscription?.cancel();
|
||||
_dataSubscription = null;
|
||||
_socketStateSubscription = null;
|
||||
_receiver.reset();
|
||||
_livenessTimer?.cancel();
|
||||
_livenessTimer = null;
|
||||
_pushSub?.cancel();
|
||||
_pushSub = null;
|
||||
_wireLogSub?.cancel();
|
||||
_wireLogSub = null;
|
||||
_lastInteractive = null;
|
||||
final session = _session;
|
||||
_session = null;
|
||||
if (session != null) {
|
||||
try {
|
||||
session.disconnect();
|
||||
} catch (_) {}
|
||||
}
|
||||
_dispatcher.clearPending();
|
||||
_handshakeSuccessController.add('disconnected');
|
||||
}
|
||||
@@ -584,17 +716,44 @@ class Api {
|
||||
_onReconnectCallback = callback;
|
||||
}
|
||||
|
||||
void _startPinging() {
|
||||
_pingTimer?.cancel();
|
||||
sendPing(interactive: !KometSettings.ghostMode.value);
|
||||
_pingTimer = Timer.periodic(ServerConfig.pingInterval, (_) {
|
||||
sendPing(interactive: !KometSettings.ghostMode.value);
|
||||
});
|
||||
/// Поллит состояние ядра (стрима состояний нет) — детект разрыва, плюс
|
||||
/// синхронизация interactive-флага пинга и присутствия.
|
||||
void _startLiveness() {
|
||||
_livenessTimer?.cancel();
|
||||
_lastInteractive = !KometSettings.ghostMode.value;
|
||||
_livenessTimer = Timer.periodic(_livenessInterval, (_) => _tickLiveness());
|
||||
}
|
||||
|
||||
void _tickLiveness() {
|
||||
final session = _session;
|
||||
if (session == null || _sessionState != SessionState.online) return;
|
||||
final st = session.state();
|
||||
if (st != 'online' && st != 'connected') {
|
||||
logger.w('kolibri сессия "$st" — реконнект');
|
||||
_onDisconnected();
|
||||
return;
|
||||
}
|
||||
final interactive = !KometSettings.ghostMode.value;
|
||||
if (interactive != _lastInteractive) {
|
||||
_lastInteractive = interactive;
|
||||
try {
|
||||
session.setPingInteractive(interactive: interactive);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (interactive) {
|
||||
SelfPresence.markOnline();
|
||||
} else {
|
||||
SelfPresence.markOfflineFromPing();
|
||||
}
|
||||
}
|
||||
|
||||
void sendPing({required bool interactive}) {
|
||||
if (_connection.isConnected) {
|
||||
_sender.send(_connection, Opcode.ping, {'interactive': interactive});
|
||||
final session = _session;
|
||||
if (session != null && _sessionState == SessionState.online) {
|
||||
try {
|
||||
session.setPingInteractive(interactive: interactive);
|
||||
} catch (_) {}
|
||||
_lastInteractive = interactive;
|
||||
if (interactive) {
|
||||
SelfPresence.markOnline();
|
||||
} else {
|
||||
@@ -603,9 +762,13 @@ class Api {
|
||||
}
|
||||
}
|
||||
|
||||
static String _archFromPlatformVersion() {
|
||||
final v = Platform.version;
|
||||
return v.substring(v.indexOf('_') + 1, v.length - 1);
|
||||
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;
|
||||
}
|
||||
|
||||
static List<CountryName>? _parseRegistrationCountries(dynamic payload) {
|
||||
|
||||
@@ -1,83 +1,205 @@
|
||||
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<int> chatTypes = {
|
||||
contact,
|
||||
notContact,
|
||||
chat,
|
||||
channel,
|
||||
bot,
|
||||
dialog,
|
||||
org,
|
||||
};
|
||||
|
||||
static const Set<int> roles = {owner, admin};
|
||||
|
||||
static const Set<int> showOnly = {
|
||||
unread,
|
||||
read,
|
||||
muted,
|
||||
notMuted,
|
||||
markedUnread,
|
||||
};
|
||||
|
||||
static const Map<String, int> _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<String, int> _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<int>? include;
|
||||
final List<dynamic> filters;
|
||||
final bool hideEmpty;
|
||||
final List<int> include;
|
||||
final List<int> filters;
|
||||
final List<int> options;
|
||||
final List<int> favorites;
|
||||
final List<ChatFolderWidget> widgets;
|
||||
final List<int>? favorites;
|
||||
final Map<String, dynamic>? filterSubjects;
|
||||
final List<int>? options;
|
||||
final int updateTime;
|
||||
final int? sourceId;
|
||||
|
||||
ChatFolder({
|
||||
const ChatFolder({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.emoji,
|
||||
this.include,
|
||||
required this.filters,
|
||||
required this.hideEmpty,
|
||||
required this.widgets,
|
||||
this.favorites,
|
||||
this.include = const [],
|
||||
this.filters = const [],
|
||||
this.options = const [],
|
||||
this.favorites = const [],
|
||||
this.widgets = const [],
|
||||
this.filterSubjects,
|
||||
this.options,
|
||||
this.updateTime = 0,
|
||||
this.sourceId,
|
||||
});
|
||||
|
||||
static List<int>? _parseIntList(dynamic raw) {
|
||||
return (raw as List<dynamic>?)?.map((e) {
|
||||
if (e is int) return e;
|
||||
if (e is String) return int.tryParse(e) ?? 0;
|
||||
return 0;
|
||||
}).toList();
|
||||
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<int> _parseIds(dynamic raw) {
|
||||
if (raw is! List) return <int>[];
|
||||
return raw
|
||||
.map((e) {
|
||||
if (e is int) return e;
|
||||
if (e is String) return int.tryParse(e);
|
||||
return null;
|
||||
})
|
||||
.whereType<int>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
static List<int> _parseCodes(dynamic raw, int? Function(dynamic) parse) {
|
||||
if (raw is! List) return <int>[];
|
||||
return raw.map(parse).whereType<int>().toList();
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _parseMap(dynamic raw) {
|
||||
if (raw is Map<String, dynamic>) return raw;
|
||||
if (raw is Map) return Map<String, dynamic>.from(raw);
|
||||
return null;
|
||||
}
|
||||
|
||||
factory ChatFolder.fromJson(Map<String, dynamic> 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: _parseIntList(json['include']),
|
||||
filters:
|
||||
(json['filters'] as List<dynamic>?)?.map((e) {
|
||||
if (e is int) return e;
|
||||
if (e is String) return int.tryParse(e) ?? e;
|
||||
return e;
|
||||
}).toList() ??
|
||||
[],
|
||||
hideEmpty: json['hideEmpty'] ?? false,
|
||||
include: _parseIds(json['include']),
|
||||
filters: _parseCodes(json['filters'], FolderFilter.parse),
|
||||
options: options,
|
||||
favorites: _parseIds(json['favorites']),
|
||||
widgets:
|
||||
(json['widgets'] as List<dynamic>?)?.map((w) {
|
||||
if (w is Map<String, dynamic>) {
|
||||
return ChatFolderWidget.fromJson(w);
|
||||
}
|
||||
return ChatFolderWidget.fromJson(
|
||||
Map<String, dynamic>.from(w as Map),
|
||||
);
|
||||
}).toList() ??
|
||||
[],
|
||||
favorites: _parseIntList(json['favorites']),
|
||||
filterSubjects: json['filterSubjects'] is Map<String, dynamic>
|
||||
? json['filterSubjects'] as Map<String, dynamic>
|
||||
: (json['filterSubjects'] is Map
|
||||
? Map<String, dynamic>.from(
|
||||
(json['filterSubjects'] as Map).cast<dynamic, dynamic>(),
|
||||
)
|
||||
: null),
|
||||
options: _parseIntList(json['options']),
|
||||
(json['widgets'] as List<dynamic>?)
|
||||
?.map(_parseMap)
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.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<int>? include,
|
||||
List<int>? filters,
|
||||
List<int>? options,
|
||||
List<int>? 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<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
if (emoji != null) 'emoji': emoji,
|
||||
if (include != null) 'include': include,
|
||||
'include': include,
|
||||
'filters': filters,
|
||||
'hideEmpty': hideEmpty,
|
||||
'options': options,
|
||||
'favorites': favorites,
|
||||
'widgets': widgets.map((w) => w.toJson()).toList(),
|
||||
if (favorites != null) 'favorites': favorites,
|
||||
if (filterSubjects != null) 'filterSubjects': filterSubjects,
|
||||
if (options != null) 'options': options,
|
||||
'updateTime': updateTime,
|
||||
if (sourceId != null) 'sourceId': sourceId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import '../api.dart';
|
||||
import '../../core/config/debug_test.dart';
|
||||
import '../../core/config/komet_settings.dart';
|
||||
import '../../core/protocol/chat_cache_fingerprint.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
@@ -10,6 +11,8 @@ 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/login_info.dart';
|
||||
import 'banners.dart';
|
||||
import 'chats.dart';
|
||||
import 'complaints.dart';
|
||||
import 'contacts.dart';
|
||||
@@ -40,7 +43,9 @@ class AccountModule {
|
||||
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<LoginStatus>.broadcast();
|
||||
final _noticeController = StreamController<AccountNotice>.broadcast();
|
||||
bool _loggedIn = false;
|
||||
|
||||
AccountModule(this._api) {
|
||||
@@ -51,6 +56,8 @@ class AccountModule {
|
||||
|
||||
Stream<LoginStatus> get loginStatusStream => _loginStatusController.stream;
|
||||
|
||||
Stream<AccountNotice> get noticeStream => _noticeController.stream;
|
||||
|
||||
/// `true`, только когда сервер считает сессию ONLINE — после успешного
|
||||
/// login (opcode 19), а не просто после хэндшейка (opcode 6).
|
||||
bool get isLoggedIn => _loggedIn;
|
||||
@@ -327,6 +334,8 @@ class AccountModule {
|
||||
ContactCache.clear();
|
||||
TranscriptionCache.clear();
|
||||
ComplaintsModule.clear();
|
||||
ContactsModule.clearBlockedCache();
|
||||
banners.clear();
|
||||
chats.resetForAccountSwitch();
|
||||
|
||||
logger.i('Добавление аккаунта: сессия сброшена, активный аккаунт очищен');
|
||||
@@ -341,6 +350,8 @@ class AccountModule {
|
||||
ContactCache.clear();
|
||||
TranscriptionCache.clear();
|
||||
ComplaintsModule.clear();
|
||||
ContactsModule.clearBlockedCache();
|
||||
banners.clear();
|
||||
chats.resetForAccountSwitch();
|
||||
|
||||
await _api.connect();
|
||||
@@ -372,6 +383,8 @@ class AccountModule {
|
||||
ContactCache.clear();
|
||||
TranscriptionCache.clear();
|
||||
ComplaintsModule.clear();
|
||||
ContactsModule.clearBlockedCache();
|
||||
banners.clear();
|
||||
chats.resetForAccountSwitch();
|
||||
await ContactsModule.primeCacheFromDb(accountId);
|
||||
|
||||
@@ -422,6 +435,8 @@ class AccountModule {
|
||||
ContactCache.clear();
|
||||
TranscriptionCache.clear();
|
||||
ComplaintsModule.clear();
|
||||
ContactsModule.clearBlockedCache();
|
||||
banners.clear();
|
||||
chats.resetForAccountSwitch();
|
||||
}
|
||||
|
||||
@@ -473,7 +488,7 @@ class AccountModule {
|
||||
final data = _requireMapPayload(packet, 'checkPassword');
|
||||
|
||||
if (data['error'] != null) {
|
||||
throw Exception('checkPassword: неверный пароль');
|
||||
throw const WrongPasswordException();
|
||||
}
|
||||
|
||||
final tokenAttrs = data['tokenAttrs'];
|
||||
@@ -565,22 +580,16 @@ class AccountModule {
|
||||
|
||||
ProfileData profile;
|
||||
final profileMap = data['profile'];
|
||||
if (profileMap is Map) {
|
||||
final contact = profileMap['contact'];
|
||||
if (contact is! Map) {
|
||||
throw Exception('login: отсутствует profile.contact в ответе');
|
||||
}
|
||||
if (!DebugTest.berserk &&
|
||||
profileMap is Map &&
|
||||
profileMap['contact'] is Map) {
|
||||
profile = ProfileData.fromServerProfile(
|
||||
profileMap.cast<dynamic, dynamic>(),
|
||||
);
|
||||
await AppDatabase.saveProfile(profile, isActive: true);
|
||||
} else {
|
||||
final cachedProfile = await AppDatabase.loadProfile(accountId);
|
||||
if (cachedProfile == null) {
|
||||
throw Exception('login: отсутствует profile в ответе');
|
||||
}
|
||||
profile = cachedProfile;
|
||||
profile = await _resurrectProfile(accountId);
|
||||
}
|
||||
await AppDatabase.saveProfile(profile, isActive: true);
|
||||
await AppDatabase.setActiveAccount(profile.id);
|
||||
|
||||
await _saveSyncState(data, serverTime, profile.id);
|
||||
@@ -600,6 +609,7 @@ class AccountModule {
|
||||
profile.id,
|
||||
config.cast<dynamic, dynamic>(),
|
||||
);
|
||||
await chats.applyFavorites(profile.id);
|
||||
final userConfig = config['user'];
|
||||
if (userConfig is Map) {
|
||||
await AppDatabase.savePrivacyConfig(profile.id, jsonEncode(userConfig));
|
||||
@@ -610,6 +620,13 @@ class AccountModule {
|
||||
} 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);
|
||||
@@ -625,6 +642,31 @@ class AccountModule {
|
||||
);
|
||||
}
|
||||
|
||||
Future<ProfileData> _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);
|
||||
}
|
||||
|
||||
Future<void> _saveSyncState(
|
||||
Map<dynamic, dynamic> data,
|
||||
int serverTime,
|
||||
@@ -652,41 +694,12 @@ class AccountModule {
|
||||
}
|
||||
|
||||
Future<void> _saveLoginInfo(Map<dynamic, dynamic> data, int accountId) async {
|
||||
final contact = data['profile']?['contact'] as Map?;
|
||||
final videoChatHistory = data['videoChatHistory'];
|
||||
final chats = data['chats'] as List?;
|
||||
final config = data['config'] as Map?;
|
||||
final serverConfig = config?['server'] as Map?;
|
||||
final userConfig = config?['user'] as Map?;
|
||||
if (serverConfig != null) {
|
||||
await _persistEntryBannerApps(accountId, serverConfig);
|
||||
}
|
||||
final yMap = serverConfig?['y-map'] as Map?;
|
||||
final whiteListLinks = serverConfig?['white-list-links'] as List?;
|
||||
final fileUploadUnsupported =
|
||||
serverConfig?['file-upload-unsupported-types'] as List?;
|
||||
final time = data['time'] as int?;
|
||||
|
||||
final info = {
|
||||
'registrationTime': contact?['registrationTime'],
|
||||
'country': contact?['country'],
|
||||
'videoChatHistory': videoChatHistory,
|
||||
'updateTime': contact?['updateTime'],
|
||||
'id': contact?['id'],
|
||||
'chatMarker': chats != null && chats.isNotEmpty
|
||||
? _extractChatMarker(chats.cast<Map>())
|
||||
: null,
|
||||
'time': time,
|
||||
'server': serverConfig != null
|
||||
? _extractServerInfo(
|
||||
serverConfig,
|
||||
yMap,
|
||||
whiteListLinks,
|
||||
fileUploadUnsupported,
|
||||
)
|
||||
: null,
|
||||
'user': userConfig != null ? _extractUserConfig(userConfig) : null,
|
||||
};
|
||||
final info = LoginInfo.fromPayload(data);
|
||||
|
||||
await AppDatabase.saveLoginInfo(accountId, jsonEncode(info));
|
||||
}
|
||||
@@ -719,86 +732,6 @@ class AccountModule {
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _extractChatMarker(List<Map> chats) {
|
||||
int? latestTime;
|
||||
for (final chat in chats) {
|
||||
final lastEventTime = chat['lastEventTime'] as int?;
|
||||
if (lastEventTime != null &&
|
||||
(latestTime == null || lastEventTime > latestTime)) {
|
||||
latestTime = lastEventTime;
|
||||
}
|
||||
}
|
||||
return {'chatMarker': latestTime};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _extractServerInfo(
|
||||
Map serverConfig,
|
||||
Map? yMap,
|
||||
List? whiteListLinks,
|
||||
List? fileUploadUnsupported,
|
||||
) {
|
||||
return {
|
||||
'account-removal-enabled': serverConfig['account-removal-enabled'],
|
||||
'image-size': serverConfig['image-size'],
|
||||
'gce': serverConfig['gce'],
|
||||
'gcce': serverConfig['gcce'],
|
||||
'max-msg-length': serverConfig['max-msg-length'],
|
||||
'quotes-enabled': serverConfig['quotes-enabled'],
|
||||
'calls-endpoint': serverConfig['calls-endpoint'],
|
||||
'send-location-enabled': serverConfig['send-location-enabled'],
|
||||
'lgce': serverConfig['lgce'],
|
||||
'wud': serverConfig['wud'],
|
||||
'video-msg-enabled': serverConfig['video-msg-enabled'],
|
||||
'grse': serverConfig['grse'],
|
||||
'edit-timeout': serverConfig['edit-timeout'],
|
||||
'image-quality': serverConfig['image-quality'],
|
||||
'unsafe-files-alert': serverConfig['unsafe-files-alert'],
|
||||
'account-nickname-enabled': serverConfig['account-nickname-enabled'],
|
||||
'mentions_entity_names_limit':
|
||||
serverConfig['mentions_entity_names_limit'],
|
||||
'reactions-enabled': serverConfig['reactions-enabled'],
|
||||
'y-map': yMap != null
|
||||
? {
|
||||
'tile': yMap['tile'],
|
||||
'geocoder': yMap['geocoder'],
|
||||
'static': yMap['static'],
|
||||
}
|
||||
: null,
|
||||
'white-list-links': whiteListLinks,
|
||||
'file-upload-unsupported-types': fileUploadUnsupported,
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _extractUserConfig(Map userConfig) {
|
||||
return {
|
||||
'CHATS_PUSH_NOTIFICATION': userConfig['CHATS_PUSH_NOTIFICATION'],
|
||||
'PUSH_DETAILS': userConfig['PUSH_DETAILS'],
|
||||
'PUSH_SOUND': userConfig['PUSH_SOUND'],
|
||||
'PHONE_NUMBER_PRIVACY': userConfig['PHONE_NUMBER_PRIVACY'],
|
||||
'INACTIVE_TTL': userConfig['INACTIVE_TTL'],
|
||||
'SHOW_READ_MARK': userConfig['SHOW_READ_MARK'],
|
||||
'AUDIO_TRANSCRIPTION_ENABLED': userConfig['AUDIO_TRANSCRIPTION_ENABLED'],
|
||||
'SEARCH_BY_PHONE': userConfig['SEARCH_BY_PHONE'],
|
||||
'INCOMING_CALL': userConfig['INCOMING_CALL'],
|
||||
'DOUBLE_TAP_REACTION_DISABLED':
|
||||
userConfig['DOUBLE_TAP_REACTION_DISABLED'],
|
||||
'SAFE_MODE_NO_PIN': userConfig['SAFE_MODE_NO_PIN'],
|
||||
'CHATS_PUSH_SOUND': userConfig['CHATS_PUSH_SOUND'],
|
||||
'DOUBLE_TAP_REACTION_VALUE': userConfig['DOUBLE_TAP_REACTION_VALUE'],
|
||||
'FAMILY_PROTECTION': userConfig['FAMILY_PROTECTION'],
|
||||
'HIDDEN': userConfig['HIDDEN'],
|
||||
'CHATS_INVITE': userConfig['CHATS_INVITE'],
|
||||
'PUSH_NEW_CONTACTS': userConfig['PUSH_NEW_CONTACTS'],
|
||||
'UNSAFE_FILES': userConfig['UNSAFE_FILES'],
|
||||
'DONT_DISTURB_UNTIL': userConfig['DONT_DISTURB_UNTIL'],
|
||||
'ALT_KEYBOARD': userConfig['ALT_KEYBOARD'],
|
||||
'CONTENT_LEVEL_ACCESS': userConfig['CONTENT_LEVEL_ACCESS'],
|
||||
'STICKERS_SUGGEST': userConfig['STICKERS_SUGGEST'],
|
||||
'SAFE_MODE': userConfig['SAFE_MODE'],
|
||||
'M_CALL_PUSH_NOTIFICATION': userConfig['M_CALL_PUSH_NOTIFICATION'],
|
||||
};
|
||||
}
|
||||
|
||||
Future<RequestCodeResult> _requestCodeInternal(
|
||||
String phone,
|
||||
AuthRequestType type,
|
||||
|
||||
@@ -282,12 +282,20 @@ enum AuthRequestType {
|
||||
|
||||
enum LoginStatus { idle, loading, success, error }
|
||||
|
||||
enum AccountNotice { resurrectingProfile }
|
||||
|
||||
class WrongDeviceTokenException implements Exception {
|
||||
const WrongDeviceTokenException();
|
||||
@override
|
||||
String toString() => 'WrongDeviceTokenException';
|
||||
}
|
||||
|
||||
class WrongPasswordException implements Exception {
|
||||
const WrongPasswordException();
|
||||
@override
|
||||
String toString() => 'WrongPasswordException';
|
||||
}
|
||||
|
||||
class RequestCodeResult {
|
||||
final String token;
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ class TwoFactorModule extends AccountApiBase {
|
||||
checkPacketError(packet, 'check2faPassword');
|
||||
final data = packet.payload;
|
||||
if (data is Map && data['error'] != null) {
|
||||
throw Exception('Неверный пароль');
|
||||
throw const WrongPasswordException();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
|
||||
@@ -23,6 +24,7 @@ class AnimojiModule {
|
||||
static const int _maxRecents = 24;
|
||||
|
||||
final Map<int, Animoji> _byId = {};
|
||||
final Map<String, Animoji> _byEmoji = {};
|
||||
List<int> _orderedIds = [];
|
||||
List<int> _recentIds = [];
|
||||
bool _recentsLoaded = false;
|
||||
@@ -50,7 +52,7 @@ class AnimojiModule {
|
||||
|
||||
Future<void> noteUsed(Animoji animoji) async {
|
||||
await ensureRecentsLoaded();
|
||||
_byId[animoji.id] = animoji;
|
||||
_remember(animoji);
|
||||
_recentIds.remove(animoji.id);
|
||||
_recentIds.insert(0, animoji.id);
|
||||
if (_recentIds.length > _maxRecents) {
|
||||
@@ -70,6 +72,28 @@ class AnimojiModule {
|
||||
return list.length <= 6 ? list : list.sublist(0, 6);
|
||||
}
|
||||
|
||||
Animoji? cached(int id) => _byId[id];
|
||||
|
||||
Animoji? findByEmoji(String emoji) =>
|
||||
_byEmoji[EmojiKeywordIndex.normalize(emoji)];
|
||||
|
||||
Future<Animoji?> 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<void> ensureLoaded() {
|
||||
return _loading ??= _load().catchError((Object e) {
|
||||
_loading = null;
|
||||
@@ -133,7 +157,7 @@ class AnimojiModule {
|
||||
for (final e in list) {
|
||||
if (e is! Map) continue;
|
||||
final animoji = Animoji.fromMap(e);
|
||||
if (animoji != null) _byId[animoji.id] = animoji;
|
||||
if (animoji != null) _remember(animoji);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +165,11 @@ class AnimojiModule {
|
||||
logger.i('Анимодзи: ${_orderedIds.length} доступно для реакций');
|
||||
}
|
||||
|
||||
void _remember(Animoji animoji) {
|
||||
_byId[animoji.id] = animoji;
|
||||
_byEmoji[EmojiKeywordIndex.normalize(animoji.emoji)] = animoji;
|
||||
}
|
||||
|
||||
List<int> _dedup(List<int> ids) {
|
||||
final seen = <int>{};
|
||||
final result = <int>[];
|
||||
|
||||
@@ -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<InformerBanner?> activeBanner = ValueNotifier(null);
|
||||
|
||||
int? _accountId;
|
||||
bool _enabled = true;
|
||||
int _updateTime = 0;
|
||||
int _showTime = _defaultShowTime;
|
||||
List<InformerBanner> _banners = const [];
|
||||
final Map<String, BannerShowState> _showState = {};
|
||||
String? _lastShownId;
|
||||
String? _pinnedId;
|
||||
Future<void>? _syncing;
|
||||
|
||||
bool get isEnabled => _enabled;
|
||||
int get showTime => _showTime;
|
||||
List<InformerBanner> get banners => List.unmodifiable(_banners);
|
||||
|
||||
BannerShowState stateOf(String bannerId) =>
|
||||
_showState[bannerId] ?? const BannerShowState();
|
||||
|
||||
Future<void> initFromLogin(
|
||||
int accountId,
|
||||
Map<dynamic, dynamic> 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<dynamic, dynamic>());
|
||||
}
|
||||
|
||||
if (applied && !_needsResync(loginData['updates'])) return;
|
||||
await syncFromServer();
|
||||
}
|
||||
|
||||
Future<void> 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<void> syncFromServer() {
|
||||
return _syncing ??= _sync().whenComplete(() => _syncing = null);
|
||||
}
|
||||
|
||||
Future<bool> applyPayload(
|
||||
int accountId,
|
||||
Map<dynamic, dynamic> 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 = <InformerBanner>[];
|
||||
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<void> 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<void> 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<void> 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<void> _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<dynamic, dynamic>());
|
||||
}
|
||||
} 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<dynamic, dynamic>()));
|
||||
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<void> _setEnabled(int accountId, bool value) async {
|
||||
_enabled = value;
|
||||
await AppDatabase.setSyncValue(accountId, _enabledKey, value ? '1' : '0');
|
||||
if (!value) activeBanner.value = null;
|
||||
}
|
||||
|
||||
Future<void> _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<String, dynamic>;
|
||||
final list = map['banners'];
|
||||
final parsed = <InformerBanner>[];
|
||||
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<void> _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<String, dynamic>;
|
||||
_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<void> _persistSnapshot(int accountId) async {
|
||||
await AppDatabase.setSyncValue(
|
||||
accountId,
|
||||
_snapshotKey,
|
||||
jsonEncode({
|
||||
'updateTime': _updateTime,
|
||||
'showTime': _showTime,
|
||||
'banners': _banners.map((b) => b.toJson()).toList(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _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;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ 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';
|
||||
@@ -27,6 +29,22 @@ class OutgoingCallParams {
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -83,24 +101,26 @@ class CallsModule {
|
||||
|
||||
_CallerEndpoint _parseCallerEndpoint(
|
||||
Map payload,
|
||||
String key, {
|
||||
List<String> keys, {
|
||||
required String context,
|
||||
}) {
|
||||
final raw = payload[key];
|
||||
final parsed = raw is String
|
||||
? jsonDecode(raw) as Map<dynamic, dynamic>
|
||||
: const <dynamic, dynamic>{};
|
||||
for (final key in keys) {
|
||||
final raw = payload[key];
|
||||
final parsed = raw is String
|
||||
? jsonDecode(raw) as Map<dynamic, dynamic>
|
||||
: const <dynamic, dynamic>{};
|
||||
|
||||
final endpoint = parsed['endpoint'] as String?;
|
||||
if (endpoint == null) {
|
||||
throw _CallerEndpointMissingException('$context: no endpoint');
|
||||
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);
|
||||
}
|
||||
|
||||
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<OutgoingCallParams> initiateCall(
|
||||
@@ -120,11 +140,9 @@ class CallsModule {
|
||||
throw Exception('initiateCall: bad response');
|
||||
}
|
||||
|
||||
final parsed = _parseCallerEndpoint(
|
||||
payload,
|
||||
final parsed = _parseCallerEndpoint(payload, const [
|
||||
'internalCallerParams',
|
||||
context: 'initiateCall',
|
||||
);
|
||||
], context: 'initiateCall');
|
||||
|
||||
return OutgoingCallParams(
|
||||
conversationId: (payload['conversationId'] as String?) ?? conversationId,
|
||||
@@ -135,19 +153,64 @@ class CallsModule {
|
||||
);
|
||||
}
|
||||
|
||||
Future<CreatedCall> 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<String?> 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.1.16.4',
|
||||
'sdkVersion': '0.2.1.3',
|
||||
'clientAppKey': 'CGPGAGLGDIHBABABA',
|
||||
'deviceId': _api.deviceId ?? '',
|
||||
'protocolVersion': 5,
|
||||
'onlyAdminCanRecord': false,
|
||||
'waitForAdmin': false,
|
||||
'capabilities': '3c03f',
|
||||
'isWaitForAdminEnabled': false,
|
||||
'hexCapability': Ws2Config.defaultCapabilities,
|
||||
});
|
||||
|
||||
Future<CallLinkPreview?> resolveCallLink(String url) async {
|
||||
final payload = await _api.sendRequestMap(Opcode.linkInfo, {'link': url});
|
||||
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'];
|
||||
@@ -165,21 +228,22 @@ class CallsModule {
|
||||
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,
|
||||
final parsed = _parseCallerEndpoint(payload, const [
|
||||
'internalParams',
|
||||
context: 'joinByLink',
|
||||
);
|
||||
'internalCallerParams',
|
||||
], context: 'joinByLink');
|
||||
|
||||
return OutgoingCallParams(
|
||||
conversationId: (payload['conversationId'] as String?) ?? '',
|
||||
|
||||
@@ -40,10 +40,18 @@ CachedChat? parseChatRow(
|
||||
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,
|
||||
@@ -55,7 +63,11 @@ CachedChat? parseChatRow(
|
||||
lastMsgTime: lastMessage.time,
|
||||
lastMsgText: lastMessage.text,
|
||||
lastMsgElements: lastMessage.elements,
|
||||
lastMsgSenderId: lastMessage.senderId,
|
||||
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,
|
||||
@@ -71,6 +83,7 @@ CachedChat? parseChatRow(
|
||||
pinnedMsgText: pinned.text,
|
||||
pinnedMsgTime: pinned.time,
|
||||
pinnedMsgIsPreview: pinned.isPreview,
|
||||
lastMentionMsgId: mentionId ?? existing[id]?.lastMentionMsgId,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.e("Ошибка при парсинге чата: $e");
|
||||
@@ -121,20 +134,42 @@ CachedChat? parseChatRow(
|
||||
);
|
||||
}
|
||||
|
||||
({int? id, int? time, String? text, String? elements, int? senderId})
|
||||
({
|
||||
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, senderId: null);
|
||||
return (
|
||||
id: null,
|
||||
time: null,
|
||||
text: null,
|
||||
elements: null,
|
||||
preview: null,
|
||||
senderId: null,
|
||||
);
|
||||
}
|
||||
return (
|
||||
id: lastMsg['id'] as int?,
|
||||
time: lastMsg['time'] as int?,
|
||||
id: _asIntOrNull(lastMsg['id']),
|
||||
time: _asIntOrNull(lastMsg['time']),
|
||||
text: messagePreviewText(lastMsg),
|
||||
elements: messagePreviewElements(lastMsg),
|
||||
senderId: lastMsg['sender'] as int?,
|
||||
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,
|
||||
) {
|
||||
@@ -159,13 +194,14 @@ _resolveLastMessage(dynamic lastMsg) {
|
||||
Map<int, CachedChat> existing,
|
||||
) {
|
||||
final config = chatsConfig[id.toString()] ?? chatsConfig[id];
|
||||
final ex = existing[id];
|
||||
if (config is Map) {
|
||||
final configFav = config['favIndex'] as int?;
|
||||
return (
|
||||
favIndex: config['favIndex'] as int?,
|
||||
favIndex: (configFav != null && configFav > 0) ? configFav : ex?.favIndex,
|
||||
dontDisturbUntil: (config['dontDisturbUntil'] as int?) ?? 0,
|
||||
);
|
||||
}
|
||||
final ex = existing[id];
|
||||
if (ex != null) {
|
||||
return (favIndex: ex.favIndex, dontDisturbUntil: ex.dontDisturbUntil);
|
||||
}
|
||||
@@ -303,6 +339,7 @@ bool sameChatContent(CachedChat a, CachedChat b) {
|
||||
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;
|
||||
|
||||
@@ -1,56 +1,121 @@
|
||||
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 'Фото';
|
||||
return (
|
||||
label: _mediaAttachCount(attaches) > 1 ? 'Изображения' : 'Изображение',
|
||||
detail: null,
|
||||
);
|
||||
case 'VIDEO':
|
||||
return _isVideoNote(first) ? 'Видео-сообщение' : 'Видео';
|
||||
if (_isVideoNote(first)) return (label: 'Видео-сообщение', detail: null);
|
||||
return (label: 'Видео', detail: null);
|
||||
case 'AUDIO':
|
||||
return 'Голосовое сообщение';
|
||||
return (label: 'Голосовое сообщение', detail: null);
|
||||
case 'FILE':
|
||||
final name = first['name']?.toString();
|
||||
return name != null && name.isNotEmpty ? 'Файл: $name' : 'Файл';
|
||||
return (label: 'Файл', detail: _nonEmpty(first['name']));
|
||||
case 'STICKER':
|
||||
return 'Стикер';
|
||||
return (label: 'Стикер', detail: null);
|
||||
case 'SHARE':
|
||||
final title = first['title']?.toString();
|
||||
return title != null && title.isNotEmpty ? 'Ссылка: $title' : 'Ссылка';
|
||||
return (label: 'Ссылка', detail: _nonEmpty(first['title']));
|
||||
case 'POLL':
|
||||
final title = first['title']?.toString();
|
||||
return title != null && title.isNotEmpty ? 'Опрос: $title' : 'Опрос';
|
||||
return (label: 'Опрос', detail: _nonEmpty(first['title']));
|
||||
case 'LOCATION':
|
||||
return 'Геопозиция';
|
||||
return (label: 'Геопозиция', detail: null);
|
||||
case 'CONTACT':
|
||||
return 'Контакт';
|
||||
return (label: 'Контакт', detail: null);
|
||||
case 'CONTROL':
|
||||
return _controlPreviewLabel(first);
|
||||
final label = _controlPreviewLabel(first);
|
||||
return label == null ? null : (label: label, detail: null);
|
||||
case 'INLINE_KEYBOARD':
|
||||
return null;
|
||||
case 'CALL':
|
||||
final video = first['callType']?.toString().toUpperCase() == 'VIDEO';
|
||||
final dur = (first['duration'] as num?)?.toInt() ?? 0;
|
||||
final hangup = first['hangupType']?.toString();
|
||||
final failed =
|
||||
dur == 0 ||
|
||||
hangup == 'CANCELED' ||
|
||||
hangup == 'REJECTED' ||
|
||||
hangup == 'MISSED';
|
||||
if (first['joinLink'] != null) {
|
||||
return video ? 'Групповой видеозвонок' : 'Групповой звонок';
|
||||
}
|
||||
if (failed) {
|
||||
return video ? 'Пропущенный видеозвонок' : 'Пропущенный звонок';
|
||||
}
|
||||
return video ? 'Видеозвонок' : 'Звонок';
|
||||
return (label: _callPreviewLabel(first), detail: null);
|
||||
default:
|
||||
return 'Вложение';
|
||||
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) {
|
||||
@@ -62,6 +127,16 @@ Map? _firstPreviewAttach(dynamic attaches) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int _mediaAttachCount(dynamic attaches) {
|
||||
if (attaches is! List) return 0;
|
||||
var count = 0;
|
||||
for (final attach in attaches.whereType<Map>()) {
|
||||
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;
|
||||
@@ -95,9 +170,8 @@ String? _controlPreviewLabel(Map c) {
|
||||
}
|
||||
|
||||
String? messagePreviewText(Map msg) {
|
||||
final link = msg['link'];
|
||||
if (link is Map && link['type']?.toString().toUpperCase() == 'FORWARD') {
|
||||
final original = link['message'];
|
||||
final original = _forwardOrigin(msg);
|
||||
if (original != null) {
|
||||
final inner = original is Map ? _bodyPreviewText(original) : null;
|
||||
return inner != null && inner.isNotEmpty
|
||||
? '↪ $inner'
|
||||
@@ -106,6 +180,62 @@ String? messagePreviewText(Map msg) {
|
||||
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<ChatPreviewThumb> _previewThumbs(dynamic attaches) {
|
||||
if (attaches is! List) return const [];
|
||||
final thumbs = <ChatPreviewThumb>[];
|
||||
for (final attach in attaches.whereType<Map>()) {
|
||||
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;
|
||||
return null;
|
||||
}
|
||||
|
||||
({String? text, bool isPreview}) pinnedMessagePreview(Map msg) {
|
||||
final link = msg['link'];
|
||||
if (link is Map && link['type']?.toString().toUpperCase() == 'FORWARD') {
|
||||
|
||||
+401
-16
@@ -8,10 +8,14 @@ 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/storage/app_database.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/chat_preview_media.dart';
|
||||
import '../../models/contact_info.dart';
|
||||
import '../api.dart';
|
||||
import 'chat_parsing.dart';
|
||||
import 'chat_preview.dart';
|
||||
@@ -35,6 +39,20 @@ Map<int, int> parseParticipants(dynamic raw) {
|
||||
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<dynamic, dynamic> message, int userId) {
|
||||
final elements = message['elements'];
|
||||
if (elements is! List) return false;
|
||||
for (final element in elements.whereType<Map>()) {
|
||||
if (element['type']?.toString() != 'USER_MENTION') continue;
|
||||
if (element['entityId'] == userId) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
class CachedChat {
|
||||
final int id;
|
||||
final int accountId;
|
||||
@@ -46,6 +64,7 @@ class CachedChat {
|
||||
final String? lastMsgText;
|
||||
final String? lastMsgTextOneLine;
|
||||
final String? lastMsgElements;
|
||||
final String? lastMsgPreview;
|
||||
final int? lastMsgSenderId;
|
||||
final String? lastMsgStatus;
|
||||
final int unreadCount;
|
||||
@@ -63,6 +82,7 @@ class CachedChat {
|
||||
final String? pinnedMsgText;
|
||||
final int? pinnedMsgTime;
|
||||
final bool pinnedMsgIsPreview;
|
||||
final int? lastMentionMsgId;
|
||||
|
||||
CachedChat({
|
||||
required this.id,
|
||||
@@ -74,6 +94,7 @@ class CachedChat {
|
||||
this.lastMsgTime,
|
||||
this.lastMsgText,
|
||||
this.lastMsgElements,
|
||||
this.lastMsgPreview,
|
||||
this.lastMsgSenderId,
|
||||
this.lastMsgStatus,
|
||||
required this.unreadCount,
|
||||
@@ -91,12 +112,17 @@ class CachedChat {
|
||||
this.pinnedMsgText,
|
||||
this.pinnedMsgTime,
|
||||
this.pinnedMsgIsPreview = false,
|
||||
this.lastMentionMsgId,
|
||||
}) : lastMsgTextOneLine = lastMsgText != null && lastMsgText.contains('\n')
|
||||
? lastMsgText.replaceAll('\n', ' ')
|
||||
: lastMsgText;
|
||||
|
||||
bool get isOfficial => options.contains('OFFICIAL');
|
||||
|
||||
late final ChatPreviewMedia? lastMsgMedia = ChatPreviewMedia.decode(
|
||||
lastMsgPreview,
|
||||
);
|
||||
|
||||
List<FormatRange> get lastMsgFormatRanges {
|
||||
final raw = lastMsgElements;
|
||||
if (raw == null || raw.isEmpty) return const [];
|
||||
@@ -127,6 +153,12 @@ class CachedChat {
|
||||
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 isMuted {
|
||||
if (dontDisturbUntil == ChatsModule.muteOff) return false;
|
||||
if (dontDisturbUntil < 0) return true;
|
||||
@@ -135,6 +167,12 @@ class CachedChat {
|
||||
|
||||
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<String, dynamic> row) => CachedChat(
|
||||
id: row['id'] as int,
|
||||
accountId: row['account_id'] as int,
|
||||
@@ -145,6 +183,7 @@ class CachedChat {
|
||||
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,
|
||||
@@ -162,6 +201,7 @@ class CachedChat {
|
||||
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?,
|
||||
);
|
||||
|
||||
static Set<String> _decodeOptions(dynamic raw) {
|
||||
@@ -188,6 +228,7 @@ class CachedChat {
|
||||
'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,
|
||||
@@ -207,6 +248,7 @@ class CachedChat {
|
||||
'pinned_msg_text': pinnedMsgText,
|
||||
'pinned_msg_time': pinnedMsgTime,
|
||||
'pinned_msg_is_preview': pinnedMsgIsPreview ? 1 : 0,
|
||||
'last_mention_msg_id': lastMentionMsgId,
|
||||
};
|
||||
|
||||
static const Object _keep = Object();
|
||||
@@ -219,6 +261,7 @@ class CachedChat {
|
||||
Object? lastMsgTime = _keep,
|
||||
Object? lastMsgText = _keep,
|
||||
Object? lastMsgElements = _keep,
|
||||
Object? lastMsgPreview = _keep,
|
||||
Object? lastMsgSenderId = _keep,
|
||||
Object? lastMsgStatus = _keep,
|
||||
int? unreadCount,
|
||||
@@ -236,6 +279,7 @@ class CachedChat {
|
||||
Object? pinnedMsgText = _keep,
|
||||
Object? pinnedMsgTime = _keep,
|
||||
bool? pinnedMsgIsPreview,
|
||||
Object? lastMentionMsgId = _keep,
|
||||
}) {
|
||||
return CachedChat(
|
||||
id: id,
|
||||
@@ -255,6 +299,9 @@ class CachedChat {
|
||||
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?,
|
||||
@@ -282,6 +329,9 @@ class CachedChat {
|
||||
? this.pinnedMsgTime
|
||||
: pinnedMsgTime as int?,
|
||||
pinnedMsgIsPreview: pinnedMsgIsPreview ?? this.pinnedMsgIsPreview,
|
||||
lastMentionMsgId: identical(lastMentionMsgId, _keep)
|
||||
? this.lastMentionMsgId
|
||||
: lastMentionMsgId as int?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -302,6 +352,37 @@ class ChatSearchHit {
|
||||
});
|
||||
}
|
||||
|
||||
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<ChatMemberEntry> members;
|
||||
final int marker;
|
||||
|
||||
const ChatMembersPage({required this.members, required this.marker});
|
||||
}
|
||||
|
||||
class MessageSearchHit {
|
||||
final int chatId;
|
||||
final String? messageId;
|
||||
@@ -398,12 +479,16 @@ class ChatsModule {
|
||||
'chatId': chatId,
|
||||
'messageId': msgIdNum,
|
||||
'mark': mark,
|
||||
});
|
||||
}, silent: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
row['unread_count'] = 0;
|
||||
await AppDatabase.saveChats([row]);
|
||||
final cached = CachedChat.fromDbRow(row);
|
||||
final currentMark = cached.participants[accountId] ?? 0;
|
||||
final participants = Map<int, int>.from(cached.participants)
|
||||
..[accountId] = mark > currentMark ? mark : currentMark;
|
||||
final updated = cached.copyWith(unreadCount: 0, participants: participants);
|
||||
await AppDatabase.saveChats([updated.toDbRow()]);
|
||||
_bump();
|
||||
}
|
||||
|
||||
@@ -423,7 +508,7 @@ class ChatsModule {
|
||||
'chatId': chatId,
|
||||
'messageId': msgIdNum,
|
||||
'mark': mark,
|
||||
});
|
||||
}, silent: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -436,7 +521,10 @@ class ChatsModule {
|
||||
if (cached.unreadCount == next && nextMark == currentMark) return;
|
||||
final participants = Map<int, int>.from(cached.participants)
|
||||
..[accountId] = nextMark;
|
||||
final updated = cached.copyWith(unreadCount: next, participants: participants);
|
||||
final updated = cached.copyWith(
|
||||
unreadCount: next,
|
||||
participants: participants,
|
||||
);
|
||||
await AppDatabase.saveChats([updated.toDbRow()]);
|
||||
_bump();
|
||||
}
|
||||
@@ -472,6 +560,7 @@ class ChatsModule {
|
||||
required String text,
|
||||
required String status,
|
||||
List<Map<String, dynamic>>? elements,
|
||||
String? preview,
|
||||
}) async {
|
||||
final thisId = int.tryParse(messageId);
|
||||
await _updateChat(accountId, chatId, (chat) {
|
||||
@@ -483,6 +572,7 @@ class ChatsModule {
|
||||
lastMsgElements: (elements != null && elements.isNotEmpty)
|
||||
? jsonEncode(elements)
|
||||
: null,
|
||||
lastMsgPreview: preview,
|
||||
lastMsgTime: time,
|
||||
lastEventTime: time,
|
||||
lastMsgSenderId: accountId,
|
||||
@@ -494,6 +584,41 @@ class ChatsModule {
|
||||
final ValueNotifier<int> chatsChanged = ValueNotifier(0);
|
||||
void _bump() => chatsChanged.value = chatsChanged.value + 1;
|
||||
|
||||
static const Set<String> _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<bool> _updateChat(
|
||||
int accountId,
|
||||
int chatId,
|
||||
@@ -560,6 +685,7 @@ class ChatsModule {
|
||||
ContactInfoFetch.clear();
|
||||
PresenceFetch.clear();
|
||||
ChatInfoFetch.clear();
|
||||
SharedContentModule.clearMediaIndex();
|
||||
}
|
||||
|
||||
void _enqueueGlobalPush(Packet packet) {
|
||||
@@ -637,6 +763,15 @@ class ChatsModule {
|
||||
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 accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) return;
|
||||
|
||||
@@ -744,6 +879,7 @@ class ChatsModule {
|
||||
final cached = CachedMessage.fromPushPayload(accountId, chatId, msg);
|
||||
await AppDatabase.saveMessages([cached.toDbRow()]);
|
||||
emittedMessage = cached;
|
||||
_applyMembershipControl(accountId, chatId, cached);
|
||||
_messageEventsController.add(MessageAddedEvent(chatId, cached));
|
||||
}
|
||||
}
|
||||
@@ -770,11 +906,19 @@ class ChatsModule {
|
||||
}
|
||||
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;
|
||||
@@ -832,7 +976,9 @@ class ChatsModule {
|
||||
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 {
|
||||
@@ -841,6 +987,7 @@ class ChatsModule {
|
||||
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'];
|
||||
@@ -848,6 +995,7 @@ class ChatsModule {
|
||||
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;
|
||||
}
|
||||
@@ -1078,13 +1226,16 @@ class ChatsModule {
|
||||
}) async {
|
||||
final cachedAt = DateTime.now().millisecondsSinceEpoch;
|
||||
final id = chat['id'];
|
||||
ChatMembersStore.instance.applyChatPayload(chat);
|
||||
Map<int, CachedChat> existing = const {};
|
||||
Map<String, dynamic>? existingRow;
|
||||
if (preloadedExisting != null) {
|
||||
existing = preloadedExisting;
|
||||
} else if (id is int) {
|
||||
final rows = await AppDatabase.loadChat(accountId, id);
|
||||
if (rows.isNotEmpty) {
|
||||
existing = {id: CachedChat.fromDbRow(rows.first)};
|
||||
existingRow = rows.first;
|
||||
existing = {id: CachedChat.fromDbRow(existingRow)};
|
||||
}
|
||||
}
|
||||
final parsed = parseChatRow(
|
||||
@@ -1102,11 +1253,14 @@ class ChatsModule {
|
||||
return null;
|
||||
}
|
||||
final ex = existing[parsed.id];
|
||||
if (ex != null && sameChatContent(ex, parsed)) {
|
||||
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'] = !inList ? 0 : (chat['status'] == 'HIDDEN' ? 2 : 1);
|
||||
row['in_list'] = listState;
|
||||
await AppDatabase.saveChats([row]);
|
||||
_bump();
|
||||
return parsed;
|
||||
@@ -1173,6 +1327,7 @@ class ChatsModule {
|
||||
final rows = <Map<String, dynamic>>[];
|
||||
for (final c in chats.whereType<Map>()) {
|
||||
final map = c.cast<dynamic, dynamic>();
|
||||
ChatMembersStore.instance.applyChatPayload(map);
|
||||
final parsed = parseChatRow(
|
||||
map,
|
||||
accountId,
|
||||
@@ -1226,17 +1381,23 @@ class ChatsModule {
|
||||
if (next is! int || next == marker || chats.length < count) break;
|
||||
marker = next;
|
||||
}
|
||||
await applyFavorites(accountId);
|
||||
} catch (e) {
|
||||
_paginatedAccountId = null;
|
||||
logger.w('Пагинация чатов: $e');
|
||||
}
|
||||
}
|
||||
|
||||
final Set<int> _repairedSenders = {};
|
||||
|
||||
Future<List<CachedChat>> getChats(
|
||||
int accountId, {
|
||||
bool includeHidden = false,
|
||||
}) async {
|
||||
try {
|
||||
if (_repairedSenders.add(accountId)) {
|
||||
await AppDatabase.repairLastMessageSenders(accountId);
|
||||
}
|
||||
final rows = await AppDatabase.loadChats(
|
||||
accountId,
|
||||
includeHidden: includeHidden,
|
||||
@@ -1272,7 +1433,21 @@ class ChatsModule {
|
||||
final payload = packet.payload as Map?;
|
||||
final chats = payload?['chats'] as List?;
|
||||
if (chats == null || chats.isEmpty) return null;
|
||||
return Map<String, dynamic>.from(chats.first as Map);
|
||||
final info = Map<String, dynamic>.from(chats.first as Map);
|
||||
ChatMembersStore.instance.applyChatPayload(info);
|
||||
return info;
|
||||
}
|
||||
|
||||
Future<Map<int, int>> 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<dynamic> searchById(Api api, int userId) async {
|
||||
@@ -1334,12 +1509,38 @@ class ChatsModule {
|
||||
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<bool> ensureChatCached(Api api, int accountId, int chatId) async {
|
||||
final rows = await AppDatabase.loadChat(accountId, chatId);
|
||||
if (rows.isNotEmpty) return true;
|
||||
@@ -1359,6 +1560,33 @@ class ChatsModule {
|
||||
required String title,
|
||||
required List<int> userIds,
|
||||
bool notify = true,
|
||||
}) => _createChat(
|
||||
api,
|
||||
chatType: 'CHAT',
|
||||
title: title,
|
||||
userIds: userIds,
|
||||
notify: notify,
|
||||
);
|
||||
|
||||
Future<CachedChat?> createChannel(
|
||||
Api api, {
|
||||
required String title,
|
||||
List<int> userIds = const [],
|
||||
bool notify = true,
|
||||
}) => _createChat(
|
||||
api,
|
||||
chatType: 'CHANNEL',
|
||||
title: title,
|
||||
userIds: userIds,
|
||||
notify: notify,
|
||||
);
|
||||
|
||||
Future<CachedChat?> _createChat(
|
||||
Api api, {
|
||||
required String chatType,
|
||||
required String title,
|
||||
required List<int> userIds,
|
||||
required bool notify,
|
||||
}) async {
|
||||
final payload = {
|
||||
'message': {
|
||||
@@ -1367,7 +1595,7 @@ class ChatsModule {
|
||||
{
|
||||
'_type': 'CONTROL',
|
||||
'event': 'new',
|
||||
'chatType': 'CHAT',
|
||||
'chatType': chatType,
|
||||
'title': title,
|
||||
'userIds': userIds,
|
||||
},
|
||||
@@ -1377,22 +1605,24 @@ class ChatsModule {
|
||||
};
|
||||
final packet = await api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!packet.isOk) {
|
||||
logger.w('createGroupChat: server error payload=${packet.payload}');
|
||||
logger.w(
|
||||
'_createChat($chatType): server error payload=${packet.payload}',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
final data = packet.payload;
|
||||
if (data is! Map) {
|
||||
logger.w('createGroupChat: payload is not a Map: $data');
|
||||
logger.w('_createChat($chatType): payload is not a Map: $data');
|
||||
return null;
|
||||
}
|
||||
final chat = data['chat'];
|
||||
if (chat is! Map) {
|
||||
logger.w('createGroupChat: response has no chat field: $data');
|
||||
logger.w('_createChat($chatType): response has no chat field: $data');
|
||||
return null;
|
||||
}
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) {
|
||||
logger.w('createGroupChat: no active account id');
|
||||
logger.w('_createChat($chatType): no active account id');
|
||||
return null;
|
||||
}
|
||||
return cacheServerChat(chat, accountId);
|
||||
@@ -1494,7 +1724,7 @@ class ChatsModule {
|
||||
: folders.first,
|
||||
);
|
||||
|
||||
final favorites = List<int>.from(allFolder.favorites ?? const []);
|
||||
final favorites = List<int>.from(allFolder.favorites);
|
||||
if (pin) {
|
||||
for (final id in chatIds) {
|
||||
if (!favorites.contains(id)) favorites.add(id);
|
||||
@@ -1538,6 +1768,40 @@ class ChatsModule {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> 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 = <int, int>{};
|
||||
for (var i = 0; i < favorites.length; i++) {
|
||||
favIndexById[favorites[i]] = i + 1;
|
||||
}
|
||||
|
||||
final rows = await AppDatabase.loadChats(accountId, includeHidden: true);
|
||||
final updates = <Map<String, dynamic>>[];
|
||||
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<String, dynamic>.from(row);
|
||||
newRow['fav_index'] = next;
|
||||
updates.add(newRow);
|
||||
}
|
||||
if (updates.isNotEmpty) {
|
||||
await AppDatabase.saveChats(updates);
|
||||
_bump();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.w('applyFavorites: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> setChatMute(
|
||||
Api api, {
|
||||
required int chatId,
|
||||
@@ -1639,6 +1903,127 @@ class ChatsModule {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> addMembers(
|
||||
Api api, {
|
||||
required int chatId,
|
||||
required List<int> 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<dynamic, dynamic>(), 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<ChatMembersPage?> 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;
|
||||
|
||||
final entries = <ChatMemberEntry>[];
|
||||
final presenceById = <int, Map<String, dynamic>>{};
|
||||
final rawMembers = payload['members'];
|
||||
if (rawMembers is List) {
|
||||
for (final m in rawMembers.whereType<Map>()) {
|
||||
final contact = m['contact'];
|
||||
if (contact is! Map) continue;
|
||||
final id = contact['id'];
|
||||
if (id is! int) continue;
|
||||
|
||||
final info = ContactInfo.fromMap(Map<String, dynamic>.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<dynamic, dynamic>());
|
||||
|
||||
final presence = m['presence'];
|
||||
var status = 0;
|
||||
int? seen;
|
||||
if (presence is Map) {
|
||||
final p = Map<String, dynamic>.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 : marker,
|
||||
);
|
||||
} on PacketError catch (e) {
|
||||
logger.w('getChatMembers $chatId: ${e.message}');
|
||||
return null;
|
||||
} catch (e) {
|
||||
logger.w('getChatMembers $chatId: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<CachedChat>> refreshChats(Api api, List<int> chatIds) async {
|
||||
if (chatIds.isEmpty) return const [];
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.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<int> revision = ValueNotifier<int>(0);
|
||||
|
||||
final _infoController =
|
||||
StreamController<Map<String, CommentsInfo>>.broadcast();
|
||||
Stream<Map<String, CommentsInfo>> get infoStream => _infoController.stream;
|
||||
|
||||
final _commentController = StreamController<CommentAddedEvent>.broadcast();
|
||||
Stream<CommentAddedEvent> get commentStream => _commentController.stream;
|
||||
|
||||
Map<String, CommentsInfo> _info = <String, CommentsInfo>{};
|
||||
Map<String, CommentsInfo> 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<Packet>? _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<dynamic, dynamic>(),
|
||||
_accountId,
|
||||
chatId,
|
||||
postId,
|
||||
);
|
||||
if (comment == null) return;
|
||||
_commentController.add(CommentAddedEvent(chatId, postId, comment));
|
||||
}
|
||||
|
||||
void handleInfoUpdate(List updates) {
|
||||
if (updates.isEmpty) return;
|
||||
Map<String, CommentsInfo>? 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<String, dynamic>.from(commentsInfo.cast()),
|
||||
);
|
||||
next ??= Map<String, CommentsInfo>.from(_info);
|
||||
next[postId] = updated;
|
||||
}
|
||||
if (next == null) return;
|
||||
_info = next;
|
||||
revision.value = revision.value + 1;
|
||||
_infoController.add(Map.unmodifiable(_info));
|
||||
}
|
||||
|
||||
Future<Map<String, CommentsInfo>> fetchInfo({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required List<String> postIds,
|
||||
}) async {
|
||||
_accountId = accountId;
|
||||
if (postIds.isEmpty) return const {};
|
||||
final response = await _api.sendRequest(Opcode.commentsInfo, {
|
||||
'chatId': chatId,
|
||||
'postIds': postIds.map((id) => int.tryParse(id) ?? id).toList(),
|
||||
});
|
||||
if (!response.isOk) return const {};
|
||||
final payload = response.payload;
|
||||
if (payload is! Map) return const {};
|
||||
final updates = payload['commentsInfoUpdates'];
|
||||
if (updates is! List) return const {};
|
||||
handleInfoUpdate(updates);
|
||||
final byPost = <String, CommentsInfo>{};
|
||||
for (final raw in updates.whereType<Map>()) {
|
||||
final postId = raw['postId']?.toString();
|
||||
final commentsInfo = raw['commentsInfo'];
|
||||
if (postId == null || commentsInfo is! Map) continue;
|
||||
byPost[postId] = CommentsInfo.fromPayload(
|
||||
postId,
|
||||
Map<String, dynamic>.from(commentsInfo.cast()),
|
||||
);
|
||||
}
|
||||
return byPost;
|
||||
}
|
||||
|
||||
Future<List<CachedMessage>> fetchHistory(
|
||||
int accountId,
|
||||
int chatId,
|
||||
String postId, {
|
||||
required int fromTime,
|
||||
int forward = 30,
|
||||
int backward = 15,
|
||||
}) async {
|
||||
_accountId = accountId;
|
||||
final payload = <String, dynamic>{
|
||||
'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 = <CachedMessage>[];
|
||||
for (var i = 0; i < messagesData.length; i++) {
|
||||
final m = messagesData[i];
|
||||
if (m is! Map) continue;
|
||||
final parsed = _parseComment(
|
||||
m.cast<dynamic, dynamic>(),
|
||||
accountId,
|
||||
chatId,
|
||||
postId,
|
||||
);
|
||||
if (parsed != null) results.add(parsed);
|
||||
if (i > 0 && i % 20 == 0) await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
Future<String> sendComment(
|
||||
int accountId,
|
||||
int chatId,
|
||||
String postId,
|
||||
String text, {
|
||||
bool notify = true,
|
||||
int? replyToMessageId,
|
||||
List<Map<String, dynamic>> elements = const [],
|
||||
}) async {
|
||||
_accountId = accountId;
|
||||
final Object postIdField = int.tryParse(postId) ?? postId;
|
||||
final message = <String, dynamic>{
|
||||
'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 = <String, dynamic>{
|
||||
'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<dynamic, dynamic> m,
|
||||
int accountId,
|
||||
int chatId,
|
||||
String postId,
|
||||
) {
|
||||
final id = m['id']?.toString();
|
||||
if (id == null) return null;
|
||||
|
||||
final full = Map<String, dynamic>.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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import '../api.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.dart';
|
||||
|
||||
class ComplaintReason {
|
||||
final int reasonId;
|
||||
@@ -9,6 +10,8 @@ class ComplaintReason {
|
||||
}
|
||||
|
||||
class ComplaintsModule {
|
||||
static const int userTypeId = 6;
|
||||
|
||||
static Map<int, List<ComplaintReason>>? _cache;
|
||||
|
||||
static void clear() => _cache = null;
|
||||
@@ -17,10 +20,15 @@ class ComplaintsModule {
|
||||
final cached = _cache;
|
||||
if (cached != null) return cached;
|
||||
|
||||
final response = await api.sendRequest(Opcode.complainReasonsGet, {
|
||||
'complainSync': 0,
|
||||
});
|
||||
if (!response.isOk) return cached ?? const {};
|
||||
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 {};
|
||||
@@ -65,14 +73,19 @@ class ComplaintsModule {
|
||||
required int reasonId,
|
||||
required int typeId,
|
||||
required List<int> ids,
|
||||
required int parentId,
|
||||
int? parentId,
|
||||
}) async {
|
||||
final response = await api.sendRequest(Opcode.complain, {
|
||||
'reasonId': reasonId,
|
||||
'typeId': typeId,
|
||||
'ids': ids,
|
||||
'parentId': parentId,
|
||||
});
|
||||
final Packet response;
|
||||
try {
|
||||
response = await api.sendRequest(Opcode.complain, {
|
||||
'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;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
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';
|
||||
|
||||
@@ -18,6 +21,7 @@ class CachedContact {
|
||||
final String? baseRawUrl;
|
||||
final int updateTime;
|
||||
final Set<String> options;
|
||||
final int accountStatus;
|
||||
|
||||
const CachedContact({
|
||||
required this.id,
|
||||
@@ -30,12 +34,14 @@ class CachedContact {
|
||||
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<String, dynamic> row) => CachedContact(
|
||||
id: row['id'] as int,
|
||||
@@ -48,6 +54,7 @@ class CachedContact {
|
||||
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<String> _decodeOptions(dynamic raw) {
|
||||
@@ -60,8 +67,14 @@ class PhoneLookupResult {
|
||||
final int id;
|
||||
final String? name;
|
||||
final String? avatarUrl;
|
||||
final int phone;
|
||||
|
||||
const PhoneLookupResult({required this.id, this.name, this.avatarUrl});
|
||||
const PhoneLookupResult({
|
||||
required this.id,
|
||||
this.name,
|
||||
this.avatarUrl,
|
||||
this.phone = 0,
|
||||
});
|
||||
}
|
||||
|
||||
class ContactPhotos {
|
||||
@@ -73,21 +86,47 @@ class ContactPhotos {
|
||||
static const empty = ContactPhotos(urls: [], total: 0);
|
||||
}
|
||||
|
||||
enum AddContactStatus { added, notFound, error }
|
||||
|
||||
class AddContactResult {
|
||||
final AddContactStatus status;
|
||||
final CachedContact? contact;
|
||||
|
||||
const AddContactResult(this.status, {this.contact});
|
||||
}
|
||||
|
||||
class ContactsModule {
|
||||
static final ValueNotifier<int> revision = ValueNotifier<int>(0);
|
||||
|
||||
static Future<PhoneLookupResult?> findByPhone(Api api, String phone) async {
|
||||
static Future<PhoneLookupResult?> findByPhone(
|
||||
Api api,
|
||||
String phone, {
|
||||
bool silent = false,
|
||||
}) async {
|
||||
final normalized = _normalizePhone(phone);
|
||||
if (normalized == null) return null;
|
||||
final packet = await api.sendRequest(Opcode.contactInfoByPhone, {
|
||||
'phone': normalized,
|
||||
});
|
||||
final Packet packet;
|
||||
try {
|
||||
packet = await api.sendRequest(Opcode.contactInfoByPhone, {
|
||||
'phone': normalized,
|
||||
}, silent: silent);
|
||||
} on PacketError {
|
||||
return null;
|
||||
}
|
||||
if (packet.isError) 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;
|
||||
|
||||
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) {
|
||||
@@ -105,6 +144,7 @@ class ContactsModule {
|
||||
id: id,
|
||||
name: name,
|
||||
avatarUrl: contact['baseUrl'] as String?,
|
||||
phone: resolvedPhone,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,7 +163,7 @@ class ContactsModule {
|
||||
final resp = await api.sendRequest(Opcode.contactUpdate, {
|
||||
'action': 'ADD',
|
||||
'contactId': id,
|
||||
'firstName': firstName,
|
||||
if (firstName.isNotEmpty) 'firstName': firstName,
|
||||
});
|
||||
|
||||
final profile = await AppDatabase.loadActiveProfile();
|
||||
@@ -147,6 +187,7 @@ class ContactsModule {
|
||||
'base_raw_url': null,
|
||||
'update_time': 0,
|
||||
'options': null,
|
||||
'account_status': 0,
|
||||
};
|
||||
|
||||
if (row == null) return null;
|
||||
@@ -154,24 +195,228 @@ class ContactsModule {
|
||||
row['phone'] = phone;
|
||||
}
|
||||
await AppDatabase.saveContacts([row]);
|
||||
if (contact != null) _primeContactCache(contact);
|
||||
if (contact != null) primeContactCache(contact);
|
||||
revision.value++;
|
||||
return CachedContact.fromDbRow(row);
|
||||
}
|
||||
|
||||
static Future<AddContactResult> addContactByPhone(
|
||||
Api api, {
|
||||
required String phone,
|
||||
required String firstName,
|
||||
String lastName = '',
|
||||
}) async {
|
||||
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 key = e.errorKey ?? '';
|
||||
final notFound =
|
||||
key == 'user.not.found' ||
|
||||
e.message.toLowerCase().contains('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<dynamic, dynamic>()
|
||||
: null;
|
||||
if (contact == null) {
|
||||
return const AddContactResult(AddContactStatus.error);
|
||||
}
|
||||
|
||||
final profile = await AppDatabase.loadActiveProfile();
|
||||
if (profile == null) {
|
||||
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<CachedContact?> updateContact(
|
||||
Api api, {
|
||||
required int contactId,
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
}) async {
|
||||
final Packet resp;
|
||||
try {
|
||||
resp = await api.sendRequest(Opcode.contactUpdate, {
|
||||
'contactId': contactId,
|
||||
'action': 'UPDATE',
|
||||
'firstName': firstName,
|
||||
'lastName': lastName,
|
||||
});
|
||||
} catch (_) {
|
||||
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<dynamic, dynamic>()
|
||||
: 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<bool> removeContact(Api api, int contactId) async {
|
||||
try {
|
||||
await api.sendRequest(Opcode.contactUpdate, {
|
||||
'contactId': contactId,
|
||||
'action': 'REMOVE',
|
||||
});
|
||||
} 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<String, dynamic>.from(info.raw)..['names'] = stripped;
|
||||
ContactInfoFetch.putContact(contactId, newRaw);
|
||||
primeContactCache(newRaw);
|
||||
} else {
|
||||
ContactInfoFetch.invalidate(contactId);
|
||||
}
|
||||
|
||||
revision.value++;
|
||||
return true;
|
||||
}
|
||||
|
||||
static final Set<int> _blockedIds = <int>{};
|
||||
static bool _blockedLoaded = false;
|
||||
|
||||
static void clearBlockedCache() {
|
||||
_blockedIds.clear();
|
||||
_blockedLoaded = false;
|
||||
}
|
||||
|
||||
static const int _blockedPageSize = 100;
|
||||
static const int _blockedMaxPages = 20;
|
||||
|
||||
static Future<bool> isBlocked(Api api, int contactId) async {
|
||||
if (!_blockedLoaded) await _loadBlockedIds(api);
|
||||
return _blockedIds.contains(contactId);
|
||||
}
|
||||
|
||||
static Future<void> _loadBlockedIds(Api api) async {
|
||||
final ids = <int>{};
|
||||
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>().map((c) => c['id']).whereType<int>(),
|
||||
);
|
||||
if (contacts.length < _blockedPageSize) break;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.w('Не удалось получить список заблокированных: $e');
|
||||
return;
|
||||
}
|
||||
_blockedIds
|
||||
..clear()
|
||||
..addAll(ids);
|
||||
_blockedLoaded = true;
|
||||
}
|
||||
|
||||
static Future<bool> 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<void> syncFromLoginPayload(
|
||||
Map<dynamic, dynamic> data,
|
||||
int accountId,
|
||||
) async {
|
||||
final contacts = data['contacts'];
|
||||
if (contacts is! List || contacts.isEmpty) return;
|
||||
if (contacts is! List) {
|
||||
logger.i('Контакты: сервер не прислал список (акк $accountId)');
|
||||
return;
|
||||
}
|
||||
if (contacts.isEmpty) {
|
||||
logger.i('Контакты: сервер прислал пустой список (акк $accountId)');
|
||||
return;
|
||||
}
|
||||
|
||||
final rows = <Map<String, dynamic>>[];
|
||||
for (final raw in contacts.whereType<Map>()) {
|
||||
final contact = raw.cast<dynamic, dynamic>();
|
||||
final row = _parseContact(contact, accountId);
|
||||
if (row != null) rows.add(row);
|
||||
_primeContactCache(contact);
|
||||
primeContactCache(contact);
|
||||
}
|
||||
|
||||
if (rows.isNotEmpty) {
|
||||
@@ -191,17 +436,32 @@ class ContactsModule {
|
||||
await syncFromLoginPayload(map.cast<dynamic, dynamic>(), accountId);
|
||||
}
|
||||
|
||||
static void _primeContactCache(Map<dynamic, dynamic> contact) {
|
||||
static Future<ProfileData?> 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<Map>()) {
|
||||
if (raw['id'] != accountId) continue;
|
||||
final contact = raw.cast<dynamic, dynamic>();
|
||||
primeContactCache(contact);
|
||||
return ProfileData.fromServerMap(contact);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static void primeContactCache(Map<dynamic, dynamic> 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 = names.firstWhere(
|
||||
(n) => n is Map && n['type'] == 'ONEME',
|
||||
orElse: () => names.firstWhere((n) => n is Map, orElse: () => null),
|
||||
);
|
||||
if (nameRaw is Map) {
|
||||
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)
|
||||
@@ -217,12 +477,17 @@ class ContactsModule {
|
||||
}
|
||||
}
|
||||
|
||||
static final Map<int, ContactPhotos> _photosHead = {};
|
||||
|
||||
static ContactPhotos? cachedPhotos(int contactId) => _photosHead[contactId];
|
||||
|
||||
static Future<ContactPhotos> 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,
|
||||
@@ -234,23 +499,67 @@ class ContactsModule {
|
||||
? rawUrls.whereType<String>().toList()
|
||||
: <String>[];
|
||||
final total = map['total'] is int ? map['total'] as int : urls.length;
|
||||
return ContactPhotos(urls: urls, total: total);
|
||||
final photos = ContactPhotos(urls: urls, total: total);
|
||||
if (from == 0) _photosHead[contactId] = photos;
|
||||
return photos;
|
||||
}
|
||||
|
||||
static Future<List<CachedContact>> getContacts(int accountId) async {
|
||||
final rows = await AppDatabase.loadContacts(accountId);
|
||||
static Future<List<CachedContact>> getContacts(
|
||||
int accountId, {
|
||||
bool includeDeleted = false,
|
||||
}) async {
|
||||
final rows = await AppDatabase.loadContacts(
|
||||
accountId,
|
||||
includeDeleted: includeDeleted,
|
||||
);
|
||||
return rows.map(CachedContact.fromDbRow).toList();
|
||||
}
|
||||
|
||||
static Future<CachedContact?> getContact(int accountId, int id) async {
|
||||
final row = await AppDatabase.loadContact(accountId, id);
|
||||
return row == null ? null : CachedContact.fromDbRow(row);
|
||||
}
|
||||
|
||||
static const List<String> _debugFirstNames = [
|
||||
'Алиса', 'Борис', 'Вера', 'Глеб', 'Дарья', 'Егор', 'Жанна', 'Захар',
|
||||
'Ирина', 'Кирилл', 'Лия', 'Максим', 'Нина', 'Олег', 'Полина', 'Роман',
|
||||
'София', 'Тимур', 'Ульяна', 'Фёдор', 'Ханна', 'Цветана', 'Чеслав', 'Шура',
|
||||
'Алиса',
|
||||
'Борис',
|
||||
'Вера',
|
||||
'Глеб',
|
||||
'Дарья',
|
||||
'Егор',
|
||||
'Жанна',
|
||||
'Захар',
|
||||
'Ирина',
|
||||
'Кирилл',
|
||||
'Лия',
|
||||
'Максим',
|
||||
'Нина',
|
||||
'Олег',
|
||||
'Полина',
|
||||
'Роман',
|
||||
'София',
|
||||
'Тимур',
|
||||
'Ульяна',
|
||||
'Фёдор',
|
||||
'Ханна',
|
||||
'Цветана',
|
||||
'Чеслав',
|
||||
'Шура',
|
||||
];
|
||||
|
||||
static const List<String> _debugLastNames = [
|
||||
'Иванов', 'Петров', 'Сидоров', 'Кузнецов', 'Смирнов', 'Попов', 'Волков',
|
||||
'Соколов', 'Морозов', 'Новиков', 'Фёдоров', 'Козлов',
|
||||
'Иванов',
|
||||
'Петров',
|
||||
'Сидоров',
|
||||
'Кузнецов',
|
||||
'Смирнов',
|
||||
'Попов',
|
||||
'Волков',
|
||||
'Соколов',
|
||||
'Морозов',
|
||||
'Новиков',
|
||||
'Фёдоров',
|
||||
'Козлов',
|
||||
];
|
||||
|
||||
static List<CachedContact> debugContacts() {
|
||||
@@ -280,8 +589,9 @@ class ContactsModule {
|
||||
/// Прогревает in-memory ContactCache из локальных контактов.
|
||||
/// Нужно вызывать на cold start: иначе кэш пуст до следующего логина.
|
||||
static Future<void> primeCacheFromDb(int accountId) async {
|
||||
final contacts = await getContacts(accountId);
|
||||
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;
|
||||
@@ -292,6 +602,19 @@ class ContactsModule {
|
||||
}
|
||||
}
|
||||
|
||||
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<String, dynamic>? _parseContact(
|
||||
Map<dynamic, dynamic> contact,
|
||||
int accountId,
|
||||
@@ -304,14 +627,10 @@ class ContactsModule {
|
||||
|
||||
final names = contact['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) return null;
|
||||
final name = nameRaw;
|
||||
firstName = (name['firstName'] as String?) ?? '';
|
||||
lastName = name['lastName'] as String?;
|
||||
final nameRaw = _preferredNameEntry(names);
|
||||
if (nameRaw == null) return null;
|
||||
firstName = (nameRaw['firstName'] as String?) ?? '';
|
||||
lastName = nameRaw['lastName'] as String?;
|
||||
}
|
||||
|
||||
final optionsRaw = contact['options'];
|
||||
@@ -331,6 +650,7 @@ class ContactsModule {
|
||||
'base_raw_url': contact['baseRawUrl'] as String?,
|
||||
'update_time': (contact['updateTime'] as int?) ?? 0,
|
||||
'options': optionsStr,
|
||||
'account_status': (contact['accountStatus'] as int?) ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
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);
|
||||
const DigitalIdException(this.code, this.message, {this.statusCode});
|
||||
|
||||
bool get isUnauthorized => code == 'UNAUTHORIZED';
|
||||
bool get isNoGosuslugiLink => code == 'NO_GOSUSLUGI_LINK';
|
||||
@@ -61,10 +66,21 @@ class DigitalIdModule {
|
||||
).firstMatch(fragment);
|
||||
final raw = match?.group(1);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
return Uri.decodeComponent(raw);
|
||||
// Фрагмент несёт WebAppData полностью percent-encoded (hash%3D…%26…);
|
||||
// сервер ждёт канонический initDataRaw (hash=…&…) — как шлёт web-страница
|
||||
// после decodeURIComponent. Декодируем один раз (фолбэк — сырое значение).
|
||||
try {
|
||||
return Uri.decodeComponent(raw);
|
||||
} catch (_) {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> 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) {
|
||||
@@ -144,6 +160,9 @@ class DigitalIdModule {
|
||||
}
|
||||
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);
|
||||
@@ -170,7 +189,7 @@ class DigitalIdModule {
|
||||
}
|
||||
} catch (_) {}
|
||||
if (statusCode == 401) code = 'UNAUTHORIZED';
|
||||
return DigitalIdException(code, message);
|
||||
return DigitalIdException(code, message, statusCode: statusCode);
|
||||
}
|
||||
|
||||
Map _unwrapData(dynamic decoded) {
|
||||
@@ -246,7 +265,7 @@ class DigitalIdModule {
|
||||
);
|
||||
return _unwrapData(decoded)['shadow_mode'] == true;
|
||||
} on DigitalIdException catch (e) {
|
||||
if (e.code == 'HTTP_404') return false;
|
||||
if (e.statusCode == 404) return false;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -377,7 +396,34 @@ class DigitalIdModule {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> 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 = <String, String>{};
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,10 @@ import 'dart:convert' show jsonDecode, utf8;
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:kolibri/kolibri.dart' as kb;
|
||||
|
||||
import '../api.dart';
|
||||
import '../../core/config/proxy_config.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/transport/proxy_connector.dart';
|
||||
import '../../core/transport/tls_config.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import 'messages.dart';
|
||||
|
||||
@@ -27,12 +26,18 @@ class UploadDone extends UploadEvent {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,10 +46,10 @@ class UploadError extends UploadEvent {
|
||||
const UploadError(this.message);
|
||||
}
|
||||
|
||||
/// Оркестратор медиа-загрузок: control-plane (URL, отправка сообщения) идёт
|
||||
/// обычными опкодами, data-plane (заливка на CDN) — через Rust-ядро kolibri,
|
||||
/// которое стримит файл с диска (не держит его целиком в памяти).
|
||||
class FileUploader {
|
||||
static const String _userAgentHeader =
|
||||
'OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)';
|
||||
|
||||
final Api api;
|
||||
final MessagesModule messages;
|
||||
|
||||
@@ -62,13 +67,11 @@ class FileUploader {
|
||||
}) {
|
||||
final ctrl = StreamController<UploadEvent>();
|
||||
var cancelled = false;
|
||||
Socket? socket;
|
||||
StreamSubscription<kb.UploadEvent>? sub;
|
||||
|
||||
ctrl.onCancel = () {
|
||||
cancelled = true;
|
||||
try {
|
||||
socket?.destroy();
|
||||
} catch (_) {}
|
||||
sub?.cancel();
|
||||
};
|
||||
|
||||
Future<void> run() async {
|
||||
@@ -80,6 +83,12 @@ class FileUploader {
|
||||
return;
|
||||
}
|
||||
|
||||
final session = api.session;
|
||||
if (session == null) {
|
||||
ctrl.add(const UploadError('no_session'));
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(() async {
|
||||
try {
|
||||
await api.sendRequest(Opcode.msgTyping, {
|
||||
@@ -89,38 +98,61 @@ class FileUploader {
|
||||
} catch (_) {}
|
||||
}());
|
||||
|
||||
final uri = Uri.parse(info.url);
|
||||
final result = await _sendHttpRequest(
|
||||
uri,
|
||||
method: 'POST',
|
||||
headers: _buildUploadHeaders(uri, filename, totalSize),
|
||||
bodyStream: file.openRead(),
|
||||
progressTotal: totalSize,
|
||||
onProgress: (sent, total) {
|
||||
if (!cancelled) ctrl.add(UploadProgress(sent: sent, total: total));
|
||||
},
|
||||
progressThrottle: progressThrottle,
|
||||
autoForceAfter: autoForceAfter,
|
||||
timeout: overallTimeout,
|
||||
onSocketReady: (s) => socket = s,
|
||||
shouldAbort: () => cancelled,
|
||||
);
|
||||
var status = 0;
|
||||
String? error;
|
||||
final done = Completer<void>();
|
||||
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;
|
||||
|
||||
final statusCode = result?.$1 ?? 0;
|
||||
if (statusCode != 200 && statusCode != 0) {
|
||||
ctrl.add(UploadError('http_$statusCode'));
|
||||
if (error != null) {
|
||||
ctrl.add(UploadError(error!));
|
||||
return;
|
||||
}
|
||||
if (status != 200 && status != 0) {
|
||||
ctrl.add(UploadError('http_$status'));
|
||||
return;
|
||||
}
|
||||
|
||||
final ok = await messages.sendFileMessage(
|
||||
final messageId = await messages.sendFileMessage(
|
||||
chatId,
|
||||
info.fileId,
|
||||
token: info.token,
|
||||
scheduledTime: scheduledTime,
|
||||
);
|
||||
if (cancelled) return;
|
||||
if (!ok) {
|
||||
if (messageId == null) {
|
||||
ctrl.add(const UploadError('send_failed'));
|
||||
return;
|
||||
}
|
||||
@@ -132,14 +164,12 @@ class FileUploader {
|
||||
url: info.url,
|
||||
filename: filename,
|
||||
size: totalSize,
|
||||
messageId: messageId,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!cancelled) ctrl.add(UploadError(e.toString()));
|
||||
} finally {
|
||||
try {
|
||||
socket?.destroy();
|
||||
} catch (_) {}
|
||||
await ctrl.close();
|
||||
}
|
||||
}
|
||||
@@ -155,133 +185,59 @@ class FileUploader {
|
||||
Duration overallTimeout = const Duration(minutes: 5),
|
||||
Duration progressThrottle = const Duration(milliseconds: 16),
|
||||
}) async {
|
||||
final session = api.session;
|
||||
if (session == null) return false;
|
||||
try {
|
||||
final total = await file.length();
|
||||
if (total <= 0) return false;
|
||||
final filename = _syntheticFilename();
|
||||
|
||||
final result = await _sendHttpRequest(
|
||||
uri,
|
||||
method: 'POST',
|
||||
headers: _buildUploadHeaders(
|
||||
uri,
|
||||
filename,
|
||||
total,
|
||||
final result = await _consume(
|
||||
session.uploadFilePath(
|
||||
url: uri.toString(),
|
||||
path: file.path,
|
||||
filename: _syntheticFilename(),
|
||||
contentType: 'application/octet-stream',
|
||||
connection: 'close',
|
||||
),
|
||||
bodyStream: file.openRead(),
|
||||
progressTotal: total,
|
||||
onProgress: onProgress,
|
||||
progressThrottle: progressThrottle,
|
||||
timeout: overallTimeout,
|
||||
);
|
||||
|
||||
final statusCode = result?.$1 ?? 0;
|
||||
final respBody = result?.$2 ?? '';
|
||||
logger.w(
|
||||
'uploadMediaFile: status=$statusCode total=$total '
|
||||
'host=${uri.host} body=${respBody.length > 200 ? respBody.substring(0, 200) : respBody}',
|
||||
);
|
||||
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');
|
||||
return statusCode == 200 && !hasError;
|
||||
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<Socket> _openSocket(Uri uri) async {
|
||||
final proxySettings = await ProxyConfig.load();
|
||||
final base = proxySettings.isEnabled
|
||||
? await ProxyConnector(proxySettings).connect(uri.host, uri.port)
|
||||
: await Socket.connect(uri.host, uri.port);
|
||||
if (uri.scheme != 'https') return base;
|
||||
final allowInsecure = await TlsConfig.isInsecureAllowed();
|
||||
if (allowInsecure) {
|
||||
logger.w(
|
||||
'TLS: проверка сертификата отключена (дебаг) — загрузка уязвима к MitM',
|
||||
);
|
||||
return SecureSocket.secure(
|
||||
base,
|
||||
host: uri.host,
|
||||
onBadCertificate: (_) => true,
|
||||
);
|
||||
}
|
||||
return SecureSocket.secure(base, host: uri.host);
|
||||
}
|
||||
|
||||
String _syntheticFilename() =>
|
||||
(DateTime.now().microsecondsSinceEpoch & 0x7FFFFFFF).toString();
|
||||
|
||||
String _multipartBoundary() =>
|
||||
'----KometBoundary${DateTime.now().microsecondsSinceEpoch}';
|
||||
|
||||
Map<String, String> _buildUploadHeaders(
|
||||
Uri uri,
|
||||
String filename,
|
||||
int total, {
|
||||
String contentType = 'application/x-binary; charset=x-user-defined',
|
||||
String connection = 'keep-alive',
|
||||
}) {
|
||||
return {
|
||||
'Host': uri.host,
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': 'attachment; filename=$filename',
|
||||
'Connection': connection,
|
||||
'User-Agent': Uri.encodeComponent(_userAgentHeader),
|
||||
'Content-Range': 'bytes 0-${total - 1}/$total',
|
||||
'Content-Length': '$total',
|
||||
};
|
||||
}
|
||||
|
||||
Future<String?> uploadImage(
|
||||
Uri uri,
|
||||
Uint8List bytes, {
|
||||
String filename = 'avatar.jpg',
|
||||
}) async {
|
||||
final session = api.session;
|
||||
if (session == null) return null;
|
||||
try {
|
||||
final boundary = _multipartBoundary();
|
||||
final preamble = utf8.encode(
|
||||
'--$boundary\r\n'
|
||||
'Content-Disposition: form-data; name="file"; filename="$filename"\r\n'
|
||||
'Content-Type: ${_contentTypeForFilename(filename)}\r\n'
|
||||
'\r\n',
|
||||
);
|
||||
final epilogue = utf8.encode('\r\n--$boundary--\r\n');
|
||||
|
||||
final response = await _sendHttpRequest(
|
||||
uri,
|
||||
method: 'POST',
|
||||
headers: _buildMultipartHeaders(
|
||||
uri,
|
||||
preamble.length + bytes.length + epilogue.length,
|
||||
boundary: boundary,
|
||||
final result = await _consume(
|
||||
session.uploadPhoto(
|
||||
url: uri.toString(),
|
||||
data: bytes,
|
||||
filename: filename,
|
||||
),
|
||||
prefixBytes: preamble,
|
||||
bodyStream: Stream.value(bytes),
|
||||
suffixBytes: epilogue,
|
||||
timeout: const Duration(minutes: 2),
|
||||
);
|
||||
|
||||
if (response == null) {
|
||||
if (result.error != null || result.status != 200) {
|
||||
logger.w('uploadImage: status=${result.status} error=${result.error}');
|
||||
return null;
|
||||
}
|
||||
final (status, body) = response;
|
||||
if (status != 200) {
|
||||
logger.w(
|
||||
'uploadImage: status=$status body=${body.length > 200 ? '${body.substring(0, 200)}…' : body}',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
final token = _parsePhotoToken(body);
|
||||
if (token == null) {
|
||||
logger.w(
|
||||
'uploadImage: photoToken not found in body=${body.length > 200 ? '${body.substring(0, 200)}…' : body}',
|
||||
);
|
||||
}
|
||||
return token;
|
||||
return _parsePhotoToken(utf8.decode(result.body, allowMalformed: true));
|
||||
} catch (e) {
|
||||
logger.w('uploadImage: $e');
|
||||
return null;
|
||||
@@ -295,41 +251,22 @@ class FileUploader {
|
||||
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 fileLength = await file.length();
|
||||
final boundary = _multipartBoundary();
|
||||
final preamble = utf8.encode(
|
||||
'--$boundary\r\n'
|
||||
'Content-Disposition: form-data; name="file"; filename="$filename"\r\n'
|
||||
'Content-Type: ${_contentTypeForFilename(filename)}\r\n'
|
||||
'\r\n',
|
||||
);
|
||||
final epilogue = utf8.encode('\r\n--$boundary--\r\n');
|
||||
|
||||
final response = await _sendHttpRequest(
|
||||
uri,
|
||||
method: 'POST',
|
||||
headers: _buildMultipartHeaders(
|
||||
uri,
|
||||
preamble.length + fileLength + epilogue.length,
|
||||
boundary: boundary,
|
||||
final result = await _consume(
|
||||
session.uploadPhotoPath(
|
||||
url: uri.toString(),
|
||||
path: file.path,
|
||||
filename: filename,
|
||||
),
|
||||
prefixBytes: preamble,
|
||||
bodyStream: file.openRead(),
|
||||
suffixBytes: epilogue,
|
||||
progressTotal: fileLength,
|
||||
onProgress: onProgress,
|
||||
progressThrottle: progressThrottle,
|
||||
timeout: const Duration(minutes: 2),
|
||||
);
|
||||
|
||||
if (response == null) return null;
|
||||
final (status, responseBody) = response;
|
||||
if (status != 200) {
|
||||
logger.w('uploadPhoto: status=$status');
|
||||
if (result.error != null || result.status != 200) {
|
||||
logger.w('uploadPhoto: status=${result.status} error=${result.error}');
|
||||
return null;
|
||||
}
|
||||
return _parsePhotoToken(responseBody);
|
||||
return _parsePhotoToken(utf8.decode(result.body, allowMalformed: true));
|
||||
} catch (e) {
|
||||
logger.w('uploadPhoto: $e');
|
||||
return null;
|
||||
@@ -344,342 +281,129 @@ class FileUploader {
|
||||
int concurrency = 4,
|
||||
Duration overallTimeout = const Duration(minutes: 30),
|
||||
}) async {
|
||||
final total = await file.length();
|
||||
if (total <= 0) return false;
|
||||
|
||||
final fileName = _syntheticFilename();
|
||||
|
||||
final handshake = await _okCdnRequest(
|
||||
uri,
|
||||
method: 'GET',
|
||||
fileName: fileName,
|
||||
timeout: const Duration(seconds: 30),
|
||||
);
|
||||
if (handshake == null || handshake.$1 != 200) return false;
|
||||
|
||||
var startOffset = 0;
|
||||
final resumed = int.tryParse(handshake.$2.trim());
|
||||
if (resumed != null && resumed > 0 && resumed <= total) {
|
||||
startOffset = resumed;
|
||||
}
|
||||
|
||||
final ranges = <(int, int)>[];
|
||||
for (var o = startOffset; o < total; o += chunkSize) {
|
||||
ranges.add((o, o + chunkSize < total ? o + chunkSize : total));
|
||||
}
|
||||
if (ranges.isEmpty) return true;
|
||||
|
||||
var nextIndex = 0;
|
||||
var sent = startOffset;
|
||||
var failed = false;
|
||||
|
||||
Future<void> worker() async {
|
||||
while (!failed) {
|
||||
final i = nextIndex++;
|
||||
if (i >= ranges.length) return;
|
||||
final (start, end) = ranges[i];
|
||||
final bytes = await _readRange(file, start, end);
|
||||
|
||||
final resp = await _okCdnRequest(
|
||||
uri,
|
||||
method: 'POST',
|
||||
fileName: fileName,
|
||||
body: bytes,
|
||||
contentRange: 'bytes $start-${end - 1}/$total',
|
||||
timeout: overallTimeout,
|
||||
);
|
||||
if (resp == null || (resp.$1 != 200 && resp.$1 != 201)) {
|
||||
logger.w('uploadVideoFile: chunk status=${resp?.$1}');
|
||||
failed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
sent += end - start;
|
||||
onProgress?.call(sent, total);
|
||||
}
|
||||
}
|
||||
|
||||
final workerCount = concurrency < ranges.length
|
||||
? concurrency
|
||||
: ranges.length;
|
||||
await Future.wait(List.generate(workerCount, (_) => worker()));
|
||||
return !failed;
|
||||
}
|
||||
|
||||
Future<Uint8List> _readRange(File file, int start, int end) async {
|
||||
final builder = BytesBuilder(copy: false);
|
||||
await for (final chunk in file.openRead(start, end)) {
|
||||
builder.add(chunk);
|
||||
}
|
||||
return builder.takeBytes();
|
||||
}
|
||||
|
||||
Future<(int, String)?> _okCdnRequest(
|
||||
Uri uri, {
|
||||
required String method,
|
||||
required String fileName,
|
||||
Uint8List? body,
|
||||
String? contentRange,
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
final session = api.session;
|
||||
if (session == null) return false;
|
||||
try {
|
||||
final headers = {
|
||||
'Host': uri.host,
|
||||
'Content-Type': 'application/x-binary; charset=x-user-defined',
|
||||
'Content-Disposition': 'attachment; fileName="$fileName"',
|
||||
'Content-Range': ?contentRange,
|
||||
'Content-Length': '${body?.length ?? 0}',
|
||||
'X-Uploading-Mode': 'parallel',
|
||||
'Connection': 'close',
|
||||
};
|
||||
return await _sendHttpRequest(
|
||||
uri,
|
||||
method: method,
|
||||
headers: headers,
|
||||
prefixBytes: body,
|
||||
timeout: timeout,
|
||||
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('_okCdnRequest($method): $e');
|
||||
return null;
|
||||
logger.w('uploadVideoFile: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> _buildMultipartHeaders(
|
||||
/// Загрузка видео для истории. В отличие от чата (чанковый `uploadVideoPath`
|
||||
/// с GET-handshake), story-эндпоинт `su.oneme.ru/uploadVideo` ждёт **один POST
|
||||
/// на весь файл** (как у оригинального клиента) и возвращает медиа-токен в теле
|
||||
/// ответа — `[{"token":"..."}]`. Именно этот токен идёт в `media.token`
|
||||
/// STORIES_SEND, а не токен из ответа VIDEO_UPLOAD.
|
||||
Future<({bool ok, String? token})> uploadVideoWithToken(
|
||||
Uri uri,
|
||||
int total, {
|
||||
required String boundary,
|
||||
}) {
|
||||
return {
|
||||
'Host': uri.host,
|
||||
'Content-Type': 'multipart/form-data; boundary=$boundary',
|
||||
'Content-Length': '$total',
|
||||
'Connection': 'keep-alive',
|
||||
'User-Agent': Uri.encodeComponent(_userAgentHeader),
|
||||
};
|
||||
}
|
||||
|
||||
Future<(int, String)?> _sendHttpRequest(
|
||||
Uri uri, {
|
||||
required String method,
|
||||
required Map<String, String> headers,
|
||||
List<int>? prefixBytes,
|
||||
Stream<List<int>>? bodyStream,
|
||||
List<int>? suffixBytes,
|
||||
int? progressTotal,
|
||||
File file, {
|
||||
void Function(int sent, int total)? onProgress,
|
||||
Duration progressThrottle = const Duration(milliseconds: 16),
|
||||
Duration? autoForceAfter,
|
||||
required Duration timeout,
|
||||
void Function(Socket socket)? onSocketReady,
|
||||
bool Function()? shouldAbort,
|
||||
}) async {
|
||||
final socket = await _openSocket(uri);
|
||||
onSocketReady?.call(socket);
|
||||
final session = api.session;
|
||||
if (session == null) return (ok: false, token: null);
|
||||
try {
|
||||
if (shouldAbort?.call() ?? false) return null;
|
||||
|
||||
_writeRequestHeaders(socket, uri, method, headers);
|
||||
if (prefixBytes != null && prefixBytes.isNotEmpty) {
|
||||
socket.add(prefixBytes);
|
||||
}
|
||||
if (bodyStream != null) {
|
||||
final stream = (onProgress != null && progressTotal != null)
|
||||
? _withProgress(
|
||||
bodyStream,
|
||||
progressTotal,
|
||||
onProgress,
|
||||
throttle: progressThrottle,
|
||||
)
|
||||
: bodyStream;
|
||||
await socket.addStream(stream);
|
||||
}
|
||||
if (suffixBytes != null && suffixBytes.isNotEmpty) {
|
||||
socket.add(suffixBytes);
|
||||
}
|
||||
await socket.flush();
|
||||
if (onProgress != null && progressTotal != null) {
|
||||
onProgress(progressTotal, progressTotal);
|
||||
}
|
||||
if (shouldAbort?.call() ?? false) return null;
|
||||
|
||||
if (autoForceAfter != null) {
|
||||
final status = await _readResponse(
|
||||
socket,
|
||||
autoForceAfter: autoForceAfter,
|
||||
overallTimeout: timeout,
|
||||
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 (status, '');
|
||||
return (ok: false, token: null);
|
||||
}
|
||||
return await _readFullResponse(socket, timeout: timeout);
|
||||
} finally {
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
void _writeRequestHeaders(
|
||||
Socket socket,
|
||||
Uri uri,
|
||||
String method,
|
||||
Map<String, String> headers,
|
||||
) {
|
||||
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
|
||||
final buffer = StringBuffer()..write('$method $path HTTP/1.1\r\n');
|
||||
for (final entry in headers.entries) {
|
||||
buffer.write('${entry.key}: ${entry.value}\r\n');
|
||||
}
|
||||
buffer.write('\r\n');
|
||||
socket.add(utf8.encode(buffer.toString()));
|
||||
}
|
||||
|
||||
Stream<List<int>> _withProgress(
|
||||
Stream<List<int>> src,
|
||||
int total,
|
||||
void Function(int sent, int total) onProgress, {
|
||||
Duration throttle = const Duration(milliseconds: 16),
|
||||
}) {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
var sent = 0;
|
||||
return src.map((chunk) {
|
||||
sent += chunk.length;
|
||||
if (stopwatch.elapsed >= throttle) {
|
||||
onProgress(sent, total);
|
||||
stopwatch.reset();
|
||||
}
|
||||
return chunk;
|
||||
});
|
||||
}
|
||||
|
||||
String _contentTypeForFilename(String filename) {
|
||||
final ext = filename.contains('.')
|
||||
? filename.split('.').last.toLowerCase()
|
||||
: '';
|
||||
switch (ext) {
|
||||
case 'png':
|
||||
return 'image/png';
|
||||
case 'gif':
|
||||
return 'image/gif';
|
||||
case 'webp':
|
||||
return 'image/webp';
|
||||
case 'heic':
|
||||
case 'heif':
|
||||
return 'image/heic';
|
||||
case 'bmp':
|
||||
return 'image/bmp';
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
default:
|
||||
return 'image/jpeg';
|
||||
}
|
||||
}
|
||||
|
||||
Future<(int, String)?> _readFullResponse(
|
||||
Socket socket, {
|
||||
required Duration timeout,
|
||||
}) {
|
||||
final bytes = <int>[];
|
||||
final completer = Completer<(int, String)?>();
|
||||
Timer? timer;
|
||||
StreamSubscription<List<int>>? sub;
|
||||
|
||||
void finishWith((int, String)? value) {
|
||||
timer?.cancel();
|
||||
sub?.cancel();
|
||||
if (!completer.isCompleted) completer.complete(value);
|
||||
}
|
||||
|
||||
(int, String)? tryParse({required bool atClose}) {
|
||||
final headerEnd = _findHeaderEnd(bytes);
|
||||
if (headerEnd == -1) return null;
|
||||
final headerStr = utf8.decode(
|
||||
bytes.sublist(0, headerEnd),
|
||||
allowMalformed: true,
|
||||
final token = _parseVideoToken(
|
||||
utf8.decode(result.body, allowMalformed: true),
|
||||
);
|
||||
final lines = headerStr.split('\r\n');
|
||||
final parts = lines.first.split(' ');
|
||||
final status = parts.length >= 2 ? (int.tryParse(parts[1]) ?? 0) : 0;
|
||||
final headerLines = lines.skip(1);
|
||||
final chunked = headerLines.any(
|
||||
(l) =>
|
||||
l.toLowerCase().startsWith('transfer-encoding:') &&
|
||||
l.toLowerCase().contains('chunked'),
|
||||
);
|
||||
int? contentLength;
|
||||
for (final l in headerLines) {
|
||||
if (l.toLowerCase().startsWith('content-length:')) {
|
||||
contentLength = int.tryParse(l.split(':').last.trim());
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
final rawBody = utf8.decode(
|
||||
bytes.sublist(headerEnd),
|
||||
allowMalformed: true,
|
||||
);
|
||||
if (chunked) {
|
||||
if (!atClose && !rawBody.contains('\r\n0\r\n')) return null;
|
||||
return (status, _decodeChunked(rawBody));
|
||||
}
|
||||
if (contentLength != null &&
|
||||
!atClose &&
|
||||
bytes.length - headerEnd < contentLength) {
|
||||
return null;
|
||||
}
|
||||
return (status, rawBody);
|
||||
}
|
||||
|
||||
sub = socket.listen(
|
||||
(chunk) {
|
||||
bytes.addAll(chunk);
|
||||
final parsed = tryParse(atClose: false);
|
||||
if (parsed != null) finishWith(parsed);
|
||||
},
|
||||
onError: (e) {
|
||||
logger.w('uploadImage: socket error after ${bytes.length} bytes: $e');
|
||||
finishWith(tryParse(atClose: true));
|
||||
},
|
||||
onDone: () {
|
||||
final parsed = tryParse(atClose: true);
|
||||
if (parsed == null) {
|
||||
logger.w(
|
||||
'uploadImage: connection closed without HTTP response (${bytes.length} bytes)',
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finishWith(parsed);
|
||||
},
|
||||
);
|
||||
timer = Timer(timeout, () {
|
||||
logger.w('uploadImage: response timeout after ${bytes.length} bytes');
|
||||
finishWith(tryParse(atClose: true));
|
||||
});
|
||||
return completer.future;
|
||||
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;
|
||||
}
|
||||
|
||||
String _decodeChunked(String body) {
|
||||
final out = StringBuffer();
|
||||
var i = 0;
|
||||
while (i < body.length) {
|
||||
final lineEnd = body.indexOf('\r\n', i);
|
||||
if (lineEnd < 0) break;
|
||||
final sizeStr = body.substring(i, lineEnd).split(';').first.trim();
|
||||
if (sizeStr.isEmpty) {
|
||||
i = lineEnd + 2;
|
||||
continue;
|
||||
}
|
||||
final size = int.tryParse(sizeStr, radix: 16);
|
||||
if (size == null) break;
|
||||
if (size == 0) break;
|
||||
final dataStart = lineEnd + 2;
|
||||
if (dataStart + size > body.length) break;
|
||||
out.write(body.substring(dataStart, dataStart + size));
|
||||
i = dataStart + size;
|
||||
if (i + 2 <= body.length && body.substring(i, i + 2) == '\r\n') {
|
||||
i += 2;
|
||||
/// Прогоняет стрим ядра до конца, форвардит прогресс, отдаёт итог.
|
||||
Future<({int status, Uint8List body, String? error})> _consume(
|
||||
Stream<kb.UploadEvent> 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 out.toString();
|
||||
return (status: status, body: body, error: error);
|
||||
}
|
||||
|
||||
String _syntheticFilename() =>
|
||||
(DateTime.now().microsecondsSinceEpoch & 0x7FFFFFFF).toString();
|
||||
|
||||
String? _parsePhotoToken(String body) {
|
||||
try {
|
||||
final json = jsonDecode(body);
|
||||
@@ -701,79 +425,4 @@ class FileUploader {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<int> _readResponse(
|
||||
Socket socket, {
|
||||
required Duration autoForceAfter,
|
||||
required Duration overallTimeout,
|
||||
}) {
|
||||
final responseBytes = <int>[];
|
||||
final completer = Completer<int>();
|
||||
Timer? force;
|
||||
Timer? overall;
|
||||
StreamSubscription<List<int>>? sub;
|
||||
|
||||
void finish(int code) {
|
||||
if (completer.isCompleted) return;
|
||||
force?.cancel();
|
||||
overall?.cancel();
|
||||
sub?.cancel();
|
||||
completer.complete(code);
|
||||
}
|
||||
|
||||
void fail(Object e) {
|
||||
if (completer.isCompleted) return;
|
||||
force?.cancel();
|
||||
overall?.cancel();
|
||||
sub?.cancel();
|
||||
completer.completeError(e);
|
||||
}
|
||||
|
||||
force = Timer(autoForceAfter, () => finish(0));
|
||||
|
||||
sub = socket.listen(
|
||||
responseBytes.addAll,
|
||||
onError: fail,
|
||||
onDone: () {
|
||||
final code = _parseHttpStatus(responseBytes);
|
||||
if (code == null) {
|
||||
fail(const SocketException('Не удалось прочитать заголовок ответа'));
|
||||
} else {
|
||||
finish(code);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
overall = Timer(
|
||||
overallTimeout,
|
||||
() => fail(TimeoutException('Тайм-аут загрузки')),
|
||||
);
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
int? _parseHttpStatus(List<int> bytes) {
|
||||
final headerEnd = _findHeaderEnd(bytes);
|
||||
if (headerEnd == -1) return null;
|
||||
final headerStr = utf8.decode(
|
||||
bytes.sublist(0, headerEnd),
|
||||
allowMalformed: true,
|
||||
);
|
||||
final statusLine = headerStr.split('\r\n').first;
|
||||
final parts = statusLine.split(' ');
|
||||
if (parts.length < 2) return null;
|
||||
return int.tryParse(parts[1]);
|
||||
}
|
||||
|
||||
int _findHeaderEnd(List<int> bytes) {
|
||||
for (var i = 0; i < bytes.length - 3; i++) {
|
||||
if (bytes[i] == 0x0D &&
|
||||
bytes[i + 1] == 0x0A &&
|
||||
bytes[i + 2] == 0x0D &&
|
||||
bytes[i + 3] == 0x0A) {
|
||||
return i + 4;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
+414
-155
@@ -1,4 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../models/chat_folder.dart';
|
||||
@@ -6,11 +10,54 @@ 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<ChatFolder> folders;
|
||||
final List<String> 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<int> revision = ValueNotifier<int>(0);
|
||||
|
||||
static StreamSubscription<Packet>? _pushSub;
|
||||
static Future<void> _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<void> _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<dynamic, dynamic>());
|
||||
await chats.applyFavorites(accountId);
|
||||
}
|
||||
|
||||
static Future<void> markFoldersListReady(int accountId) async {
|
||||
await AppDatabase.setSyncValue(accountId, _listReadyKey, '1');
|
||||
}
|
||||
@@ -23,7 +70,7 @@ class FoldersModule {
|
||||
}
|
||||
|
||||
static bool isAllChatsFolder(ChatFolder f) {
|
||||
if (f.id == 'all.chat.folder') return true;
|
||||
if (f.id == allChatsFolderId) return true;
|
||||
final t = f.title.trim().toLowerCase();
|
||||
return t == 'все' || t == 'все чаты' || t == 'all' || t == 'all chats';
|
||||
}
|
||||
@@ -36,14 +83,21 @@ class FoldersModule {
|
||||
return folders.first.id;
|
||||
}
|
||||
|
||||
static void sortFoldersInPlace(
|
||||
List<ChatFolder> folders,
|
||||
List<dynamic>? foldersOrder,
|
||||
) {
|
||||
if (foldersOrder == null || foldersOrder.isEmpty) return;
|
||||
static String newFolderId() {
|
||||
final random = Random.secure();
|
||||
final bytes = List<int>.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<ChatFolder> folders, List<String> order) {
|
||||
if (order.isEmpty) return;
|
||||
final orderIndex = <String, int>{};
|
||||
for (var i = 0; i < foldersOrder.length; i++) {
|
||||
orderIndex.putIfAbsent(foldersOrder[i].toString(), () => i);
|
||||
for (var i = 0; i < order.length; i++) {
|
||||
orderIndex.putIfAbsent(order[i], () => i);
|
||||
}
|
||||
folders.sort((a, b) {
|
||||
final aIndex = orderIndex[a.id] ?? -1;
|
||||
@@ -55,35 +109,58 @@ class FoldersModule {
|
||||
});
|
||||
}
|
||||
|
||||
static const int filterUnread = 0;
|
||||
static const int filterChannel = 2;
|
||||
static const int filterGroup = 3;
|
||||
static const int filterContact = 8;
|
||||
static const int filterNotContact = 9;
|
||||
static const int filterBot = 10;
|
||||
static bool _matchesType(
|
||||
int filter,
|
||||
CachedChat chat, {
|
||||
required int myId,
|
||||
required Set<int> 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);
|
||||
|
||||
static int? _filterCode(dynamic raw) {
|
||||
if (raw is int) return raw;
|
||||
if (raw is String) {
|
||||
final n = int.tryParse(raw);
|
||||
if (n != null) return n;
|
||||
switch (raw) {
|
||||
case 'UNREAD':
|
||||
return filterUnread;
|
||||
case 'CHANNEL':
|
||||
return filterChannel;
|
||||
case 'GROUP':
|
||||
case 'CHAT':
|
||||
return filterGroup;
|
||||
case 'CONTACT':
|
||||
return filterContact;
|
||||
case 'NOT_CONTACT':
|
||||
return filterNotContact;
|
||||
case 'BOT':
|
||||
return filterBot;
|
||||
}
|
||||
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 null;
|
||||
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 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(
|
||||
@@ -92,112 +169,167 @@ class FoldersModule {
|
||||
required int myId,
|
||||
required Set<int> contactIds,
|
||||
}) {
|
||||
if (folder.include != null && folder.include!.contains(chat.id)) {
|
||||
return true;
|
||||
}
|
||||
if (folder.filters.isEmpty) return false;
|
||||
|
||||
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);
|
||||
|
||||
for (final raw in folder.filters) {
|
||||
switch (_filterCode(raw)) {
|
||||
case filterUnread:
|
||||
if (chat.unreadCount > 0) return true;
|
||||
case filterChannel:
|
||||
if (chat.type == 'CHANNEL') return true;
|
||||
case filterGroup:
|
||||
if (chat.type == 'CHAT' || chat.type == 'GROUP') return true;
|
||||
case filterContact:
|
||||
if (isDialog && !isBot && isContact) return true;
|
||||
case filterNotContact:
|
||||
if (isDialog && !isBot && !isSelf && !isContact) return true;
|
||||
case filterBot:
|
||||
if (isBot) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static List<ChatFolder> _parseFolderList(
|
||||
List<dynamic> json, {
|
||||
bool lenient = true,
|
||||
}) {
|
||||
if (lenient) {
|
||||
return json
|
||||
.map((e) {
|
||||
try {
|
||||
final m = e is Map<String, dynamic>
|
||||
? e
|
||||
: Map<String, dynamic>.from(e as Map);
|
||||
return ChatFolder.fromJson(m);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whereType<ChatFolder>()
|
||||
if (!folder.include.contains(chat.id)) {
|
||||
final typeFilters = folder.filters
|
||||
.where(FolderFilter.chatTypes.contains)
|
||||
.toList();
|
||||
if (typeFilters.isEmpty) return false;
|
||||
final matchesType = typeFilters.any(
|
||||
(f) => _matchesType(f, chat, myId: myId, contactIds: contactIds),
|
||||
);
|
||||
if (!matchesType) return false;
|
||||
}
|
||||
return json.map((e) {
|
||||
final m = e is Map<String, dynamic>
|
||||
? e
|
||||
: Map<String, dynamic>.from(e as Map);
|
||||
return ChatFolder.fromJson(m);
|
||||
}).toList();
|
||||
|
||||
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 Future<List<ChatFolder>> loadFolders(int accountId) async {
|
||||
static List<ChatFolder> _parseFolderList(dynamic raw) {
|
||||
if (raw is! List) return [];
|
||||
return raw
|
||||
.map((e) {
|
||||
try {
|
||||
final m = e is Map<String, dynamic>
|
||||
? e
|
||||
: Map<String, dynamic>.from(e as Map);
|
||||
return ChatFolder.fromJson(m);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whereType<ChatFolder>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
static List<String>? _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 [];
|
||||
if (raw == null || raw.isEmpty) return const _FoldersSnapshot();
|
||||
try {
|
||||
final map = jsonDecode(raw) as Map<String, dynamic>;
|
||||
final foldersJson = map['folders'] as List<dynamic>?;
|
||||
final folders = foldersJson == null
|
||||
? <ChatFolder>[]
|
||||
: _parseFolderList(foldersJson, lenient: false);
|
||||
final order = map['foldersOrder'] as List<dynamic>?;
|
||||
sortFoldersInPlace(folders, order);
|
||||
return folders;
|
||||
final folders = _parseFolderList(map['folders']);
|
||||
final order = _parseOrder(map['foldersOrder']) ?? const <String>[];
|
||||
_sortInPlace(folders, order);
|
||||
return _FoldersSnapshot(
|
||||
folders: folders,
|
||||
order: order,
|
||||
folderSync: _parseSync(map['folderSync']) ?? 0,
|
||||
);
|
||||
} catch (_) {
|
||||
return [];
|
||||
return const _FoldersSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _persist(
|
||||
static Future<void> _saveSnapshot(
|
||||
int accountId,
|
||||
List<ChatFolder> folders,
|
||||
List<dynamic>? order,
|
||||
_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': folders.map((f) => f.toJson()).toList(),
|
||||
'folders': snapshot.folders.map((f) => f.toJson()).toList(),
|
||||
'foldersOrder': order,
|
||||
'folderSync': snapshot.folderSync,
|
||||
}),
|
||||
);
|
||||
revision.value++;
|
||||
}
|
||||
|
||||
static Future<List<ChatFolder>> loadFolders(int accountId) async {
|
||||
return (await _loadSnapshot(accountId)).folders;
|
||||
}
|
||||
|
||||
static Future<List<String>> loadFoldersOrder(int accountId) async {
|
||||
return (await _loadSnapshot(accountId)).order;
|
||||
}
|
||||
|
||||
static Future<int> loadFolderSync(int accountId) async {
|
||||
return (await _loadSnapshot(accountId)).folderSync;
|
||||
}
|
||||
|
||||
static Future<void> applyPayload(
|
||||
int accountId,
|
||||
Map<dynamic, dynamic> payload,
|
||||
) async {
|
||||
final foldersJson = payload['folders'] as List<dynamic>?;
|
||||
final order = payload['foldersOrder'] as List<dynamic>?;
|
||||
if (foldersJson == null && order == null) return;
|
||||
|
||||
List<ChatFolder> folders;
|
||||
if (foldersJson != null) {
|
||||
folders = _parseFolderList(foldersJson);
|
||||
} else {
|
||||
folders = await loadFolders(accountId);
|
||||
Map<dynamic, dynamic> 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;
|
||||
}
|
||||
sortFoldersInPlace(folders, order);
|
||||
await _persist(accountId, folders, order);
|
||||
|
||||
final incoming = <ChatFolder>[
|
||||
..._parseFolderList(foldersRaw),
|
||||
if (folderRaw is Map)
|
||||
ChatFolder.fromJson(Map<String, dynamic>.from(folderRaw)),
|
||||
];
|
||||
|
||||
final current = await _loadSnapshot(accountId);
|
||||
var folders = replace && foldersRaw is List
|
||||
? List<ChatFolder>.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<ChatFolder> _merge(
|
||||
List<ChatFolder> current,
|
||||
List<ChatFolder> incoming,
|
||||
) {
|
||||
final merged = List<ChatFolder>.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<void> applyFromLoginConfig(
|
||||
@@ -206,57 +338,180 @@ class FoldersModule {
|
||||
) async {
|
||||
final chatFolders = config['chatFolders'];
|
||||
if (chatFolders is! Map) return;
|
||||
final foldersJson = chatFolders['FOLDERS'] as List<dynamic>?;
|
||||
if (foldersJson == null) return;
|
||||
final order = chatFolders['foldersOrder'] as List<dynamic>?;
|
||||
final folders = _parseFolderList(foldersJson);
|
||||
sortFoldersInPlace(folders, order);
|
||||
await _persist(accountId, folders, order);
|
||||
if (chatFolders['FOLDERS'] == null) return;
|
||||
await applyPayload(accountId, {
|
||||
'folders': chatFolders['FOLDERS'],
|
||||
'foldersOrder': chatFolders['foldersOrder'],
|
||||
'folderSync': chatFolders['folderSync'],
|
||||
}, replace: true);
|
||||
await markFoldersListReady(accountId);
|
||||
}
|
||||
|
||||
static Future<ChatFolder?> setFolderFavorites(
|
||||
static Future<ChatFolder> createFolder(
|
||||
Api api,
|
||||
int accountId, {
|
||||
required String title,
|
||||
List<int> include = const [],
|
||||
List<int> filters = const [],
|
||||
List<int> options = const [],
|
||||
List<int> favorites = const [],
|
||||
}) {
|
||||
return _sendUpdate(
|
||||
api,
|
||||
accountId,
|
||||
id: newFolderId(),
|
||||
title: title,
|
||||
include: include,
|
||||
filters: filters,
|
||||
options: options,
|
||||
favorites: favorites,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<ChatFolder> updateFolder(
|
||||
Api api,
|
||||
int accountId,
|
||||
ChatFolder folder, {
|
||||
String? title,
|
||||
List<int>? include,
|
||||
List<int>? filters,
|
||||
List<int>? options,
|
||||
List<int>? 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<ChatFolder> _sendUpdate(
|
||||
Api api,
|
||||
int accountId, {
|
||||
required String id,
|
||||
required String title,
|
||||
required List<int> include,
|
||||
required List<int> filters,
|
||||
required List<int> options,
|
||||
required List<int> 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<dynamic, dynamic>());
|
||||
return ChatFolder.fromJson(Map<String, dynamic>.from(folderJson));
|
||||
}
|
||||
|
||||
static Future<ChatFolder> setFolderFavorites(
|
||||
Api api,
|
||||
int accountId,
|
||||
ChatFolder folder,
|
||||
List<int> favorites,
|
||||
) {
|
||||
return updateFolder(api, accountId, folder, favorites: favorites);
|
||||
}
|
||||
|
||||
static Future<void> deleteFolders(
|
||||
Api api,
|
||||
int accountId,
|
||||
List<String> folderIds,
|
||||
) async {
|
||||
final packet = await api.sendRequest(Opcode.foldersUpdate, {
|
||||
'id': folder.id,
|
||||
'title': folder.title,
|
||||
'include': folder.include ?? const [],
|
||||
'favorites': favorites,
|
||||
'filters': folder.filters,
|
||||
'options': folder.options ?? const [],
|
||||
if (folderIds.isEmpty) return;
|
||||
final packet = await api.sendRequest(Opcode.foldersDelete, {
|
||||
'folderIds': folderIds,
|
||||
});
|
||||
throwIfPacketError(packet);
|
||||
final data = packet.payload;
|
||||
if (data is! Map) return null;
|
||||
final folderJson = data['folder'];
|
||||
if (folderJson is! Map) return null;
|
||||
final updated = ChatFolder.fromJson(
|
||||
folderJson is Map<String, dynamic>
|
||||
? folderJson
|
||||
: Map<String, dynamic>.from(folderJson),
|
||||
|
||||
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 currentRaw = await AppDatabase.getSyncValue(accountId, _syncKey);
|
||||
final snapshot = (currentRaw != null && currentRaw.isNotEmpty)
|
||||
? jsonDecode(currentRaw) as Map<String, dynamic>
|
||||
: <String, dynamic>{};
|
||||
final existingRaw = snapshot['folders'] as List<dynamic>?;
|
||||
final existing = existingRaw == null
|
||||
? <ChatFolder>[]
|
||||
: _parseFolderList(existingRaw, lenient: false);
|
||||
final idx = existing.indexWhere((f) => f.id == updated.id);
|
||||
if (idx >= 0) {
|
||||
existing[idx] = updated;
|
||||
} else {
|
||||
existing.add(updated);
|
||||
final data = packet.payload;
|
||||
if (data is Map) {
|
||||
await applyPayload(accountId, data.cast<dynamic, dynamic>());
|
||||
}
|
||||
final order = snapshot['foldersOrder'] as List<dynamic>?;
|
||||
await _persist(accountId, existing, order);
|
||||
return updated;
|
||||
}
|
||||
|
||||
static Future<void> reorderFolders(
|
||||
Api api,
|
||||
int accountId,
|
||||
List<String> 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<ChatFolder>.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<List<ChatFolder>> fetchFoldersByIds(
|
||||
Api api,
|
||||
int accountId,
|
||||
List<String> 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<void> syncFromServer(Api api, int accountId) async {
|
||||
@@ -267,7 +522,11 @@ class FoldersModule {
|
||||
throwIfPacketError(packet);
|
||||
final data = packet.payload;
|
||||
if (data is Map) {
|
||||
await applyPayload(accountId, data.cast<dynamic, dynamic>());
|
||||
await applyPayload(
|
||||
accountId,
|
||||
data.cast<dynamic, dynamic>(),
|
||||
replace: true,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await markFoldersListReady(accountId);
|
||||
|
||||
@@ -31,7 +31,9 @@ abstract class LinkModule {
|
||||
static Future<ResolvedLink?> resolve(Api api, String url) async {
|
||||
final Packet response;
|
||||
try {
|
||||
response = await api.sendRequest(Opcode.linkInfo, {'link': url});
|
||||
response = await api.sendRequest(Opcode.linkInfo, {
|
||||
'link': url,
|
||||
}, silent: true);
|
||||
} on TimeoutException {
|
||||
return const ResolvedLinkError('Превышено время ожидания');
|
||||
} on PacketError catch (e) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../api.dart';
|
||||
import '../../core/config/komet_settings.dart';
|
||||
import '../../core/contacts/device_contacts_service.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
@@ -17,6 +18,7 @@ class ContactCache {
|
||||
static final Map<int, String> _nameCache = {};
|
||||
static final Map<int, String> _avatarCache = {};
|
||||
static final Map<int, Set<String>> _optionsCache = {};
|
||||
static final Map<int, int> _phoneCache = {};
|
||||
|
||||
static const _prefsKey = 'contact_cache_v1';
|
||||
static Timer? _saveTimer;
|
||||
@@ -61,16 +63,37 @@ class ContactCache {
|
||||
_scheduleSave();
|
||||
}
|
||||
|
||||
static String? get(int id) => _nameCache[id];
|
||||
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<String>? 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());
|
||||
@@ -126,16 +149,92 @@ class TranscriptionResult {
|
||||
|
||||
class TranscriptionCache {
|
||||
static final Map<String, TranscriptionResult> _cache = {};
|
||||
static final Map<String, Set<VoidCallback>> _listeners = {};
|
||||
static final Set<String> _expanded = {};
|
||||
|
||||
static void put(String messageId, TranscriptionResult result) {
|
||||
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 void clear() => _cache.clear();
|
||||
static bool isExpanded(String messageId) => _expanded.contains(messageId);
|
||||
|
||||
static void setExpanded(String messageId, bool value) {
|
||||
if (value) {
|
||||
_expanded.add(messageId);
|
||||
} else {
|
||||
_expanded.remove(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
static void listen(String messageId, VoidCallback listener) {
|
||||
_listeners.putIfAbsent(messageId, () => <VoidCallback>{}).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<Packet>? _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 == null || rawText.isEmpty)
|
||||
? 'не удалось распознать текст'
|
||||
: rawText,
|
||||
messageId: messageId,
|
||||
chatId: source['chatId'] as int?,
|
||||
mediaId: source['mediaId'] as int?,
|
||||
),
|
||||
expanded: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FileHistoryEntry {
|
||||
@@ -274,6 +373,8 @@ class ReplyInfo {
|
||||
this.attachments,
|
||||
});
|
||||
|
||||
bool get missing => previewText().isEmpty;
|
||||
|
||||
static ReplyInfo? fromPayload(Map<String, dynamic>? payload) {
|
||||
if (payload == null) return null;
|
||||
final link = payload['link'];
|
||||
@@ -389,6 +490,30 @@ class CachedMessage {
|
||||
this.editHistory,
|
||||
});
|
||||
|
||||
ControlAttachment? get controlAttachment =>
|
||||
attachments?.whereType<ControlAttachment>().firstOrNull;
|
||||
|
||||
ForwardedMessageAttachment? get forwardedAttachment =>
|
||||
attachments?.whereType<ForwardedMessageAttachment>().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,
|
||||
@@ -742,6 +867,7 @@ class MessagesModule {
|
||||
bool notify = true,
|
||||
int? scheduledTime,
|
||||
int? replyToMessageId,
|
||||
int? replySourceChatId,
|
||||
List<Map<String, dynamic>> elements = const [],
|
||||
}) async {
|
||||
final message = <String, dynamic>{
|
||||
@@ -753,7 +879,7 @@ class MessagesModule {
|
||||
if (replyToMessageId != null) {
|
||||
message['link'] = {
|
||||
'type': 'REPLY',
|
||||
'chatId': chatId,
|
||||
'chatId': replySourceChatId ?? chatId,
|
||||
'messageId': replyToMessageId,
|
||||
};
|
||||
}
|
||||
@@ -768,6 +894,43 @@ class MessagesModule {
|
||||
return _sendAndExtractMessageId(payload, 'Ошибка отправки');
|
||||
}
|
||||
|
||||
Future<Packet> sendControlMessage(
|
||||
int chatId,
|
||||
Map<String, dynamic> control, {
|
||||
bool notify = true,
|
||||
}) {
|
||||
final payload = {
|
||||
'chatId': chatId,
|
||||
'message': {
|
||||
'cid': DateTime.now().millisecondsSinceEpoch * -1,
|
||||
'text': '',
|
||||
'attaches': [control],
|
||||
},
|
||||
'notify': notify,
|
||||
};
|
||||
return _api.sendRequest(Opcode.msgSend, payload);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> sendBotStart(
|
||||
int chatId,
|
||||
String startPayload,
|
||||
) async {
|
||||
final response = await _api.sendRequest(Opcode.msgSend, {
|
||||
'chatId': chatId,
|
||||
'message': {
|
||||
'cid': DateTime.now().millisecondsSinceEpoch * -1,
|
||||
'attaches': [
|
||||
{
|
||||
'_type': 'CONTROL',
|
||||
'event': ControlAttachment.botStartedEvent,
|
||||
'startPayload': startPayload,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return _sentMessageMap(response);
|
||||
}
|
||||
|
||||
Future<String> _sendAndExtractMessageId(
|
||||
Map<String, dynamic> payload,
|
||||
String defaultError,
|
||||
@@ -862,23 +1025,41 @@ class MessagesModule {
|
||||
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<String, dynamic> originalMsg;
|
||||
if (srcLink is Map &&
|
||||
srcLink['type'] == 'FORWARD' &&
|
||||
srcLink['message'] is Map) {
|
||||
if (isForwardedSource) {
|
||||
originalMsg = Map<String, dynamic>.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 = <String, dynamic>{
|
||||
'elements': const [],
|
||||
'attaches': const [],
|
||||
@@ -887,6 +1068,12 @@ class MessagesModule {
|
||||
'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(
|
||||
@@ -1085,6 +1272,42 @@ class MessagesModule {
|
||||
return _applyReactionResponse(chatId, messageId, response);
|
||||
}
|
||||
|
||||
Future<Map<int, String>> 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<int, String> _parseDetailedReactions(dynamic raw) {
|
||||
if (raw is! List) return const {};
|
||||
final result = <int, String>{};
|
||||
for (final entry in raw.whereType<Map>()) {
|
||||
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<String, dynamic>? info})> _applyReactionResponse(
|
||||
int chatId,
|
||||
String messageId,
|
||||
@@ -1132,7 +1355,11 @@ class MessagesModule {
|
||||
) async {
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) return;
|
||||
final existing = await AppDatabase.loadMessage(accountId, chatId, messageId);
|
||||
final existing = await AppDatabase.loadMessage(
|
||||
accountId,
|
||||
chatId,
|
||||
messageId,
|
||||
);
|
||||
if (existing == null) return;
|
||||
|
||||
Map<String, dynamic> payloadMap;
|
||||
@@ -1233,7 +1460,10 @@ class MessagesModule {
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> sendFileMessage(
|
||||
/// 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<String?> sendFileMessage(
|
||||
int chatId,
|
||||
int fileId, {
|
||||
String? token,
|
||||
@@ -1262,17 +1492,20 @@ class MessagesModule {
|
||||
}
|
||||
final payload = {'chatId': chatId, 'message': message, 'notify': notify};
|
||||
|
||||
return _sendWithNotReadyRetry<bool>(
|
||||
return _sendWithNotReadyRetry<String?>(
|
||||
payload: payload,
|
||||
maxAttempts: maxAttempts,
|
||||
retryDelay: retryDelay,
|
||||
onResult: (response) => response.isOk,
|
||||
onExhausted: false,
|
||||
onResult: (response) => _sentMessageMap(response)?['id']?.toString(),
|
||||
onExhausted: null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> requestPhotoUploadUrl() async {
|
||||
final response = await _api.sendRequest(Opcode.photoUpload, {'count': 1});
|
||||
Future<String?> 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;
|
||||
@@ -1313,10 +1546,10 @@ class MessagesModule {
|
||||
);
|
||||
}
|
||||
|
||||
Future<VideoUploadInfo?> requestVideoUploadUrl() async {
|
||||
Future<VideoUploadInfo?> requestVideoUploadUrl({int type = 0}) async {
|
||||
final response = await _api.sendRequest(Opcode.videoUpload, {
|
||||
'uploaderType': 0,
|
||||
'type': 0,
|
||||
'type': type,
|
||||
'count': 1,
|
||||
});
|
||||
if (!response.isOk) return null;
|
||||
@@ -1526,6 +1759,26 @@ class MessagesModule {
|
||||
return _sentMessageMap(response);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> sendContactMessage(
|
||||
int chatId,
|
||||
int contactId, {
|
||||
bool notify = true,
|
||||
}) async {
|
||||
final payload = {
|
||||
'chatId': chatId,
|
||||
'message': {
|
||||
'cid': DateTime.now().millisecondsSinceEpoch * -1,
|
||||
'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;
|
||||
|
||||
@@ -1736,9 +1989,10 @@ class MessagesModule {
|
||||
}) async {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||
'messageId': int.tryParse(messageId) ?? 0,
|
||||
'chatId': chatId,
|
||||
'fileId': fileId,
|
||||
'chatId': chatId,
|
||||
'messageId': int.tryParse(messageId) ?? 0,
|
||||
'itemType': 'REGULAR',
|
||||
});
|
||||
|
||||
if (!response.isOk) return null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../../core/protocol/packet.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/storage/token_storage.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
@@ -47,6 +48,7 @@ class OutboxService {
|
||||
|
||||
final payload = pending.payload;
|
||||
final replyToMessageId = _replyIdFromPayload(payload);
|
||||
final replySourceChatId = _replySourceChatIdFromPayload(payload);
|
||||
final elements = _elementsFromPayload(payload);
|
||||
|
||||
try {
|
||||
@@ -55,6 +57,7 @@ class OutboxService {
|
||||
pending.chatId,
|
||||
text,
|
||||
replyToMessageId: replyToMessageId,
|
||||
replySourceChatId: replySourceChatId,
|
||||
elements: elements,
|
||||
);
|
||||
final sent = CachedMessage(
|
||||
@@ -86,8 +89,23 @@ class OutboxService {
|
||||
elements: elements.isEmpty ? null : elements,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.w('Outbox: отправка ${pending.id} не удалась: $e');
|
||||
continue;
|
||||
if (!isPermanentSendFailure(e)) {
|
||||
logger.w('Outbox: отправка ${pending.id} не удалась: $e');
|
||||
continue;
|
||||
}
|
||||
logger.w('Outbox: ${pending.id} отклонено сервером: $e');
|
||||
final failed = pending.copyWith(status: 'error');
|
||||
await AppDatabase.saveMessages([failed.toDbRow()]);
|
||||
chats.emitMessageSent(pending.chatId, pending.id, failed);
|
||||
await chats.applyOutgoing(
|
||||
accountId,
|
||||
pending.chatId,
|
||||
messageId: failed.id,
|
||||
time: failed.time,
|
||||
text: text,
|
||||
status: 'error',
|
||||
elements: elements.isEmpty ? null : elements,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -114,6 +132,17 @@ class OutboxService {
|
||||
return null;
|
||||
}
|
||||
|
||||
int? _replySourceChatIdFromPayload(Map<String, dynamic>? 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 chatId = link['chatId'];
|
||||
if (chatId is int) return chatId;
|
||||
if (chatId != null) return int.tryParse(chatId.toString());
|
||||
return null;
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _elementsFromPayload(
|
||||
Map<String, dynamic>? payload,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
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/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<PreparedShareFile> files;
|
||||
final String? text;
|
||||
|
||||
const PreparedShare({required this.files, this.text});
|
||||
|
||||
List<PreparedShareFile> get photos =>
|
||||
files.where((f) => f.kind == SharedFileKind.photo).toList();
|
||||
|
||||
List<PreparedShareFile> get videos =>
|
||||
files.where((f) => f.kind == SharedFileKind.video).toList();
|
||||
|
||||
List<PreparedShareFile> get documents =>
|
||||
files.where((f) => f.kind == SharedFileKind.file).toList();
|
||||
|
||||
bool get isTextOnly => files.isEmpty;
|
||||
|
||||
static Future<PreparedShare> prepare(SharedPayload payload) async {
|
||||
final prepared = <PreparedShareFile>[];
|
||||
for (final source in payload.files) {
|
||||
prepared.add(await _prepareOne(source));
|
||||
}
|
||||
return PreparedShare(files: prepared, text: payload.text);
|
||||
}
|
||||
|
||||
static Future<PreparedShareFile> _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<UploadJobEvent>? _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<ShareSendResult> send({
|
||||
required int accountId,
|
||||
required List<int> chatIds,
|
||||
required PreparedShare share,
|
||||
required String caption,
|
||||
}) 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<int> _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<void> _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';
|
||||
try {
|
||||
final sent = await messagesModule.sendMessage(accountId, chatId, text);
|
||||
if (sent.isNotEmpty) realId = sent;
|
||||
} catch (e) {
|
||||
logger.w('Поделиться: текст в $chatId не ушёл: $e');
|
||||
status = 'error';
|
||||
}
|
||||
final settled = CachedMessage(
|
||||
id: realId,
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
senderId: accountId,
|
||||
text: text,
|
||||
time: now,
|
||||
status: status,
|
||||
);
|
||||
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<void> _sendPhotos({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required List<PreparedShareFile> photos,
|
||||
required String caption,
|
||||
}) async {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final tempId = UploadService.instance.newTempId();
|
||||
|
||||
final jobs = <({File file, GalleryItem? item})>[];
|
||||
final attachments = <PhotoAttachment>[];
|
||||
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(
|
||||
UploadService.instance.sendPhotos(
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
tempId: tempId,
|
||||
jobs: jobs,
|
||||
caption: caption,
|
||||
placeholder: placeholder,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _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(
|
||||
UploadService.instance.sendVideo(
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
tempId: tempId,
|
||||
file: video.file,
|
||||
caption: caption,
|
||||
placeholder: placeholder,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _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(
|
||||
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<PreparedShareFile> files,
|
||||
String? label,
|
||||
}) {
|
||||
final thumbs = <ChatPreviewThumb>[];
|
||||
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<void> _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<void> _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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ class SharedMediaItem {
|
||||
final int senderId;
|
||||
final int time;
|
||||
final MessageAttachment attachment;
|
||||
final String? text;
|
||||
|
||||
const SharedMediaItem({
|
||||
required this.messageId,
|
||||
@@ -24,6 +25,7 @@ class SharedMediaItem {
|
||||
required this.senderId,
|
||||
required this.time,
|
||||
required this.attachment,
|
||||
this.text,
|
||||
});
|
||||
|
||||
String get dedupKey {
|
||||
@@ -93,11 +95,144 @@ class CommonChatEntry {
|
||||
}
|
||||
}
|
||||
|
||||
class ChatMediaFeed {
|
||||
final List<SharedMediaItem> items;
|
||||
final int total;
|
||||
final bool reachedEnd;
|
||||
|
||||
const ChatMediaFeed({
|
||||
required this.items,
|
||||
required this.total,
|
||||
required this.reachedEnd,
|
||||
});
|
||||
}
|
||||
|
||||
class _ChatMediaIndex {
|
||||
final List<SharedMediaItem> items = [];
|
||||
final Set<String> seen = {};
|
||||
int total = 0;
|
||||
bool reachedEnd = false;
|
||||
bool started = false;
|
||||
Future<void>? 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<int, _ChatMediaIndex> _mediaIndexes = {};
|
||||
|
||||
final Api _api;
|
||||
|
||||
SharedContentModule(this._api);
|
||||
|
||||
static void clearMediaIndex() => _mediaIndexes.clear();
|
||||
|
||||
Future<ChatMediaFeed?> mediaFeedFor({
|
||||
required int chatId,
|
||||
required String mediaKey,
|
||||
required Future<String?> 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<ChatMediaFeed> loadMoreMedia({
|
||||
required int chatId,
|
||||
required Future<String?> 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<void> _nextMediaPage(
|
||||
int chatId,
|
||||
_ChatMediaIndex index,
|
||||
Future<String?> 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<void> _loadMediaPage(
|
||||
int chatId,
|
||||
_ChatMediaIndex index,
|
||||
Future<String?> 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 = <SharedMediaItem>[];
|
||||
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) {
|
||||
index.items.sort((a, b) => b.time.compareTo(a.time));
|
||||
}
|
||||
}
|
||||
|
||||
Future<SharedMediaPage> fetchMedia({
|
||||
required int chatId,
|
||||
required String anchorMessageId,
|
||||
@@ -134,6 +269,7 @@ class SharedContentModule {
|
||||
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) {
|
||||
@@ -147,6 +283,7 @@ class SharedContentModule {
|
||||
senderId: sender,
|
||||
time: time,
|
||||
attachment: att,
|
||||
text: text,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ class StoriesModule {
|
||||
if (acc == null) return;
|
||||
final map = <String, dynamic>{};
|
||||
_peerStories.forEach((ownerId, stories) {
|
||||
if (!_previews.containsKey(ownerId)) return;
|
||||
map['$ownerId'] = stories.map((s) => s.toJson()).toList();
|
||||
});
|
||||
await AppDatabase.setSyncValue(acc, _peersKey, jsonEncode(map));
|
||||
@@ -147,6 +148,11 @@ class StoriesModule {
|
||||
|
||||
int? lastViewedStoryId(int ownerId) => _lastViewed[ownerId];
|
||||
|
||||
void clearLastViewed(int ownerId) {
|
||||
if (_lastViewed.remove(ownerId) == null) return;
|
||||
unawaited(_persistProgress());
|
||||
}
|
||||
|
||||
/// Кольца-превью, отсортированные: сначала непрочитанные, затем по времени.
|
||||
List<StoryPreview> get previews {
|
||||
final list = _previews.values.where((p) => !p.isEmpty).toList();
|
||||
@@ -161,6 +167,83 @@ class StoriesModule {
|
||||
|
||||
StoryPreview? previewFor(int ownerId) => _previews[ownerId];
|
||||
|
||||
final Map<int, StoryPreview> _peerPreviews = {};
|
||||
final Set<int> _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<void> loadOwnersPreviews(List<int> 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<int> requested) {
|
||||
if (data is! Map) return false;
|
||||
var changed = false;
|
||||
final seen = <int>{};
|
||||
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<Story>? cachedStories(int ownerId) => _peerStories[ownerId];
|
||||
|
||||
/// Подписка на серверные пуши обновления колец (NOTIF_STORIES_UPDATE).
|
||||
@@ -180,6 +263,11 @@ class StoriesModule {
|
||||
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);
|
||||
@@ -234,7 +322,7 @@ class StoriesModule {
|
||||
if (rawPreviews is List) {
|
||||
for (final raw in rawPreviews) {
|
||||
final preview = StoryPreview.fromMap(raw);
|
||||
if (preview != null) _applyPreview(preview);
|
||||
if (preview != null) _refreshPreview(preview);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +346,31 @@ class StoriesModule {
|
||||
}
|
||||
}
|
||||
|
||||
Future<StoryPreview?> 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<bool> mark(StoryOwner owner, int storyId) async {
|
||||
if (_api.state != SessionState.online) return false;
|
||||
@@ -283,52 +396,46 @@ class StoriesModule {
|
||||
unawaited(_persistPreviews());
|
||||
}
|
||||
|
||||
/// Поставить ([reaction] != null) или снять (null) реакцию на историю.
|
||||
Future<bool> react(
|
||||
StoryOwner owner,
|
||||
int storyId,
|
||||
StoryReaction? reaction,
|
||||
) async {
|
||||
if (_api.state != SessionState.online) return false;
|
||||
try {
|
||||
final ok = await _api.sendRequestOk(Opcode.storiesReact, {
|
||||
'owner': owner.toMap(),
|
||||
'storyId': storyId,
|
||||
if (reaction != null) 'reaction': reaction.toMap(),
|
||||
});
|
||||
if (ok) _applyReactionLocally(owner.ownerId, storyId, reaction);
|
||||
return ok;
|
||||
} catch (e) {
|
||||
logger.w('StoriesModule.react: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void _applyReactionLocally(
|
||||
int ownerId,
|
||||
int storyId,
|
||||
StoryReaction? reaction,
|
||||
) {
|
||||
final stories = _peerStories[ownerId];
|
||||
if (stories == null) return;
|
||||
final idx = stories.indexWhere((s) => s.id == storyId);
|
||||
if (idx < 0) return;
|
||||
stories[idx] = stories[idx].copyWith(
|
||||
reaction: reaction,
|
||||
clearReaction: reaction == null,
|
||||
);
|
||||
_bump();
|
||||
unawaited(_persistPeers());
|
||||
}
|
||||
|
||||
/// Публикация фото-истории. [photoToken] — токен уже загруженного фото.
|
||||
/// [settings]: 1 = видно всем, 2 = только контактам. [expiration] — TTL, сек.
|
||||
/// [settings]: 1 = видно всем, 2 = только контактам. [expiration] — TTL, мс.
|
||||
/// Бросает [PacketError]/[TimeoutException] при ошибке сервера — чтобы UI
|
||||
/// показал реальную причину, а не общее «не удалось».
|
||||
Future<void> publishPhoto({
|
||||
required String photoToken,
|
||||
int settings = 1,
|
||||
int expiration = 86400,
|
||||
int expiration = 86400000,
|
||||
}) {
|
||||
return _publishMedia(
|
||||
media: {'_type': 'PHOTO', 'photoToken': photoToken},
|
||||
settings: settings,
|
||||
expiration: expiration,
|
||||
);
|
||||
}
|
||||
|
||||
/// Публикация видео-истории. [videoToken] — токен уже загруженного видео
|
||||
/// (`VideoUploadInfo.token`), [durationMs] — длительность ролика в мс.
|
||||
Future<void> 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<void> _publishMedia({
|
||||
required Map<String, dynamic> media,
|
||||
required int settings,
|
||||
required int expiration,
|
||||
}) async {
|
||||
if (_api.state != SessionState.online) {
|
||||
throw const PacketError('Нет соединения с сервером');
|
||||
@@ -339,7 +446,7 @@ class StoriesModule {
|
||||
{
|
||||
'cid': cid,
|
||||
'settings': settings,
|
||||
'media': {'_type': 'PHOTO', 'photoToken': photoToken},
|
||||
'media': media,
|
||||
'expiration': expiration,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import '../../main.dart';
|
||||
import 'cloud_storage.dart';
|
||||
import 'file_uploader.dart';
|
||||
import 'upload_notification_service.dart';
|
||||
|
||||
class UploadManager {
|
||||
UploadManager._();
|
||||
static final instance = UploadManager._();
|
||||
|
||||
StreamSubscription<UploadEvent>? _sub;
|
||||
bool get isActive => _sub != null;
|
||||
|
||||
// UI callbacks — registered by the screen while it is mounted
|
||||
void Function(double progress, int speedBps)? onProgress;
|
||||
void Function(CloudFile file)? onDone;
|
||||
void Function(String error)? onError;
|
||||
|
||||
Future<void> start({
|
||||
required int chatId,
|
||||
required int accountId,
|
||||
required File file,
|
||||
required String filename,
|
||||
required int totalSize,
|
||||
}) async {
|
||||
await cancel(); // cancel any previous upload
|
||||
|
||||
await UploadNotificationService.start(filename);
|
||||
|
||||
var lastSentBytes = 0;
|
||||
var lastSpeedMs = DateTime.now().millisecondsSinceEpoch;
|
||||
var speedBps = 0;
|
||||
var lastNotifPercent = -1;
|
||||
|
||||
_sub = fileUploader
|
||||
.upload(
|
||||
chatId: chatId,
|
||||
file: file,
|
||||
filename: filename,
|
||||
totalSize: totalSize,
|
||||
)
|
||||
.listen(
|
||||
(event) async {
|
||||
switch (event) {
|
||||
case UploadProgress(:final sent, :final total):
|
||||
final progress = total > 0 ? sent / total : 0.0;
|
||||
|
||||
// Speed: recompute every 500 ms
|
||||
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||
final elapsed = nowMs - lastSpeedMs;
|
||||
if (elapsed >= 500) {
|
||||
speedBps = ((sent - lastSentBytes) * 1000 / elapsed).round();
|
||||
lastSentBytes = sent;
|
||||
lastSpeedMs = nowMs;
|
||||
}
|
||||
|
||||
onProgress?.call(progress, speedBps);
|
||||
|
||||
// Throttle notification to once per 1% change
|
||||
final percent = total > 0 ? (sent * 100 ~/ total) : 0;
|
||||
if (percent != lastNotifPercent) {
|
||||
lastNotifPercent = percent;
|
||||
UploadNotificationService.update(
|
||||
filename: filename,
|
||||
progressPercent: percent,
|
||||
speedBps: speedBps,
|
||||
);
|
||||
}
|
||||
|
||||
case UploadDone(:final fileId):
|
||||
_sub = null;
|
||||
UploadNotificationService.stop();
|
||||
final newest = await CloudStorageModule.fetchLatestFile(
|
||||
messagesModule,
|
||||
accountId,
|
||||
chatId,
|
||||
expectedFileId: fileId,
|
||||
);
|
||||
if (newest != null) {
|
||||
onDone?.call(newest);
|
||||
}
|
||||
|
||||
case UploadError(:final message):
|
||||
_sub = null;
|
||||
UploadNotificationService.stop();
|
||||
onError?.call(message);
|
||||
}
|
||||
},
|
||||
onError: (_) {
|
||||
_sub = null;
|
||||
UploadNotificationService.stop();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cancel() async {
|
||||
await _sub?.cancel();
|
||||
_sub = null;
|
||||
await UploadNotificationService.stop();
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,209 @@
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class UploadNotificationService {
|
||||
static const _ch = MethodChannel('ru.komet.app/upload_service');
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../main.dart' show KometApp;
|
||||
|
||||
static Future<void> start(String filename) async {
|
||||
if (!Platform.isAndroid) return;
|
||||
try { await _ch.invokeMethod('start', {'filename': filename}); } catch (_) {}
|
||||
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;
|
||||
}
|
||||
|
||||
static Future<void> update({
|
||||
required String filename,
|
||||
required int progressPercent,
|
||||
required int speedBps,
|
||||
}) async {
|
||||
if (!Platform.isAndroid) return;
|
||||
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.komet.app/upload_service',
|
||||
);
|
||||
static const int _minIntervalMs = 350;
|
||||
static const Duration _startDelay = Duration(milliseconds: 700);
|
||||
|
||||
static final Map<String, _NotificationJob> _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 <String, dynamic>{});
|
||||
}
|
||||
|
||||
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 = <String, dynamic>{
|
||||
'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 = KometApp.navigatorKey.currentContext;
|
||||
if (context != null) {
|
||||
final scoped = Localizations.of<AppLocalizations>(
|
||||
context,
|
||||
AppLocalizations,
|
||||
);
|
||||
if (scoped != null) return scoped;
|
||||
}
|
||||
final code = WidgetsBinding.instance.platformDispatcher.locale.languageCode;
|
||||
return lookupAppLocalizations(Locale(code == 'ru' ? 'ru' : 'en'));
|
||||
}
|
||||
|
||||
static Future<void> _invoke(String method, Map<String, dynamic> args) async {
|
||||
try {
|
||||
await _ch.invokeMethod('update', {
|
||||
'filename': filename,
|
||||
'progress': progressPercent,
|
||||
'speed': speedBps,
|
||||
});
|
||||
await _channel.invokeMethod(method, args);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> stop() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
try { await _ch.invokeMethod('stop'); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,690 @@
|
||||
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 '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<int>.filled(slots < 1 ? 1 : slots, 0),
|
||||
_total = List<int>.filled(slots < 1 ? 1 : slots, 0),
|
||||
progress = ValueNotifier<List<double>>(
|
||||
List<double>.filled(slots < 1 ? 1 : slots, 0),
|
||||
),
|
||||
bytes = ValueNotifier<UploadBytes>(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<List<double>> progress;
|
||||
final ValueNotifier<UploadBytes> bytes;
|
||||
|
||||
int? resultFileId;
|
||||
String? resultFileToken;
|
||||
|
||||
final int _slots;
|
||||
final List<int> _sent;
|
||||
final List<int> _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<double>.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<UploadJobEvent> _events =
|
||||
StreamController<UploadJobEvent>.broadcast();
|
||||
|
||||
final Map<String, UploadJob> _jobs = {};
|
||||
final Map<String, CachedMessage> _completed = {};
|
||||
final Set<String> _failed = {};
|
||||
|
||||
int _tempIdCounter = 0;
|
||||
|
||||
Stream<UploadJobEvent> get events => _events.stream;
|
||||
|
||||
String newTempId() =>
|
||||
'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}';
|
||||
|
||||
UploadJob? job(String tempId) => _jobs[tempId];
|
||||
|
||||
ValueListenable<List<double>>? 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<CachedMessage> pendingFor(int chatId) {
|
||||
final pending = <CachedMessage>[];
|
||||
for (final job in _jobs.values) {
|
||||
final placeholder = job.placeholder;
|
||||
if (job.chatId == chatId && placeholder != null) pending.add(placeholder);
|
||||
}
|
||||
return List<CachedMessage>.unmodifiable(pending);
|
||||
}
|
||||
|
||||
CachedMessage? completedFor(String tempId) => _completed[tempId];
|
||||
|
||||
bool didFail(String tempId) => _failed.contains(tempId);
|
||||
|
||||
Future<void> 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<String>(),
|
||||
caption: caption.isEmpty ? null : caption,
|
||||
scheduledTime: scheduledTime,
|
||||
);
|
||||
if (sent == null) return null;
|
||||
return CachedMessage.fromPushPayload(accountId, chatId, sent);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> 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<void> 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<void> 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<void> _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<Map<String, dynamic>?> 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<void> sendFile({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required String tempId,
|
||||
required File source,
|
||||
required String filename,
|
||||
required int size,
|
||||
CachedMessage? placeholder,
|
||||
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,
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<UploadDone> _uploadFile({
|
||||
required int chatId,
|
||||
required UploadJob job,
|
||||
required File source,
|
||||
required String filename,
|
||||
required int size,
|
||||
int? scheduledTime,
|
||||
}) async {
|
||||
final result = Completer<UploadDone>();
|
||||
late final StreamSubscription<UploadEvent> sub;
|
||||
sub = fileUploader
|
||||
.upload(
|
||||
chatId: chatId,
|
||||
file: source,
|
||||
filename: filename,
|
||||
totalSize: size,
|
||||
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<void> _run(
|
||||
UploadJob job,
|
||||
Future<CachedMessage?> 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);
|
||||
}
|
||||
_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);
|
||||
_events.add(
|
||||
UploadJobDone(
|
||||
chatId: job.chatId,
|
||||
tempId: job.id,
|
||||
kind: job.kind,
|
||||
scheduled: false,
|
||||
message: message,
|
||||
fileId: job.resultFileId,
|
||||
fileToken: job.resultFileToken,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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<List<String?>> _uploadPhotos(
|
||||
List<({File file, GalleryItem? item})> jobs,
|
||||
UploadJob job,
|
||||
) async {
|
||||
final tokens = List<String?>.filled(jobs.length, null);
|
||||
var nextIndex = 0;
|
||||
var failed = false;
|
||||
|
||||
Future<void> 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<String?> _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<CachedMessage>.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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,64 @@ abstract class EntryBannerApps {
|
||||
};
|
||||
}
|
||||
|
||||
const Set<String> kMiniAppOptions = {'HAS_WEBAPP', 'HAS_WEB_APP', 'WEBAPP'};
|
||||
|
||||
bool hasMiniAppOption(Set<String>? options) =>
|
||||
options != null && options.any(kMiniAppOptions.contains);
|
||||
|
||||
class WebAppLaunch {
|
||||
final String url;
|
||||
final String? queryId;
|
||||
final int botId;
|
||||
|
||||
const WebAppLaunch({required this.url});
|
||||
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 {
|
||||
@@ -24,6 +78,10 @@ class WebAppModule {
|
||||
|
||||
WebAppModule(this._api);
|
||||
|
||||
/// device_id сессии (опкод 6 sessionInit, с учётом спуфинга) — тот же id,
|
||||
/// к которому сервер привязывает Цифровой ID.
|
||||
String? get sessionDeviceId => _api.deviceId;
|
||||
|
||||
Future<WebAppLaunch> fetchLaunch(
|
||||
int botId, {
|
||||
String? startParam,
|
||||
@@ -32,9 +90,11 @@ class WebAppModule {
|
||||
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': ?startParam,
|
||||
'startParam': ?normalizedStartParam,
|
||||
'chatId': ?chatId,
|
||||
});
|
||||
if (!packet.isOk) {
|
||||
@@ -45,7 +105,33 @@ class WebAppModule {
|
||||
if (url == null || url.isEmpty) {
|
||||
throw const WebAppUnavailable('Сервер не вернул адрес приложения');
|
||||
}
|
||||
return WebAppLaunch(url: url);
|
||||
final queryId = (data is Map) ? data['query_id']?.toString() : null;
|
||||
return WebAppLaunch(url: url, botId: botId, queryId: queryId);
|
||||
}
|
||||
|
||||
Future<WebAppPhone> 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<WebAppLaunch> fetchSferum() async {
|
||||
@@ -68,6 +154,26 @@ class WebAppModule {
|
||||
return fetchLaunch(botId);
|
||||
}
|
||||
|
||||
Future<WebAppLaunch> 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<int?> _resolveEntryApp(String key) async {
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) return null;
|
||||
|
||||
Vendored
+104
@@ -3,9 +3,11 @@ 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;
|
||||
|
||||
@@ -109,6 +111,53 @@ class ContactInfoFetch {
|
||||
static void invalidate(int id) => _cache.invalidate(id);
|
||||
static void clear() => _cache.clear();
|
||||
|
||||
static void putContact(int id, Map<dynamic, dynamic> contact) {
|
||||
_cache.putValue(
|
||||
id,
|
||||
ContactInfo.fromMap(Map<String, dynamic>.from(contact)),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<Map<int, ContactInfo>> getMany(
|
||||
List<int> ids, {
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final result = <int, ContactInfo>{};
|
||||
final missing = <int>[];
|
||||
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<Map>()) {
|
||||
final id = c['id'];
|
||||
if (id is! int) continue;
|
||||
final info = ContactInfo.fromMap(Map<String, dynamic>.from(c));
|
||||
_cache.putValue(id, info, at: now);
|
||||
result[id] = info;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return result;
|
||||
}
|
||||
|
||||
static Future<ContactInfo?> _fetch(int id) async {
|
||||
final api = _api;
|
||||
if (api == null || api.state != SessionState.online) return null;
|
||||
@@ -197,19 +246,43 @@ class PresenceFetch {
|
||||
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<void> ensureFor(Iterable<int> ids) async {
|
||||
final wanted = <int>{};
|
||||
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<Map<int, Map<String, dynamic>>> _fetchBatch(
|
||||
List<int> ids,
|
||||
) async {
|
||||
@@ -235,6 +308,36 @@ class PresenceFetch {
|
||||
}
|
||||
}
|
||||
|
||||
class BotInfoFetch {
|
||||
static final _cache = InfoCache<BotInfo>(
|
||||
ttl: const Duration(minutes: 30),
|
||||
fetcher: _fetch,
|
||||
);
|
||||
|
||||
static Future<BotInfo?> get(int botId, {bool forceRefresh = false}) =>
|
||||
_cache.get(botId, forceRefresh: forceRefresh);
|
||||
|
||||
static BotInfo? peek(int botId) => _cache.peek(botId);
|
||||
|
||||
static List<BotCommand> commandsOf(int botId) =>
|
||||
_cache.peek(botId)?.commands ?? const [];
|
||||
|
||||
static void invalidate(int botId) => _cache.invalidate(botId);
|
||||
static void clear() => _cache.clear();
|
||||
|
||||
static Future<BotInfo?> _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<String, dynamic>.from(data));
|
||||
final contact = info.contact;
|
||||
if (contact != null) ContactInfoFetch.putContact(botId, contact.raw);
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
class ChatInfoFetch {
|
||||
static final _cache = InfoCache<ChatInfo>(
|
||||
ttl: const Duration(minutes: 5),
|
||||
@@ -261,6 +364,7 @@ class ChatInfoFetch {
|
||||
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<String, dynamic>.from(first));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ActiveCallPresentation?> current = ValueNotifier(null);
|
||||
|
||||
final ValueNotifier<bool> screenVisible = ValueNotifier(false);
|
||||
|
||||
int _openScreens = 0;
|
||||
StreamSubscription<CallSessionState>? _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<T>(ValueNotifier<T> notifier, T value) {
|
||||
if (SchedulerBinding.instance.schedulerPhase ==
|
||||
SchedulerPhase.persistentCallbacks) {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
notifier.value = value;
|
||||
});
|
||||
return;
|
||||
}
|
||||
notifier.value = value;
|
||||
}
|
||||
}
|
||||
@@ -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<List<AudioInputDevice>> microphones() async {
|
||||
try {
|
||||
final devices = await navigator.mediaDevices.enumerateDevices();
|
||||
final mics = <AudioInputDevice>[];
|
||||
final seen = <String>{};
|
||||
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<void> 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 = <String, dynamic>{};
|
||||
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<String?> 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<void>.delayed(const Duration(milliseconds: 250));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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<void> requestMedia(
|
||||
Set<CallMedia> media, {
|
||||
CallParticipantRef? participant,
|
||||
String? roomId,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'mute-participant',
|
||||
extra: {
|
||||
'participantId': ?participant?.wire,
|
||||
'requestedMedia': media.map((m) => m.wire).toList(),
|
||||
'roomId': ?roomId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setMuteStates(
|
||||
Map<CallMedia, CallMuteState?> 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<void> muteMicrophone(
|
||||
CallParticipantRef participant, {
|
||||
bool muted = true,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'switch-micro',
|
||||
extra: {'eId': participant.wire, 'muteTarget': muted},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> muteEveryone() {
|
||||
return _signaling.sendCommand(
|
||||
'switch-micro',
|
||||
extra: const {'all': true, 'muteTarget': true},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setPromoted(CallParticipantRef participant, bool promoted) {
|
||||
return _signaling.sendCommand(
|
||||
'promote-participant',
|
||||
extra: {'participantId': participant.wire, 'demote': !promoted},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setRoles(
|
||||
CallParticipantRef participant,
|
||||
List<CallRoleName> roles, {
|
||||
bool revoke = false,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'grant-roles',
|
||||
extra: {
|
||||
'participantId': participant.wire,
|
||||
'roles': roles.map((r) => r.wire).toList(),
|
||||
'revoke': revoke,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeParticipant(CallParticipantRef participant) {
|
||||
return _signaling.sendCommand(
|
||||
'remove-participant',
|
||||
extra: {'participantId': participant.wire},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setPinned(
|
||||
CallParticipantRef participant,
|
||||
bool pinned, {
|
||||
String? roomId,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'pin-participant',
|
||||
extra: {
|
||||
'participantId': participant.wire,
|
||||
'unpin': !pinned,
|
||||
'roomId': ?roomId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setOptions(Map<CallOption, bool> options) {
|
||||
return _signaling.sendCommand(
|
||||
'change-options',
|
||||
extra: {
|
||||
'options': {
|
||||
for (final entry in options.entries) entry.key.wire: entry.value,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> enableFeatureForRoles(
|
||||
CallFeature feature,
|
||||
List<CallRoleName> roles,
|
||||
) {
|
||||
return _signaling.sendCommand(
|
||||
'enable-feature-for-roles',
|
||||
extra: {
|
||||
'feature': feature.wire,
|
||||
'roles': roles.map((r) => r.wire).toList(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> lowerAllHands() => _signaling.sendCommand('put-hands-down');
|
||||
|
||||
Future<void> setHandRaised(bool raised, {CallParticipantRef? participant}) {
|
||||
return _setState({'hand': raised ? '1' : '0'}, participant: participant);
|
||||
}
|
||||
|
||||
Future<void> setAssistanceRequested(
|
||||
bool requested, {
|
||||
CallParticipantRef? participant,
|
||||
}) {
|
||||
return _setState({'drat': requested ? '1' : '0'}, participant: participant);
|
||||
}
|
||||
|
||||
Future<void> _setState(
|
||||
Map<String, String> state, {
|
||||
CallParticipantRef? participant,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'change-participant-state',
|
||||
extra: {
|
||||
'participantState': {'state': state},
|
||||
'participantId': ?participant?.wire,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addParticipants(
|
||||
List<String> 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<void> addParticipantByLink(String link) {
|
||||
return _signaling.sendCommand(
|
||||
'add-participant',
|
||||
extra: {'participantIdAsQRCodeLink': link},
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> 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<void> stopRecord({bool remove = false, String? roomId}) {
|
||||
return _signaling.sendCommand(
|
||||
'record-stop',
|
||||
extra: {if (remove) 'remove': true, 'roomId': ?roomId},
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> 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<Map<String, dynamic>> waitingHall({
|
||||
int count = 50,
|
||||
String? fromId,
|
||||
bool backward = false,
|
||||
}) {
|
||||
return _signaling.sendCommand(
|
||||
'get-waiting-hall',
|
||||
extra: {'count': count, 'fromId': ?fromId, 'backward': backward},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,36 @@ class CallBridge {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> ensureOngoing({String? caller}) async {
|
||||
if (!_android) return;
|
||||
try {
|
||||
await _method.invokeMethod<void>('ensureOngoing', {'caller': caller});
|
||||
} catch (e) {
|
||||
logger.w('CallBridge.ensureOngoing: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setScreenShare(bool enabled, {String? caller}) async {
|
||||
if (!_android) return;
|
||||
try {
|
||||
await _method.invokeMethod<void>('setScreenShare', {
|
||||
'enabled': enabled,
|
||||
'caller': caller,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.w('CallBridge.setScreenShare: enabled=$enabled $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> dropOngoing() async {
|
||||
if (!_android) return;
|
||||
try {
|
||||
await _method.invokeMethod<void>('dropOngoing');
|
||||
} catch (e) {
|
||||
logger.w('CallBridge.dropOngoing: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> notifyEnded() async {
|
||||
if (!_android) return;
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -56,6 +57,7 @@ class CallController {
|
||||
Stream<void> get incomingCanceled => _canceled.stream;
|
||||
|
||||
CallSession? _active;
|
||||
StreamSubscription<CallSessionState>? _activeSub;
|
||||
CallSession? get activeSession => _active;
|
||||
|
||||
IncomingCall? _pending;
|
||||
@@ -72,7 +74,6 @@ class CallController {
|
||||
|
||||
void _onPush(Packet packet) {
|
||||
if (packet.opcode != Opcode.notifCallStart) return;
|
||||
if (!appResumed) return;
|
||||
final payload = packet.payload;
|
||||
if (payload is! Map) return;
|
||||
|
||||
@@ -81,6 +82,13 @@ class CallController {
|
||||
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;
|
||||
|
||||
@@ -150,12 +158,16 @@ class CallController {
|
||||
final config = Ws2Config.fromEndpoint(
|
||||
out.endpoint,
|
||||
userId: out.callsUserId,
|
||||
device: _api?.callsDevice,
|
||||
osVersion: _api?.callsOsVersion,
|
||||
);
|
||||
final session = CallSession(ws2Config: config, role: CallRole.caller);
|
||||
_bind(session);
|
||||
await session.start();
|
||||
CallBridge.instance.notifyAccepted();
|
||||
return session;
|
||||
return _launch(session, session.start);
|
||||
}
|
||||
|
||||
Future<CreatedCall> createConference() async {
|
||||
if (_active != null) throw StateError('уже идёт звонок');
|
||||
return _calls!.createConference();
|
||||
}
|
||||
|
||||
Future<CallLinkPreview?> previewCallLink(String url) =>
|
||||
@@ -167,12 +179,15 @@ class CallController {
|
||||
final config = Ws2Config.fromEndpoint(
|
||||
params.endpoint,
|
||||
userId: params.callsUserId,
|
||||
device: _api?.callsDevice,
|
||||
osVersion: _api?.callsOsVersion,
|
||||
);
|
||||
final session = CallSession(ws2Config: config, role: CallRole.joiner);
|
||||
_bind(session);
|
||||
await session.start();
|
||||
CallBridge.instance.notifyAccepted();
|
||||
return session;
|
||||
final session = CallSession(
|
||||
ws2Config: config,
|
||||
role: CallRole.joiner,
|
||||
isGroup: true,
|
||||
);
|
||||
return _launch(session, session.start);
|
||||
}
|
||||
|
||||
Future<CallSession> acceptIncoming(IncomingCall call) async {
|
||||
@@ -181,17 +196,22 @@ class CallController {
|
||||
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,
|
||||
);
|
||||
_bind(session);
|
||||
await session.start();
|
||||
await session.accept();
|
||||
CallBridge.instance.notifyAccepted(caller: call.callerName);
|
||||
return session;
|
||||
return _launch(
|
||||
session,
|
||||
() async {
|
||||
await session.start();
|
||||
await session.accept();
|
||||
},
|
||||
caller: call.callerName,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> rejectIncoming(IncomingCall call) async {
|
||||
@@ -200,6 +220,8 @@ class CallController {
|
||||
final config = Ws2Config.fromVcp(
|
||||
call.params,
|
||||
conversationId: call.conversationId,
|
||||
device: _api?.callsDevice,
|
||||
osVersion: _api?.callsOsVersion,
|
||||
);
|
||||
final signaling = Ws2Signaling(config);
|
||||
try {
|
||||
@@ -220,18 +242,45 @@ class CallController {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<CallSession> _launch(
|
||||
CallSession session,
|
||||
Future<void> 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;
|
||||
session.stateStream.listen((state) {
|
||||
if (state == CallSessionState.ended && _active == session) {
|
||||
_active = null;
|
||||
CallBridge.instance.notifyEnded();
|
||||
_ended.add(null);
|
||||
}
|
||||
_activeSub = session.stateStream.listen((state) {
|
||||
if (state != CallSessionState.ended) return;
|
||||
unawaited(_release(session));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _release(CallSession session) async {
|
||||
if (!identical(_active, session)) return;
|
||||
_active = null;
|
||||
await _activeSub?.cancel();
|
||||
_activeSub = null;
|
||||
CallBridge.instance.notifyEnded();
|
||||
_ended.add(null);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_activeSub?.cancel();
|
||||
_pushSub?.cancel();
|
||||
_incoming.close();
|
||||
_ended.close();
|
||||
|
||||
@@ -61,7 +61,8 @@ class CallParse {
|
||||
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('mozilla') || l.contains('sdparta'))
|
||||
return 'Firefox (web)';
|
||||
if (l.contains('gstreamer')) return 'GStreamer';
|
||||
return 'нативный libwebrtc';
|
||||
}
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
class CallLink {
|
||||
static const String base = 'https://max.ru/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? 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';
|
||||
}
|
||||
|
||||
+1339
-140
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../protocol/lz4_block.dart';
|
||||
import 'package:kolibri/kolibri.dart' as kb;
|
||||
|
||||
/// Параметры подключения к звонку (`vcp`), которые сервер присылает в пуше
|
||||
/// входящего звонка (opcode 137) и в ответе на инициацию исходящего.
|
||||
@@ -79,73 +76,19 @@ class ConversationParams {
|
||||
return nowSec >= expiresAt! - 5;
|
||||
}
|
||||
|
||||
static List<String> _splitTurn(Object? value) {
|
||||
if (value is! String || value.isEmpty) return const [];
|
||||
return value
|
||||
.split(',')
|
||||
.map((e) => e.trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
static List<String> _stringList(Object? value) {
|
||||
if (value is! List) return const [];
|
||||
return value.whereType<String>().toList();
|
||||
}
|
||||
|
||||
/// Распаковывает и парсит строку `vcp`. Возвращает `null`, если формат
|
||||
/// не распознан.
|
||||
/// Распаковывает и парсит строку `vcp` через Rust-ядро (kolibri). Возвращает
|
||||
/// `null`, если формат не распознан. Требует инициализации `initKolibri()`.
|
||||
static ConversationParams? decode(String vcp) {
|
||||
final sep = vcp.indexOf(':');
|
||||
if (sep <= 0) return null;
|
||||
|
||||
final rawLen = int.tryParse(vcp.substring(0, sep));
|
||||
if (rawLen == null || rawLen <= 0) return null;
|
||||
|
||||
final Uint8List compressed;
|
||||
try {
|
||||
compressed = base64.decode(vcp.substring(sep + 1));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final Uint8List bytes;
|
||||
try {
|
||||
final decompressed = lz4BlockDecompress(compressed, rawLen);
|
||||
bytes = decompressed.length > rawLen
|
||||
? Uint8List.sublistView(decompressed, 0, rawLen)
|
||||
: decompressed;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final Object? json;
|
||||
try {
|
||||
json = jsonDecode(utf8.decode(bytes));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
if (json is! Map) return null;
|
||||
|
||||
final token = json['tkn'];
|
||||
final wse = json['wse'];
|
||||
if (token is! String || wse is! String) return null;
|
||||
|
||||
final kb.CallParams? p = kb.decodeVcp(vcp: vcp, conversationId: '');
|
||||
if (p == null) return null;
|
||||
return ConversationParams(
|
||||
token: token,
|
||||
wsEndpoint: wse,
|
||||
wsIps: _stringList(json['wsip']),
|
||||
wtEndpoint: json['wte'] as String?,
|
||||
wtIps: _stringList(json['wtip']),
|
||||
callsApiEndpoint: json['vcae'] as String?,
|
||||
callsApiIps: _stringList(json['vcaip']),
|
||||
clientType: json['srcp'] as String?,
|
||||
expiresAt: json['et'] is int ? json['et'] as int : null,
|
||||
stun: json['stne'] as String?,
|
||||
turn: _splitTurn(json['trne']),
|
||||
turnUser: json['trnu'] as String?,
|
||||
turnPassword: json['trnp'] as String?,
|
||||
isVideo: json['iv'] == true,
|
||||
token: p.token,
|
||||
wsEndpoint: p.wsEndpoint,
|
||||
stun: p.stun,
|
||||
turn: p.turn,
|
||||
turnUser: p.turnUser,
|
||||
turnPassword: p.turnPassword,
|
||||
isVideo: p.isVideo,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = 'komet_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<ProcessResult?> _pactl(List<String> 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<bool> isAvailable() async =>
|
||||
(await _pactl(const ['info']))?.exitCode == 0;
|
||||
|
||||
static Future<List<PulseSource>> 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<PulseSource> parseSources(String json) {
|
||||
final List<dynamic> entries;
|
||||
try {
|
||||
entries = jsonDecode(json) as List<dynamic>;
|
||||
} catch (e) {
|
||||
logger.w('[call][pulse] список источников: $e');
|
||||
return const [];
|
||||
}
|
||||
final sources = <PulseSource>[];
|
||||
for (final entry in entries.whereType<Map<String, dynamic>>()) {
|
||||
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<PulseSource?> find(String name) async {
|
||||
for (final source in await sources()) {
|
||||
if (source.name == name) return source;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> 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<void> closeBridge() async {
|
||||
final module = _bridgeModule;
|
||||
_bridgeModule = null;
|
||||
_bridgeMaster = null;
|
||||
_bridgeSource = null;
|
||||
if (module == null) return;
|
||||
await _pactl(['unload-module', module]);
|
||||
}
|
||||
|
||||
static Future<void> _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<String, dynamic> 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)';
|
||||
}
|
||||
@@ -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<int, String> _aliases = {};
|
||||
final _slots = StreamController<Map<String, int>>.broadcast();
|
||||
final _levels = StreamController<Map<String, int>>.broadcast();
|
||||
|
||||
Stream<Map<String, int>> get slotUpdates => _slots.stream;
|
||||
Stream<Map<String, int>> 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<bool> sendDisplayLayout(
|
||||
List<SfuLayoutItem> 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 = <String, int>{};
|
||||
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 = <String, int>{};
|
||||
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<void> dispose() async {
|
||||
_command = null;
|
||||
_aliases.clear();
|
||||
if (!_slots.isClosed) await _slots.close();
|
||||
if (!_levels.isClosed) await _levels.close();
|
||||
}
|
||||
}
|
||||
+152
-169
@@ -1,8 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../utils/logger.dart';
|
||||
import 'package:kolibri/kolibri.dart' as kb;
|
||||
|
||||
import 'conversation_params.dart';
|
||||
|
||||
/// Параметры подключения к сигналинг-сокету ws2.
|
||||
@@ -20,55 +20,63 @@ class Ws2Config {
|
||||
|
||||
const Ws2Config({required this.uri, required this.userId});
|
||||
|
||||
static const _defaultCapabilities = '3c03f';
|
||||
static const _appVersion = 'sdk-0.1.16.4';
|
||||
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 = 'Komet',
|
||||
String osVersion = '36',
|
||||
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',
|
||||
'entityType': 'USER',
|
||||
'conversationId': conversationId,
|
||||
'token': params.token,
|
||||
'version': '5',
|
||||
'capabilities': capabilities,
|
||||
'device': device,
|
||||
'platform': 'ANDROID',
|
||||
'clientType': 'ONE_ME',
|
||||
'appVersion': _appVersion,
|
||||
'osVersion': 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 уже содержит токен и
|
||||
/// conversationId/userId в query — дописываем клиентские параметры.
|
||||
/// Исходящий звонок или вход в конференцию: `endpoint` из ответа opcode 78 /
|
||||
/// 166 уже содержит токен и conversationId/userId в query — дописываем
|
||||
/// клиентские параметры. Без `tgt=start` медиасервер принимает сокет, но не
|
||||
/// поднимает разговор и не шлёт нотификацию `connection`.
|
||||
factory Ws2Config.fromEndpoint(
|
||||
String endpoint, {
|
||||
required int userId,
|
||||
String capabilities = _defaultCapabilities,
|
||||
String device = 'Komet',
|
||||
String capabilities = defaultCapabilities,
|
||||
String? device,
|
||||
String? osVersion,
|
||||
}) {
|
||||
final base = Uri.parse(endpoint);
|
||||
final uri = base.replace(queryParameters: {
|
||||
...base.queryParameters,
|
||||
'platform': 'ANDROID',
|
||||
'version': '5',
|
||||
'capabilities': capabilities,
|
||||
'clientType': 'ONE_ME',
|
||||
'appVersion': _appVersion,
|
||||
'device': device,
|
||||
'tgt': 'start',
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -84,17 +92,19 @@ class Ws2CommandException implements Exception {
|
||||
|
||||
/// Клиент сигналинга звонка поверх WebSocket `ws2`.
|
||||
///
|
||||
/// Конверт сообщений (подтверждено захватом `docs/ws2_capture.log`):
|
||||
/// Тонкий адаптер над Rust-ядром (kolibri [kb.CallSignaling]): ядро держит
|
||||
/// WebSocket, корреляцию `sequence`/`response`, keepalive `ping`→`pong` и
|
||||
/// разбор кадров; здесь — прежний Dart-интерфейс для [call_session].
|
||||
///
|
||||
/// Конверт сообщений:
|
||||
/// - запрос: `{"command": ..., ..., "sequence": N}`
|
||||
/// - ответ: `{"sequence": N, "response": "<command>", "type": "response"}`
|
||||
/// - пуш: `{..., "notification": "<name>", "type": "notification"}`
|
||||
/// - keepalive: текстовый кадр `ping` → ответ `pong`.
|
||||
class Ws2Signaling {
|
||||
final Ws2Config config;
|
||||
|
||||
WebSocket? _socket;
|
||||
int _sequence = 0;
|
||||
final Map<int, Completer<Map<String, dynamic>>> _pending = {};
|
||||
kb.CallSignaling? _call;
|
||||
StreamSubscription<String>? _notifSub;
|
||||
|
||||
final _notifications = StreamController<Map<String, dynamic>>.broadcast();
|
||||
final _closed = Completer<Object?>();
|
||||
@@ -104,106 +114,58 @@ class Ws2Signaling {
|
||||
/// Пуши сервера (`type == "notification"`). Фильтруй по полю `notification`.
|
||||
Stream<Map<String, dynamic>> get notifications => _notifications.stream;
|
||||
|
||||
/// Завершается, когда сокет закрыт (значение — причина закрытия, если была).
|
||||
/// Завершается, когда сокет закрыт.
|
||||
Future<Object?> get done => _closed.future;
|
||||
|
||||
bool get isConnected => _socket != null;
|
||||
bool get isConnected => _call?.isConnected() ?? false;
|
||||
|
||||
Future<void> connect() async {
|
||||
final socket = await WebSocket.connect(
|
||||
config.uri.toString(),
|
||||
headers: {'User-Agent': 'okhttp/4.12.0'},
|
||||
final call = await kb.connectCallSignaling(
|
||||
url: config.uri.toString(),
|
||||
userAgent: 'okhttp/4.12.0',
|
||||
);
|
||||
_socket = socket;
|
||||
socket.listen(
|
||||
_onFrame,
|
||||
onError: _onDone,
|
||||
_call = call;
|
||||
_notifSub = call.notifications().listen(
|
||||
(json) {
|
||||
Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(json);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (decoded is Map<String, dynamic>) _notifications.add(decoded);
|
||||
},
|
||||
onError: (_) => _onDone(null),
|
||||
onDone: () => _onDone(null),
|
||||
cancelOnError: false,
|
||||
);
|
||||
}
|
||||
|
||||
void _onFrame(dynamic frame) {
|
||||
if (frame is String && frame == 'ping') {
|
||||
_socket?.add('pong');
|
||||
return;
|
||||
}
|
||||
|
||||
final String text;
|
||||
if (frame is String) {
|
||||
text = frame;
|
||||
} else if (frame is List<int>) {
|
||||
text = utf8.decode(frame);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(text);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (decoded is! Map<String, dynamic>) return;
|
||||
|
||||
final label =
|
||||
decoded['notification'] ?? decoded['response'] ?? decoded['type'];
|
||||
final dump = jsonEncode(decoded);
|
||||
logger.t('[ws2] ← $label');
|
||||
logger.t(dump.length > 1500
|
||||
? '${dump.substring(0, 1500)}… (${dump.length}b)'
|
||||
: dump);
|
||||
|
||||
final type = decoded['type'];
|
||||
if (type == 'response' || type == 'error') {
|
||||
final seq = decoded['sequence'];
|
||||
if (seq is int) {
|
||||
final completer = _pending.remove(seq);
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.complete(decoded);
|
||||
}
|
||||
}
|
||||
if (type == 'error') _notifications.add(decoded);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == 'notification' || decoded.containsKey('notification')) {
|
||||
_notifications.add(decoded);
|
||||
}
|
||||
}
|
||||
|
||||
void _onDone(Object? error) {
|
||||
for (final c in _pending.values) {
|
||||
if (!c.isCompleted) c.completeError(error ?? const SocketException('ws2 closed'));
|
||||
}
|
||||
_pending.clear();
|
||||
if (!_closed.isCompleted) _closed.complete(error);
|
||||
if (!_notifications.isClosed) _notifications.close();
|
||||
}
|
||||
|
||||
/// Отправляет команду и ждёт ответ сервера. Бросает [Ws2CommandException],
|
||||
/// если в ответе есть поле `error`.
|
||||
/// если сервер вернул ошибку.
|
||||
Future<Map<String, dynamic>> sendCommand(
|
||||
String command, {
|
||||
Map<String, dynamic> extra = const {},
|
||||
Duration timeout = const Duration(seconds: 15),
|
||||
}) {
|
||||
final socket = _socket;
|
||||
if (socket == null) {
|
||||
}) async {
|
||||
final call = _call;
|
||||
if (call == null) {
|
||||
return Future.error(StateError('ws2 не подключён'));
|
||||
}
|
||||
|
||||
final seq = ++_sequence;
|
||||
final completer = Completer<Map<String, dynamic>>();
|
||||
_pending[seq] = completer;
|
||||
|
||||
socket.add(jsonEncode({'command': command, ...extra, 'sequence': seq}));
|
||||
|
||||
return completer.future.timeout(timeout).then((response) {
|
||||
final error = response['error'];
|
||||
if (error != null) throw Ws2CommandException(command, error);
|
||||
return response;
|
||||
});
|
||||
try {
|
||||
final response = await call
|
||||
.sendCommand(command: command, extraJson: jsonEncode(extra))
|
||||
.timeout(timeout);
|
||||
final decoded = jsonDecode(response);
|
||||
return decoded is Map<String, dynamic> ? decoded : <String, dynamic>{};
|
||||
} catch (e) {
|
||||
throw Ws2CommandException(command, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Передаёт SDP (offer/answer) другому участнику.
|
||||
@@ -213,7 +175,7 @@ class Ws2Signaling {
|
||||
required String sdp,
|
||||
String participantType = 'USER',
|
||||
int deviceIdx = 0,
|
||||
String capabilities = '1',
|
||||
String capabilities = Ws2Config.defaultCapabilities,
|
||||
}) {
|
||||
return sendCommand(
|
||||
'transmit-data',
|
||||
@@ -260,9 +222,45 @@ class Ws2Signaling {
|
||||
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<void> switchTopology({
|
||||
String topology = 'SERVER',
|
||||
bool force = false,
|
||||
}) {
|
||||
return sendCommand(
|
||||
'switch-topology',
|
||||
extra: {'topology': topology, 'force': force},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> requestRealloc() => sendCommand('request-realloc');
|
||||
|
||||
/// Принять входящий звонок (сторона вызываемого).
|
||||
Future<void> acceptCall({
|
||||
bool isAudioEnabled = true,
|
||||
bool isVideoEnabled = false,
|
||||
bool isScreenSharingEnabled = false,
|
||||
bool isAnimojiEnabled = false,
|
||||
}) {
|
||||
return sendCommand(
|
||||
'accept-call',
|
||||
extra: {
|
||||
'mediaSettings': {
|
||||
'isVideoEnabled': isVideoEnabled,
|
||||
@@ -274,62 +272,47 @@ class Ws2Signaling {
|
||||
);
|
||||
}
|
||||
|
||||
/// Принять входящий звонок (сторона вызываемого).
|
||||
Future<void> acceptCall() => sendCommand('accept-call');
|
||||
|
||||
Future<void> hangup({String reason = 'HUNGUP'}) =>
|
||||
sendCommand('hangup', extra: {'reason': reason});
|
||||
|
||||
Future<void> 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,
|
||||
'producerScreenDataChannelVersion': 1,
|
||||
'consumerScreenDataChannelVersion': 1,
|
||||
'animojiDataChannelVersion': 2,
|
||||
'animojiBackendRender': true,
|
||||
'asrDataChannelVersion': 1,
|
||||
'consumerFastScreenShare': true,
|
||||
'consumerFastScreenShareQualityOnDemand': true,
|
||||
'audioShare': true,
|
||||
'simulcast': true,
|
||||
'simulcastNativeOrder': true,
|
||||
'red': true,
|
||||
'videoTracksCount': 10,
|
||||
'csrcAccessible': true,
|
||||
},
|
||||
},
|
||||
);
|
||||
Future<Map<String, dynamic>> 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<void> acceptProducer({
|
||||
Future<Map<String, dynamic>> acceptProducer({
|
||||
required String description,
|
||||
required List<int> ssrcs,
|
||||
required List<String> ssrcs,
|
||||
Object? sessionId,
|
||||
}) =>
|
||||
sendCommand('accept-producer', extra: {
|
||||
'description': description,
|
||||
'ssrcs': ssrcs,
|
||||
'sessionId': ?sessionId,
|
||||
});
|
||||
|
||||
Future<void> changeSimulcast({
|
||||
String mediaSource = 'CAMERA',
|
||||
required List<Map<String, dynamic>> layers,
|
||||
}) =>
|
||||
sendCommand('change-simulcast',
|
||||
extra: {'mediaSource': mediaSource, 'layers': layers});
|
||||
}) => sendCommand(
|
||||
'accept-producer',
|
||||
extra: {
|
||||
'description': description,
|
||||
if (ssrcs.isNotEmpty) 'ssrcs': ssrcs,
|
||||
'sessionId': ?sessionId,
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> close() async {
|
||||
await _socket?.close();
|
||||
_socket = null;
|
||||
await _notifSub?.cancel();
|
||||
_notifSub = null;
|
||||
_call?.close();
|
||||
_call = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@ import 'persisted_setting.dart';
|
||||
class AppAmoled {
|
||||
static const prefKey = 'app_amoled';
|
||||
|
||||
static bool get systemIsDark =>
|
||||
PlatformDispatcher.instance.platformBrightness == Brightness.dark;
|
||||
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: false,
|
||||
read: (prefs, key) => prefs.getBool(key),
|
||||
defaultValue: systemIsDark,
|
||||
read: (prefs, key) => prefs.getBool(key) ?? systemIsDark,
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setBool(key, value);
|
||||
},
|
||||
|
||||
@@ -6,4 +6,7 @@ class AppAnimations {
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../frontend/widgets/liquid_glass.dart';
|
||||
import 'persisted_setting.dart';
|
||||
|
||||
enum ChatChromeStyle { color, blur, none, transparent }
|
||||
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';
|
||||
|
||||
@@ -6,6 +6,25 @@ extension AppColorTokens on ColorScheme {
|
||||
|
||||
const int kAvatarThumbSize = 144;
|
||||
|
||||
const Color kSuccessGreen = Color(0xFF2EC36B);
|
||||
const Color kDangerRed = Color(0xFFE5484D);
|
||||
const Color kReadReceiptBlue = Color(0xFF4FC3F7);
|
||||
const Color kOnlineGreen = Color(0xFF34C759);
|
||||
const Color kEditorAccent = Color(0xFF2F8FFF);
|
||||
|
||||
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 = ColorScheme.fromSeed(
|
||||
seedColor: seed,
|
||||
brightness: Brightness.dark,
|
||||
);
|
||||
}
|
||||
return _scheme!;
|
||||
}
|
||||
|
||||
static Color of(BuildContext context) => schemeOf(context).primary;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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<ComposerBackground>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: ComposerBackground.standard,
|
||||
encode: (value) => value.name,
|
||||
decode: _parse,
|
||||
);
|
||||
|
||||
static ValueNotifier<ComposerBackground> get current => _setting.current;
|
||||
|
||||
static Future<ComposerBackground> load() => _setting.load();
|
||||
|
||||
static Future<void> save(ComposerBackground value) => _setting.save(value);
|
||||
|
||||
static ComposerBackground _parse(String? val) => enumFromName(
|
||||
ComposerBackground.values,
|
||||
val,
|
||||
ComposerBackground.standard,
|
||||
);
|
||||
}
|
||||
@@ -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<ComposerStyle>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: ComposerStyle.glossy,
|
||||
encode: (value) => value.name,
|
||||
decode: _parse,
|
||||
);
|
||||
|
||||
static ValueNotifier<ComposerStyle> get current => _setting.current;
|
||||
|
||||
static Future<ComposerStyle> load() => _setting.load();
|
||||
|
||||
static Future<void> save(ComposerStyle value) => _setting.save(value);
|
||||
|
||||
static ComposerStyle _parse(String? val) =>
|
||||
enumFromName(ComposerStyle.values, val, ComposerStyle.glossy);
|
||||
}
|
||||
@@ -1,11 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'custom_font_service.dart';
|
||||
|
||||
const String kDisplayFontFamily = 'Outfit';
|
||||
|
||||
@immutable
|
||||
class AppDisplayFont extends ThemeExtension<AppDisplayFont> {
|
||||
final String? family;
|
||||
|
||||
const AppDisplayFont(this.family);
|
||||
|
||||
@override
|
||||
AppDisplayFont copyWith({String? family}) =>
|
||||
AppDisplayFont(family ?? this.family);
|
||||
|
||||
@override
|
||||
AppDisplayFont lerp(ThemeExtension<AppDisplayFont>? other, double t) =>
|
||||
t < 0.5 ? this : (other as AppDisplayFont? ?? this);
|
||||
}
|
||||
|
||||
String? displayFontOf(BuildContext context) =>
|
||||
Theme.of(context).extension<AppDisplayFont>()?.family ?? kDisplayFontFamily;
|
||||
|
||||
class AppFont {
|
||||
final String id;
|
||||
final String label;
|
||||
final String? fontFamily;
|
||||
|
||||
const AppFont({required this.id, required this.label, this.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);
|
||||
@@ -22,8 +51,18 @@ class AppFonts {
|
||||
|
||||
static const List<AppFont> builtIn = [
|
||||
AppFont(id: 'system', label: 'Системный'),
|
||||
AppFont(id: 'inter', label: 'Inter', fontFamily: 'Inter'),
|
||||
AppFont(id: 'unbounded', label: 'Unbounded', fontFamily: 'Unbounded'),
|
||||
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;
|
||||
@@ -33,11 +72,24 @@ class AppFonts {
|
||||
static AppFont resolve(String id) {
|
||||
if (id.startsWith(customPrefix)) {
|
||||
final family = id.substring(customPrefix.length);
|
||||
return AppFont(id: id, label: family, fontFamily: family);
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -5,15 +5,34 @@ import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
enum AppIcon {
|
||||
defaultIcon('default', 'Default', 'assets/komet_icon.png', 'MainActivity'),
|
||||
minimal('minimal', 'Minimal', 'assets/meteor_icon.png', 'MinimalIcon');
|
||||
defaultIcon(
|
||||
'default',
|
||||
'Default',
|
||||
'assets/komet_icon.png',
|
||||
'MainActivity',
|
||||
null,
|
||||
),
|
||||
minimal(
|
||||
'minimal',
|
||||
'Minimal',
|
||||
'assets/meteor_icon.png',
|
||||
'MinimalIcon',
|
||||
'MinimalIcon',
|
||||
);
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String previewAsset;
|
||||
final String platformName;
|
||||
final String androidAlias;
|
||||
final String? iosAlternateName;
|
||||
|
||||
const AppIcon(this.id, this.title, this.previewAsset, this.platformName);
|
||||
const AppIcon(
|
||||
this.id,
|
||||
this.title,
|
||||
this.previewAsset,
|
||||
this.androidAlias,
|
||||
this.iosAlternateName,
|
||||
);
|
||||
}
|
||||
|
||||
class AppIconConfig {
|
||||
@@ -29,21 +48,37 @@ class AppIconConfig {
|
||||
static Future<void> load() async {
|
||||
if (!isSupported) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final id = prefs.getString(prefKey);
|
||||
current.value = _parse(id);
|
||||
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<void> apply(AppIcon icon) async {
|
||||
if (!isSupported) return;
|
||||
if (current.value == icon) return;
|
||||
await _channel.invokeMethod<void>('setAppIcon', {
|
||||
'name': icon.platformName,
|
||||
'name': Platform.isIOS ? icon.iosAlternateName : icon.androidAlias,
|
||||
});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(prefKey, icon.id);
|
||||
current.value = icon;
|
||||
}
|
||||
|
||||
static Future<AppIcon?> _appliedIcon() async {
|
||||
if (!Platform.isIOS) return null;
|
||||
try {
|
||||
final name = await _channel.invokeMethod<String>('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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user