Merge pull request #57 from KometTeam/merge/fullstack-into-0.5.0

merge: feature/FullStack into dev/0.5.0
This commit is contained in:
klockky
2026-07-14 18:47:25 +03:00
committed by GitHub
185 changed files with 18040 additions and 4603 deletions
+22
View File
@@ -0,0 +1,22 @@
name: Setup Rust
description: >
Install the Rust toolchain with platform targets and a build cache, so
cargokit can compile the kolibri native library during the Flutter build.
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: third_party/kolibri/kolibri-dart/rust
+7
View File
@@ -25,6 +25,8 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Setup Java
uses: actions/setup-java@v4
@@ -39,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
+7
View File
@@ -25,6 +25,8 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Setup Java
uses: actions/setup-java@v4
@@ -39,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
+7
View File
@@ -28,6 +28,8 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Setup Flutter
uses: subosito/flutter-action@v2
@@ -35,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
+7
View File
@@ -28,6 +28,8 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Setup Flutter
uses: subosito/flutter-action@v2
@@ -40,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
+7
View File
@@ -28,6 +28,8 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Setup Flutter
uses: subosito/flutter-action@v2
@@ -35,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
+7
View File
@@ -28,6 +28,8 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Setup Flutter
uses: subosito/flutter-action@v2
@@ -35,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
+7
View File
@@ -12,6 +12,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
submodules: recursive
- name: Setup Java
uses: actions/setup-java@v4
@@ -27,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
+29 -4
View File
@@ -11,6 +11,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
submodules: recursive
- name: Setup Java
uses: actions/setup-java@v4
@@ -26,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
@@ -64,11 +71,13 @@ jobs:
- name: Build Android APK
run: flutter build apk --release --flavor komet
web-linux:
linux:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
submodules: recursive
- name: Set up Flutter
uses: subosito/flutter-action@v2
@@ -81,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
@@ -95,6 +106,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
submodules: recursive
- name: Set up Flutter
uses: subosito/flutter-action@v2
@@ -102,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
@@ -113,6 +131,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
submodules: recursive
- name: Set up Flutter
uses: subosito/flutter-action@v2
@@ -120,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
+31
View File
@@ -26,6 +26,8 @@ jobs:
flavor: [komet, oneme]
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Setup Java
uses: actions/setup-java@v4
@@ -40,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
@@ -101,11 +108,17 @@ jobs:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: x86_64-pc-windows-msvc
- name: Get dependencies
run: flutter pub get
- name: Build Windows
@@ -127,6 +140,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
@@ -136,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
@@ -156,11 +175,17 @@ jobs:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- name: Get dependencies
run: flutter pub get
- name: Fix opus/ogg libs for macOS
@@ -184,11 +209,17 @@ jobs:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios
- name: Get dependencies
run: flutter pub get
- name: Build iOS (no codesign)
+33
View File
@@ -21,6 +21,8 @@ jobs:
flavor: [komet, oneme]
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Setup Java
uses: actions/setup-java@v4
@@ -35,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
@@ -96,11 +103,17 @@ jobs:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: x86_64-pc-windows-msvc
- name: Get dependencies
run: flutter pub get
- name: Build Windows
@@ -122,6 +135,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
@@ -131,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
@@ -151,11 +170,17 @@ jobs:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- name: Get dependencies
run: flutter pub get
- name: Build macOS
@@ -177,11 +202,17 @@ jobs:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios
- name: Get dependencies
run: flutter pub get
- name: Build iOS (no codesign)
@@ -238,6 +269,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Download all artifacts
uses: actions/download-artifact@v4
+6
View File
@@ -0,0 +1,6 @@
[submodule "third_party/rlottie"]
path = third_party/rlottie
url = https://github.com/Samsung/rlottie.git
[submodule "third_party/kolibri"]
path = third_party/kolibri
url = https://github.com/KometTeam/kolibri.git
+12
View File
@@ -37,6 +37,18 @@ android {
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
externalNativeBuild {
cmake {
arguments += listOf("-DBUILD_SHARED_LIBS=ON")
}
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
}
}
flavorDimensions += "distribution"
+3 -3
View File
@@ -33,7 +33,7 @@
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:name="ru.komet.app.MainActivity"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="Komet"
@@ -73,12 +73,12 @@
</intent-filter>
</activity>
<activity-alias
android:name=".MinimalIcon"
android:name="ru.komet.app.MinimalIcon"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_minimal"
android:label="Komet"
android:targetActivity=".MainActivity">
android:targetActivity="ru.komet.app.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
+7
View File
@@ -0,0 +1,7 @@
cmake_minimum_required(VERSION 3.10)
project(komet_rlottie LANGUAGES C CXX ASM)
add_subdirectory(
"${CMAKE_CURRENT_SOURCE_DIR}/../../../../../third_party/rlottie_build"
"${CMAKE_BINARY_DIR}/rlottie")
@@ -52,7 +52,11 @@ import java.util.concurrent.atomic.AtomicBoolean
class MainActivity : FlutterActivity() {
private val channelName = "ru.komet.app/vpn_bypass"
private val iconComponents = listOf("MainActivity", "MinimalIcon")
private val iconPackage = MainActivity::class.java.name.substringBeforeLast('.')
private val iconComponents = mapOf(
"MainActivity" to "$iconPackage.MainActivity",
"MinimalIcon" to "$iconPackage.MinimalIcon",
)
private var nfcAdapter: NfcAdapter? = null
private var nfcEvents: EventChannel.EventSink? = null
@@ -86,8 +90,8 @@ class MainActivity : FlutterActivity() {
private fun applyIcon(name: String) {
val pm = packageManager
for (alias in iconComponents) {
val component = ComponentName(packageName, "$packageName.$alias")
for ((alias, className) in iconComponents) {
val component = ComponentName(packageName, className)
val state = if (alias == name) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
@@ -165,7 +169,7 @@ class MainActivity : FlutterActivity() {
when (call.method) {
"setAppIcon" -> {
val name = call.argument<String>("name")
if (name == null || !iconComponents.contains(name)) {
if (name == null || !iconComponents.containsKey(name)) {
result.error("INVALID_ICON", "Unknown icon: $name", null)
return@setMethodCallHandler
}
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none">
<g fill="#ffffff">
<path d="M18 20c0-4 3-7 7-7h20c4 0 7 3 7 7v8c0 4-3 7-7 7H33l-8 6 1-6h-1c-4 0-7-3-7-7z"/>
<path d="M74 58c0-3.5 3-6.5 6.5-6.5H98c3.5 0 6.5 3 6.5 6.5v7c0 3.5-3 6.5-6.5 6.5H86l-7 5 1-5c-3.5 0-6-3-6-6.5z"/>
<path d="M22 82c0-3.5 3-6.5 6.5-6.5H46c3.5 0 6.5 3 6.5 6.5v7c0 3.5-3 6.5-6.5 6.5H34l-7 5 1-5c-3.5 0-6-3-6-6.5z"/>
<circle cx="92" cy="24" r="4"/>
<circle cx="104" cy="34" r="2.5"/>
<circle cx="10" cy="56" r="2.5"/>
<circle cx="64" cy="104" r="3.5"/>
<circle cx="80" cy="110" r="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 653 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none">
<g fill="#ffffff">
<path d="M30 18c-3-5-11-5-13 1-2 5 2 10 13 18 11-8 15-13 13-18-2-6-10-6-13-1z"/>
<path d="M92 46c-2.4-4-8.8-4-10.4.8-1.6 4 1.6 8 10.4 14.4 8.8-6.4 12-10.4 10.4-14.4-1.6-4.8-8-4.8-10.4-.8z"/>
<path d="M34 78c-2.4-4-8.8-4-10.4.8-1.6 4 1.6 8 10.4 14.4 8.8-6.4 12-10.4 10.4-14.4-1.6-4.8-8-4.8-10.4-.8z"/>
<circle cx="70" cy="96" r="3"/>
<circle cx="102" cy="94" r="2.5"/>
<circle cx="58" cy="52" r="2.5"/>
<circle cx="10" cy="44" r="2.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 599 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none">
<g fill="#ffffff">
<path d="M14 34 46 20 34 52l-6-9-8 6z"/>
<path d="M78 78 106 66 96 92l-5-7-6 5z"/>
<path d="M30 92 52 82 44 102l-4-6-5 4z"/>
<circle cx="88" cy="30" r="3"/>
<circle cx="104" cy="42" r="2"/>
<circle cx="60" cy="58" r="2.5"/>
<circle cx="12" cy="70" r="2.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 421 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 60 60" fill="none">
<g fill="#ffffff">
<path d="M28 12h4v7h7v4h-7v7h-4v-7h-7v-4h7z"/>
<circle cx="0" cy="0" r="2"/>
<circle cx="60" cy="0" r="2"/>
<circle cx="0" cy="60" r="2"/>
<circle cx="60" cy="60" r="2"/>
<circle cx="30" cy="0" r="2"/>
<circle cx="0" cy="30" r="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 392 B

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none">
<g fill="none" stroke="#ffffff" stroke-width="3">
<circle cx="30" cy="30" r="16"/>
<circle cx="90" cy="66" r="12"/>
<circle cx="24" cy="88" r="10"/>
<circle cx="96" cy="16" r="7"/>
<circle cx="60" cy="60" r="4"/>
</g>
<g fill="#ffffff">
<circle cx="66" cy="102" r="3"/>
<circle cx="6" cy="54" r="2.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 451 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none">
<g fill="#ffffff">
<path d="M28 14c1.5 8 3 9.5 11 11-8 1.5-9.5 3-11 11-1.5-8-3-9.5-11-11 8-1.5 9.5-3 11-11z"/>
<path d="M90 50c1.2 6.4 2.4 7.6 8.8 8.8-6.4 1.2-7.6 2.4-8.8 8.8-1.2-6.4-2.4-7.6-8.8-8.8 6.4-1.2 7.6-2.4 8.8-8.8z"/>
<path d="M40 82c1 5.6 2 6.6 7.6 7.6-5.6 1-6.6 2-7.6 7.6-1-5.6-2-6.6-7.6-7.6 5.6-1 6.6-2 7.6-7.6z"/>
<circle cx="70" cy="22" r="2.5"/>
<circle cx="104" cy="30" r="2"/>
<circle cx="12" cy="60" r="2"/>
<circle cx="66" cy="104" r="2.5"/>
<circle cx="100" cy="98" r="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 642 B

+1
View File
@@ -62,6 +62,7 @@ flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
pod 'rlottie', :path => '../third_party'
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
+320 -239
View File
@@ -1,46 +1,52 @@
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;
@@ -49,6 +55,9 @@ class Api {
int? get callsSeed => _callsSeed;
String? get deviceId => _deviceId;
/// Сырой доступ к сессии ядра — для медиа-загрузок (data-plane).
KolibriSession? get session => _session;
String? spoofScope;
static bool _tzInitialized = false;
@@ -63,28 +72,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,31 +106,17 @@ 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;
try {
endpoint = await ServerConfig.loadEndpoint().timeout(_endpointTimeout);
@@ -137,80 +129,68 @@ class Api {
}
if (gen != _connectGen) return;
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 +224,6 @@ class Api {
_connectGen++;
_cancelConnectWatchdog();
_cleanup();
try {
await _connection.disconnect();
} catch (_) {}
_setSessionState(SessionState.disconnected);
if (_autoReconnect) _scheduleReconnect();
}
@@ -254,37 +231,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,7 +265,101 @@ class Api {
}
}
Future<Packet> sendHandshake() async {
/// Отправляет запрос и ждёт ответ от сервера.
Future<Packet> sendRequest(int opcode, Map<dynamic, dynamic> payload) 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)} таймаут'),
);
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) _errorController.add(text);
final err = PacketError(
messageFromErrorPayload(packet.payload),
errorKey: resp.errorKey,
);
throw err;
}
return packet;
}
Future<Map<dynamic, dynamic>?> sendRequestMap(
int opcode,
Map<dynamic, dynamic> payload,
) async {
final response = await sendRequest(opcode, payload);
if (!response.isOk || response.payload is! Map) return null;
return response.payload as Map<dynamic, dynamic>;
}
Future<bool> sendRequestOk(int opcode, Map<dynamic, dynamic> payload) async {
final response = await sendRequest(opcode, payload);
return response.isOk;
}
Future<Packet> sendRequestOrThrow(
int opcode,
Map<dynamic, dynamic> payload,
) async {
final response = await sendRequest(opcode, payload);
throwIfPacketError(response);
return response;
}
/// Вешает обработчик на пуши с указанным опкодом.
void registerPushHandler(int opcode, void Function(Packet) handler) {
_dispatcher.registerHandler(opcode, handler);
}
/// Снимает обработчик пушей с указанного опкода.
void unregisterPushHandler(int opcode) {
_dispatcher.unregisterHandler(opcode);
}
/// Стрим всех входящих пушей от сервера.
Stream<Packet> get pushStream => _dispatcher.pushStream;
Future<void> dispose() async {
_autoReconnect = false;
_reconnectTimer?.cancel();
_cleanup();
_dispatcher.dispose();
await _stateController.close();
await _sessionExpiredController.close();
await _handshakeSuccessController.close();
await _errorController.close();
}
// Внутрянка
/// Строит устройство-поля и создаёт сессию ядра. Заодно заполняет
/// [_userAgent] и [_deviceId] для геттеров.
Future<(KolibriSession, Stream<WireLogEvent>)> _buildSessionOptions(
({String host, int port}) endpoint,
) async {
final deviceInfo = DeviceInfoPlugin();
String deviceType = 'ANDROID';
@@ -404,95 +460,100 @@ class Api {
'deviceName': deviceName,
'deviceLocale': deviceLocale,
};
_deviceId = deviceId;
final payload = <dynamic, dynamic>{
'mt_instanceid': instanceId,
'userAgent': _userAgent,
'clientSessionId': clientSessionId,
'deviceId': deviceId,
};
final insecureTls = await TlsConfig.isInsecureAllowed();
final proxy = await _buildProxyUrl();
return sendRequest(Opcode.sessionInit, payload);
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,
);
}
/// Отправляет запрос и ждёт ответ от сервера.
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)
.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);
},
);
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}';
}
Future<Map<dynamic, dynamic>?> sendRequestMap(
int opcode,
Map<dynamic, dynamic> payload,
) async {
final response = await sendRequest(opcode, payload);
if (!response.isOk || response.payload is! Map) return null;
return response.payload as Map<dynamic, dynamic>;
void _onPush((int, Map<String, dynamic>) event) {
final packet = Packet(
cmd: CmdType.push,
opcode: event.$1,
payload: event.$2,
);
// Учёт трафика пушей ведётся из wire-лога ядра (_onWireLog).
_dispatcher.dispatch(packet);
}
Future<bool> sendRequestOk(int opcode, Map<dynamic, dynamic> payload) async {
final response = await sendRequest(opcode, payload);
return response.isOk;
/// Единый источник лога трафика: ядро отдаёт сюда каждый пакет обеих сторон —
/// включая 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,
);
}
Future<Packet> sendRequestOrThrow(
int opcode,
Map<dynamic, dynamic> payload,
) async {
final response = await sendRequest(opcode, payload);
throwIfPacketError(response);
return response;
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'
}
}
/// Вешает обработчик на пуши с указанным опкодом.
void registerPushHandler(int opcode, void Function(Packet) handler) {
_dispatcher.registerHandler(opcode, handler);
static dynamic _decodeWireJson(String json) {
try {
return jsonDecode(json);
} catch (_) {
return json;
}
}
/// Снимает обработчик пушей с указанного опкода.
void unregisterPushHandler(int opcode) {
_dispatcher.unregisterHandler(opcode);
}
/// Стрим всех входящих пушей от сервера.
Stream<Packet> get pushStream => _dispatcher.pushStream;
Future<void> dispose() async {
_autoReconnect = false;
_reconnectTimer?.cancel();
_cleanup();
_dispatcher.dispose();
await _connection.dispose();
await _stateController.close();
await _sessionExpiredController.close();
await _handshakeSuccessController.close();
}
// Внутрянка
void _setSessionState(SessionState state) {
if (_sessionState == state) return;
_sessionState = state;
@@ -500,35 +561,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 +568,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 +592,6 @@ class Api {
Future<void> _forceReconnect() async {
_connectGen++;
_cleanup();
await _connection.disconnect();
_reconnectAttempts = 0;
_reconnectTimer?.cancel();
_setSessionState(SessionState.disconnected);
@@ -564,12 +600,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,16 +628,44 @@ class Api {
_onReconnectCallback = callback;
}
void _startPinging() {
_pingTimer?.cancel();
_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 {
@@ -607,6 +679,15 @@ class Api {
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) {
if (payload is! Map) return null;
final raw = payload['reg-country-code'];
+54 -3
View File
@@ -177,9 +177,13 @@ class AccountModule {
final result = VerifyCodeResult(payload: data.cast<dynamic, dynamic>());
final sessionToken = result.loginToken ?? result.registerToken;
final accountId = result.accountId;
final verifiedProfile = _profileFromVerifyPayload(result.payload);
final accountId = result.accountId ?? verifiedProfile?.id;
if (sessionToken != null && accountId != null) {
if (result.loginToken != null && verifiedProfile != null) {
await AppDatabase.saveProfile(verifiedProfile, isActive: true);
}
await TokenStorage.saveToken(sessionToken, accountId);
await TokenStorage.setActiveAccount(accountId);
await SpoofingService.commitPendingSpoof(accountId);
@@ -188,6 +192,12 @@ class AccountModule {
return result;
}
ProfileData? _profileFromVerifyPayload(Map<dynamic, dynamic> payload) {
final profileMap = payload['profile'];
if (profileMap is! Map) return null;
return ProfileData.fromServerProfile(profileMap.cast<dynamic, dynamic>());
}
Future<int> completeRegistration({
required String token,
required String firstName,
@@ -396,10 +406,16 @@ class AccountModule {
}
Future<void> logout() async {
final accountId = await TokenStorage.getActiveAccountId();
try {
await _logoutOnServer(accountId);
} on SessionExpiredException catch (e) {
logger.w('logout: сервер отклонил сессию: ${e.message}');
}
_loggedIn = false;
try {
await _api.disconnect();
} catch (_) {}
final accountId = await TokenStorage.getActiveAccountId();
if (accountId != null) {
await removeAccount(accountId);
}
@@ -409,6 +425,33 @@ class AccountModule {
chats.resetForAccountSwitch();
}
Future<void> _logoutOnServer(int? accountId) async {
await _ensureLogoutSession(accountId);
await _api.sendRequestOrThrow(Opcode.logout, <dynamic, dynamic>{});
}
Future<void> _ensureLogoutSession(int? accountId) async {
if (_api.state == SessionState.disconnected) {
await _api.connect();
}
if (_api.state != SessionState.online) {
await _api.stateStream
.firstWhere((state) => state == SessionState.online)
.timeout(const Duration(seconds: 20));
}
if (_loggedIn) return;
if (accountId == null) return;
final token = await TokenStorage.readToken(accountId);
if (token == null || token.isEmpty) {
throw StateError('logout: нет токена для серверного выхода');
}
await _api.sendRequestOrThrow(
Opcode.login,
buildLoginPayload(token, interactive: false),
);
_loggedIn = true;
}
Future<TwoFactorResult> checkPassword({
required String password,
required String trackId,
@@ -501,7 +544,8 @@ class AccountModule {
payload['lastLogin'] = sync.lastLogin;
if (sync.configHash != null) payload['configHash'] = sync.configHash;
} else {
payload['presenceSync'] = 0;
payload['presenceSync'] = -1;
payload['chatsSync'] = -1;
}
return payload;
@@ -542,6 +586,13 @@ class AccountModule {
await _saveSyncState(data, serverTime, profile.id);
await ContactsModule.syncFromLoginPayload(data, profile.id);
await chats.syncFromLoginPayload(data, profile.id, profile.id);
unawaited(chats.paginateChats(_api, profile.id, profile.id, data));
try {
await ContactsModule.syncFromServer(_api, profile.id);
} catch (e) {
logger.w('Контакты: $e');
}
final config = data['config'];
if (config is Map) {
@@ -91,15 +91,9 @@ class PrivacyModule extends AccountApiBase {
}
}
Future<void> unregisterPushToken(String pushToken) async {
Future<void> unregisterPushToken(String _) async {
if (api.state != SessionState.online) return;
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) return;
final authToken = await TokenStorage.readToken(accountId);
if (authToken == null) return;
await api.sendRequest(Opcode.logout, <dynamic, dynamic>{
'token': authToken,
'pushToken': pushToken,
});
final packet = await api.sendRequest(Opcode.logout, <dynamic, dynamic>{});
throwIfPacketError(packet);
}
}
+165
View File
@@ -0,0 +1,165 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../api.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/utils/logger.dart';
import '../../models/animoji.dart';
class AnimojiModule {
final Api _api;
AnimojiModule(this._api);
static const List<String> fallbackReactions = [
'👍',
'❤️',
'🔥',
'🤣',
'😭',
'😍',
];
static const String _recentsKey = 'komet_recent_animoji';
static const int _maxRecents = 24;
final Map<int, Animoji> _byId = {};
List<int> _orderedIds = [];
List<int> _recentIds = [];
bool _recentsLoaded = false;
Future<void>? _loading;
bool get isLoaded => _orderedIds.isNotEmpty;
List<Animoji> get animojis =>
_orderedIds.map((id) => _byId[id]).whereType<Animoji>().toList();
List<Animoji> get recentAnimojis =>
_recentIds.map((id) => _byId[id]).whereType<Animoji>().toList();
List<String> get emojis => animojis.map((a) => a.emoji).toList();
Future<void> ensureRecentsLoaded() async {
if (_recentsLoaded) return;
_recentsLoaded = true;
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getStringList(_recentsKey) ?? const [];
_recentIds = raw.map(int.tryParse).whereType<int>().toList();
} catch (_) {}
}
Future<void> noteUsed(Animoji animoji) async {
await ensureRecentsLoaded();
_byId[animoji.id] = animoji;
_recentIds.remove(animoji.id);
_recentIds.insert(0, animoji.id);
if (_recentIds.length > _maxRecents) {
_recentIds = _recentIds.sublist(0, _maxRecents);
}
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(
_recentsKey,
_recentIds.map((e) => e.toString()).toList(),
);
} catch (_) {}
}
List<Animoji> get quickAnimojis {
final list = animojis;
return list.length <= 6 ? list : list.sublist(0, 6);
}
Future<void> ensureLoaded() {
return _loading ??= _load().catchError((Object e) {
_loading = null;
throw e;
});
}
Future<void> _load() async {
final setIds = <int>[];
final fallbackIds = <int>[];
final sync = await _api.sendRequestMap(Opcode.assetsUpdate, {
'type': 'ANIMOJI_SET',
'sync': 0,
});
if (sync != null) {
final sections = sync['sections'];
if (sections is List) {
for (final s in sections) {
if (s is Map) _appendIntList(setIds, s['animojiSetIds']);
}
}
final updates = sync['animojiUpdates'];
if (updates is Map) {
for (final key in updates.keys) {
final id = key is int ? key : int.tryParse(key.toString());
if (id != null) fallbackIds.add(id);
}
}
}
final orderedIds = <int>[];
if (setIds.isNotEmpty) {
final setMap = await _api.sendRequestMap(Opcode.assetsGetByIds, {
'type': 'ANIMOJI_SET',
'ids': setIds,
});
if (setMap != null) {
final sets = setMap['animojiSets'];
if (sets is List) {
for (final set in sets) {
if (set is! Map) continue;
_appendIntList(orderedIds, set['animojis']);
_appendIntList(orderedIds, set['animojiIds']);
}
}
}
}
final ids = _dedup(orderedIds.isNotEmpty ? orderedIds : fallbackIds);
if (ids.isEmpty) return;
for (final batch in _chunk(ids, 100)) {
final map = await _api.sendRequestMap(Opcode.assetsGetByIds, {
'type': 'ANIMOJI',
'ids': batch,
});
if (map == null) continue;
final list = map['animojis'];
if (list is! List) continue;
for (final e in list) {
if (e is! Map) continue;
final animoji = Animoji.fromMap(e);
if (animoji != null) _byId[animoji.id] = animoji;
}
}
_orderedIds = ids.where(_byId.containsKey).toList();
logger.i('Анимодзи: ${_orderedIds.length} доступно для реакций');
}
List<int> _dedup(List<int> ids) {
final seen = <int>{};
final result = <int>[];
for (final id in ids) {
if (seen.add(id)) result.add(id);
}
return result;
}
void _appendIntList(List<int> target, dynamic raw) {
if (raw is! List) return;
for (final e in raw) {
if (e is int) target.add(e);
}
}
Iterable<List<T>> _chunk<T>(List<T> list, int size) sync* {
for (var i = 0; i < list.length; i += size) {
yield list.sublist(i, i + size > list.length ? list.length : i + size);
}
}
}
+27
View File
@@ -43,6 +43,7 @@ CachedChat? parseChatRow(
final muteFav = _resolveMuteAndFavorite(chatsConfig, id, existing);
final presence = _resolvePresence(type, otherId, presenceMap);
final adminsOwner = _resolveAdmins(chat);
final pinned = _resolvePinnedMessage(chat['pinnedMessage']);
return CachedChat(
id: id,
@@ -66,6 +67,10 @@ CachedChat? parseChatRow(
options: titleIcon.options,
owner: adminsOwner.owner,
admins: adminsOwner.admins,
pinnedMsgId: pinned.id,
pinnedMsgText: pinned.text,
pinnedMsgTime: pinned.time,
pinnedMsgIsPreview: pinned.isPreview,
);
} catch (e) {
logger.e("Ошибка при парсинге чата: $e");
@@ -130,6 +135,24 @@ _resolveLastMessage(dynamic lastMsg) {
);
}
({int? id, String? text, int? time, bool isPreview}) _resolvePinnedMessage(
dynamic pinned,
) {
if (pinned is! Map) {
return (id: null, text: null, time: null, isPreview: false);
}
final rawId = pinned['id'];
final id = rawId is int ? rawId : int.tryParse(rawId?.toString() ?? '');
if (id == null) return (id: null, text: null, time: null, isPreview: false);
final preview = pinnedMessagePreview(pinned);
return (
id: id,
text: preview.text,
time: pinned['time'] as int?,
isPreview: preview.isPreview,
);
}
({int? favIndex, int dontDisturbUntil}) _resolveMuteAndFavorite(
Map<dynamic, dynamic> chatsConfig,
int id,
@@ -270,6 +293,10 @@ bool sameChatContent(CachedChat a, CachedChat b) {
if (a.title != b.title) return false;
if (a.iconUrl != b.iconUrl) return false;
if (a.owner != b.owner) return false;
if (a.pinnedMsgId != b.pinnedMsgId) return false;
if (a.pinnedMsgText != b.pinnedMsgText) return false;
if (a.pinnedMsgTime != b.pinnedMsgTime) return false;
if (a.pinnedMsgIsPreview != b.pinnedMsgIsPreview) return false;
if (a.dontDisturbUntil != b.dontDisturbUntil) return false;
if (a.favIndex != b.favIndex) return false;
if (a.lastMsgId != b.lastMsgId) return false;
+74 -4
View File
@@ -1,15 +1,14 @@
import 'dart:convert';
String? attachPreviewLabel(dynamic attaches) {
if (attaches is! List || attaches.isEmpty) return null;
final first = attaches.first;
if (first is! Map) return null;
final first = _firstPreviewAttach(attaches);
if (first == null) return null;
final type = (first['_type'] as String? ?? '').toUpperCase();
switch (type) {
case 'PHOTO':
return 'Фото';
case 'VIDEO':
return 'Видео';
return _isVideoNote(first) ? 'Видео-сообщение' : 'Видео';
case 'AUDIO':
return 'Голосовое сообщение';
case 'FILE':
@@ -52,6 +51,23 @@ String? attachPreviewLabel(dynamic attaches) {
}
}
Map? _firstPreviewAttach(dynamic attaches) {
if (attaches is! List || attaches.isEmpty) return null;
for (final attach in attaches) {
if (attach is! Map) continue;
final type = (attach['_type'] as String? ?? '').toUpperCase();
if (type == 'INLINE_KEYBOARD') continue;
return attach;
}
return null;
}
bool _isVideoNote(Map attach) {
final raw = attach['videoType'];
if (raw is int) return raw == 1;
return raw?.toString() == '1';
}
String? _controlPreviewLabel(Map c) {
final title = c['title']?.toString();
if (title != null && title.isNotEmpty) return title;
@@ -90,6 +106,60 @@ String? messagePreviewText(Map msg) {
return _bodyPreviewText(msg);
}
({String? text, bool isPreview}) pinnedMessagePreview(Map msg) {
final link = msg['link'];
if (link is Map && link['type']?.toString().toUpperCase() == 'FORWARD') {
final original = link['message'];
if (original is Map) {
final inner = pinnedMessagePreview(original);
return inner.text != null && inner.text!.isNotEmpty
? (text: '${inner.text}', isPreview: inner.isPreview)
: (text: '↪ пересланное сообщение', isPreview: true);
}
return (text: '↪ пересланное сообщение', isPreview: true);
}
return _pinnedBodyPreview(msg);
}
({String? text, bool isPreview}) _pinnedBodyPreview(Map msg) {
final text = msg['text']?.toString();
if (text != null && text.isNotEmpty) return (text: text, isPreview: false);
final label = _pinnedAttachPreviewLabel(msg['attaches']);
return (text: label, isPreview: label != null);
}
String? _pinnedAttachPreviewLabel(dynamic attaches) {
final first = _firstPreviewAttach(attaches);
if (first == null) return null;
final type = (first['_type'] as String? ?? '').toUpperCase();
switch (type) {
case 'PHOTO':
return 'фото';
case 'VIDEO':
return _isVideoNote(first) ? 'кружок' : 'видео';
case 'AUDIO':
return 'голосовое сообщение';
case 'FILE':
return 'файл';
case 'STICKER':
return 'стикер';
case 'SHARE':
return 'ссылка';
case 'POLL':
return 'голосование';
case 'LOCATION':
return 'геопозиция';
case 'CONTACT':
return 'контакт';
case 'CALL':
return 'звонок';
case 'CONTROL':
return _controlPreviewLabel(first)?.toLowerCase();
default:
return 'вложение';
}
}
String? _bodyPreviewText(Map msg) {
final text = msg['text']?.toString();
if (text != null && text.isNotEmpty) return text;
+259 -37
View File
@@ -8,6 +8,7 @@ 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/token_storage.dart';
import '../../core/utils/logger.dart';
@@ -59,6 +60,10 @@ class CachedChat {
final Set<String> options;
final int? owner;
final Set<int> admins;
final int? pinnedMsgId;
final String? pinnedMsgText;
final int? pinnedMsgTime;
final bool pinnedMsgIsPreview;
CachedChat({
required this.id,
@@ -83,6 +88,10 @@ class CachedChat {
this.options = const {},
this.owner,
this.admins = const {},
this.pinnedMsgId,
this.pinnedMsgText,
this.pinnedMsgTime,
this.pinnedMsgIsPreview = false,
}) : lastMsgTextOneLine = lastMsgText != null && lastMsgText.contains('\n')
? lastMsgText.replaceAll('\n', ' ')
: lastMsgText;
@@ -110,6 +119,15 @@ class CachedChat {
bool iAmAdmin(int myId) => owner == myId || admins.contains(myId);
bool get hasPinnedMessage => pinnedMsgId != null;
bool get isGroupChat => type == 'CHAT' || type == 'GROUP';
bool canPinMessages(int myId) {
if (!isGroupChat) return false;
return iAmAdmin(myId) || options.contains('ALL_CAN_PIN_MESSAGE');
}
bool get isMuted {
if (dontDisturbUntil == ChatsModule.muteOff) return false;
if (dontDisturbUntil < 0) return true;
@@ -141,6 +159,10 @@ class CachedChat {
options: _decodeOptions(row['options']),
owner: row['owner'] as int?,
admins: _decodeAdmins(row['admins']),
pinnedMsgId: row['pinned_msg_id'] as int?,
pinnedMsgText: row['pinned_msg_text'] as String?,
pinnedMsgTime: row['pinned_msg_time'] as int?,
pinnedMsgIsPreview: (row['pinned_msg_is_preview'] as int? ?? 0) == 1,
);
static Set<String> _decodeOptions(dynamic raw) {
@@ -182,6 +204,10 @@ class CachedChat {
'options': options.isEmpty ? null : options.join(','),
'owner': owner,
'admins': admins.isEmpty ? null : admins.join(','),
'pinned_msg_id': pinnedMsgId,
'pinned_msg_text': pinnedMsgText,
'pinned_msg_time': pinnedMsgTime,
'pinned_msg_is_preview': pinnedMsgIsPreview ? 1 : 0,
};
static const Object _keep = Object();
@@ -207,6 +233,10 @@ class CachedChat {
Set<String>? options,
Object? owner = _keep,
Set<int>? admins,
Object? pinnedMsgId = _keep,
Object? pinnedMsgText = _keep,
Object? pinnedMsgTime = _keep,
bool? pinnedMsgIsPreview,
}) {
return CachedChat(
id: id,
@@ -243,6 +273,16 @@ class CachedChat {
options: options ?? this.options,
owner: identical(owner, _keep) ? this.owner : owner as int?,
admins: admins ?? this.admins,
pinnedMsgId: identical(pinnedMsgId, _keep)
? this.pinnedMsgId
: pinnedMsgId as int?,
pinnedMsgText: identical(pinnedMsgText, _keep)
? this.pinnedMsgText
: pinnedMsgText as String?,
pinnedMsgTime: identical(pinnedMsgTime, _keep)
? this.pinnedMsgTime
: pinnedMsgTime as int?,
pinnedMsgIsPreview: pinnedMsgIsPreview ?? this.pinnedMsgIsPreview,
);
}
}
@@ -333,6 +373,8 @@ class ChatsModule {
final _messageEventsController = StreamController<MessageEvent>.broadcast();
Stream<MessageEvent> get messageEvents => _messageEventsController.stream;
int? _paginatedAccountId;
void emitMessageSent(int chatId, String tempId, CachedMessage message) {
_messageEventsController.add(MessageSentEvent(chatId, tempId, message));
}
@@ -366,6 +408,40 @@ class ChatsModule {
_bump();
}
Future<void> markReadUpTo(
Api api,
int accountId,
int chatId,
String messageId,
int mark, {
required int remaining,
}) async {
final msgIdNum = int.tryParse(messageId);
if (msgIdNum != null && !KometSettings.antiRead.value) {
try {
await api.sendRequest(Opcode.chatMark, {
'type': 'READ_MESSAGE',
'chatId': chatId,
'messageId': msgIdNum,
'mark': mark,
});
} catch (_) {}
}
final rows = await AppDatabase.loadChat(accountId, chatId);
if (rows.isEmpty) return;
final cached = CachedChat.fromDbRow(rows.first);
final next = remaining < 0 ? 0 : remaining;
final currentMark = cached.participants[accountId] ?? 0;
final nextMark = mark > currentMark ? mark : currentMark;
if (cached.unreadCount == next && nextMark == currentMark) return;
final participants = Map<int, int>.from(cached.participants)
..[accountId] = nextMark;
final updated = cached.copyWith(unreadCount: next, participants: participants);
await AppDatabase.saveChats([updated.toDbRow()]);
_bump();
}
Future<int?> markUnread(Api api, int accountId, int chatId, int mark) async {
int? unread;
try {
@@ -381,11 +457,11 @@ class ChatsModule {
}
if (unread == null) return null;
await _updateChat(
accountId,
chatId,
(chat) => chat.copyWith(unreadCount: unread),
);
await _updateChat(accountId, chatId, (chat) {
final participants = Map<int, int>.from(chat.participants)
..[accountId] = mark - 1;
return chat.copyWith(unreadCount: unread, participants: participants);
});
return unread;
}
@@ -485,6 +561,7 @@ class ChatsModule {
ContactInfoFetch.clear();
PresenceFetch.clear();
ChatInfoFetch.clear();
SharedContentModule.clearPhotoIndex();
}
void _enqueueGlobalPush(Packet packet) {
@@ -700,10 +777,45 @@ class ChatsModule {
}
if (unread != null) newRow['unread_count'] = unread;
final pinned = _extractPinnedMessage(msg);
if (pinned != null) {
newRow['pinned_msg_id'] = pinned.id;
newRow['pinned_msg_text'] = pinned.text;
newRow['pinned_msg_time'] = pinned.time;
newRow['pinned_msg_is_preview'] = pinned.isPreview ? 1 : 0;
}
await AppDatabase.saveChats([newRow]);
_bump();
}
({int? id, String? text, int? time, bool isPreview})? _extractPinnedMessage(
Map msg,
) {
final attaches = msg['attaches'];
if (attaches is! List) return null;
for (final a in attaches.whereType<Map>()) {
if ((a['_type'] as String?) != 'CONTROL') continue;
final event = a['event']?.toString();
if (event != 'pin' && event != 'unpin') continue;
final pinned = a['pinnedMessage'];
if (event == 'unpin' || pinned is! Map) {
return (id: null, text: null, time: null, isPreview: false);
}
final rawId = pinned['id'];
final id = rawId is int ? rawId : int.tryParse(rawId?.toString() ?? '');
if (id == null) return null;
final preview = pinnedMessagePreview(pinned.cast<dynamic, dynamic>());
return (
id: id,
text: preview.text,
time: pinned['time'] as int?,
isPreview: preview.isPreview,
);
}
return null;
}
Future<void> _reconcileLastMessage(
int accountId,
int chatId,
@@ -996,7 +1108,7 @@ class ChatsModule {
return parsed;
}
final row = parsed.toDbRow();
row['in_list'] = inList ? 1 : 0;
row['in_list'] = !inList ? 0 : (chat['status'] == 'HIDDEN' ? 2 : 1);
await AppDatabase.saveChats([row]);
_bump();
return parsed;
@@ -1028,44 +1140,109 @@ class ChatsModule {
? data['presence'] as Map
: {};
PresenceFetch.primeAll(presenceMap);
final cachedAt = DateTime.now().millisecondsSinceEpoch;
final existingRows = await AppDatabase.loadChats(accountId);
final existing = {
for (final row in existingRows)
row['id'] as int: CachedChat.fromDbRow(row),
};
final rows = chats
.whereType<Map>()
.map(
(c) => parseChatRow(
c.cast<dynamic, dynamic>(),
accountId,
currentUserId,
contactsMap,
chatsConfig,
presenceMap,
existing,
cachedAt,
),
)
.whereType<CachedChat>()
.map((c) => c.toDbRow()..['in_list'] = 1)
.toList();
if (rows.isNotEmpty) {
await AppDatabase.saveChats(rows);
_bump();
}
await _persistChatMaps(
chats,
accountId,
currentUserId,
contactsMap: contactsMap,
chatsConfig: chatsConfig,
presenceMap: presenceMap,
);
} catch (e) {
logger.e("Ошибка при синке: $e");
}
}
Future<List<CachedChat>> getChats(int accountId) async {
Future<int> _persistChatMaps(
List<dynamic> chats,
int accountId,
int currentUserId, {
Map<int, Map<dynamic, dynamic>> contactsMap = const {},
Map<dynamic, dynamic> chatsConfig = const {},
Map<dynamic, dynamic> presenceMap = const {},
}) async {
final cachedAt = DateTime.now().millisecondsSinceEpoch;
final existingRows = await AppDatabase.loadChats(
accountId,
includeHidden: true,
);
final existing = {
for (final row in existingRows)
row['id'] as int: CachedChat.fromDbRow(row),
};
final rows = <Map<String, dynamic>>[];
for (final c in chats.whereType<Map>()) {
final map = c.cast<dynamic, dynamic>();
final parsed = parseChatRow(
map,
accountId,
currentUserId,
contactsMap,
chatsConfig,
presenceMap,
existing,
cachedAt,
);
if (parsed == null) continue;
final row = parsed.toDbRow();
row['in_list'] = map['status'] == 'HIDDEN' ? 2 : 1;
rows.add(row);
}
if (rows.isNotEmpty) {
await AppDatabase.saveChats(rows);
_bump();
}
return rows.length;
}
Future<void> paginateChats(
Api api,
int accountId,
int currentUserId,
Map<dynamic, dynamic> loginData,
) async {
if (_paginatedAccountId == accountId) return;
_paginatedAccountId = accountId;
try {
final rows = await AppDatabase.loadChats(accountId);
var marker = loginData['chatMarker'];
if (marker is! int || marker <= 0) return;
const count = 50;
var page = 0;
while (page < 200) {
page++;
final resp = await api.sendRequestMap(Opcode.chatsList, {
'marker': marker,
'count': count,
});
if (resp == null) break;
final chats = resp['chats'];
if (chats is! List || chats.isEmpty) break;
await _persistChatMaps(chats, accountId, currentUserId);
final next = resp['marker'];
if (next is! int || next == marker || chats.length < count) break;
marker = next;
}
} catch (e) {
_paginatedAccountId = null;
logger.w('Пагинация чатов: $e');
}
}
Future<List<CachedChat>> getChats(
int accountId, {
bool includeHidden = false,
}) async {
try {
final rows = await AppDatabase.loadChats(
accountId,
includeHidden: includeHidden,
);
final chats = rows.map(CachedChat.fromDbRow).toList();
return chats;
} catch (e) {
@@ -1100,6 +1277,18 @@ class ChatsModule {
return Map<String, dynamic>.from(chats.first as Map);
}
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 {
final packet = await api.sendRequest(Opcode.publicSearch, {
'query': userId.toString(),
@@ -1269,6 +1458,39 @@ class ChatsModule {
return true;
}
Future<String?> setPinnedMessage(
Api api, {
required int chatId,
required int? messageId,
bool notify = true,
}) async {
try {
final packet = await api.sendRequest(Opcode.chatUpdate, {
'chatId': chatId,
'notifyPin': notify,
'pinMessageId': messageId ?? 0,
});
if (!packet.isOk) {
return messageFromErrorPayload(packet.payload);
}
final data = packet.payload;
final chat = data is Map ? data['chat'] : null;
if (chat is Map) {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId != null) {
await cacheServerChat(chat.cast<dynamic, dynamic>(), accountId);
}
}
return null;
} on PacketError catch (e) {
logger.w('setPinnedMessage $chatId: ${e.message}');
return e.message;
} catch (e) {
logger.w('setPinnedMessage $chatId: $e');
return 'Не удалось изменить закрепление';
}
}
Future<String?> togglePin(
Api api, {
required List<int> chatIds,
+49
View File
@@ -1,7 +1,9 @@
import 'package:flutter/foundation.dart';
import '../../core/config/debug_test.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/storage/app_database.dart';
import '../../core/utils/logger.dart';
import '../api.dart';
import 'messages.dart';
@@ -174,7 +176,19 @@ class ContactsModule {
if (rows.isNotEmpty) {
await AppDatabase.saveContacts(rows);
revision.value++;
}
logger.i(
'Контакты: получено ${contacts.length}, сохранено ${rows.length} (акк $accountId)',
);
}
static Future<void> syncFromServer(Api api, int accountId) async {
final map = await api.sendRequestMap(Opcode.contactsGet, {
'contactsSync': 0,
});
if (map == null) return;
await syncFromLoginPayload(map.cast<dynamic, dynamic>(), accountId);
}
static void _primeContactCache(Map<dynamic, dynamic> contact) {
@@ -228,6 +242,41 @@ class ContactsModule {
return rows.map(CachedContact.fromDbRow).toList();
}
static const List<String> _debugFirstNames = [
'Алиса', 'Борис', 'Вера', 'Глеб', 'Дарья', 'Егор', 'Жанна', 'Захар',
'Ирина', 'Кирилл', 'Лия', 'Максим', 'Нина', 'Олег', 'Полина', 'Роман',
'София', 'Тимур', 'Ульяна', 'Фёдор', 'Ханна', 'Цветана', 'Чеслав', 'Шура',
];
static const List<String> _debugLastNames = [
'Иванов', 'Петров', 'Сидоров', 'Кузнецов', 'Смирнов', 'Попов', 'Волков',
'Соколов', 'Морозов', 'Новиков', 'Фёдоров', 'Козлов',
];
static List<CachedContact> debugContacts() {
final count = DebugTest.contactCount;
final out = <CachedContact>[];
for (var i = 0; i < count; i++) {
final first = _debugFirstNames[i % _debugFirstNames.length];
final last =
_debugLastNames[(i ~/ _debugFirstNames.length) %
_debugLastNames.length];
out.add(
CachedContact(
id: 900000000 + i,
accountId: DebugTest.debugAccountId,
firstName: '$first ${i + 1}',
lastName: last,
phone: 79000000000 + i,
baseUrl: 'https://i.pravatar.cc/150?u=komet_debug_$i',
updateTime: 1,
options: i % 6 == 0 ? const {'OFFICIAL'} : const {},
),
);
}
return out;
}
/// Прогревает in-memory ContactCache из локальных контактов.
/// Нужно вызывать на cold start: иначе кэш пуст до следующего логина.
static Future<void> primeCacheFromDb(int accountId) async {
+126 -564
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';
@@ -41,10 +40,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 +61,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 +77,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,27 +92,50 @@ 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;
}
@@ -137,9 +163,6 @@ class FileUploader {
} catch (e) {
if (!cancelled) ctrl.add(UploadError(e.toString()));
} finally {
try {
socket?.destroy();
} catch (_) {}
await ctrl.close();
}
}
@@ -155,133 +178,53 @@ 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;
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 +238,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 +268,55 @@ 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(
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,
/// Прогоняет стрим ядра до конца, форвардит прогресс, отдаёт итог.
Future<({int status, Uint8List body, String? error})> _consume(
Stream<kb.UploadEvent> stream, {
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);
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,
);
return (status, '');
}
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 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());
}
}
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)',
);
}
finishWith(parsed);
},
);
timer = Timer(timeout, () {
logger.w('uploadImage: response timeout after ${bytes.length} bytes');
finishWith(tryParse(atClose: true));
});
return completer.future;
}
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;
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 +338,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;
}
}
+59 -21
View File
@@ -55,31 +55,69 @@ class FoldersModule {
});
}
static bool chatMatchesFolder(CachedChat chat, ChatFolder folder) {
if (folder.include != null && folder.include!.isNotEmpty) {
return folder.include!.contains(chat.id);
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 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;
}
}
return null;
}
static bool chatMatchesFolder(
CachedChat chat,
ChatFolder folder, {
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 hasContact = folder.filters.any(
(f) => f == 9 || f == '9' || f == 'CONTACT',
);
final hasNotContact = folder.filters.any(
(f) => f == 8 || f == '8' || f == 'NOT_CONTACT',
);
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);
if (hasContact && hasNotContact) {
if (chat.type != 'DIALOG') return false;
return true;
}
for (final filter in folder.filters) {
if (filter == 0 || filter == '0' || filter == 'UNREAD') {
if (chat.unreadCount > 0) return true;
} else if (filter == 9 || filter == '9' || filter == 'CONTACT') {
if (chat.type == 'DIALOG') return true;
} else if (filter == 8 || filter == '8' || filter == 'NOT_CONTACT') {
if (chat.type == 'CHAT' || chat.type == 'CHANNEL') return true;
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;
+140 -1
View File
@@ -7,6 +7,7 @@ import '../../core/config/komet_settings.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart';
import '../../core/storage/app_database.dart';
import '../../core/storage/token_storage.dart';
import '../../core/utils/logger.dart';
import '../../core/utils/text_format.dart';
import '../../models/attachment.dart';
@@ -393,6 +394,7 @@ class CachedMessage {
bool? deleted,
List<MessageAttachment>? attachments,
List<Map<String, dynamic>>? editHistory,
Map<String, dynamic>? payload,
}) => CachedMessage(
id: id,
accountId: accountId,
@@ -401,7 +403,7 @@ class CachedMessage {
text: text,
time: time,
status: status ?? this.status,
payload: payload,
payload: payload ?? this.payload,
attachments: attachments ?? this.attachments,
isControl: isControl,
deleted: deleted ?? this.deleted,
@@ -1055,6 +1057,143 @@ class MessagesModule {
return _api.sendRequestOk(Opcode.msgDelete, payload);
}
Future<({bool ok, Map<String, dynamic>? info})> setReaction(
int chatId,
String messageId,
String emoji,
) async {
final id = int.tryParse(messageId);
if (id == null) return (ok: false, info: null);
final response = await _api.sendRequest(Opcode.msgReaction, {
'chatId': chatId,
'messageId': id,
'reaction': {'reactionType': 'EMOJI', 'id': emoji},
});
return _applyReactionResponse(chatId, messageId, response);
}
Future<({bool ok, Map<String, dynamic>? info})> cancelReaction(
int chatId,
String messageId,
) async {
final id = int.tryParse(messageId);
if (id == null) return (ok: false, info: null);
final response = await _api.sendRequest(Opcode.msgCancelReaction, {
'chatId': chatId,
'messageId': id,
});
return _applyReactionResponse(chatId, messageId, response);
}
Future<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,
Packet response,
) async {
if (!response.isOk) return (ok: false, info: null);
final payload = response.payload;
final info = payload is Map
? _normalizeReactionInfo(payload['reactionInfo'])
: null;
try {
await _persistReaction(chatId, messageId, info);
} catch (_) {}
return (ok: true, info: info);
}
static Map<String, dynamic>? _normalizeReactionInfo(dynamic raw) {
if (raw is! Map) return null;
final rawCounters = raw['counters'];
if (rawCounters is! List) return null;
final counters = <Map<String, dynamic>>[];
for (final c in rawCounters) {
if (c is! Map) continue;
final reaction = c['reaction']?.toString();
if (reaction == null || reaction.isEmpty) continue;
final count = c['count'];
counters.add({'reaction': reaction, 'count': count is int ? count : 0});
}
if (counters.isEmpty) return null;
final your = raw['yourReaction']?.toString();
final total = raw['totalCount'];
return {
'counters': counters,
if (your != null && your.isNotEmpty) 'yourReaction': your,
'totalCount': total is int
? total
: counters.fold<int>(0, (a, b) => a + (b['count'] as int)),
};
}
Future<void> _persistReaction(
int chatId,
String messageId,
Map<String, dynamic>? info,
) async {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) return;
final existing = await AppDatabase.loadMessage(accountId, chatId, messageId);
if (existing == null) return;
Map<String, dynamic> payloadMap;
final raw = existing['payload'];
if (raw is String && raw.isNotEmpty) {
try {
payloadMap = Map<String, dynamic>.from(jsonDecode(raw) as Map);
} catch (_) {
payloadMap = <String, dynamic>{};
}
} else {
payloadMap = <String, dynamic>{};
}
if (info == null) {
payloadMap.remove('reactionInfo');
} else {
payloadMap['reactionInfo'] = info;
}
final newRow = Map<String, dynamic>.from(existing);
newRow['payload'] = jsonEncode(payloadMap);
await AppDatabase.saveMessages([newRow]);
}
Future<Map<String, dynamic>?> sendButtonCallback({
required int chatId,
required String messageId,
+324
View File
@@ -0,0 +1,324 @@
import '../../core/protocol/opcode_map.dart';
import '../../core/utils/logger.dart';
import '../../models/attachment.dart';
import '../api.dart';
const Map<String, AttachmentType> _attachTypeByName = {
'PHOTO': AttachmentType.photo,
'VIDEO': AttachmentType.video,
'AUDIO': AttachmentType.audio,
'FILE': AttachmentType.file,
'SHARE': AttachmentType.share,
};
class SharedMediaItem {
final String messageId;
final int chatId;
final int senderId;
final int time;
final MessageAttachment attachment;
final String? text;
const SharedMediaItem({
required this.messageId,
required this.chatId,
required this.senderId,
required this.time,
required this.attachment,
this.text,
});
String get dedupKey {
final a = attachment;
final String tail;
if (a is PhotoAttachment) {
tail = 'p${a.photoId ?? a.baseUrl}';
} else if (a is VideoAttachment) {
tail = 'v${a.videoId ?? a.baseUrl}';
} else if (a is FileAttachment) {
tail = 'f${a.fileId ?? a.name}';
} else if (a is AudioAttachment) {
tail = 'a${a.audioId ?? a.fileUrl}';
} else if (a is ShareAttachment) {
tail = 's${a.shareId ?? a.url}';
} else {
tail = a.hashCode.toString();
}
return '$messageId:$tail';
}
}
class SharedMediaPage {
final List<SharedMediaItem> items;
final int total;
const SharedMediaPage({required this.items, required this.total});
static const empty = SharedMediaPage(items: [], total: 0);
}
class CommonChatEntry {
final int id;
final String type;
final String title;
final String? iconUrl;
final int participantsCount;
final List<int> participantIds;
const CommonChatEntry({
required this.id,
required this.type,
required this.title,
required this.iconUrl,
required this.participantsCount,
required this.participantIds,
});
factory CommonChatEntry.fromMap(Map<String, dynamic> map) {
final participants = map['participants'];
final ids = <int>[];
if (participants is Map) {
for (final key in participants.keys) {
final id = key is int ? key : int.tryParse(key.toString());
if (id != null) ids.add(id);
}
}
return CommonChatEntry(
id: (map['id'] as num?)?.toInt() ?? 0,
type: map['type']?.toString() ?? 'CHAT',
title: map['title']?.toString() ?? '',
iconUrl: map['baseIconUrl'] as String?,
participantsCount:
(map['participantsCount'] as num?)?.toInt() ?? ids.length,
participantIds: ids,
);
}
}
class ChatPhotoFeed {
final List<SharedMediaItem> items;
final int total;
final bool reachedEnd;
const ChatPhotoFeed({
required this.items,
required this.total,
required this.reachedEnd,
});
}
class _ChatPhotoIndex {
final List<SharedMediaItem> items = [];
final Set<String> seen = {};
int total = 0;
bool reachedEnd = false;
bool started = false;
Future<void>? inFlight;
}
String photoDedupKey(String messageId, PhotoAttachment photo) =>
'$messageId:p${photo.photoId ?? photo.baseUrl}';
class SharedContentModule {
static const int _photoIndexPageSize = 60;
static const int _photoIndexMaxPages = 40;
static final Map<int, _ChatPhotoIndex> _photoIndexes = {};
final Api _api;
SharedContentModule(this._api);
static void clearPhotoIndex() => _photoIndexes.clear();
Future<ChatPhotoFeed?> photoFeedFor({
required int chatId,
required String photoKey,
required Future<String?> Function() resolveAnchor,
}) async {
final index = _photoIndexes.putIfAbsent(chatId, _ChatPhotoIndex.new);
for (var page = 0; page < _photoIndexMaxPages; page++) {
if (index.seen.contains(photoKey)) return _snapshot(index);
if (index.reachedEnd) return null;
await _nextPhotoPage(chatId, index, resolveAnchor);
}
return null;
}
Future<ChatPhotoFeed> loadMorePhotos({
required int chatId,
required Future<String?> Function() resolveAnchor,
}) async {
final index = _photoIndexes.putIfAbsent(chatId, _ChatPhotoIndex.new);
if (!index.reachedEnd) {
await _nextPhotoPage(chatId, index, resolveAnchor);
}
return _snapshot(index);
}
ChatPhotoFeed _snapshot(_ChatPhotoIndex index) {
final counted = index.items.length;
final total = index.reachedEnd
? counted
: (index.total > counted ? index.total : counted);
return ChatPhotoFeed(
items: List.unmodifiable(index.items),
total: total,
reachedEnd: index.reachedEnd,
);
}
Future<void> _nextPhotoPage(
int chatId,
_ChatPhotoIndex index,
Future<String?> Function() resolveAnchor,
) async {
final pending = index.inFlight;
if (pending != null) {
await pending;
return;
}
final task = _loadPhotoPage(chatId, index, resolveAnchor);
index.inFlight = task;
try {
await task;
} finally {
index.inFlight = null;
}
}
Future<void> _loadPhotoPage(
int chatId,
_ChatPhotoIndex 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'],
forward: initial ? _photoIndexPageSize : 0,
backward: _photoIndexPageSize,
);
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,
required List<String> attachTypes,
int forward = 0,
int backward = 60,
}) async {
try {
final response = await _api.sendRequest(Opcode.chatMedia, {
'chatId': chatId,
'messageId': int.tryParse(anchorMessageId) ?? 0,
'attachTypes': attachTypes,
'forward': forward,
'backward': backward,
});
if (!response.isOk) return SharedMediaPage.empty;
final data = response.payload;
if (data is! Map) return SharedMediaPage.empty;
final messages = data['messages'];
if (messages is! List) return SharedMediaPage.empty;
final wanted = attachTypes
.map((t) => _attachTypeByName[t])
.whereType<AttachmentType>()
.toSet();
final out = <SharedMediaItem>[];
for (final m in messages) {
if (m is! Map) continue;
final map = Map<String, dynamic>.from(m);
final id = map['id']?.toString();
if (id == null) continue;
final sender = (map['sender'] as num?)?.toInt() ?? 0;
final time = (map['time'] as num?)?.toInt() ?? 0;
final text = map['text'] as String?;
final attaches = map['attaches'];
if (attaches is! List) continue;
for (final a in attaches) {
if (a is! Map) continue;
final att = MessageAttachment.fromMap(Map<String, dynamic>.from(a));
if (!wanted.contains(att.type)) continue;
out.add(
SharedMediaItem(
messageId: id,
chatId: chatId,
senderId: sender,
time: time,
attachment: att,
text: text,
),
);
}
}
out.sort((a, b) => b.time.compareTo(a.time));
final total = (data['total'] as num?)?.toInt() ?? out.length;
return SharedMediaPage(items: out, total: total);
} catch (e) {
logger.w('SharedContent.fetchMedia failed: $e');
return SharedMediaPage.empty;
}
}
Future<List<CommonChatEntry>> fetchCommonChats(int userId) async {
try {
final response = await _api.sendRequest(
Opcode.chatSearchCommonParticipants,
{
'userIds': [userId],
},
);
if (!response.isOk) return const [];
final data = response.payload;
if (data is! Map) return const [];
final chats = data['commonChats'];
if (chats is! List) return const [];
final out = <CommonChatEntry>[];
for (final c in chats) {
if (c is Map) {
out.add(CommonChatEntry.fromMap(Map<String, dynamic>.from(c)));
}
}
return out;
} catch (e) {
logger.w('SharedContent.fetchCommonChats failed: $e');
return const [];
}
}
}
+383
View File
@@ -0,0 +1,383 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart';
import '../../core/storage/app_database.dart';
import '../../core/storage/token_storage.dart';
import '../../core/utils/logger.dart';
import '../../models/story.dart';
import '../api.dart';
/// Работа с «Историями»: лента-кольца, полные истории владельца, отметка
/// просмотра и реакции. Кэшируется в SQLite (превью, полные истории и позиция
/// просмотра) — переживает перезапуск; истёкшие кольца отсеиваются при загрузке.
class StoriesModule {
StoriesModule(this._api);
static const _previewsKey = 'stories_previews';
static const _peersKey = 'stories_peers';
static const _progressKey = 'stories_progress';
final Api _api;
final Map<int, StoryPreview> _previews = {};
final Map<int, List<Story>> _peerStories = {};
/// ownerId → storyId, на котором пользователь остановил просмотр.
final Map<int, int> _lastViewed = {};
int? _accountId;
StreamSubscription<Packet>? _pushSub;
/// Бампается при любом изменении лент/историй — UI слушает и перечитывает.
final ValueNotifier<int> storiesChanged = ValueNotifier<int>(0);
void _bump() => storiesChanged.value++;
Future<int?> _acc() async {
_accountId ??= await TokenStorage.getActiveAccountId();
return _accountId;
}
int _nowMs() => DateTime.now().millisecondsSinceEpoch;
int _normMs(int t) => t <= 0
? 0
: (t < 1000000000000 ? t * 1000 : t);
// ── Кэш (SQLite) ───────────────────────────────────────────────────────
/// Загружает кэш из БД (превью/истории/позиции) и показывает мгновенно,
/// до сетевого ответа. Истёкшие кольца отбрасываются.
Future<void> loadCache() async {
final acc = await _acc();
if (acc == null) return;
try {
final rawPreviews = await AppDatabase.getSyncValue(acc, _previewsKey);
if (rawPreviews != null && rawPreviews.isNotEmpty) {
final list = jsonDecode(rawPreviews);
final now = _nowMs();
if (list is List) {
for (final raw in list) {
final preview = StoryPreview.fromMap(raw);
if (preview == null || preview.isEmpty) continue;
final exp = _normMs(preview.lastStoryExpirationTime);
if (exp != 0 && exp < now) continue;
// Не затираем уже загруженные из сети (более свежие) кольца.
_previews.putIfAbsent(preview.owner.ownerId, () => preview);
}
}
}
final rawPeers = await AppDatabase.getSyncValue(acc, _peersKey);
if (rawPeers != null && rawPeers.isNotEmpty) {
final map = jsonDecode(rawPeers);
if (map is Map) {
map.forEach((key, value) {
final ownerId = int.tryParse(key.toString());
if (ownerId == null || value is! List) return;
if (!_previews.containsKey(ownerId)) return;
if (_peerStories.containsKey(ownerId)) return;
final stories = <Story>[];
for (final s in value) {
final story = Story.fromMap(s);
if (story != null) stories.add(story);
}
if (stories.isNotEmpty) _peerStories[ownerId] = stories;
});
}
}
final rawProgress = await AppDatabase.getSyncValue(acc, _progressKey);
if (rawProgress != null && rawProgress.isNotEmpty) {
final map = jsonDecode(rawProgress);
if (map is Map) {
map.forEach((key, value) {
final ownerId = int.tryParse(key.toString());
final storyId = value is int ? value : int.tryParse('$value');
if (ownerId != null && storyId != null) {
_lastViewed.putIfAbsent(ownerId, () => storyId);
}
});
}
}
_bump();
} catch (e) {
logger.w('StoriesModule.loadCache: $e');
}
}
Future<void> _persistPreviews() async {
final acc = await _acc();
if (acc == null) return;
final list = _previews.values.map((p) => p.toJson()).toList();
await AppDatabase.setSyncValue(acc, _previewsKey, jsonEncode(list));
}
Future<void> _persistPeers() async {
final acc = await _acc();
if (acc == null) return;
final map = <String, dynamic>{};
_peerStories.forEach((ownerId, stories) {
map['$ownerId'] = stories.map((s) => s.toJson()).toList();
});
await AppDatabase.setSyncValue(acc, _peersKey, jsonEncode(map));
}
Future<void> _persistProgress() async {
final acc = await _acc();
if (acc == null) return;
final map = <String, int>{};
_lastViewed.forEach((ownerId, storyId) => map['$ownerId'] = storyId);
await AppDatabase.setSyncValue(acc, _progressKey, jsonEncode(map));
}
// ── Позиция просмотра ──────────────────────────────────────────────────
/// Запоминает, что у [ownerId] пользователь остановился на [storyId].
void setLastViewed(int ownerId, int storyId) {
if (storyId == 0 || _lastViewed[ownerId] == storyId) return;
_lastViewed[ownerId] = storyId;
unawaited(_persistProgress());
}
int? lastViewedStoryId(int ownerId) => _lastViewed[ownerId];
/// Кольца-превью, отсортированные: сначала непрочитанные, затем по времени.
List<StoryPreview> get previews {
final list = _previews.values.where((p) => !p.isEmpty).toList();
list.sort((a, b) {
if (a.hasUnread != b.hasUnread) return a.hasUnread ? -1 : 1;
return b.updateTime.compareTo(a.updateTime);
});
return list;
}
bool get hasAny => previews.isNotEmpty;
StoryPreview? previewFor(int ownerId) => _previews[ownerId];
List<Story>? cachedStories(int ownerId) => _peerStories[ownerId];
/// Подписка на серверные пуши обновления колец (NOTIF_STORIES_UPDATE).
void attach() {
_pushSub ??= _api.pushStream
.where((p) => p.opcode == Opcode.notifStoriesUpdate)
.listen(_onPush);
}
void _onPush(Packet packet) {
final payload = packet.payload;
if (payload is! Map) return;
final preview = StoryPreview.fromMap(payload['storiesPreview']);
if (preview == null) return;
_applyPreview(preview);
_bump();
unawaited(_persistPreviews());
}
void _applyPreview(StoryPreview preview) {
if (preview.isEmpty) {
_previews.remove(preview.owner.ownerId);
_peerStories.remove(preview.owner.ownerId);
} else {
_previews[preview.owner.ownerId] = preview;
}
}
/// Первая страница ленты историй. Возвращает false при ошибке/оффлайне.
Future<bool> loadFeed({int count = 20}) async {
if (_api.state != SessionState.online) return false;
try {
final packet = await _api.sendRequest(Opcode.storiesList, {
'cursor': '',
'count': count,
});
throwIfPacketError(packet);
final data = packet.payload;
if (data is! Map) return false;
final rawPreviews = data['storiesPreviews'];
if (rawPreviews is List) {
_previews.clear();
for (final raw in rawPreviews) {
final preview = StoryPreview.fromMap(raw);
if (preview != null) _applyPreview(preview);
}
}
_bump();
unawaited(_persistPreviews());
return true;
} catch (e) {
logger.w('StoriesModule.loadFeed: $e');
return false;
}
}
/// Полные истории владельца. Обновляет кэш и кольцо, возвращает список.
Future<List<Story>> getByOwner(StoryOwner owner) async {
if (_api.state != SessionState.online) {
return _peerStories[owner.ownerId] ?? const [];
}
try {
final packet = await _api.sendRequest(Opcode.storiesGetByOwner, {
'owners': [owner.toMap()],
});
throwIfPacketError(packet);
final data = packet.payload;
if (data is! Map) return _peerStories[owner.ownerId] ?? const [];
final rawPreviews = data['storiesPreviews'];
if (rawPreviews is List) {
for (final raw in rawPreviews) {
final preview = StoryPreview.fromMap(raw);
if (preview != null) _applyPreview(preview);
}
}
final rawPeers = data['peerStories'];
List<Story> result = const [];
if (rawPeers is List) {
for (final raw in rawPeers) {
final peer = PeerStories.fromMap(raw);
if (peer == null) continue;
_peerStories[peer.owner.ownerId] = peer.stories;
if (peer.owner.ownerId == owner.ownerId) result = peer.stories;
}
}
_bump();
unawaited(_persistPreviews());
unawaited(_persistPeers());
return result;
} catch (e) {
logger.w('StoriesModule.getByOwner: $e');
return _peerStories[owner.ownerId] ?? const [];
}
}
/// Отметить историю просмотренной. Оптимистично поднимает readCount кольца.
Future<bool> mark(StoryOwner owner, int storyId) async {
if (_api.state != SessionState.online) return false;
try {
final ok = await _api.sendRequestOk(Opcode.storiesMark, {
'owner': owner.toMap(),
'storyId': storyId,
});
if (ok) _markReadLocally(owner.ownerId);
return ok;
} catch (e) {
logger.w('StoriesModule.mark: $e');
return false;
}
}
void _markReadLocally(int ownerId) {
final preview = _previews[ownerId];
if (preview == null) return;
if (preview.readCount >= preview.totalCount) return;
_previews[ownerId] = preview.copyWith(readCount: preview.readCount + 1);
_bump();
unawaited(_persistPreviews());
}
/// Поставить ([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, сек.
/// Бросает [PacketError]/[TimeoutException] при ошибке сервера — чтобы UI
/// показал реальную причину, а не общее «не удалось».
Future<void> publishPhoto({
required String photoToken,
int settings = 1,
int expiration = 86400,
}) async {
if (_api.state != SessionState.online) {
throw const PacketError('Нет соединения с сервером');
}
final cid = DateTime.now().millisecondsSinceEpoch;
final packet = await _api.sendRequest(Opcode.storiesSend, {
'stories': [
{
'cid': cid,
'settings': settings,
'media': {'_type': 'PHOTO', 'photoToken': photoToken},
'expiration': expiration,
},
],
});
throwIfPacketError(packet);
final data = packet.payload;
if (data is Map) {
final preview = StoryPreview.fromMap(data['storiesPreview']);
if (preview != null) _applyPreview(preview);
final rawStories = data['stories'];
if (rawStories is List) {
for (final raw in rawStories) {
final story = Story.fromMap(raw);
if (story == null) continue;
final list = _peerStories.putIfAbsent(
story.owner.ownerId,
() => <Story>[],
);
list.add(story);
}
}
_bump();
unawaited(_persistPreviews());
unawaited(_persistPeers());
}
}
void clear() {
_previews.clear();
_peerStories.clear();
_lastViewed.clear();
_accountId = null;
_bump();
}
void dispose() {
_pushSub?.cancel();
_pushSub = null;
storiesChanged.dispose();
}
}
+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,
);
}
}
+45 -87
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.
@@ -84,17 +84,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 +106,60 @@ 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) другому участнику.
@@ -329,7 +285,9 @@ class Ws2Signaling {
extra: {'mediaSource': mediaSource, 'layers': layers});
Future<void> close() async {
await _socket?.close();
_socket = null;
await _notifSub?.cancel();
_notifSub = null;
_call?.close();
_call = null;
}
}
+9 -3
View File
@@ -1,15 +1,21 @@
import 'package:flutter/foundation.dart';
import '../../frontend/widgets/liquid_glass.dart';
import 'persisted_setting.dart';
enum ChatChromeStyle { color, blur, none }
enum ChatChromeStyle { color, blur, none, transparent, liquidGlass }
class ChatChromeMaterial {
static bool isLiquid(ChatChromeStyle style) =>
style == ChatChromeStyle.liquidGlass && LiquidGlass.isSupported;
}
class AppChatChrome {
static const prefKey = 'app_chat_chrome';
static final _setting = PersistedEnum<ChatChromeStyle>(
prefKey: prefKey,
defaultValue: ChatChromeStyle.none,
defaultValue: ChatChromeStyle.transparent,
encode: _encode,
decode: _parse,
);
@@ -17,7 +23,7 @@ class AppChatChrome {
static ValueNotifier<ChatChromeStyle> get current => _setting.current;
static ChatChromeStyle _parse(String? value) =>
enumFromName(ChatChromeStyle.values, value, ChatChromeStyle.none);
enumFromName(ChatChromeStyle.values, value, ChatChromeStyle.transparent);
static String _encode(ChatChromeStyle value) => value.name;
@@ -0,0 +1,35 @@
import 'package:flutter/foundation.dart';
import '../../frontend/widgets/liquid_glass.dart';
import 'persisted_setting.dart';
enum ComposerBackground { standard, frostBlur, liquidGlass }
class ComposerMaterial {
static bool isLiquid(ComposerBackground value) =>
value == ComposerBackground.liquidGlass && LiquidGlass.isSupported;
static bool isFrost(ComposerBackground value) =>
value == ComposerBackground.frostBlur ||
(value == ComposerBackground.liquidGlass && !LiquidGlass.isSupported);
}
class AppComposerBackground {
static const prefKey = 'app_composer_background';
static final _setting = PersistedEnum<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);
}
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
enum ComposerStyle { glossy, materialYou }
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);
}
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
class AppFrost {
static const double sigma = 34;
static const double panelSigma = 24;
static Color panelTint(ColorScheme cs) => cs.surface.withValues(alpha: 0.38);
static Color blurPanelTint(ColorScheme cs) =>
cs.surfaceContainerHigh.withValues(alpha: 0.55);
static Color pillTint(ColorScheme cs) =>
cs.surfaceContainerHigh.withValues(alpha: 0.45);
static Color navPillTint(ColorScheme cs) =>
cs.surfaceContainerHigh.withValues(alpha: 0.28);
static Color fabTint(ColorScheme cs) => navPillTint(cs);
static Color inputTint(ColorScheme cs) =>
cs.surfaceContainerHighest.withValues(alpha: 0.45);
static BorderSide hairline(ColorScheme cs) =>
BorderSide(color: cs.outlineVariant.withValues(alpha: 0.4), width: 0.5);
}
+3 -3
View File
@@ -36,12 +36,12 @@ class AppIconConfig {
static Future<void> apply(AppIcon icon) async {
if (!isSupported) return;
if (current.value == icon) return;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(prefKey, icon.id);
current.value = icon;
await _channel.invokeMethod<void>('setAppIcon', {
'name': icon.platformName,
});
final prefs = await SharedPreferences.getInstance();
await prefs.setString(prefKey, icon.id);
current.value = icon;
}
static AppIcon _parse(String? val) {
+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);
}
@@ -9,7 +9,7 @@ class AppMessageActionsStyle {
static final _setting = PersistedEnum<MessageActionsStyle>(
prefKey: prefKey,
defaultValue: MessageActionsStyle.radial,
defaultValue: MessageActionsStyle.list,
encode: (value) => value.name,
decode: _parse,
);
@@ -21,7 +21,7 @@ class AppMessageActionsStyle {
static Future<void> save(MessageActionsStyle style) => _setting.save(style);
static MessageActionsStyle _parse(String? val) =>
enumFromName(MessageActionsStyle.values, val, MessageActionsStyle.radial);
enumFromName(MessageActionsStyle.values, val, MessageActionsStyle.list);
static String label(MessageActionsStyle style) {
switch (style) {
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter/foundation.dart';
import '../../frontend/widgets/liquid_glass.dart';
import 'persisted_setting.dart';
enum NavPillStyle { glossy, frostBlur, liquidGlass }
class NavPillMaterial {
static bool isLiquid(NavPillStyle style) =>
style == NavPillStyle.liquidGlass && LiquidGlass.isSupported;
static bool isFrost(NavPillStyle style) =>
style == NavPillStyle.frostBlur ||
(style == NavPillStyle.liquidGlass && !LiquidGlass.isSupported);
}
class AppNavPillStyle {
static const prefKey = 'app_nav_pill_style';
static final _setting = PersistedEnum<NavPillStyle>(
prefKey: prefKey,
defaultValue: NavPillStyle.glossy,
encode: (value) => value.name,
decode: _parse,
);
static ValueNotifier<NavPillStyle> get current => _setting.current;
static Future<NavPillStyle> load() => _setting.load();
static Future<void> save(NavPillStyle value) => _setting.save(value);
static NavPillStyle _parse(String? val) =>
enumFromName(NavPillStyle.values, val, NavPillStyle.glossy);
}
+1 -1
View File
@@ -7,7 +7,7 @@ class AppPillGradient {
static final _setting = PersistedSetting<bool>(
prefKey: prefKey,
defaultValue: true,
defaultValue: false,
read: (prefs, key) => prefs.getBool(key),
write: (prefs, key, value) async {
await prefs.setBool(key, value);
+7 -3
View File
@@ -2,14 +2,18 @@ import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
enum VisualStyle { materialYou, glossy }
enum VisualStyle { materialYou, glossy, liquidGlass }
extension VisualStyleChrome on VisualStyle {
bool get glossyChrome => this != VisualStyle.materialYou;
}
class AppVisualStyle {
static const prefKey = 'app_visual_style';
static final _setting = PersistedEnum<VisualStyle>(
prefKey: prefKey,
defaultValue: VisualStyle.materialYou,
defaultValue: VisualStyle.glossy,
encode: _encode,
decode: _parse,
);
@@ -23,5 +27,5 @@ class AppVisualStyle {
static String _encode(VisualStyle value) => value.name;
static VisualStyle _parse(String? val) =>
enumFromName(VisualStyle.values, val, VisualStyle.materialYou);
enumFromName(VisualStyle.values, val, VisualStyle.glossy);
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
class AppWallpaperTint {
static const prefKey = 'app_wallpaper_tint';
static final _setting = PersistedSetting<bool>(
prefKey: prefKey,
defaultValue: false,
read: (prefs, key) => prefs.getBool(key),
write: (prefs, key, value) async {
await prefs.setBool(key, value);
},
);
static ValueNotifier<bool> get current => _setting.current;
static Future<bool> load() => _setting.load();
static Future<void> save(bool value) => _setting.save(value);
}
+146 -13
View File
@@ -1,31 +1,164 @@
import 'package:flutter/material.dart';
import '../utils/tiled_svg.dart';
@immutable
class ChatWallpaperTheme {
final String id;
final String name;
final Gradient gradient;
final Color bubbleTint;
final List<Color> colors;
final AlignmentGeometry begin;
final AlignmentGeometry end;
final String? pattern;
final Color patternColor;
final double patternOpacity;
final double tileSize;
final bool dark;
const ChatWallpaperTheme({
required this.id,
required this.name,
required this.gradient,
this.bubbleTint = Colors.transparent,
required this.colors,
this.begin = Alignment.topLeft,
this.end = Alignment.bottomRight,
this.pattern,
this.patternColor = Colors.white,
this.patternOpacity = 0.1,
this.tileSize = 130,
this.dark = true,
});
Widget buildBackground() => DecoratedBox(
decoration: BoxDecoration(gradient: gradient),
child: const SizedBox.expand(),
);
Gradient get gradient =>
LinearGradient(colors: colors, begin: begin, end: end);
Widget buildPreview() => DecoratedBox(
decoration: BoxDecoration(gradient: gradient),
child: const SizedBox.expand(),
);
Color get bubbleTint => colors.first;
Widget buildBackground() => _ChatWallpaperThemeView(theme: this);
Widget buildPreview() =>
_ChatWallpaperThemeView(theme: this, tileScale: 0.42);
}
const List<ChatWallpaperTheme> kChatWallpaperThemes = <ChatWallpaperTheme>[];
class _ChatWallpaperThemeView extends StatelessWidget {
final ChatWallpaperTheme theme;
final double tileScale;
const _ChatWallpaperThemeView({required this.theme, this.tileScale = 1});
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
DecoratedBox(decoration: BoxDecoration(gradient: theme.gradient)),
if (theme.pattern != null)
TiledSvgPattern(
asset: theme.pattern!,
color: theme.patternColor,
opacity: theme.patternOpacity,
tileSize: theme.tileSize * tileScale,
),
],
);
}
}
const String _kPatternDir = 'assets/wallpapers/patterns';
const List<ChatWallpaperTheme> kChatWallpaperThemes = <ChatWallpaperTheme>[
ChatWallpaperTheme(
id: 'ocean',
name: 'Океан',
colors: [Color(0xFF2A7B9B), Color(0xFF57C1EB), Color(0xFF246FA8)],
pattern: '$_kPatternDir/bubbles.svg',
patternOpacity: 0.1,
),
ChatWallpaperTheme(
id: 'sunset',
name: 'Закат',
colors: [Color(0xFFFF7E5F), Color(0xFFFEB47B)],
pattern: '$_kPatternDir/hearts.svg',
patternOpacity: 0.12,
),
ChatWallpaperTheme(
id: 'lavender',
name: 'Лаванда',
colors: [Color(0xFF9D50BB), Color(0xFF6E48AA)],
pattern: '$_kPatternDir/stars.svg',
patternOpacity: 0.11,
),
ChatWallpaperTheme(
id: 'mint',
name: 'Мята',
colors: [Color(0xFF43E97B), Color(0xFF38F9D7)],
pattern: '$_kPatternDir/plus.svg',
patternColor: Colors.black,
patternOpacity: 0.06,
tileSize: 66,
dark: false,
),
ChatWallpaperTheme(
id: 'graphite',
name: 'Графит',
colors: [Color(0xFF232526), Color(0xFF414345)],
pattern: '$_kPatternDir/plus.svg',
patternOpacity: 0.06,
tileSize: 66,
),
ChatWallpaperTheme(
id: 'sky',
name: 'Небо',
colors: [Color(0xFF2193B0), Color(0xFF6DD5ED)],
pattern: '$_kPatternDir/planes.svg',
patternOpacity: 0.11,
),
ChatWallpaperTheme(
id: 'peach',
name: 'Персик',
colors: [Color(0xFFFFD3A5), Color(0xFFFD6585)],
pattern: '$_kPatternDir/rings.svg',
patternColor: Colors.black,
patternOpacity: 0.05,
dark: false,
),
ChatWallpaperTheme(
id: 'forest',
name: 'Лес',
colors: [Color(0xFF134E5E), Color(0xFF71B280)],
pattern: '$_kPatternDir/rings.svg',
patternOpacity: 0.09,
),
ChatWallpaperTheme(
id: 'grape',
name: 'Виноград',
colors: [Color(0xFF4776E6), Color(0xFF8E54E9)],
pattern: '$_kPatternDir/stars.svg',
patternOpacity: 0.11,
),
ChatWallpaperTheme(
id: 'night',
name: 'Ночь',
colors: [Color(0xFF0F2027), Color(0xFF203A43), Color(0xFF2C5364)],
pattern: '$_kPatternDir/stars.svg',
patternOpacity: 0.08,
),
ChatWallpaperTheme(
id: 'rose',
name: 'Роза',
colors: [Color(0xFFF4C4F3), Color(0xFFFC67FA)],
pattern: '$_kPatternDir/hearts.svg',
patternOpacity: 0.14,
),
ChatWallpaperTheme(
id: 'amber',
name: 'Янтарь',
colors: [Color(0xFFF7971E), Color(0xFFFFD200)],
pattern: '$_kPatternDir/bubbles.svg',
patternColor: Colors.black,
patternOpacity: 0.05,
dark: false,
),
];
ChatWallpaperTheme? chatWallpaperThemeById(String? id) {
if (id == null) return null;
+39
View File
@@ -0,0 +1,39 @@
class DebugTest {
static bool enabled = false;
static int contactCount = 0;
static const int debugAccountId = -424242;
static const bool _envEnabled = bool.fromEnvironment('DEBUG_TEST');
static const int _envContacts = int.fromEnvironment(
'DEBUG_CONTACTS',
defaultValue: -1,
);
static const String _flag = '--debug-test';
static const String _contactsFlag = '--contacts';
static void parse(List<String> args) {
if (_envEnabled) enabled = true;
if (_envContacts >= 0) {
enabled = true;
contactCount = _envContacts;
}
for (var i = 0; i < args.length; i++) {
final arg = args[i];
if (arg == _flag) {
enabled = true;
} else if (arg.startsWith('$_contactsFlag=')) {
enabled = true;
contactCount =
int.tryParse(arg.substring(_contactsFlag.length + 1)) ??
contactCount;
} else if (arg == _contactsFlag && i + 1 < args.length) {
enabled = true;
contactCount = int.tryParse(args[i + 1]) ?? contactCount;
i++;
}
}
}
}
+18
View File
@@ -8,6 +8,8 @@ class KometSettings {
static const _kGhostMode = 'komet_ghost_mode';
static const _kAntiRead = 'komet_anti_read';
static const _kSelfOnlineCheck = 'komet_self_online_check';
static const _kHideAllChatsFolder = 'komet_hide_all_chats_folder';
static const _kShowHiddenChats = 'komet_show_hidden_chats';
static final ValueNotifier<bool> viewDeleted = ValueNotifier(false);
static final ValueNotifier<bool> viewRedacted = ValueNotifier(false);
@@ -15,6 +17,8 @@ class KometSettings {
static final ValueNotifier<bool> ghostMode = ValueNotifier(false);
static final ValueNotifier<bool> antiRead = ValueNotifier(false);
static final ValueNotifier<bool> selfOnlineCheck = ValueNotifier(true);
static final ValueNotifier<bool> hideAllChatsFolder = ValueNotifier(false);
static final ValueNotifier<bool> showHiddenChats = ValueNotifier(false);
static Future<void> load() async {
final prefs = await SharedPreferences.getInstance();
@@ -24,6 +28,8 @@ class KometSettings {
ghostMode.value = prefs.getBool(_kGhostMode) ?? false;
antiRead.value = prefs.getBool(_kAntiRead) ?? false;
selfOnlineCheck.value = prefs.getBool(_kSelfOnlineCheck) ?? true;
hideAllChatsFolder.value = prefs.getBool(_kHideAllChatsFolder) ?? false;
showHiddenChats.value = prefs.getBool(_kShowHiddenChats) ?? false;
}
static Future<void> setViewDeleted(bool value) async {
@@ -61,4 +67,16 @@ class KometSettings {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kSelfOnlineCheck, value);
}
static Future<void> setHideAllChatsFolder(bool value) async {
hideAllChatsFolder.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kHideAllChatsFolder, value);
}
static Future<void> setShowHiddenChats(bool value) async {
showHiddenChats.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kShowHiddenChats, value);
}
}
+2
View File
@@ -0,0 +1,2 @@
export 'rlottie_engine_stub.dart'
if (dart.library.io) 'rlottie_engine.dart';
@@ -0,0 +1,198 @@
import 'dart:async';
import 'dart:io';
import 'dart:isolate';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:path_provider/path_provider.dart';
import '../../utils/logger.dart';
class DiskClip {
DiskClip({
required this.px,
required this.frameCount,
required this.frameRate,
required this.durationMs,
required this.frames,
});
final int px;
final int frameCount;
final double frameRate;
final int durationMs;
final List<Uint8List> frames;
}
class RlottieDiskCache {
RlottieDiskCache._();
static final RlottieDiskCache instance = RlottieDiskCache._();
static const _magic = 0x4b524c46;
static const _version = 2;
static const int _maxBytes = 256 * 1024 * 1024;
Directory? _dir;
Future<Directory>? _dirFuture;
Future<Directory> _directory() {
return _dirFuture ??= () async {
final base = await getApplicationSupportDirectory();
final dir = Directory('${base.path}/rlottie_frames');
if (!await dir.exists()) await dir.create(recursive: true);
_dir = dir;
return dir;
}();
}
String _key(String url, int px) {
final digest = sha1.convert(url.codeUnits).toString().substring(0, 20);
return '${digest}_$px.krlf';
}
Future<File> _file(String url, int px) async {
final dir = await _directory();
return File('${dir.path}/${_key(url, px)}');
}
Future<DiskClip?> load(String url, int px) async {
try {
final file = await _file(url, px);
if (!await file.exists()) return null;
final bytes = await file.readAsBytes();
final clip = await _decode(bytes);
if (clip != null) {
unawaited(file.setLastModified(DateTime.now()).catchError((_) {}));
}
return clip;
} catch (e) {
logger.w('RlottieDiskCache.load failed: $e');
return null;
}
}
Future<void> store({
required String url,
required int px,
required int frameCount,
required double frameRate,
required int durationMs,
required List<Uint8List> frames,
}) async {
if (frames.isEmpty || frames.length != frameCount) return;
try {
final bytes = await _encode(px, frameCount, frameRate, durationMs, frames);
final file = await _file(url, px);
await file.writeAsBytes(bytes, flush: false);
unawaited(_evict());
} catch (e) {
logger.w('RlottieDiskCache.store failed: $e');
}
}
static Future<Uint8List> _encode(
int px,
int frameCount,
double frameRate,
int durationMs,
List<Uint8List> frames,
) {
return Isolate.run(() {
final frameBytes = px * px * 4;
final payload = Uint8List(frameBytes * frameCount);
for (var i = 0; i < frameCount; i++) {
payload.setRange(i * frameBytes, (i + 1) * frameBytes, frames[i]);
}
for (var i = frameCount - 1; i >= 1; i--) {
final cur = i * frameBytes;
final prev = (i - 1) * frameBytes;
for (var b = 0; b < frameBytes; b++) {
payload[cur + b] ^= payload[prev + b];
}
}
final compressed = gzip.encode(payload);
final header = ByteData(28);
header.setUint32(0, _magic);
header.setUint8(4, _version);
header.setUint32(8, px);
header.setUint32(12, frameCount);
header.setFloat64(16, frameRate);
header.setUint32(24, durationMs);
final out = BytesBuilder();
out.add(header.buffer.asUint8List());
out.add(compressed);
return out.toBytes();
});
}
static Future<DiskClip?> _decode(Uint8List bytes) {
return Isolate.run(() {
if (bytes.length < 28) return null;
final header = ByteData.sublistView(bytes, 0, 28);
if (header.getUint32(0) != _magic) return null;
if (header.getUint8(4) != _version) return null;
final px = header.getUint32(8);
final frameCount = header.getUint32(12);
final frameRate = header.getFloat64(16);
final durationMs = header.getUint32(24);
final frameBytes = px * px * 4;
final payload = Uint8List.fromList(gzip.decode(bytes.sublist(28)));
if (payload.length != frameBytes * frameCount) return null;
for (var i = 1; i < frameCount; i++) {
final cur = i * frameBytes;
final prev = (i - 1) * frameBytes;
for (var b = 0; b < frameBytes; b++) {
payload[cur + b] ^= payload[prev + b];
}
}
final frames = <Uint8List>[];
for (var i = 0; i < frameCount; i++) {
frames.add(Uint8List.sublistView(
payload, i * frameBytes, (i + 1) * frameBytes));
}
return DiskClip(
px: px,
frameCount: frameCount,
frameRate: frameRate,
durationMs: durationMs,
frames: frames,
);
});
}
Future<void> _evict() async {
try {
final dir = _dir ?? await _directory();
final files = await dir
.list()
.where((e) => e is File && e.path.endsWith('.krlf'))
.cast<File>()
.toList();
var total = 0;
final stats = <(File, FileStat)>[];
for (final f in files) {
final st = await f.stat();
total += st.size;
stats.add((f, st));
}
if (total <= _maxBytes) return;
stats.sort((a, b) => a.$2.modified.compareTo(b.$2.modified));
for (final (file, st) in stats) {
if (total <= _maxBytes) break;
total -= st.size;
await file.delete().catchError((_) => file);
}
} catch (e) {
logger.w('RlottieDiskCache.evict failed: $e');
}
}
Future<void> clear() async {
try {
final dir = await _directory();
if (await dir.exists()) await dir.delete(recursive: true);
_dir = null;
_dirFuture = null;
} catch (_) {}
}
}
+328
View File
@@ -0,0 +1,328 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'dart:isolate';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import '../../utils/logger.dart';
import 'rlottie_disk_cache.dart';
import 'rlottie_ffi.dart';
import 'rlottie_worker.dart';
class RlottieClip {
RlottieClip({required this.key, required this.px});
final String key;
final int px;
int frameCount = 0;
int durationMs = 1000;
double frameRate = 60;
List<ui.Image?> _images = const [];
ui.Image? _lastImage;
final ValueNotifier<int> ready = ValueNotifier(0);
bool complete = false;
int bytes = 0;
int lastUsed = 0;
int active = 0;
bool get playable => frameCount > 0;
ui.Image? frameAt(int index) {
if (index < 0 || index >= _images.length) return _lastImage;
return _images[index] ?? _lastImage;
}
void _allocate(int count) {
_images = List<ui.Image?>.filled(count, null);
}
void _setFrame(int index, ui.Image image) {
if (index < 0 || index >= _images.length) {
image.dispose();
return;
}
_images[index] = image;
_lastImage = image;
bytes += px * px * 4;
var r = ready.value;
while (r < _images.length && _images[r] != null) {
r++;
}
if (r != ready.value) ready.value = r;
}
void dispose() {
for (final img in _images) {
img?.dispose();
}
_images = const [];
_lastImage = null;
bytes = 0;
ready.dispose();
}
}
class _Job {
_Job(this.clip, this.url, this.completer);
final RlottieClip clip;
final String url;
final Completer<RlottieClip?> completer;
List<Uint8List?> rawFrames = const [];
}
class RlottieEngine {
RlottieEngine._();
static final RlottieEngine instance = RlottieEngine._();
static String? debugLibraryPath;
static const int _maxBytes = 192 * 1024 * 1024;
static const int _modelCacheBytes = 20 * 1024 * 1024;
final Map<String, RlottieClip> _clips = {};
final Map<String, Future<RlottieClip?>> _loading = {};
final Map<int, _Job> _jobs = {};
int _totalBytes = 0;
int _clock = 0;
int _nextJobId = 1;
int _rrIndex = 0;
bool? _available;
Future<List<SendPort>>? _poolFuture;
int _tick() => ++_clock;
String _keyFor(String url, int px) => '$url@$px';
bool get available {
return _available ??= () {
final bindings = RlottieBindings.open(path: debugLibraryPath);
if (bindings == null) return false;
bindings.configureModelCache(_modelCacheBytes);
return true;
}();
}
Future<SendPort> _worker() async {
final pool = await (_poolFuture ??= _spawnPool());
return pool[_rrIndex++ % pool.length];
}
Future<List<SendPort>> _spawnPool() async {
final count = (Platform.numberOfProcessors - 1).clamp(1, 3);
final ports = <SendPort>[];
for (var i = 0; i < count; i++) {
final receive = ReceivePort();
await Isolate.spawn(rlottieWorkerMain, receive.sendPort);
final broadcast = receive.asBroadcastStream();
final port = await broadcast.first as SendPort;
broadcast.listen(_onWorkerMessage);
ports.add(port);
}
return ports;
}
void _onWorkerMessage(dynamic message) {
if (message is ClipMeta) {
_onMeta(message);
} else if (message is RenderedFrame) {
_onFrame(message);
} else if (message is RenderDone) {
_onDone(message);
} else if (message is RenderError) {
_onError(message);
}
}
void _onMeta(ClipMeta meta) {
final job = _jobs[meta.jobId];
if (job == null) return;
final clip = job.clip
..frameCount = meta.totalFrame
..frameRate = meta.frameRate
..durationMs = meta.durationMs;
clip._allocate(meta.totalFrame);
job.rawFrames = List<Uint8List?>.filled(meta.totalFrame, null);
if (!job.completer.isCompleted) {
job.completer.complete(clip);
}
}
void _onFrame(RenderedFrame frame) {
final job = _jobs[frame.jobId];
if (job == null) return;
final bytes = frame.data.materialize().asUint8List();
if (frame.index < job.rawFrames.length) {
job.rawFrames[frame.index] = bytes;
}
ui.decodeImageFromPixels(
bytes,
frame.px,
frame.px,
ui.PixelFormat.bgra8888,
(image) {
job.clip._setFrame(frame.index, image);
_totalBytes += frame.px * frame.px * 4;
_evictIfNeeded();
},
);
}
void _onDone(RenderDone done) {
final job = _jobs.remove(done.jobId);
if (job == null) return;
final clip = job.clip..complete = true;
final raw = job.rawFrames;
if (raw.length == clip.frameCount && !raw.contains(null)) {
unawaited(RlottieDiskCache.instance.store(
url: job.url,
px: clip.px,
frameCount: clip.frameCount,
frameRate: clip.frameRate,
durationMs: clip.durationMs,
frames: raw.cast<Uint8List>(),
));
}
job.rawFrames = const [];
}
void _onError(RenderError error) {
final job = _jobs.remove(error.jobId);
if (job == null) return;
logger.w('rlottie render failed (${job.url}): ${error.message}');
if (!job.completer.isCompleted) job.completer.complete(null);
if (job.clip.frameCount == 0) {
_clips.remove(job.clip.key);
job.clip.dispose();
}
}
Future<RlottieClip?> acquire(String url, int px, {String? inlineJson}) async {
if (!available) return null;
final key = _keyFor(url, px);
final cached = _clips[key];
if (cached != null) {
cached.lastUsed = _tick();
cached.active++;
return cached;
}
final pending = _loading[key];
if (pending != null) {
final clip = await pending;
if (clip != null) {
clip.lastUsed = _tick();
clip.active++;
}
return clip;
}
final future = _load(url, px, key, inlineJson);
_loading[key] = future;
final clip = await future;
_loading.remove(key);
if (clip != null) {
clip.lastUsed = _tick();
clip.active++;
}
return clip;
}
Future<RlottieClip?> _load(
String url, int px, String key, String? inlineJson) async {
if (inlineJson == null) {
final disk = await RlottieDiskCache.instance.load(url, px);
if (disk != null) {
final clip = RlottieClip(key: key, px: px)
..frameCount = disk.frameCount
..frameRate = disk.frameRate
..durationMs = disk.durationMs;
clip._allocate(disk.frameCount);
_clips[key] = clip;
unawaited(_decodeDiskProgressive(clip, disk));
return clip;
}
}
final json = inlineJson ?? await _fetchJson(url);
if (json == null) return null;
final clip = RlottieClip(key: key, px: px);
_clips[key] = clip;
final jobId = _nextJobId++;
final completer = Completer<RlottieClip?>();
_jobs[jobId] = _Job(clip, url, completer);
final port = await _worker();
port.send(RenderJob(
jobId: jobId,
json: json,
cacheKey: url,
px: px,
libPath: debugLibraryPath,
));
return completer.future;
}
Future<void> _decodeDiskProgressive(RlottieClip clip, DiskClip disk) async {
for (var i = 0; i < disk.frameCount; i++) {
if (!identical(_clips[clip.key], clip)) return;
final image = await _decode(disk.frames[i], clip.px);
if (!identical(_clips[clip.key], clip)) {
image.dispose();
return;
}
clip._setFrame(i, image);
_totalBytes += clip.px * clip.px * 4;
}
clip.complete = true;
_evictIfNeeded();
}
Future<ui.Image> _decode(Uint8List bgra, int px) {
final completer = Completer<ui.Image>();
ui.decodeImageFromPixels(
bgra, px, px, ui.PixelFormat.bgra8888, completer.complete);
return completer.future;
}
Future<String?> _fetchJson(String url) async {
try {
final file = await DefaultCacheManager().getSingleFile(url);
return await file.readAsString();
} catch (e) {
logger.w('rlottie fetch failed ($url): $e');
return null;
}
}
Future<void> prewarm(String url, int px) async {
if (!available) return;
final clip = await acquire(url, px);
if (clip != null) release(clip);
}
void release(RlottieClip clip) {
if (clip.active > 0) clip.active--;
clip.lastUsed = _tick();
_evictIfNeeded();
}
void _evictIfNeeded() {
if (_totalBytes <= _maxBytes) return;
final candidates = _clips.values.where((c) => c.active <= 0).toList()
..sort((a, b) => a.lastUsed.compareTo(b.lastUsed));
for (final clip in candidates) {
if (_totalBytes <= _maxBytes) break;
_totalBytes -= clip.bytes;
_clips.remove(clip.key);
clip.dispose();
}
}
}
@@ -0,0 +1,31 @@
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
class RlottieClip {
RlottieClip({this.px = 0});
final int px;
int frameCount = 0;
int durationMs = 1000;
double frameRate = 60;
final ValueNotifier<int> ready = ValueNotifier(0);
ui.Image? frameAt(int index) => null;
}
class RlottieEngine {
RlottieEngine._();
static final RlottieEngine instance = RlottieEngine._();
static String? debugLibraryPath;
bool get available => false;
Future<RlottieClip?> acquire(String url, int px, {String? inlineJson}) async =>
null;
Future<void> prewarm(String url, int px) async {}
void release(RlottieClip clip) {}
}
+112
View File
@@ -0,0 +1,112 @@
import 'dart:ffi';
import 'dart:io';
import 'package:ffi/ffi.dart';
typedef _InitNative = Void Function();
typedef _VoidFn = void Function();
typedef _FromDataNative = Pointer<Void> Function(
Pointer<Utf8>, Pointer<Utf8>, Pointer<Utf8>);
typedef _SizeGetterNative = Size Function(Pointer<Void>);
typedef _SizeGetter = int Function(Pointer<Void>);
typedef _DoubleGetterNative = Double Function(Pointer<Void>);
typedef _DoubleGetter = double Function(Pointer<Void>);
typedef _RenderNative = Void Function(
Pointer<Void>, Size, Pointer<Uint32>, Size, Size, Size);
typedef _Render = void Function(
Pointer<Void>, int, Pointer<Uint32>, int, int, int);
typedef _DestroyNative = Void Function(Pointer<Void>);
typedef _Destroy = void Function(Pointer<Void>);
typedef _CacheSizeNative = Void Function(Size);
typedef _CacheSize = void Function(int);
class RlottieBindings {
RlottieBindings._(this._lib) {
_init = _lib.lookupFunction<_InitNative, _VoidFn>('lottie_init');
_shutdown = _lib.lookupFunction<_InitNative, _VoidFn>('lottie_shutdown');
_fromData = _lib.lookupFunction<_FromDataNative, _FromDataNative>(
'lottie_animation_from_data');
_totalFrame = _lib.lookupFunction<_SizeGetterNative, _SizeGetter>(
'lottie_animation_get_totalframe');
_frameRate = _lib.lookupFunction<_DoubleGetterNative, _DoubleGetter>(
'lottie_animation_get_framerate');
_duration = _lib.lookupFunction<_DoubleGetterNative, _DoubleGetter>(
'lottie_animation_get_duration');
_render =
_lib.lookupFunction<_RenderNative, _Render>('lottie_animation_render');
_destroy = _lib
.lookupFunction<_DestroyNative, _Destroy>('lottie_animation_destroy');
_cacheSize = _lib.lookupFunction<_CacheSizeNative, _CacheSize>(
'lottie_configure_model_cache_size');
_init();
}
final DynamicLibrary _lib;
late final _VoidFn _init;
late final _VoidFn _shutdown;
late final _FromDataNative _fromData;
late final _SizeGetter _totalFrame;
late final _DoubleGetter _frameRate;
late final _DoubleGetter _duration;
late final _Render _render;
late final _Destroy _destroy;
late final _CacheSize _cacheSize;
static RlottieBindings? open({String? path}) {
try {
final lib = _openLibrary(path);
return lib == null ? null : RlottieBindings._(lib);
} catch (_) {
return null;
}
}
static DynamicLibrary? _openLibrary(String? path) {
if (path != null) return DynamicLibrary.open(path);
if (Platform.isMacOS || Platform.isIOS) return DynamicLibrary.process();
try {
return DynamicLibrary.open(rlottieLibraryName);
} catch (_) {
return null;
}
}
Pointer<Void>? loadFromData(String data, String key) {
final dataC = data.toNativeUtf8();
final keyC = key.toNativeUtf8();
final resC = ''.toNativeUtf8();
try {
final anim = _fromData(dataC, keyC, resC);
return anim == nullptr ? null : anim;
} finally {
calloc.free(dataC);
calloc.free(keyC);
calloc.free(resC);
}
}
int totalFrame(Pointer<Void> anim) => _totalFrame(anim);
double frameRate(Pointer<Void> anim) => _frameRate(anim);
double duration(Pointer<Void> anim) => _duration(anim);
void render(Pointer<Void> anim, int frameNo, Pointer<Uint32> buffer, int px) {
_render(anim, frameNo, buffer, px, px, px * 4);
}
void destroy(Pointer<Void> anim) => _destroy(anim);
void configureModelCache(int bytes) => _cacheSize(bytes);
void shutdown() => _shutdown();
}
String get rlottieLibraryName {
if (Platform.isWindows) return 'rlottie.dll';
return 'librlottie.so';
}
+151
View File
@@ -0,0 +1,151 @@
import 'dart:ffi';
import 'dart:isolate';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'rlottie_ffi.dart';
class RenderJob {
const RenderJob({
required this.jobId,
required this.json,
required this.cacheKey,
required this.px,
this.libPath,
});
final int jobId;
final String json;
final String cacheKey;
final int px;
final String? libPath;
}
class ClipMeta {
const ClipMeta({
required this.jobId,
required this.totalFrame,
required this.frameRate,
required this.durationMs,
});
final int jobId;
final int totalFrame;
final double frameRate;
final int durationMs;
}
class RenderedFrame {
const RenderedFrame({
required this.jobId,
required this.index,
required this.data,
required this.px,
});
final int jobId;
final int index;
final TransferableTypedData data;
final int px;
}
class RenderDone {
const RenderDone(this.jobId);
final int jobId;
}
class RenderError {
const RenderError(this.jobId, this.message);
final int jobId;
final String message;
}
class CancelJob {
const CancelJob(this.jobId);
final int jobId;
}
const double _maxCacheFps = 30.0;
void rlottieWorkerMain(SendPort toMain) {
final port = ReceivePort();
toMain.send(port.sendPort);
final cancelled = <int>{};
RlottieBindings? bindings;
String? boundLibPath;
port.listen((message) {
if (message is CancelJob) {
cancelled.add(message.jobId);
return;
}
if (message is! RenderJob) return;
final job = message;
cancelled.remove(job.jobId);
if (bindings == null || boundLibPath != job.libPath) {
bindings = RlottieBindings.open(path: job.libPath);
boundLibPath = job.libPath;
}
final rl = bindings;
if (rl == null) {
toMain.send(RenderError(job.jobId, 'rlottie unavailable'));
return;
}
final anim = rl.loadFromData(job.json, job.cacheKey);
if (anim == null) {
toMain.send(RenderError(job.jobId, 'parse failed'));
return;
}
try {
final total = rl.totalFrame(anim);
final fps = rl.frameRate(anim);
final durationMs = fps <= 0 ? 1000 : (total / fps * 1000).round();
var outCount = total;
if (fps > _maxCacheFps && total > 1) {
outCount = (durationMs / 1000.0 * _maxCacheFps).round().clamp(2, total);
}
final outFps = durationMs <= 0 ? fps : outCount * 1000.0 / durationMs;
toMain.send(ClipMeta(
jobId: job.jobId,
totalFrame: outCount,
frameRate: outFps,
durationMs: durationMs,
));
final px = job.px;
final buffer = calloc<Uint32>(px * px);
final byteView = buffer.cast<Uint8>().asTypedList(px * px * 4);
try {
for (var i = 0; i < outCount; i++) {
if (cancelled.contains(job.jobId)) break;
final src = outCount == total
? i
: (i * (total - 1) / (outCount - 1)).round().clamp(0, total - 1);
rl.render(anim, src, buffer, px);
toMain.send(RenderedFrame(
jobId: job.jobId,
index: i,
data: TransferableTypedData.fromList([Uint8List.fromList(byteView)]),
px: px,
));
}
} finally {
calloc.free(buffer);
}
toMain.send(RenderDone(job.jobId));
} catch (e) {
toMain.send(RenderError(job.jobId, e.toString()));
} finally {
rl.destroy(anim);
cancelled.remove(job.jobId);
}
});
}
-71
View File
@@ -1,71 +0,0 @@
import 'dart:typed_data';
/// LZ4 block декомпрессия (без frame-заголовка).
///
/// Сервер шлёт block-формат как в транспорте (payload пакетов), так и в
/// `vcp`-параметрах звонка. dart_lz4 поддерживает только frame-формат, поэтому
/// block распаковывается вручную.
Uint8List lz4BlockDecompress(Uint8List src, int maxSize) {
var out = Uint8List(1024);
int outLen = 0;
int pos = 0;
void ensure(int extra) {
if (outLen + extra > maxSize) throw StateError('LZ4: превышен лимит');
if (outLen + extra <= out.length) return;
var newCap = out.length * 2;
while (newCap < outLen + extra) {
newCap *= 2;
}
if (newCap > maxSize) newCap = maxSize;
final grown = Uint8List(newCap);
grown.setRange(0, outLen, out);
out = grown;
}
while (pos < src.length) {
final token = src[pos++];
var litLen = token >> 4;
if (litLen == 15) {
while (pos < src.length) {
final b = src[pos++];
litLen += b;
if (b != 255) break;
}
}
if (litLen > 0) {
ensure(litLen);
out.setRange(outLen, outLen + litLen, src, pos);
outLen += litLen;
pos += litLen;
}
if (pos >= src.length) break;
if (pos + 1 >= src.length) throw StateError('LZ4: unexpected end of input');
final offset = src[pos] | (src[pos + 1] << 8);
pos += 2;
if (offset == 0) throw StateError('LZ4: offset = 0');
var matchLen = (token & 0x0F) + 4;
if ((token & 0x0F) == 0x0F) {
while (pos < src.length) {
final b = src[pos++];
matchLen += b;
if (b != 255) break;
}
}
ensure(matchLen);
final start = outLen - offset;
if (start < 0) throw StateError('LZ4: offset за пределами вывода');
for (var i = 0; i < matchLen; i++) {
out[outLen + i] = out[start + i];
}
outLen += matchLen;
}
return Uint8List.sublistView(out, 0, outLen);
}
+28
View File
@@ -11,6 +11,7 @@ abstract class Opcode {
static const int reconnect = 3; // Реконнект
static const int log = 5; // Аналитика / события
static const int sessionInit = 6; // Инициализация сессии (хэндшейк)
static const int contactsGet = 8; // Синхронизация списка контактов
// ── Profile ────────────────────────────────────────────────────────
static const int profile = 16; // Обновление профиля
@@ -202,6 +203,20 @@ abstract class Opcode {
static const int foldersReorder = 275; // Сортировка папок
static const int foldersDelete = 276; // Удаление папки
// ── Stories ────────────────────────────────────────────────────────
static const int storiesList = 208; // Лента историй (кольца-превью)
static const int storiesListByOwner = 209; // Превью по списку владельцев
static const int storiesGetByOwner = 210; // Полные истории владельцев
static const int storiesGetStats = 211; // Агрегированная статистика
static const int storiesGetDetailedStats = 212; // Детальная статистика
static const int storiesReact = 213; // Реакция на историю
static const int storiesMark = 214; // Отметка просмотренной
static const int storiesSend = 215; // Публикация истории
static const int notifStoriesUpdate = 216; // Обновление кольца (push)
static const int storiesEdit = 217; // Изменение настроек истории
static const int storiesDelete = 218; // Удаление историй
static const int storiesGetByStoryId = 220; // Истории по ID
// ── Human-readable names ───────────────────────────────────────────
static String name(int opcode) => _names[opcode] ?? 'UNKNOWN($opcode)';
@@ -212,6 +227,7 @@ abstract class Opcode {
reconnect: 'RECONNECT',
log: 'LOG',
sessionInit: 'SESSION_INIT',
contactsGet: 'CONTACTS_GET',
profile: 'PROFILE',
authRequest: 'AUTH_REQUEST',
auth: 'AUTH',
@@ -362,5 +378,17 @@ abstract class Opcode {
foldersUpdate: 'FOLDERS_UPDATE',
foldersReorder: 'FOLDERS_REORDER',
foldersDelete: 'FOLDERS_DELETE',
storiesList: 'STORIES_LIST',
storiesListByOwner: 'STORIES_LIST_BY_OWNER_ID',
storiesGetByOwner: 'STORIES_GET_BY_OWNER_ID',
storiesGetStats: 'STORIES_GET_STATS',
storiesGetDetailedStats: 'STORIES_GET_DETAILED_STATS',
storiesReact: 'STORIES_REACT',
storiesMark: 'STORIES_MARK',
storiesSend: 'STORIES_SEND',
notifStoriesUpdate: 'NOTIF_STORIES_UPDATE',
storiesEdit: 'STORIES_EDIT',
storiesDelete: 'STORIES_DELETE',
storiesGetByStoryId: 'STORIES_GET_BY_STORY_ID',
};
}
+4 -150
View File
@@ -1,14 +1,3 @@
import 'dart:typed_data';
import 'dart:isolate';
import 'package:dart_lz4/dart_lz4.dart';
import 'package:libcompress/libcompress.dart';
import 'package:msgpack_dart/msgpack_dart.dart' as msgpack;
import 'lz4_block.dart';
/// ver(1) + cmd(1) + seq(2) + opcode(2) + packedLen(4) = 10
const int headerSize = 10;
const int _maxDecompressedSize = 1048576; // 1 MB
/// Типы команд в протоколе
abstract class CmdType {
static const int request =
@@ -20,17 +9,11 @@ abstract class CmdType {
static const int error = 3; // ответ: ошибка
}
/// Распакованный бинарный пакет
/// Распакованный пакет.
///
/// Формат заголовка (10 байт):
/// ```
/// [0] ver — версия протокола (uint8) (по умолчанию 10)
/// [1] cmd — тип команды (uint8) (при отправке от клиента равно 0)
/// [2..3] seq — порядковый номер (uint16 BE)
/// [4..5] opcode — код операции (uint16 BE)
/// [6..9] packedLen — флаг сжатия [6] + длина payload [7..9] (uint32 BE)
/// [10..] payload — данные в MsgPack, опционально сжатые LZ4
/// ```
/// Провод (фрейминг, MsgPack, сжатие) живёт в Rust-ядре kolibri; здесь пакет —
/// это уже декодированный [payload] (Map/List/скаляр, бинарь — Uint8List) плюс
/// метаданные заголовка.
class Packet {
int api;
int cmd;
@@ -107,132 +90,3 @@ bool isSessionStateError(Object error) {
text.contains('авторизационная сессия') ||
text.contains('сессия не онлайн');
}
/// Payload меньше этого размера отправляется без сжатия (как в оригинале).
const int _compressionThreshold = 32;
/// Упаковка пакета для отправки на сервер.
///
/// Payload сериализуется в MsgPack и при размере >= [_compressionThreshold]
/// сжимается LZ4-block. Старший байт поля packedLen — флаг сжатия:
/// `0` — без сжатия, иначе `(rawLen ~/ compLen) + 1` (множитель размера, по
/// которому получатель выделяет буфер под распаковку).
Uint8List packPacket(int opcode, Map<dynamic, dynamic> payload, {int seq = 0}) {
final Uint8List raw = msgpack.serialize(payload);
final List<int> body;
final int flag;
if (raw.length < _compressionThreshold) {
body = raw;
flag = 0;
} else {
body = lz4Compress(raw);
flag = (raw.length ~/ body.length) + 1;
}
final out = Uint8List(headerSize + body.length);
final header = ByteData.view(out.buffer, out.offsetInBytes, headerSize);
header.setUint8(0, 10);
header.setUint8(1, CmdType.request);
header.setUint16(2, seq, Endian.big);
header.setUint16(4, opcode, Endian.big);
header.setUint32(
6,
((flag & 0xFF) << 24) | (body.length & 0xFFFFFF),
Endian.big,
);
out.setRange(headerSize, out.length, body);
return out;
}
const int _isolateDecodeThreshold = 4096;
Future<Packet> unpackPacket(Uint8List packet) async {
final header = ByteData.sublistView(packet);
final apiVer = header.getUint8(0) & 0xFF;
final cmd = header.getUint8(1) & 0xFF;
final seq = header.getUint16(2) & 0xFFFF;
final opcode = header.getUint16(4) & 0xFFFF;
final packedLen = header.getUint32(6);
final compFlag = packedLen >> 24;
final payloadLength = packedLen & 0xFFFFFF;
if (payloadLength == 0) {
return Packet(api: apiVer, cmd: cmd, seq: seq, opcode: opcode);
}
final end = headerSize + payloadLength;
if (end > packet.length) {
throw Exception('Packet payload length $payloadLength exceeds buffer');
}
final slice = Uint8List.sublistView(packet, headerSize, end);
dynamic payload;
if (compFlag == 0 && slice.length < _isolateDecodeThreshold) {
payload = _deserializePayload(slice, compFlag);
} else {
final owned = Uint8List.fromList(slice);
payload = await Isolate.run(() => _deserializePayload(owned, compFlag));
}
return Packet(
api: apiVer,
cmd: cmd,
seq: seq,
opcode: opcode,
payload: payload,
);
}
dynamic _deserializePayload(Uint8List payloadBytes, int compFlag) {
var bytes = payloadBytes;
if (compFlag != 0) {
bytes = _decompressPayload(bytes);
}
if (bytes.isEmpty) return null;
try {
return msgpack.deserialize(bytes);
} catch (e) {
throw Exception('MsgPack deserialization error: $e');
}
}
/// Определяет формат сжатия по magic-number и распаковывает payload.
/// Сервер может присылать LZ4 block ИЛИ Zstandard в зависимости от ответа.
Uint8List _decompressPayload(Uint8List src) {
// Zstandard: magic 28 B5 2F FD (little-endian)
if (src.length >= 4 &&
src[0] == 0x28 &&
src[1] == 0xB5 &&
src[2] == 0x2F &&
src[3] == 0xFD) {
try {
return ZstdCodec(
maxDecompressedSize: _maxDecompressedSize,
).decompress(src);
} catch (e) {
throw Exception('Zstd decompression error: $e');
}
}
// LZ4 frame: magic 04 22 4D 18
if (src.length >= 4 &&
src[0] == 0x04 &&
src[1] == 0x22 &&
src[2] == 0x4D &&
src[3] == 0x18) {
try {
return lz4Decompress(src, decompressedSize: _maxDecompressedSize);
} catch (e) {
throw Exception('LZ4 frame decompression error: $e');
}
}
// По умолчанию — LZ4 block (без magic)
try {
return lz4BlockDecompress(src, _maxDecompressedSize);
} catch (e) {
throw Exception('LZ4 block decompression error: $e');
}
}
+5
View File
@@ -5,6 +5,7 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:kolibri/kolibri.dart' show initKolibri;
import 'package:shared_preferences/shared_preferences.dart';
import '../../backend/api.dart';
@@ -53,6 +54,9 @@ Future<void> _handleCallDecline(String payloadJson) async {
}
if (vcp.isEmpty || conversationId.isEmpty) return;
// Фоновый изолят: инициализируем ядро перед vcp-декодом/сигналингом.
await initKolibri();
final params = ConversationParams.decode(vcp);
if (params == null) return;
@@ -83,6 +87,7 @@ Future<void> _handleReply(String payloadJson, String text) async {
if (account == 0 || chatId == 0) return;
WidgetsFlutterBinding.ensureInitialized();
await initKolibri();
if (AppInstance.isNamed) {
try {
SharedPreferences.setPrefix('flutter.${AppInstance.id}.');
+107 -11
View File
@@ -212,7 +212,7 @@ class AppDatabase {
await _migrateLegacyDb(target);
return openDatabase(
target,
version: 17,
version: 19,
onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'),
onCreate: (db, _) => _createTables(db),
onUpgrade: (db, oldVersion, newVersion) async {
@@ -296,6 +296,34 @@ class AppDatabase {
await _createChatParticipantsIndex(db);
await _backfillChatParticipants(db);
}
if (oldVersion < 18) {
await _addColumnIfMissing(
db,
'chats_cache',
'pinned_msg_id',
'INTEGER',
);
await _addColumnIfMissing(
db,
'chats_cache',
'pinned_msg_text',
'TEXT',
);
await _addColumnIfMissing(
db,
'chats_cache',
'pinned_msg_time',
'INTEGER',
);
}
if (oldVersion < 19) {
await _addColumnIfMissing(
db,
'chats_cache',
'pinned_msg_is_preview',
'INTEGER NOT NULL DEFAULT 0',
);
}
},
);
}
@@ -444,6 +472,10 @@ class AppDatabase {
owner INTEGER,
admins TEXT,
in_list INTEGER NOT NULL DEFAULT 1,
pinned_msg_id INTEGER,
pinned_msg_text TEXT,
pinned_msg_time INTEGER,
pinned_msg_is_preview INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (id, account_id)
)
''';
@@ -677,27 +709,41 @@ class AppDatabase {
);
}
static Future<List<Map<String, dynamic>>> loadChats(int accountId) async {
static Future<List<Map<String, dynamic>>> loadChats(
int accountId, {
bool includeHidden = false,
}) async {
final db = await _instance;
return db.query(
'chats_cache',
where: 'account_id = ? AND in_list = 1',
where: includeHidden
? 'account_id = ? AND in_list IN (1, 2)'
: 'account_id = ? AND in_list = 1',
whereArgs: [accountId],
orderBy: 'last_event_time DESC',
);
}
static Future<int> sumUnread(int accountId, {int? excludeChatId}) async {
static Future<int> sumUnread(
int accountId, {
int? excludeChatId,
Set<int>? excludeChatIds,
}) async {
final db = await _instance;
final where = excludeChatId != null
? 'account_id = ? AND in_list = 1 AND id != ?'
: 'account_id = ? AND in_list = 1';
final args = excludeChatId != null
? [accountId, excludeChatId]
: [accountId];
final buffer = StringBuffer('account_id = ? AND in_list = 1');
final args = <Object?>[accountId];
if (excludeChatId != null) {
buffer.write(' AND id != ?');
args.add(excludeChatId);
}
if (excludeChatIds != null && excludeChatIds.isNotEmpty) {
final placeholders = List.filled(excludeChatIds.length, '?').join(', ');
buffer.write(' AND id NOT IN ($placeholders)');
args.addAll(excludeChatIds);
}
final result = await db.rawQuery(
'SELECT COALESCE(SUM(unread_count), 0) AS total '
'FROM chats_cache WHERE $where',
'FROM chats_cache WHERE $buffer',
args,
);
return (result.first['total'] as int?) ?? 0;
@@ -882,6 +928,56 @@ class AppDatabase {
);
}
static Future<List<Map<String, dynamic>>> loadMessagesBetween(
int accountId,
int chatId, {
required int afterTime,
required int beforeTime,
int limit = 60,
bool onlyVisible = false,
}) async {
final db = await _instance;
return db.query(
'messages',
where: onlyVisible
? 'account_id = ? AND chat_id = ? AND deleted = 0 '
'AND time > ? AND time < ?'
: 'account_id = ? AND chat_id = ? AND time > ? AND time < ?',
whereArgs: [accountId, chatId, afterTime, beforeTime],
orderBy: 'time ASC',
limit: limit,
);
}
static Future<List<Map<String, dynamic>>> loadMessagesAround(
int accountId,
int chatId, {
required int centerTime,
int before = 40,
int after = 20,
bool onlyVisible = false,
}) async {
final db = await _instance;
final base = onlyVisible
? 'account_id = ? AND chat_id = ? AND deleted = 0'
: 'account_id = ? AND chat_id = ?';
final older = await db.query(
'messages',
where: '$base AND time <= ?',
whereArgs: [accountId, chatId, centerTime],
orderBy: 'time DESC',
limit: before,
);
final newer = await db.query(
'messages',
where: '$base AND time > ?',
whereArgs: [accountId, chatId, centerTime],
orderBy: 'time ASC',
limit: after,
);
return [...newer.reversed, ...older];
}
static Future<void> markMessageDeleted(
int accountId,
int chatId,
@@ -0,0 +1,31 @@
import 'per_chat_json_store.dart';
class ArchivedChatsStore extends PerChatJsonStore<bool> {
ArchivedChatsStore._()
: super(
prefsKey: 'archived_chats',
fromJson: (raw) => raw == true ? true : null,
toJson: (value) => value,
);
static final ArchivedChatsStore instance = ArchivedChatsStore._();
bool isArchived(int accountId, int chatId) =>
read(accountId, chatId) == true;
Future<void> setArchived(int accountId, int chatId, bool archived) =>
write(accountId, chatId, archived ? true : null);
Set<int> archivedChatIds(int accountId) {
if (accountId == 0) return const {};
final prefix = '$accountId/';
final ids = <int>{};
for (final entry in allEntries) {
if (entry.value != true) continue;
if (!entry.key.startsWith(prefix)) continue;
final id = int.tryParse(entry.key.substring(prefix.length));
if (id != null) ids.add(id);
}
return ids;
}
}
@@ -7,6 +7,8 @@ import 'package:path_provider/path_provider.dart';
import '../utils/logger.dart';
import 'per_chat_json_store.dart';
const int kGlobalWallpaperChatId = 0;
enum ChatWallpaperKind { image, theme }
@immutable
@@ -22,6 +22,9 @@ abstract class PerChatJsonStore<T> {
String _buildKey(int accountId, int chatId) => '$accountId/$chatId';
@protected
Iterable<MapEntry<String, T>> get allEntries => _values.entries;
@protected
void onBeforeWrite(String key, T? previous, T? next) {}
-189
View File
@@ -1,189 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import '../config/proxy_config.dart';
import '../utils/logger.dart';
import 'proxy_connector.dart';
import 'tls_config.dart';
import 'traffic_monitor.dart';
import 'vpn_bypass.dart';
enum SocketState { disconnected, connecting, connected }
/// Обёртка над TCP + TLS сокетом.
/// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver].
class Connection {
static const Duration _defaultConnectTimeout = Duration(seconds: 15);
static const Duration _proxyLoadTimeout = Duration(seconds: 8);
static const Duration _vpnCallTimeout = Duration(seconds: 5);
SecureSocket? _socket;
StreamSubscription<Uint8List>? _subscription;
SocketState _state = SocketState.disconnected;
final _dataController = StreamController<Uint8List>.broadcast();
final _stateController = StreamController<SocketState>.broadcast();
Stream<Uint8List> get dataStream => _dataController.stream;
Stream<SocketState> get stateStream => _stateController.stream;
SocketState get state => _state;
bool get isConnected => _state == SocketState.connected;
void _setState(SocketState newState) {
if (_state == newState) return;
_state = newState;
if (!_stateController.isClosed) _stateController.add(newState);
}
Future<void> connect(
String host,
int port, {
bool bypassVpn = false,
Duration? timeout,
}) async {
if (_state != SocketState.disconnected) {
logger.w('Connection.connect пропущен: state=$_state (уже $_state)');
return;
}
_setState(SocketState.connecting);
try {
logger.i('Connection: загрузка прокси-конфига');
ProxySettings proxySettings;
try {
proxySettings = await ProxyConfig.load().timeout(_proxyLoadTimeout);
} catch (e) {
logger.w('Connection: ProxyConfig.load завис/упал ($e) — без прокси');
proxySettings = const ProxySettings();
}
logger.i(
'Connection: VPN ${bypassVpn ? 'bind (обход)' : 'restoreDefault'}',
);
try {
if (bypassVpn) {
await VpnBypassService.instance.bind().timeout(_vpnCallTimeout);
} else {
await VpnBypassService.instance
.restoreDefault()
.timeout(_vpnCallTimeout);
}
} catch (e) {
logger.w('Connection: VPN-вызов завис/упал ($e) — продолжаю');
}
logger.i(
'Connection: открываю сокет $host:$port '
'(прокси: ${proxySettings.isEnabled ? proxySettings.type.name : 'нет'})',
);
final socket = await _openSecureSocket(
host,
port,
proxySettings,
timeout: timeout,
);
_socket = socket;
_setState(SocketState.connected);
logger.i('Подключено к $host:$port');
final route = proxySettings.isEnabled
? 'через прокси ${proxySettings.type.name}'
: bypassVpn
? 'напрямую (обход VPN)'
: 'прямое соединение';
TrafficMonitor.instance.recordEvent(
'Подключено',
detail: '$host:$port · TLS · $route',
endpoint: '$host:$port',
);
_subscription = _socket!.listen(
(data) {
if (!_dataController.isClosed) _dataController.add(data);
},
onError: (Object error) {
logger.e('Ошибка сокета: $error');
disconnect();
},
onDone: () {
logger.w('Сокет закрыт сервером');
disconnect();
},
);
} catch (e) {
logger.e('Не удалось подключиться: $e');
_setState(SocketState.disconnected);
rethrow;
}
}
Future<SecureSocket> _openSecureSocket(
String host,
int port,
ProxySettings proxySettings, {
Duration? timeout,
}) async {
final connectTimeout = timeout ?? _defaultConnectTimeout;
Socket socket;
if (proxySettings.isEnabled) {
final connector = ProxyConnector(proxySettings);
socket = await connector.connect(host, port).timeout(connectTimeout);
logger.i('Подключено через прокси ${proxySettings.type.name}');
} else {
logger.i('Connection: TCP connect $host:$port (лимит ${connectTimeout.inSeconds}с)');
socket = await Socket.connect(host, port, timeout: connectTimeout);
logger.i('Connection: TCP установлен, начинаю TLS');
}
final allowInsecure = await TlsConfig.isInsecureAllowed();
if (allowInsecure) {
logger.w(
'TLS: проверка сертификата отключена (дебаг) — соединение уязвимо к MitM',
);
}
final secured = allowInsecure
? SecureSocket.secure(socket, host: host, onBadCertificate: (_) => true)
: SecureSocket.secure(socket, host: host);
try {
final result = await secured.timeout(connectTimeout);
logger.i('Connection: TLS-handshake завершён');
return result;
} on TimeoutException {
logger.w('Connection: TLS-handshake таймаут ${connectTimeout.inSeconds}с');
socket.destroy();
rethrow;
}
}
void write(Uint8List data) {
if (_socket == null || !isConnected) {
throw StateError('Нельзя писать: сокет не подключён');
}
_socket!.add(data);
}
Future<void> disconnect() async {
_subscription?.cancel();
_subscription = null;
final socket = _socket;
_socket = null;
if (socket != null) {
TrafficMonitor.instance.recordEvent('Соединение закрыто');
try {
await socket.close();
} catch (e) {
logger.w('Ошибка при закрытии сокета: $e');
}
}
_setState(SocketState.disconnected);
}
Future<void> dispose() async {
await disconnect();
await _dataController.close();
await _stateController.close();
}
}
-360
View File
@@ -1,360 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import '../config/proxy_config.dart';
import '../utils/logger.dart';
class ProxyConnector {
final ProxySettings settings;
ProxyConnector(this.settings);
Future<Socket> connect(String targetHost, int targetPort) async {
switch (settings.type) {
case ProxyType.socks5:
return _connectSocks5(targetHost, targetPort);
case ProxyType.httpConnect:
return _connectHttpConnect(targetHost, targetPort);
case ProxyType.none:
return Socket.connect(targetHost, targetPort);
}
}
// ── SOCKS5 (RFC 1928) ──────────────────────────────────────────────────
Future<Socket> _connectSocks5(String targetHost, int targetPort) async {
final proxySocket = await RawSocket.connect(settings.host, settings.port);
logger.i('SOCKS5: подключено к прокси ${settings.host}:${settings.port}');
final io = _RawSocketIO(proxySocket);
try {
// 1. Greeting
final useAuth = settings.hasCredentials;
if (useAuth) {
await io.write([0x05, 0x02, 0x00, 0x02]);
} else {
await io.write([0x05, 0x01, 0x00]);
}
var response = await io.readExact(2);
if (response[0] != 0x05) {
throw SocketException(
'SOCKS5: неверная версия протокола: ${response[0]}',
);
}
final method = response[1];
if (method == 0xFF) {
throw SocketException(
'SOCKS5: сервер отклонил все методы аутентификации',
);
}
// 2. Аутентификация (RFC 1929)
if (method == 0x02) {
if (!useAuth) {
throw SocketException('SOCKS5: прокси требует аутентификацию');
}
final usernameBytes = utf8.encode(settings.username ?? '');
final passwordBytes = utf8.encode(settings.password ?? '');
final authPacket = BytesBuilder()
..addByte(0x01)
..addByte(usernameBytes.length)
..add(usernameBytes)
..addByte(passwordBytes.length)
..add(passwordBytes);
await io.write(authPacket.toBytes());
final authResponse = await io.readExact(2);
if (authResponse[1] != 0x00) {
throw SocketException('SOCKS5: аутентификация не пройдена');
}
logger.i('SOCKS5: аутентификация пройдена');
}
// 3. Connect request
final hostBytes = utf8.encode(targetHost);
final connectPacket = BytesBuilder()
..addByte(0x05) // VER
..addByte(0x01) // CMD: CONNECT
..addByte(0x00) // RSV
..addByte(0x03) // ATYP: domain
..addByte(hostBytes.length)
..add(hostBytes)
..addByte((targetPort >> 8) & 0xFF)
..addByte(targetPort & 0xFF);
await io.write(connectPacket.toBytes());
// 4. Reply
final reply = await io.readExact(4);
if (reply[0] != 0x05) {
throw SocketException('SOCKS5: неверная версия в ответе');
}
if (reply[1] != 0x00) {
throw SocketException('SOCKS5: ошибка подключения, код: ${reply[1]}');
}
// Пропускаем bind address
switch (reply[3]) {
case 0x01:
await io.readExact(4 + 2);
break;
case 0x03:
final lenBuf = await io.readExact(1);
await io.readExact(lenBuf[0] + 2);
break;
case 0x04:
await io.readExact(16 + 2);
break;
}
logger.i('SOCKS5: туннель к $targetHost:$targetPort установлен');
// Создаём локальную пару и проксируем данные
return _bridgeToFreshSocket(proxySocket, io);
} catch (e) {
io.dispose();
proxySocket.close();
rethrow;
}
}
// ── HTTP CONNECT ────────────────────────────────────────────────────────
Future<Socket> _connectHttpConnect(String targetHost, int targetPort) async {
final proxySocket = await RawSocket.connect(settings.host, settings.port);
logger.i(
'HTTP CONNECT: подключено к прокси ${settings.host}:${settings.port}',
);
final io = _RawSocketIO(proxySocket);
try {
final request = StringBuffer()
..write('CONNECT $targetHost:$targetPort HTTP/1.1\r\n')
..write('Host: $targetHost:$targetPort\r\n');
if (settings.hasCredentials) {
final credentials = base64Encode(
utf8.encode('${settings.username}:${settings.password}'),
);
request.write('Proxy-Authorization: Basic $credentials\r\n');
}
request.write('\r\n');
await io.write(utf8.encode(request.toString()));
// Читаем HTTP-ответ до \r\n\r\n
final headerBytes = <int>[];
while (true) {
final byte = await io.readExact(1);
headerBytes.add(byte[0]);
if (headerBytes.length >= 4 &&
headerBytes[headerBytes.length - 4] == 0x0D &&
headerBytes[headerBytes.length - 3] == 0x0A &&
headerBytes[headerBytes.length - 2] == 0x0D &&
headerBytes[headerBytes.length - 1] == 0x0A) {
break;
}
if (headerBytes.length > 8192) {
throw SocketException(
'HTTP CONNECT: заголовок ответа слишком большой',
);
}
}
final responseStr = utf8.decode(headerBytes, allowMalformed: true);
final statusLine = responseStr.split('\r\n').first;
final parts = statusLine.split(' ');
if (parts.length < 2) {
throw SocketException('HTTP CONNECT: некорректный ответ: $statusLine');
}
final statusCode = int.tryParse(parts[1]) ?? 0;
if (statusCode != 200) {
throw SocketException('HTTP CONNECT: прокси вернул статус $statusCode');
}
logger.i('HTTP CONNECT: туннель к $targetHost:$targetPort установлен');
return _bridgeToFreshSocket(proxySocket, io);
} catch (e) {
io.dispose();
proxySocket.close();
rethrow;
}
}
Future<Socket> _bridgeToFreshSocket(
RawSocket proxySocket,
_RawSocketIO io,
) async {
ServerSocket? server;
try {
server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
} catch (e) {
io.dispose();
proxySocket.close();
rethrow;
}
final clientFuture = Socket.connect(
InternetAddress.loopbackIPv4,
server.port,
);
final serverSide = await server.first;
final clientSide = await clientFuture;
await server.close();
io.onData = (data) {
serverSide.add(data);
};
io.onClosed = () {
serverSide.close();
};
serverSide.listen(
(data) {
unawaited(
io.write(data).catchError((Object _) {
try {
serverSide.destroy();
} catch (_) {}
}),
);
},
onError: (Object _) {
proxySocket.shutdown(SocketDirection.send);
},
onDone: () {
proxySocket.shutdown(SocketDirection.send);
},
);
// Сливаем данные, буферизованные во время handshake
io.flushBuffered();
logger.i('Прокси-мост через loopback создан');
return clientSide;
}
}
/// Обёртка над единственной подпиской [RawSocket], с буфером для чтения.
///
/// После handshake переключается в режим моста:
/// данные из proxy-сокета пересылаются через [onData] в loopback-пару.
class _RawSocketIO {
final RawSocket _socket;
late final StreamSubscription<RawSocketEvent> _sub;
final _readBuffer = <int>[];
Completer<void>? _readWaiter;
Completer<void>? _writeWaiter;
bool _closed = false;
Object? _error;
/// Коллбэк для данных в режиме моста.
void Function(Uint8List data)? onData;
/// Коллбэк закрытия в режиме моста.
void Function()? onClosed;
_RawSocketIO(this._socket) {
_sub = _socket.listen(
_onEvent,
onError: (Object err) {
_error = err;
_closed = true;
_readWaiter?.completeError(err);
_readWaiter = null;
_writeWaiter?.completeError(err);
_writeWaiter = null;
},
);
}
void _onEvent(RawSocketEvent event) {
switch (event) {
case RawSocketEvent.read:
final data = _socket.read();
if (data != null) {
if (onData != null) {
// Режим моста — пересылаем напрямую
onData!(data);
} else {
// Режим handshake — буферизуем
_readBuffer.addAll(data);
_readWaiter?.complete();
_readWaiter = null;
}
}
break;
case RawSocketEvent.write:
_writeWaiter?.complete();
_writeWaiter = null;
break;
case RawSocketEvent.readClosed:
case RawSocketEvent.closed:
_closed = true;
onClosed?.call();
_readWaiter?.completeError(SocketException('Прокси закрыл соединение'));
_readWaiter = null;
_writeWaiter?.completeError(
SocketException('Прокси закрыл соединение'),
);
_writeWaiter = null;
break;
}
}
/// Читает ровно [count] байт.
Future<Uint8List> readExact(int count) async {
while (_readBuffer.length < count) {
if (_error != null) throw _error!;
if (_closed) {
throw SocketException(
'Соединение закрыто '
'(ожидали $count байт, получили ${_readBuffer.length})',
);
}
_readWaiter = Completer<void>();
await _readWaiter!.future.timeout(
const Duration(seconds: 15),
onTimeout: () => throw SocketException('Тайм-аут при чтении от прокси'),
);
}
final result = Uint8List.fromList(_readBuffer.sublist(0, count));
_readBuffer.removeRange(0, count);
return result;
}
/// Записывает все байты.
Future<void> write(List<int> data) async {
var offset = 0;
while (offset < data.length) {
if (_error != null) throw _error!;
if (_closed) throw SocketException('Соединение закрыто при записи');
final written = _socket.write(data, offset);
if (written > 0) {
offset += written;
} else {
_writeWaiter = Completer<void>();
await _writeWaiter!.future.timeout(
const Duration(seconds: 15),
onTimeout: () =>
throw SocketException('Тайм-аут при записи в прокси'),
);
}
}
}
/// Пересылает данные, оставшиеся в буфере после handshake, в мост.
void flushBuffered() {
if (_readBuffer.isNotEmpty && onData != null) {
onData!(Uint8List.fromList(_readBuffer));
_readBuffer.clear();
}
}
void dispose() {
_sub.cancel();
}
}
-74
View File
@@ -1,74 +0,0 @@
import 'dart:typed_data';
import '../protocol/packet.dart';
class ReceiverOverflowException implements Exception {
final int size;
const ReceiverOverflowException(this.size);
@override
String toString() => 'PacketReceiver: переполнение буфера ($size B)';
}
class PacketReceiver {
Uint8List _buffer = Uint8List(0);
int _start = 0;
int _end = 0;
static const int _maxBufferSize = 2 * 1024 * 1024;
List<Uint8List> feed(Uint8List data) {
_append(data);
if (_end - _start > _maxBufferSize) {
final overflow = _end - _start;
reset();
throw ReceiverOverflowException(overflow);
}
final packets = <Uint8List>[];
while (_end - _start >= headerSize) {
final bd = ByteData.view(
_buffer.buffer,
_buffer.offsetInBytes + _start,
headerSize,
);
final packedLen = bd.getUint32(6, Endian.big);
final payloadLength = packedLen & 0xFFFFFF;
final totalLength = headerSize + payloadLength;
if (_end - _start < totalLength) break;
packets.add(Uint8List.sublistView(_buffer, _start, _start + totalLength));
_start += totalLength;
}
if (_start == _end) {
_start = 0;
_end = 0;
}
return packets;
}
void _append(Uint8List data) {
final pending = _end - _start;
if (pending == 0) {
_buffer = Uint8List.fromList(data);
_start = 0;
_end = data.length;
return;
}
final total = pending + data.length;
final newBuffer = Uint8List(total);
newBuffer.setRange(0, pending, _buffer, _start);
newBuffer.setRange(pending, total, data);
_buffer = newBuffer;
_start = 0;
_end = total;
}
void reset() {
_buffer = Uint8List(0);
_start = 0;
_end = 0;
}
}
-27
View File
@@ -1,27 +0,0 @@
import '../protocol/packet.dart';
import '../utils/log_redact.dart';
import '../utils/logger.dart';
import 'connection.dart';
import 'traffic_monitor.dart';
class PacketSender {
int _seq = 0;
int get currentSeq => _seq;
int _nextSeq() {
_seq = (_seq + 1) % 65536;
return _seq;
}
int send(Connection connection, int opcode, Map<dynamic, dynamic> payload) {
final seq = _nextSeq();
final data = packPacket(opcode, payload, seq: seq);
connection.write(data);
TrafficMonitor.instance.recordOutgoing(opcode, payload, seq, data.length);
logger.i(
'=> {ver: 10, cmd: 0, seq: $seq, opcode: $opcode, payload: ${payloadForLog(payload)}}',
);
return seq;
}
}
+12
View File
@@ -32,6 +32,18 @@ class EmojiKeywordIndex {
});
}
List<String> get all =>
List.unmodifiable(_entries.map((e) => e.emoji));
List<String> search(String query) {
final targets = resolve(query);
if (targets.isEmpty) return const [];
return [
for (final entry in _entries)
if (targets.contains(entry.emoji)) entry.emoji,
];
}
static String normalize(String emoji) =>
emoji.replaceAll(_variationSelector, '');
+73 -16
View File
@@ -30,27 +30,84 @@ Future<MediaSaveResult> saveImageFromUrl(String url) async {
if (file == null) {
return const MediaSaveResult(ok: false, error: 'не удалось загрузить');
}
final saveName = 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg';
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
final state = await PhotoManager.requestPermissionExtend();
if (!state.isAuth && !state.hasAccess) {
return const MediaSaveResult(ok: false, error: 'нет доступа к галерее');
}
final bytes = await file.readAsBytes();
await PhotoManager.editor.saveImage(bytes, filename: saveName);
return const MediaSaveResult(ok: true, toGallery: true);
}
final dir = await _targetDirectory();
final target = File('${dir.path}${Platform.pathSeparator}$saveName');
await file.copy(target.path);
return MediaSaveResult(ok: true, location: target.path);
return _persist(
file,
saveName: 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg',
kind: SaveMediaKind.image,
);
} catch (e) {
return MediaSaveResult(ok: false, error: e.toString());
}
}
enum SaveMediaKind { image, video, file }
Future<MediaSaveResult> saveMediaFile({
required String cacheName,
required Future<String?> Function() resolveUrl,
required String saveName,
required SaveMediaKind kind,
}) async {
try {
var file = await MediaCache.existing(cacheName);
if (file == null) {
final url = await resolveUrl();
if (url == null || url.isEmpty) {
return const MediaSaveResult(ok: false, error: 'нет ссылки');
}
file = await MediaCache.getOrDownload(cacheName, url);
}
if (file == null) {
return const MediaSaveResult(ok: false, error: 'не удалось загрузить');
}
return _persist(file, saveName: saveName, kind: kind);
} catch (e) {
return MediaSaveResult(ok: false, error: e.toString());
}
}
Future<MediaSaveResult> saveLocalImage(String path, {String? saveName}) async {
try {
final file = File(path);
if (!await file.exists()) {
return const MediaSaveResult(ok: false, error: 'файл не найден');
}
return _persist(
file,
saveName: saveName ?? 'IMG_${DateTime.now().millisecondsSinceEpoch}.jpg',
kind: SaveMediaKind.image,
);
} catch (e) {
return MediaSaveResult(ok: false, error: e.toString());
}
}
Future<MediaSaveResult> _persist(
File file, {
required String saveName,
required SaveMediaKind kind,
}) async {
final toGallery = kind == SaveMediaKind.image || kind == SaveMediaKind.video;
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS) && toGallery) {
final state = await PhotoManager.requestPermissionExtend();
if (!state.isAuth && !state.hasAccess) {
return const MediaSaveResult(ok: false, error: 'нет доступа к галерее');
}
if (kind == SaveMediaKind.video) {
await PhotoManager.editor.saveVideo(file, title: saveName);
} else {
final bytes = await file.readAsBytes();
await PhotoManager.editor.saveImage(bytes, filename: saveName);
}
return const MediaSaveResult(ok: true, toGallery: true);
}
final dir = await _targetDirectory();
final target = File('${dir.path}${Platform.pathSeparator}$saveName');
await file.copy(target.path);
return MediaSaveResult(ok: true, location: target.path);
}
Future<Directory> _targetDirectory() async {
try {
final downloads = await getDownloadsDirectory();
+46 -1
View File
@@ -8,6 +8,7 @@ enum TextFormat {
monospaced,
quote,
link,
animoji,
}
const Map<TextFormat, String> _formatToServer = {
@@ -18,6 +19,7 @@ const Map<TextFormat, String> _formatToServer = {
TextFormat.monospaced: 'MONOSPACED',
TextFormat.quote: 'QUOTE',
TextFormat.link: 'LINK',
TextFormat.animoji: 'ANIMOJI',
};
final Map<String, TextFormat> _serverToFormat = {
@@ -49,6 +51,11 @@ class FormatRange {
return value is String ? value : null;
}
String? get animojiUrl {
final value = attributes?['animojiLottieUrl'];
return value is String && value.isNotEmpty ? value : null;
}
Map<String, dynamic> toServer() => {
'type': textFormatToServer(format),
'from': start,
@@ -87,6 +94,34 @@ List<Map<String, dynamic>> serializeFormatElements(
Iterable<FormatRange> ranges,
) => [for (final range in ranges) range.toServer()];
List<String>? animojiOnlyLottieUrls(
String? text,
List<FormatRange> ranges, {
int limit = 4,
}) {
if (text == null || text.isEmpty) return null;
final len = text.length;
final animoji =
ranges
.where((r) => r.format == TextFormat.animoji && r.animojiUrl != null)
.toList()
..sort((a, b) => a.start.compareTo(b.start));
if (animoji.isEmpty || animoji.length > limit) return null;
var cursor = 0;
for (final r in animoji) {
final start = r.start.clamp(0, len).toInt();
if (text.substring(cursor.clamp(0, len).toInt(), start).trim().isNotEmpty) {
return null;
}
cursor = r.end.clamp(0, len).toInt();
}
if (text.substring(cursor.clamp(0, len).toInt()).trim().isNotEmpty) {
return null;
}
return [for (final r in animoji) r.animojiUrl!];
}
int _asInt(dynamic value) {
if (value is int) return value;
if (value is String) return int.tryParse(value) ?? 0;
@@ -98,12 +133,14 @@ class FormatSegment {
final int end;
final Set<TextFormat> formats;
final String? url;
final String? animojiUrl;
const FormatSegment({
required this.start,
required this.end,
required this.formats,
this.url,
this.animojiUrl,
});
}
@@ -142,14 +179,22 @@ List<FormatSegment> segmentizeFormats(String text, List<FormatRange> ranges) {
if (end <= start) continue;
final formats = <TextFormat>{};
String? url;
String? animojiUrl;
for (final range in clamped) {
if (range.start <= start && range.end >= end) {
formats.add(range.format);
if (range.format == TextFormat.link) url ??= range.url;
if (range.format == TextFormat.animoji) animojiUrl ??= range.animojiUrl;
}
}
segments.add(
FormatSegment(start: start, end: end, formats: formats, url: url),
FormatSegment(
start: start,
end: end,
formats: formats,
url: url,
animojiUrl: animojiUrl,
),
);
}
return segments;
+134
View File
@@ -0,0 +1,134 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
class TiledSvgPattern extends StatefulWidget {
final String asset;
final Color color;
final double opacity;
final double tileSize;
const TiledSvgPattern({
super.key,
required this.asset,
required this.color,
this.opacity = 0.12,
this.tileSize = 120,
});
@override
State<TiledSvgPattern> createState() => _TiledSvgPatternState();
}
class _TiledSvgPatternState extends State<TiledSvgPattern> {
static final Map<String, ui.Image> _cache = {};
static final Map<String, Future<ui.Image>> _pending = {};
ui.Image? _image;
double _dpr = 1;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final dpr = MediaQuery.maybeOf(context)?.devicePixelRatio ?? 1;
if (dpr != _dpr || _image == null) {
_dpr = dpr;
_resolve();
}
}
@override
void didUpdateWidget(TiledSvgPattern old) {
super.didUpdateWidget(old);
if (old.asset != widget.asset || old.tileSize != widget.tileSize) {
_resolve();
}
}
Future<void> _resolve() async {
final px = (widget.tileSize * _dpr).clamp(1, 4096).round();
final key = '${widget.asset}@$px';
final cached = _cache[key];
if (cached != null) {
if (_image != cached) setState(() => _image = cached);
return;
}
final future = _pending.putIfAbsent(key, () => _rasterize(widget.asset, px));
try {
final image = await future;
_cache[key] = image;
_pending.remove(key);
if (mounted) setState(() => _image = image);
} catch (_) {
_pending.remove(key);
}
}
static Future<ui.Image> _rasterize(String asset, int px) async {
final info = await vg.loadPicture(SvgAssetLoader(asset), null);
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final size = info.size;
if (size.width > 0 && size.height > 0) {
canvas.scale(px / size.width, px / size.height);
}
canvas.drawPicture(info.picture);
final picture = recorder.endRecording();
final image = await picture.toImage(px, px);
info.picture.dispose();
picture.dispose();
return image;
}
@override
Widget build(BuildContext context) {
final image = _image;
if (image == null) return const SizedBox.expand();
return CustomPaint(
size: Size.infinite,
painter: _PatternPainter(
image: image,
color: widget.color.withValues(alpha: widget.opacity),
tileSize: widget.tileSize,
dpr: _dpr,
),
);
}
}
class _PatternPainter extends CustomPainter {
final ui.Image image;
final Color color;
final double tileSize;
final double dpr;
const _PatternPainter({
required this.image,
required this.color,
required this.tileSize,
required this.dpr,
});
@override
void paint(Canvas canvas, Size size) {
final s = 1 / dpr;
final matrix = Matrix4.identity()..scaleByDouble(s, s, 1, 1);
final paint = Paint()
..shader = ImageShader(
image,
TileMode.repeated,
TileMode.repeated,
matrix.storage,
)
..colorFilter = ColorFilter.mode(color, BlendMode.srcIn);
canvas.drawRect(Offset.zero & size, paint);
}
@override
bool shouldRepaint(_PatternPainter old) =>
old.image != image ||
old.color != color ||
old.tileSize != tileSize ||
old.dpr != dpr;
}
+49
View File
@@ -0,0 +1,49 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image/image.dart' as img;
import '../config/chat_wallpaper_themes.dart';
import '../storage/chat_wallpaper_store.dart';
Future<Color?> computeWallpaperSeed(ChatWallpaper? wallpaper) async {
if (wallpaper == null) return null;
if (!wallpaper.isImage) {
final theme = chatWallpaperThemeById(wallpaper.themeId);
if (theme == null) return null;
return _mostVivid(theme.colors);
}
final path = wallpaper.imagePath;
if (path == null) return null;
try {
final bytes = await File(path).readAsBytes();
final decoded = img.decodeImage(bytes);
if (decoded == null) return null;
final small = img.copyResize(decoded, width: 8, height: 8);
var r = 0, g = 0, b = 0, n = 0;
for (final pixel in small) {
r += pixel.r.toInt();
g += pixel.g.toInt();
b += pixel.b.toInt();
n++;
}
if (n == 0) return null;
return Color.fromARGB(255, r ~/ n, g ~/ n, b ~/ n);
} catch (_) {
return null;
}
}
Color _mostVivid(List<Color> colors) {
var best = colors.first;
var bestScore = -1.0;
for (final color in colors) {
final hsl = HSLColor.fromColor(color);
final score = hsl.saturation * (1 - (hsl.lightness - 0.5).abs());
if (score > bestScore) {
bestScore = score;
best = color;
}
}
return best;
}
+2 -8
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../core/utils/format.dart';
import '../widgets/small_spinner.dart';
class DebugCacheSection extends StatelessWidget {
final int cacheSize;
@@ -129,14 +130,7 @@ class DebugCacheSection extends StatelessWidget {
),
),
if (clearingCache)
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onSurfaceVariant,
),
),
SmallSpinner(size: 20, color: cs.onSurfaceVariant),
],
),
),
+2 -5
View File
@@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../widgets/custom_notification.dart';
import '../widgets/glossy_pill.dart';
import '../widgets/small_spinner.dart';
class DebugIdSearchSection extends StatelessWidget {
final TextEditingController idController;
@@ -72,11 +73,7 @@ class DebugIdSearchSection extends StatelessWidget {
FilledButton(
onPressed: isSearching ? null : onSearch,
child: isSearching
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
? const SmallSpinner(size: 20)
: const Icon(Symbols.search, size: 20),
),
],
+2 -5
View File
@@ -6,6 +6,7 @@ import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart';
import '../../main.dart';
import '../widgets/glossy_pill.dart';
import '../widgets/small_spinner.dart';
class DebugSyncProbeSection extends StatefulWidget {
const DebugSyncProbeSection({super.key});
@@ -147,11 +148,7 @@ class _DebugSyncProbeSectionState extends State<DebugSyncProbeSection> {
),
),
child: _loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
? const SmallSpinner(size: 20)
: const Text('Отправить'),
),
if (_result != null) ...[
@@ -11,6 +11,7 @@ import '../../../core/utils/sms_code_listener.dart';
import '../../../main.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/login_success_screen.dart';
import '../../widgets/small_spinner.dart';
class CodeConfirmationScreen extends StatefulWidget {
final String phoneNumber;
@@ -525,14 +526,7 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
borderRadius: BorderRadius.circular(50),
),
child: recovering
? SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onPrimaryContainer,
),
)
? SmallSpinner(size: 24, color: cs.onPrimaryContainer)
: Icon(
Icons.arrow_forward,
color: _codeController.text.length == 6
+4 -7
View File
@@ -18,6 +18,7 @@ import '../digital_id/digital_id_web_screen.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/adaptive_shell.dart';
import '../../widgets/sheet_helpers.dart';
import '../../widgets/small_spinner.dart';
import '../../../backend/api.dart';
import '../../../core/protocol/packet.dart';
import '../../../main.dart';
@@ -998,13 +999,9 @@ class _LoginScreenState extends State<LoginScreen> {
borderRadius: BorderRadius.circular(50),
),
child: _isPhoneValid && !_isOnline
? SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onPrimaryContainer,
),
? SmallSpinner(
size: 24,
color: cs.onPrimaryContainer,
)
: Icon(
Icons.arrow_forward,
@@ -3,6 +3,7 @@ import '../../../core/protocol/packet.dart';
import '../../../main.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/login_success_screen.dart';
import '../../widgets/small_spinner.dart';
import 'session_stale_recovery.dart';
class Password2FAScreen extends StatefulWidget {
@@ -199,14 +200,7 @@ class _Password2FAScreenState extends State<Password2FAScreen>
borderRadius: BorderRadius.circular(50),
),
child: _isLoading
? SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onPrimaryContainer,
),
)
? SmallSpinner(size: 24, color: cs.onPrimaryContainer)
: Icon(
Icons.arrow_forward,
color: _passwordController.text.isNotEmpty
@@ -6,6 +6,7 @@ import '../../../backend/modules/account.dart';
import '../../../main.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/login_success_screen.dart';
import '../../widgets/small_spinner.dart';
class RegistrationScreen extends StatefulWidget {
final String phoneNumber;
@@ -110,14 +111,7 @@ class _RegistrationScreenState extends State<RegistrationScreen> {
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(50)),
child: _isSubmitting
? SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: cs.onSurfaceVariant,
),
)
? SmallSpinner(size: 22, color: cs.onSurfaceVariant)
: Icon(
Icons.arrow_forward,
color: _canSubmit ? cs.onPrimaryContainer : cs.onSurfaceVariant,
@@ -8,6 +8,7 @@ import '../../../models/spoof_profile.dart';
import '../../widgets/adaptive_shell.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/section_header.dart';
import '../../widgets/small_spinner.dart';
class TokenLoginScreen extends StatefulWidget {
final int? returnToAccountId;
@@ -143,11 +144,7 @@ class _TokenLoginScreenState extends State<TokenLoginScreen> {
shape: const StadiumBorder(),
),
child: _isLoading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
)
? const SmallSpinner(size: 22)
: Text(l10n.tokenLoginButton),
),
),
+2 -8
View File
@@ -24,6 +24,7 @@ import '../../../l10n/app_localizations.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/sheet_helpers.dart';
import '../../widgets/small_spinner.dart';
import 'komet_hub.dart';
const Color _kEndRed = Color(0xFFE5484D);
@@ -1261,14 +1262,7 @@ class _CallButton extends StatelessWidget {
depth: 9,
child: Center(
child: busy
? SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
valueColor: AlwaysStoppedAnimation(foreground),
),
)
? SmallSpinner(size: 22, color: foreground)
: Icon(icon, color: foreground, size: 26, fill: 1),
),
),
+2 -1
View File
@@ -12,6 +12,7 @@ import '../../widgets/komet_avatar.dart';
import '../../widgets/connection_status.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/chat_menu_overlay.dart';
import '../../widgets/small_spinner.dart';
import 'call_screen.dart';
class CallsTab extends StatefulWidget {
@@ -421,7 +422,7 @@ class _CallsTabState extends State<CallsTab> {
),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
? const Center(child: SmallSpinner(size: 36))
: filteredCalls.isEmpty
? Center(
child: Text(
@@ -10,9 +10,25 @@ import '../../../../core/storage/app_database.dart';
import '../../../../core/utils/logger.dart';
import '../../../../main.dart';
class HistoryGap {
HistoryGap({
required this.edgeId,
required this.edgeTime,
required this.tailTime,
});
String edgeId;
int edgeTime;
final int tailTime;
}
class ChatController extends ChangeNotifier {
static const int historyPageSize = 30;
static const int historyInitialLimit = 50;
static const int jumpWindowBefore = 40;
static const int jumpWindowAfter = 20;
static const int historyWalkPageSize = 200;
static const int gapPageSize = 60;
int chatId = 0;
int myId = 0;
@@ -23,6 +39,11 @@ class ChatController extends ChangeNotifier {
bool hasMoreHistory = true;
bool isLoadingMore = false;
bool historyKickedOff = false;
bool loadingGap = false;
final List<HistoryGap> gaps = [];
bool get hasGap => gaps.isNotEmpty;
bool Function() isMounted = () => true;
@@ -93,20 +114,179 @@ class ChatController extends ChangeNotifier {
Future<List<CachedMessage>> loadOlderFromDb(
int beforeTime,
bool onlyVisible,
) async {
bool onlyVisible, {
int? limit,
}) async {
final rows = await AppDatabase.loadMessagesBefore(
myId,
chatId,
beforeTime: beforeTime,
limit: historyPageSize,
limit: limit ?? historyPageSize,
onlyVisible: onlyVisible,
);
return CachedMessage.fromDbRowsAsync(rows);
}
Future<List<CachedMessage>> loadGapSliceFromDb(
int afterTime,
int beforeTime,
bool onlyVisible,
) async {
final rows = await AppDatabase.loadMessagesBetween(
myId,
chatId,
afterTime: afterTime,
beforeTime: beforeTime,
limit: gapPageSize,
onlyVisible: onlyVisible,
);
return CachedMessage.fromDbRowsAsync(rows);
}
Future<List<CachedMessage>> loadWindowFromDb(
int centerTime,
bool onlyVisible,
) async {
final rows = await AppDatabase.loadMessagesAround(
myId,
chatId,
centerTime: centerTime,
before: jumpWindowBefore,
after: jumpWindowAfter,
onlyVisible: onlyVisible,
);
return CachedMessage.fromDbRowsAsync(rows);
}
Future<bool> loadMessageWindow({
required String targetId,
required int targetTime,
}) async {
if (myId == 0 || targetTime <= 0) return false;
final onlyVisible = !KometSettings.viewDeleted.value;
var window = await loadWindowFromDb(targetTime, onlyVisible);
if (!isMounted()) return false;
if (!window.any((m) => m.id == targetId)) {
final fetched = await messagesModule.fetchHistory(
myId,
chatId,
fromTime: targetTime + 1,
forward: jumpWindowAfter,
backward: jumpWindowBefore + 1,
);
if (!isMounted()) return false;
if (fetched.isNotEmpty && KometSettings.viewDeleted.value) {
await chats.reconcileDeletedFromFetch(myId, chatId, fetched);
}
window = await loadWindowFromDb(targetTime, onlyVisible);
if (!isMounted()) return false;
}
if (window.isEmpty) return false;
final oldestLoaded = messages.isEmpty ? 0 : messages.first.time;
final reachesLoaded =
messages.isEmpty || window.any((m) => m.time >= oldestLoaded);
mergeMessages(window);
if (reachesLoaded) {
persistSessionCache();
} else {
_markGapAfterWindow(window);
}
return messages.any((m) => m.id == targetId);
}
void _markGapAfterWindow(List<CachedMessage> window) {
var edge = window.first;
for (final m in window) {
if (m.time > edge.time) edge = m;
}
final idx = messages.indexWhere((m) => m.id == edge.id);
if (idx == -1 || idx + 1 >= messages.length) return;
final tailTime = messages[idx + 1].time;
gaps.removeWhere((g) => g.tailTime == tailTime);
gaps.add(
HistoryGap(edgeId: edge.id, edgeTime: edge.time, tailTime: tailTime),
);
}
void _closeGap(HistoryGap gap) {
gaps.remove(gap);
if (gaps.isEmpty) persistSessionCache();
}
Future<int> fillGapForward(HistoryGap gap) async {
if (loadingGap || myId == 0 || !gaps.contains(gap)) return 0;
if (gap.edgeTime <= 0 || gap.tailTime <= gap.edgeTime) {
_closeGap(gap);
return 0;
}
loadingGap = true;
try {
final onlyVisible = !KometSettings.viewDeleted.value;
var slice = await loadGapSliceFromDb(
gap.edgeTime,
gap.tailTime,
onlyVisible,
);
if (!isMounted()) return 0;
if (slice.length < gapPageSize) {
final fetched = await messagesModule.fetchHistory(
myId,
chatId,
fromTime: gap.edgeTime,
forward: gapPageSize,
backward: 0,
);
if (!isMounted()) return 0;
if (fetched.isNotEmpty && KometSettings.viewDeleted.value) {
await chats.reconcileDeletedFromFetch(myId, chatId, fetched);
}
final refreshed = await loadGapSliceFromDb(
gap.edgeTime,
gap.tailTime,
onlyVisible,
);
if (!isMounted()) return 0;
if (refreshed.length <= slice.length) {
if (refreshed.isNotEmpty) mergeMessages(refreshed);
_closeGap(gap);
return refreshed.length;
}
slice = refreshed;
}
if (slice.isEmpty) {
_closeGap(gap);
return 0;
}
mergeMessages(slice);
var edge = slice.first;
for (final m in slice) {
if (m.time > edge.time) edge = m;
}
gap.edgeId = edge.id;
gap.edgeTime = edge.time;
if (edge.time >= gap.tailTime) _closeGap(gap);
return slice.length;
} catch (e) {
logger.e('Error filling history gap: $e');
return 0;
} finally {
loadingGap = false;
}
}
void persistSessionCache() {
if (myId == 0 || messages.isEmpty) return;
if (myId == 0 || messages.isEmpty || hasGap) return;
MessageSessionCache.save(
myId,
chatId,
@@ -119,29 +299,32 @@ class ChatController extends ChangeNotifier {
required void Function() onLoadingStarted,
required void Function(int added) onLoaded,
required void Function(Object error) onError,
int? pageSize,
bool persist = true,
}) async {
if (isLoadingMore || !hasMoreHistory || messages.isEmpty) return;
isLoadingMore = true;
onLoadingStarted();
final size = pageSize ?? historyPageSize;
final oldest = messages.first;
final onlyVisible = !KometSettings.viewDeleted.value;
try {
var older = await loadOlderFromDb(oldest.time, onlyVisible);
var older = await loadOlderFromDb(oldest.time, onlyVisible, limit: size);
if (older.length < historyPageSize) {
if (older.length < size) {
final fetched = await messagesModule.fetchHistory(
myId,
chatId,
fromTime: oldest.time,
count: historyPageSize,
count: size,
);
if (fetched.isNotEmpty) {
if (KometSettings.viewDeleted.value) {
await chats.reconcileDeletedFromFetch(myId, chatId, fetched);
}
older = await loadOlderFromDb(oldest.time, onlyVisible);
older = await loadOlderFromDb(oldest.time, onlyVisible, limit: size);
}
}
@@ -149,7 +332,7 @@ class ChatController extends ChangeNotifier {
final added = prependOlder(older);
isLoadingMore = false;
if (added == 0) hasMoreHistory = false;
persistSessionCache();
if (persist) persistSessionCache();
onLoaded(added);
} catch (e) {
logger.e('Error loading more history: $e');
@@ -14,6 +14,7 @@ class StickerPanelController {
duration: const Duration(milliseconds: 240),
reverseDuration: const Duration(milliseconds: 200),
);
anim.addStatusListener(_onAnimStatus);
showPanel.addListener(_onToggle);
}
@@ -21,11 +22,17 @@ class StickerPanelController {
late final AnimationController anim;
final ValueNotifier<bool> showPanel = ValueNotifier(false);
final ValueNotifier<bool> panelHold = ValueNotifier(true);
double panelHeight = 300;
Timer? _typingTimer;
void hide() => showPanel.value = false;
void _onAnimStatus(AnimationStatus status) {
final held = status != AnimationStatus.completed;
if (panelHold.value != held) panelHold.value = held;
}
void _onToggle() {
if (showPanel.value) {
anim.forward();
@@ -50,7 +57,9 @@ class StickerPanelController {
void dispose() {
_typingTimer?.cancel();
showPanel.removeListener(_onToggle);
anim.removeStatusListener(_onAnimStatus);
anim.dispose();
showPanel.dispose();
panelHold.dispose();
}
}
@@ -2,11 +2,15 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:komet/core/config/app_frost.dart';
import 'package:komet/frontend/widgets/glossy_pill.dart';
import 'package:komet/frontend/widgets/online_dot.dart';
class ChatHeaderRow extends StatelessWidget {
final bool glossy;
final bool frosted;
final bool liquid;
final BackdropKey? backdropKey;
final ColorScheme cs;
final bool embedded;
final int chatId;
@@ -28,6 +32,9 @@ class ChatHeaderRow extends StatelessWidget {
const ChatHeaderRow({
super.key,
required this.glossy,
required this.frosted,
this.liquid = false,
this.backdropKey,
required this.cs,
required this.embedded,
required this.chatId,
@@ -51,6 +58,10 @@ class ChatHeaderRow extends StatelessWidget {
Widget build(BuildContext context) =>
glossy ? _glossyRow(context) : _materialRow(context);
Color? get _pillColor => frosted || liquid ? AppFrost.pillTint(cs) : null;
double? get _pillBlur => frosted && !liquid ? AppFrost.sigma : null;
Widget _glossyRow(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(10, 4, 10, 8),
@@ -62,6 +73,10 @@ class ChatHeaderRow extends StatelessWidget {
width: 56,
height: 56,
child: GlossyPill(
color: _pillColor,
blurSigma: _pillBlur,
liquid: liquid,
backdropKey: backdropKey,
onTap: () {
if (embedded) {
onClose?.call();
@@ -83,6 +98,10 @@ class ChatHeaderRow extends StatelessWidget {
const SizedBox(width: 8),
Expanded(
child: GlossyPill(
color: _pillColor,
blurSigma: _pillBlur,
liquid: liquid,
backdropKey: backdropKey,
onTap: onOpenInfo,
padding: const EdgeInsets.fromLTRB(6, 6, 16, 6),
child: Row(
@@ -168,6 +187,10 @@ class ChatHeaderRow extends StatelessWidget {
),
const SizedBox(width: 8),
GlossyPill(
color: _pillColor,
blurSigma: _pillBlur,
liquid: liquid,
backdropKey: backdropKey,
padding: const EdgeInsets.symmetric(horizontal: 2),
child: SizedBox(
height: 56,
@@ -6,17 +6,26 @@ import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:komet/backend/modules/messages.dart';
import 'package:komet/core/config/app_chat_chrome.dart';
import 'package:komet/core/config/app_colors.dart';
import 'package:komet/core/config/app_composer_background.dart';
import 'package:komet/core/config/app_composer_style.dart';
import 'package:komet/core/config/app_frost.dart';
import 'package:komet/frontend/screens/chats/chat/upload_status.dart';
import 'package:komet/frontend/screens/chats/chat/video_note_controller.dart';
import 'package:komet/frontend/screens/chats/chat/voice_record_controller.dart';
import 'package:komet/frontend/widgets/glossy_pill.dart';
import 'package:komet/frontend/widgets/liquid_glass.dart';
import 'package:komet/frontend/widgets/rich_message_controller.dart';
class ComposerInputBar extends StatelessWidget {
const ComposerInputBar({
super.key,
required this.chatType,
required this.chrome,
required this.style,
required this.background,
this.backdropKey,
required this.attachAnim,
required this.replyTo,
required this.myId,
@@ -40,6 +49,10 @@ class ComposerInputBar extends StatelessWidget {
});
final String chatType;
final ChatChromeStyle chrome;
final ComposerStyle style;
final ComposerBackground background;
final BackdropKey? backdropKey;
final Animation<double> attachAnim;
final ValueListenable<CachedMessage?> replyTo;
final int myId;
@@ -101,7 +114,7 @@ class ComposerInputBar extends StatelessWidget {
);
}
return SafeArea(
final bar = SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -121,18 +134,9 @@ class ComposerInputBar extends StatelessWidget {
minHeight: 54,
maxHeight: 180,
),
child: GlossyPill(
color: Color.alphaBlend(
cs.surfaceContainerHighest.withValues(alpha: 0.92),
cs.surface,
),
borderRadius: BorderRadius.circular(28),
depth: 8,
borderSide: BorderSide(
color: cs.outlineVariant.withValues(alpha: 0.5),
width: 0.5,
),
child: Stack(
child: _fieldSurface(
cs,
Stack(
alignment: Alignment.center,
children: [
AnimatedBuilder(
@@ -314,14 +318,16 @@ class ComposerInputBar extends StatelessWidget {
builder: (context, videoMode, _) {
final sendMode =
hasText || locked;
final pill = GlossyPill(
color: sendMode
final pill = _actionSurface(
color: _flat
? Colors.transparent
: sendMode
? cs.primary
: recording
? cs.error
: _frost
? AppFrost.inputTint(cs)
: cs.surfaceContainerHighest,
borderRadius:
BorderRadius.circular(27),
onTap: hasText
? onSendText
: locked
@@ -332,7 +338,6 @@ class ComposerInputBar extends StatelessWidget {
onLongPress: hasText
? onScheduleMessage
: null,
depth: 8,
child: SizedBox(
width: 54,
height: 54,
@@ -343,7 +348,13 @@ class ComposerInputBar extends StatelessWidget {
: videoMode
? Symbols.videocam
: Symbols.mic,
color: sendMode
color: _flat
? (sendMode
? cs.primary
: recording
? cs.error
: cs.onSurfaceVariant)
: sendMode
? cs.onPrimary
: recording
? cs.onError
@@ -402,6 +413,78 @@ class ComposerInputBar extends StatelessWidget {
],
),
);
return _barSurface(cs, bar);
}
bool get _flat => style == ComposerStyle.materialYou;
bool get _frost => ComposerMaterial.isFrost(background);
bool get _liquid => ComposerMaterial.isLiquid(background);
bool get _translucent => _frost || _liquid;
Widget _barSurface(ColorScheme cs, Widget child) {
if (!_flat || _translucent) return child;
return DecoratedBox(
decoration: BoxDecoration(
color: cs.surface,
border: Border(top: AppFrost.hairline(cs)),
),
child: child,
);
}
Widget _fieldSurface(ColorScheme cs, Widget child) {
if (_flat) return child;
return GlossyPill(
color: _translucent
? AppFrost.inputTint(cs)
: Color.alphaBlend(
cs.surfaceContainerHighest.withValues(alpha: 0.92),
cs.surface,
),
blurSigma: _frost ? AppFrost.sigma : null,
liquid: _liquid,
backdropKey: backdropKey,
borderRadius: BorderRadius.circular(28),
depth: 8,
borderSide: BorderSide(
color: cs.outlineVariant.withValues(alpha: 0.5),
width: 0.5,
),
child: child,
);
}
Widget _actionSurface({
required Color color,
required Widget child,
VoidCallback? onTap,
VoidCallback? onLongPress,
}) {
if (_flat) {
return Material(
color: color,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: onTap == null && onLongPress == null
? child
: InkWell(onTap: onTap, onLongPress: onLongPress, child: child),
);
}
return GlossyPill(
color: color,
blurSigma: _frost ? AppFrost.sigma : null,
liquid: _liquid,
backdropKey: backdropKey,
borderRadius: BorderRadius.circular(27),
onTap: onTap,
onLongPress: onLongPress,
depth: 8,
child: child,
);
}
Widget _replyPreview(ColorScheme cs) {
@@ -418,7 +501,7 @@ class ComposerInputBar extends StatelessWidget {
attachments: reply.attachments,
);
final preview = info.previewText();
return Padding(
final row = Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 8, 2),
child: Row(
children: [
@@ -462,6 +545,15 @@ class ComposerInputBar extends StatelessWidget {
],
),
);
if (_flat && _translucent) return row;
if (!_translucent && chrome != ChatChromeStyle.transparent) return row;
return GlassSurface(
liquid: _liquid,
frostTint: AppFrost.panelTint(cs),
border: Border(top: AppFrost.hairline(cs)),
backdropKey: backdropKey,
child: row,
);
},
);
}
@@ -9,6 +9,7 @@ import 'package:komet/core/utils/format.dart';
import 'package:komet/frontend/widgets/animated_lottie_icon.dart';
import 'package:komet/frontend/widgets/glossy_pill.dart';
import 'package:komet/frontend/widgets/komet_avatar.dart';
import 'package:komet/frontend/widgets/small_spinner.dart';
import 'package:komet/frontend/screens/chats/chat/chat_search_controller.dart';
import 'package:komet/frontend/screens/chats/chat/message_search_result.dart';
@@ -168,14 +169,7 @@ class SearchOverlay extends StatelessWidget {
}
if (loading) {
return Center(
child: SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(
strokeWidth: 2.4,
color: cs.onSurfaceVariant,
),
),
child: SmallSpinner(size: 26, color: cs.onSurfaceVariant),
);
}
return ValueListenableBuilder<bool>(
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:komet/frontend/screens/chats/chat/sticker_panel_controller.dart';
import 'package:komet/frontend/widgets/lottie_image.dart';
import 'package:komet/frontend/widgets/sticker_panel.dart';
import 'package:komet/models/animoji.dart';
import 'package:komet/models/sticker.dart';
class StickerPanelView extends StatelessWidget {
@@ -9,18 +11,24 @@ class StickerPanelView extends StatelessWidget {
super.key,
required this.stickers,
required this.onStickerTap,
this.onEmojiTap,
});
final StickerPanelController stickers;
final void Function(StickerItem sticker) onStickerTap;
final void Function(Animoji animoji)? onEmojiTap;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: stickers.anim,
child: StickerPanel(
height: stickers.panelHeight,
onStickerTap: onStickerTap,
child: LottieHoldScope(
isHeld: stickers.panelHold,
child: StickerPanel(
height: stickers.panelHeight,
onStickerTap: onStickerTap,
onEmojiTap: onEmojiTap,
),
),
builder: (context, child) {
final t = Curves.easeOutCubic.transform(
@@ -1,6 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:komet/main.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/messages.dart' show ContactCache;
import '../../../core/cache/info_cache.dart';
@@ -11,6 +12,7 @@ import '../../../l10n/app_localizations.dart';
import '../../../models/chat_info.dart';
import '../../../models/contact_info.dart';
import '../../widgets/avatar_history_screen.dart';
import '../../widgets/chat_info/shared_content_tabs.dart';
import '../../widgets/connection_status.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/komet_avatar.dart';
@@ -35,6 +37,8 @@ class _MemberInfo {
});
}
enum ChatInfoTab { media }
class ChatInfoScreen extends StatefulWidget {
final int chatId;
final String name;
@@ -42,6 +46,9 @@ class ChatInfoScreen extends StatefulWidget {
final String chatType;
final int? dialogPeerId;
final ChatInfoTab? initialTab;
final void Function(String messageId, int time)? onJumpToMessage;
const ChatInfoScreen({
super.key,
@@ -50,6 +57,8 @@ class ChatInfoScreen extends StatefulWidget {
required this.imageUrl,
required this.chatType,
this.dialogPeerId,
this.initialTab,
this.onJumpToMessage,
});
@override
@@ -58,6 +67,7 @@ class ChatInfoScreen extends StatefulWidget {
class _ChatInfoScreenState extends State<ChatInfoScreen> {
final _tabScrollController = ScrollController();
final _bodyScrollController = ScrollController();
int _myId = 0;
bool _isLoading = true;
@@ -76,6 +86,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
List<_MemberInfo> _members = [];
int _onlineCount = 0;
int _mediaChatId = 0;
String? _anchorMsgId;
@override
void initState() {
super.initState();
@@ -85,6 +98,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
@override
void dispose() {
_tabScrollController.dispose();
_bodyScrollController.dispose();
super.dispose();
}
@@ -140,6 +154,23 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
if (!mounted) return;
_chatInfo = info;
_mediaChatId = (info?.raw['id'] as int?) ?? widget.chatId;
final lastMessage = info?.raw['lastMessage'];
if (lastMessage is Map) {
_anchorMsgId = lastMessage['id']?.toString();
}
if (_anchorMsgId == null && info != null) {
try {
final recent = await messagesModule.fetchHistory(
_myId,
_mediaChatId,
count: 1,
);
if (recent.isNotEmpty) _anchorMsgId = recent.first.id;
} catch (_) {}
if (!mounted) return;
}
if (widget.chatType == 'DIALOG') {
_otherId = widget.dialogPeerId;
if (_otherId == null && info != null) {
@@ -204,12 +235,18 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
setState(() {
_isLoading = false;
if (_selectedTab.isEmpty && _tabs.isNotEmpty) {
_selectedTab = _tabs.first;
_selectedTab = _initialTabLabel() ?? _tabs.first;
}
});
}
}
String? _initialTabLabel() {
if (widget.initialTab != ChatInfoTab.media) return null;
final media = AppLocalizations.of(context)!.chatInfoTabMedia;
return _tabs.contains(media) ? media : null;
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
@@ -226,6 +263,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
Widget _buildScrollBody(ColorScheme cs) {
return CustomScrollView(
controller: _bodyScrollController,
slivers: [
SliverAppBar(
backgroundColor: Colors.transparent,
@@ -290,7 +328,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
case 'DIALOG':
if (_isBot) return l10n.contactProfileBot;
if (_isOnline) return l10n.contactProfileOnline;
if (_presenceStatus == 3) return l10n.contactProfileRecentlyActive;
if (_presenceStatus == 2 || _presenceStatus == 3) return l10n.contactProfileRecentlyActive;
if (_seenTime != null && _seenTime! > 0) {
return formatLastSeen(_seenTime!);
}
@@ -671,27 +709,95 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
return _buildMembersTabContent(cs);
}
if (_selectedTab == l10n.chatInfoTabGeneralChats) {
return _buildPlaceholder(cs, l10n.chatInfoEmptyGeneralChats, Icons.group);
final peerId = _otherId;
if (peerId == null) {
return _buildPlaceholder(
cs,
l10n.chatInfoEmptyGeneralChats,
Icons.group,
);
}
return CommonChatsTab(
key: const ValueKey('tab-common-chats'),
userId: peerId,
emptyLabel: l10n.chatInfoEmptyGeneralChats,
);
}
if (_selectedTab == l10n.chatInfoTabMedia) {
return _buildPlaceholder(
return _sharedTab(
cs,
SharedContentKind.media,
l10n.chatInfoEmptyMedia,
Icons.photo_library,
);
}
if (_selectedTab == l10n.chatInfoTabFiles) {
return _buildPlaceholder(cs, l10n.chatInfoEmptyFiles, Icons.description);
return _sharedTab(
cs,
SharedContentKind.files,
l10n.chatInfoEmptyFiles,
Icons.description,
);
}
if (_selectedTab == l10n.chatInfoTabVoice) {
return _buildPlaceholder(cs, l10n.chatInfoEmptyVoice, Icons.mic);
return _sharedTab(
cs,
SharedContentKind.voice,
l10n.chatInfoEmptyVoice,
Icons.mic,
);
}
if (_selectedTab == l10n.chatInfoTabLinks) {
return _buildPlaceholder(cs, l10n.chatInfoEmptyLinks, Icons.link);
return _sharedTab(
cs,
SharedContentKind.links,
l10n.chatInfoEmptyLinks,
Icons.link,
);
}
return const SizedBox.shrink();
}
Widget _sharedTab(
ColorScheme cs,
SharedContentKind kind,
String emptyLabel,
IconData emptyIcon,
) {
final anchor = _anchorMsgId;
if (anchor == null) return _buildPlaceholder(cs, emptyLabel, emptyIcon);
return SharedMediaTab(
key: ValueKey('tab-shared-$kind'),
chatId: _mediaChatId,
anchorMessageId: anchor,
myId: _myId,
kind: kind,
emptyLabel: emptyLabel,
emptyIcon: emptyIcon,
onGoToMessage: _goToMessage,
scrollController: _bodyScrollController,
);
}
void _goToMessage(String messageId, int time) {
final jumpInParent = widget.onJumpToMessage;
if (jumpInParent != null && _mediaChatId == widget.chatId) {
jumpInParent(messageId, time);
return;
}
pushSwipeable(
context,
(_) => ChatScreen(
chatId: _mediaChatId,
name: widget.name,
imageUrl: widget.imageUrl,
chatType: widget.chatType,
initialMessageId: messageId,
initialMessageTime: time,
),
);
}
Widget _buildPlaceholder(ColorScheme cs, String label, IconData icon) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,7 @@ import '../../../main.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/komet_avatar.dart';
import '../../widgets/sheet_helpers.dart';
import '../../widgets/small_spinner.dart';
import '../../widgets/swipe_route.dart';
import 'chat_screen.dart';
@@ -295,7 +296,7 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
child: _loading
? const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
child: Center(child: SmallSpinner(size: 36)),
)
: ListView.builder(
padding: EdgeInsets.zero,
@@ -1,6 +1,8 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart'
show ExpressiveRefreshIndicator;
import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/messages.dart';
@@ -15,6 +17,7 @@ import '../../widgets/confirm_dialog.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/schedule_time_picker.dart';
import '../../widgets/sheet_helpers.dart';
import '../../widgets/small_spinner.dart';
class ScheduledMessagesScreen extends StatefulWidget {
final int chatId;
@@ -258,10 +261,10 @@ class _ScheduledMessagesScreenState extends State<ScheduledMessagesScreen> {
),
),
body: _loading
? const Center(child: CircularProgressIndicator())
? const Center(child: SmallSpinner(size: 36))
: _messages.isEmpty
? _empty(cs)
: RefreshIndicator(
: ExpressiveRefreshIndicator(
onRefresh: _load,
child: ListView.separated(
padding: const EdgeInsets.all(16),
+15 -16
View File
@@ -10,8 +10,9 @@ import '../../../core/storage/app_database.dart';
import '../../../core/utils/debouncer.dart';
import '../../../core/utils/names.dart';
import '../../widgets/komet_avatar.dart';
import '../../widgets/small_spinner.dart';
import '../../widgets/swipe_route.dart';
import '../contacts/contact_profile_screen.dart';
import '../contacts/open_contact_profile.dart';
import 'chat_screen.dart';
class SearchScreen extends StatefulWidget {
@@ -147,13 +148,12 @@ class _SearchScreenState extends State<SearchScreen> {
}
void _openContact(Map<String, dynamic> row) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ContactProfileScreen(
contactId: row['id'] as int,
initialName: _contactName(row),
initialAvatarUrl: row['base_url'] as String?,
),
unawaited(
openContactDialogProfile(
context,
contactId: row['id'] as int,
name: _contactName(row),
avatarUrl: row['base_url'] as String?,
),
);
}
@@ -166,13 +166,12 @@ class _SearchScreenState extends State<SearchScreen> {
}
void _openPhoneResult(PhoneLookupResult result) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ContactProfileScreen(
contactId: result.id,
initialName: result.name,
initialAvatarUrl: result.avatarUrl,
),
unawaited(
openContactDialogProfile(
context,
contactId: result.id,
name: result.name ?? 'User #${result.id}',
avatarUrl: result.avatarUrl,
),
);
}
@@ -234,7 +233,7 @@ class _SearchScreenState extends State<SearchScreen> {
}
if (!hasResults) {
if (_loading) {
return const Center(child: CircularProgressIndicator());
return const Center(child: SmallSpinner(size: 36));
}
return _buildHint(cs, Symbols.search_off, 'Ничего не найдено');
}
@@ -1,420 +0,0 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/cache/info_cache.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/storage/token_storage.dart';
import '../../../core/utils/format.dart';
import '../../../l10n/app_localizations.dart';
import '../../../models/contact_info.dart';
import '../../widgets/avatar_history_screen.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/komet_avatar.dart';
import '../../widgets/connection_status.dart';
import '../../widgets/swipe_route.dart';
import '../chats/chat_screen.dart';
class ContactProfileScreen extends StatefulWidget {
final int contactId;
final String? initialName;
final String? initialAvatarUrl;
const ContactProfileScreen({
super.key,
required this.contactId,
this.initialName,
this.initialAvatarUrl,
});
@override
State<ContactProfileScreen> createState() => _ContactProfileScreenState();
}
class _ContactProfileScreenState extends State<ContactProfileScreen> {
bool _loading = true;
ContactInfo? _contact;
int? _seenTime;
int _presenceStatus = 0;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final contactFuture = ContactInfoFetch.get(widget.contactId);
final presenceFuture = PresenceFetch.get(widget.contactId);
final contact = await contactFuture;
final presence = await presenceFuture;
if (!mounted) return;
if (contact != null) {
_contact = contact;
}
if (presence != null) {
_seenTime = presence['seen'] as int?;
_presenceStatus = (presence['status'] as int?) ?? 0;
}
} catch (e) {
if (mounted) {
showCustomNotification(
context,
AppLocalizations.of(context)!.contactProfileLoadError(e.toString()),
);
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
String _displayName() {
return _contact?.displayName ??
widget.initialName ??
'User #${widget.contactId}';
}
String? _avatarUrl() {
return _contact?.avatarUrl ?? widget.initialAvatarUrl;
}
Set<String> _options() {
return _contact?.options.toSet() ?? const {};
}
bool get _isBot => _options().contains('BOT');
bool get _isVerified => _options().contains('OFFICIAL');
String _subtitle() {
final l10n = AppLocalizations.of(context)!;
if (_isBot) return l10n.contactProfileBot;
if (_presenceStatus == 1) return l10n.contactProfileOnline;
if (_presenceStatus == 3) return l10n.contactProfileRecentlyActive;
if (_seenTime != null && _seenTime! > 0) return formatLastSeen(_seenTime!);
return '';
}
Future<void> _openChat() async {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) return;
final existing = await AppDatabase.findDialogChatByParticipant(
accountId,
widget.contactId,
);
final chatId = existing ?? (accountId ^ widget.contactId);
if (!mounted) return;
pushSwipeable(
context,
(_) => ChatScreen(
chatId: chatId,
name: _displayName(),
imageUrl: _avatarUrl() ?? '',
chatType: 'DIALOG',
),
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: cs.surface,
floatingActionButtonLocation: FloatingActionButtonLocation.startFloat,
floatingActionButton: const ConnectionSpinner(),
body: SafeArea(
child: _loading
? const Center(child: CircularProgressIndicator())
: _buildBody(cs),
),
);
}
Widget _buildBody(ColorScheme cs) {
return CustomScrollView(
slivers: [
SliverAppBar(
backgroundColor: Colors.transparent,
elevation: 0,
floating: true,
leading: IconButton(
icon: Icon(Symbols.arrow_back, color: cs.onSurface),
onPressed: () => Navigator.pop(context),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
GestureDetector(
onTap: () => AvatarHistoryScreen.open(
context,
contactId: widget.contactId,
name: _displayName(),
currentAvatarUrl: _avatarUrl(),
),
child: KometAvatar(
name: _displayName(),
imageUrl: _avatarUrl(),
size: 96,
fontSize: 36,
),
),
const SizedBox(height: 14),
_buildNameRow(cs),
const SizedBox(height: 4),
Text(
_subtitle(),
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
),
const SizedBox(height: 20),
_buildActions(cs),
const SizedBox(height: 16),
_buildInfoCard(cs),
const SizedBox(height: 40),
],
),
),
),
],
);
}
Widget _buildNameRow(ColorScheme cs) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
_displayName(),
style: TextStyle(
color: cs.onSurface,
fontSize: 22,
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (_isVerified) ...[
const SizedBox(width: 6),
Icon(Symbols.verified, color: cs.primary, size: 20, fill: 1),
],
],
);
}
Widget _buildActions(ColorScheme cs) {
final l10n = AppLocalizations.of(context)!;
final actions = <({IconData icon, String label, VoidCallback? onTap})>[
(
icon: Symbols.chat_bubble,
label: l10n.contactProfileActionChat,
onTap: _openChat,
),
(
icon: Symbols.notifications,
label: l10n.contactProfileActionSound,
onTap: null,
),
if (!_isBot)
(icon: Symbols.call, label: l10n.contactProfileActionCall, onTap: null),
];
return Row(
children: [
for (var i = 0; i < actions.length; i++) ...[
Expanded(
child: GestureDetector(
onTap: actions[i].onTap,
child: GlossyPill(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(14),
padding: const EdgeInsets.symmetric(vertical: 12),
depth: 6,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(actions[i].icon, color: cs.primary, size: 22),
const SizedBox(height: 4),
Text(
actions[i].label,
style: TextStyle(color: cs.onSurface, fontSize: 12),
),
],
),
),
),
),
if (i < actions.length - 1) const SizedBox(width: 8),
],
],
);
}
Widget _buildInfoCard(ColorScheme cs) {
final c = _contact;
if (c == null) return const SizedBox.shrink();
final l10n = AppLocalizations.of(context)!;
final rows = <Widget>[];
final phoneStr = formatPhone(c.raw['phone']);
if (phoneStr != null) {
rows.add(
_infoRow(cs, Symbols.phone, l10n.contactProfileInfoPhone, phoneStr),
);
}
final country = c.raw['country'] as String?;
if (country != null && country.isNotEmpty) {
rows.add(
_infoRow(cs, Symbols.public, l10n.contactProfileInfoCountry, country),
);
}
final genderStr = formatGender(c.raw['gender']);
if (genderStr != null) {
rows.add(
_infoRow(cs, Symbols.wc, l10n.contactProfileInfoGender, genderStr),
);
}
final regTime = c.raw['registrationTime'] as int?;
if (regTime != null && regTime > 0) {
rows.add(
_infoRow(
cs,
Symbols.event,
l10n.contactProfileInfoRegistration,
formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(regTime)),
),
);
}
final updateTime = c.raw['updateTime'] as int?;
if (updateTime != null && updateTime > 0) {
rows.add(
_infoRow(
cs,
Symbols.update,
l10n.contactProfileInfoUpdated,
formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(updateTime)),
),
);
}
final accountStatus = c.raw['accountStatus'];
if (accountStatus is int && accountStatus != 0) {
rows.add(
_infoRow(
cs,
Symbols.account_circle,
l10n.contactProfileInfoAccountStatus,
accountStatus.toString(),
),
);
}
final desc = (c.raw['description'] as String?)?.trim();
if (desc != null && desc.isNotEmpty) {
rows.add(
_infoRow(
cs,
Symbols.info,
l10n.contactProfileInfoDescription,
desc,
multiline: true,
),
);
}
final link = c.raw['link'] as String?;
if (link != null && link.isNotEmpty) {
rows.add(_infoRow(cs, Symbols.link, l10n.contactProfileInfoLink, link));
}
final webApp = c.raw['webApp'] as String?;
if (webApp != null && webApp.isNotEmpty) {
rows.add(_infoRow(cs, Symbols.web, 'Web app', webApp));
}
final opts = _options();
if (opts.isNotEmpty) {
rows.add(
_infoRow(
cs,
Symbols.label,
l10n.contactProfileInfoFlags,
opts.join(', '),
multiline: true,
),
);
}
rows.add(_infoRow(cs, Symbols.tag, 'ID', widget.contactId.toString()));
if (rows.isEmpty) return const SizedBox.shrink();
return GlossyPill(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
depth: 6,
child: SizedBox(
width: double.infinity,
child: Column(
children: [
for (var i = 0; i < rows.length; i++) ...[
if (i > 0)
Divider(
height: 1,
color: cs.outlineVariant.withValues(alpha: 0.3),
),
rows[i],
],
],
),
),
);
}
Widget _infoRow(
ColorScheme cs,
IconData icon,
String label,
String value, {
bool multiline = false,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: cs.onSurfaceVariant, size: 20),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
),
const SizedBox(height: 2),
Text(
value,
style: TextStyle(color: cs.onSurface, fontSize: 14),
maxLines: multiline ? null : 1,
overflow: multiline ? null : TextOverflow.ellipsis,
),
],
),
),
],
),
);
}
}
+90 -102
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/config/debug_test.dart';
import '../../../core/protocol/opcode_map.dart';
import '../../../core/protocol/packet.dart';
import '../../../core/storage/app_database.dart';
@@ -10,34 +11,11 @@ import '../../../models/contact_info.dart';
import '../../widgets/komet_avatar.dart';
import '../../widgets/connection_status.dart';
import '../../widgets/sheet_helpers.dart';
import '../../widgets/small_spinner.dart';
import '../../widgets/springy_tap.dart';
import '../chats/chat_info_screen.dart';
import 'nfc_exchange_sheet.dart';
Future<void> openContactDialogProfile(
BuildContext context, {
required int contactId,
required String name,
String? avatarUrl,
}) async {
final accountId = await TokenStorage.getActiveAccountId();
final existing = accountId == null
? null
: await AppDatabase.findDialogChatByParticipant(accountId, contactId);
final chatId = existing ?? ((accountId ?? 0) ^ contactId);
if (!context.mounted) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ChatInfoScreen(
chatId: chatId,
name: name,
imageUrl: avatarUrl ?? '',
chatType: 'DIALOG',
dialogPeerId: contactId,
),
),
);
}
import 'open_contact_profile.dart';
class ContactsTab extends StatefulWidget {
const ContactsTab({super.key});
@@ -102,6 +80,18 @@ class _ContactsTabState extends State<ContactsTab> {
}
Future<void> _loadContacts() async {
if (DebugTest.enabled) {
final debug = ContactsModule.debugContacts()
..sort((a, b) => a.firstName.compareTo(b.firstName));
if (mounted) {
setState(() {
_contacts = debug;
_isLoading = false;
});
}
return;
}
final p = await AppDatabase.loadActiveProfile();
if (p == null) {
if (mounted) setState(() => _isLoading = false);
@@ -127,83 +117,85 @@ class _ContactsTabState extends State<ContactsTab> {
.trim();
final nameToDisplay = fullName.isEmpty ? '+${contact.phone}' : fullName;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () => openContactDialogProfile(
context,
contactId: contact.id,
name: nameToDisplay,
avatarUrl: contact.baseUrl,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: cs.primary.withValues(alpha: 0.1),
width: 1,
return SpringyTap(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => openContactDialogProfile(
context,
contactId: contact.id,
name: nameToDisplay,
avatarUrl: contact.baseUrl,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: cs.primary.withValues(alpha: 0.1),
width: 1,
),
),
child: KometAvatar(
name: nameToDisplay,
imageUrl: contact.baseUrl,
size: 48,
),
),
child: KometAvatar(
name: nameToDisplay,
imageUrl: contact.baseUrl,
size: 48,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
nameToDisplay,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
nameToDisplay,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (contact.isVerified) ...[
const SizedBox(width: 4),
Icon(
Symbols.verified,
color: cs.primary,
size: 16,
weight: 600,
fill: 1,
),
if (contact.isVerified) ...[
const SizedBox(width: 4),
Icon(
Symbols.verified,
color: cs.primary,
size: 16,
weight: 600,
fill: 1,
),
],
],
],
),
const SizedBox(height: 4),
Text(
contact.updateTime > 0
? 'Был(а) недавно'
: '+${contact.phone}',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 4),
Text(
contact.updateTime > 0
? 'Был(а) недавно'
: '+${contact.phone}',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
],
],
),
),
),
),
@@ -255,7 +247,7 @@ class _ContactsTabState extends State<ContactsTab> {
),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
? const Center(child: SmallSpinner(size: 36))
: _contacts.isEmpty
? Center(
child: Text(
@@ -469,11 +461,7 @@ class _SearchContactSheetState extends State<_SearchContactSheet> {
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: _loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
? const SmallSpinner(size: 20)
: const Text('Найти'),
),
],
@@ -15,6 +15,7 @@ import '../../../main.dart';
import '../../../models/contact_info.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/komet_avatar.dart';
import '../../widgets/small_spinner.dart';
enum _Stage {
checking,
@@ -243,7 +244,7 @@ class _NfcExchangeSheetState extends State<NfcExchangeSheet>
case _Stage.checking:
return const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: CircularProgressIndicator(),
child: SmallSpinner(size: 36),
);
case _Stage.unsupported:
return _message(cs, Symbols.nfc, l10n.nfcUnsupported);
@@ -420,11 +421,7 @@ class _NfcExchangeSheetState extends State<NfcExchangeSheet>
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: _stage == _Stage.adding
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
? const SmallSpinner(size: 20)
: Text(
_stage == _Stage.added
? l10n.nfcAdded

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