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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
@@ -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'];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (_) {}
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,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}.');
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, '');
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user