Merge pull request #77 from KometTeam/feature/FullStack

Feature/full stack
This commit is contained in:
klockky
2026-08-26 18:00:03 +03:00
committed by GitHub
513 changed files with 76092 additions and 12938 deletions
+24
View File
@@ -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
+5
View File
@@ -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
+5
View File
@@ -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
+8
View File
@@ -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
+5
View File
@@ -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
+5
View File
@@ -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
+5
View File
@@ -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
+34
View File
@@ -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
+21 -4
View File
@@ -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
+24
View File
@@ -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>
+24
View File
@@ -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>
+6
View File
@@ -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
View File
@@ -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/ руками не правь — только через генератор.
+17
View File
@@ -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
+9
View File
@@ -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:
+50 -1
View File
@@ -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.
+1
View File
@@ -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":[]}
+1
View File
@@ -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":[]}
+1
View File
@@ -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
View File
@@ -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
+17
View File
@@ -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
View File
@@ -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)
}
}
}
+28
View File
@@ -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>
+105
View File
@@ -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()
}
}
+333
View File
@@ -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)
}
}
+402
View File
@@ -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)
}
}
+10
View File
@@ -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
View File
@@ -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) {
+171 -49
View File
@@ -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,
};
}
+55 -122
View File
@@ -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();
}
}
+31 -2
View File
@@ -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>[];
+337
View File
@@ -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;
}
}
+89 -25
View File
@@ -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?) ?? '',
+45 -8
View File
@@ -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;
+162 -32
View File
@@ -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
View File
@@ -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 {
+307
View File
@@ -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;
}
}
+24 -11
View File
@@ -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;
+352 -32
View File
@@ -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,
};
}
}
+50 -4
View File
@@ -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;
}
}
+205 -556
View File
@@ -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
View File
@@ -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);
+3 -1
View File
@@ -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) {
+272 -18
View File
@@ -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;
+31 -2
View File
@@ -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,
) {
+547
View File
@@ -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');
}
}
}
+137
View File
@@ -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,
),
);
}
+149 -42
View File
@@ -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,
},
],
-103
View File
@@ -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 (_) {}
}
}
+690
View File
@@ -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,
);
}
}
+109 -3
View File
@@ -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;
+104
View File
@@ -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));
}
}
+93
View File
@@ -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;
}
}
+89
View File
@@ -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;
}
}
+298
View File
@@ -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},
);
}
}
+30
View File
@@ -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 {
+70 -21
View File
@@ -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();
+2 -1
View File
@@ -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';
}
+17 -2
View File
@@ -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';
}
File diff suppressed because it is too large Load Diff
+12 -69
View File
@@ -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,
);
}
}
+168
View File
@@ -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)';
}
+346
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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);
},
+3
View File
@@ -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';
}
+7 -1
View File
@@ -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';
+21 -2
View File
@@ -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,
);
}
+34
View File
@@ -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);
}
+56 -4
View File
@@ -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;
+22
View File
@@ -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);
}
+42 -7
View File
@@ -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;
+19
View File
@@ -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