Compare commits

...

11 Commits
2.0.0 ... main

89 changed files with 10973 additions and 1294 deletions

25
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,25 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "chepuhagram",
"request": "launch",
"type": "dart"
},
{
"name": "chepuhagram (profile mode)",
"request": "launch",
"type": "dart",
"flutterMode": "profile"
},
{
"name": "chepuhagram (release mode)",
"request": "launch",
"type": "dart",
"flutterMode": "release"
}
]
}

View File

@ -34,6 +34,8 @@ android {
buildTypes { buildTypes {
release { release {
isMinifyEnabled = true
isShrinkResources = true
// TODO: Add your own signing config for the release build. // TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works. // Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")

15
android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,15 @@
# Защита ffmpeg-kit от R8
-keep class com.arthenica.ffmpegkit.** { *; }
-keep interface com.arthenica.ffmpegkit.** { *; }
# Защита от удаления нативных методов JNI
-keepclassmembers class * {
native <methods>;
}
# Если используете конкретно ваш форк, добавим и его:
-keep class com.antonkarpenko.ffmpegkit.** { *; }
# Защита Firebase (если падает Firebase, когда включен R8)
-keep class com.google.firebase.** { *; }
-keep class com.google.android.gms.** { *; }

View File

@ -4,12 +4,16 @@
<uses-permission android:name="android.permission.USE_BIOMETRIC" /> <uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" /> <uses-permission android:name="android.permission.USE_FINGERPRINT" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<application <application
android:label="Chepuhagram" android:label="Chepuhagram"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/launcher_icon"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:enableOnBackInvokedCallback="true"> android:enableOnBackInvokedCallback="true">
<activity <activity
@ -49,6 +53,15 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileProvider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and

View File

@ -1,5 +1,45 @@
package ru.chepuhagram.app package ru.chepuhagram.app
import android.app.AlertDialog
import android.os.Bundle
import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterFragmentActivity() class MainActivity : FlutterFragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
try {
super.onCreate(savedInstanceState)
} catch (e: UnsatisfiedLinkError) {
handleFatalStartupError("Ошибка загрузки нативных библиотек: ${e.message}")
} catch (e: Exception) {
handleFatalStartupError("Произошла системная ошибка при запуске: ${e.message}")
}
}
// Дополнительная защита при создании движка, если инициализация происходит там
override fun provideFlutterEngine(context: android.content.Context): FlutterEngine? {
return try {
super.provideFlutterEngine(context)
} catch (e: UnsatisfiedLinkError) {
handleFatalStartupError("Ошибка инициализации движка (FFmpegKit): ${e.message}")
null // Возвращаем null, чтобы предотвратить дальнейший краш
}
}
private fun handleFatalStartupError(message: String) {
// Мы не можем использовать стандартные диалоги из темы Activity,
// так как они могут быть повреждены, используем чистый Android AlertDialog
runOnUiThread {
AlertDialog.Builder(this)
.setTitle("Критическая ошибка")
.setMessage("Приложение не может быть запущено на данном устройстве.\n\nТехническая информация: $message")
.setPositiveButton("Закрыть") { _, _ ->
finishAffinity() // Полностью закрывает приложение
}
.setCancelable(false)
.create()
.show()
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="internal_files" path="." />
<cache-path name="internal_cache" path="." />
<external-path name="external_files" path="." />
</paths>

View File

@ -1,7 +1,17 @@
allprojects { allprojects {
repositories { repositories {
google()
mavenCentral() mavenCentral()
google()
maven { url = uri("https://jitpack.io") }
maven { url = uri("https://storage.googleapis.com/download.flutter.io") }
}
}
buildscript {
repositories {
mavenCentral()
google()
maven { url = uri("https://plugins.gradle.org/m2/") }
} }
} }

File diff suppressed because one or more lines are too long

View File

@ -1,2 +1,8 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
kotlin.incremental=false
kotlin.incremental.useGradleBuilder=false

BIN
assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -1,3 +1,5 @@
description: This file stores settings for Dart & Flutter DevTools. description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions: extensions:
- provider: true
- shared_preferences: true

View File

@ -427,7 +427,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";
@ -484,7 +484,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";

View File

@ -1,122 +1 @@
{ {"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -6,9 +6,11 @@ class ThemeProvider extends ChangeNotifier {
ThemeMode _themeMode = ThemeMode.system; ThemeMode _themeMode = ThemeMode.system;
Color _accentColor = const Color(0xFF24A1DE); Color _accentColor = const Color(0xFF24A1DE);
String? _wallpaperPath;
ThemeMode get themeMode => _themeMode; ThemeMode get themeMode => _themeMode;
Color get accentColor => _accentColor; Color get accentColor => _accentColor;
String? get wallpaperPath => _wallpaperPath;
bool isLight = false; bool isLight = false;
@ -20,12 +22,14 @@ class ThemeProvider extends ChangeNotifier {
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
final mode = await _storage.read(key: 'theme_mode'); final mode = await _storage.read(key: 'theme_mode');
final color = await _storage.read(key: 'accent_color'); final color = await _storage.read(key: 'accent_color');
final wallpaper = await _storage.read(key: 'wallpaper_path');
if (mode != null) { if (mode != null) {
_themeMode = mode == 'dark' ? ThemeMode.dark : ThemeMode.light; _themeMode = mode == 'dark' ? ThemeMode.dark : ThemeMode.light;
isLight = mode == 'light'; isLight = mode == 'light';
} }
if (color != null) _accentColor = Color(int.parse(color)); if (color != null) _accentColor = Color(int.parse(color));
_wallpaperPath = wallpaper;
notifyListeners(); notifyListeners();
} }
@ -42,6 +46,16 @@ class ThemeProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void updateWallpaper(String? path) {
_wallpaperPath = path;
if (path != null) {
_storage.write(key: 'wallpaper_path', value: path);
} else {
_storage.delete(key: 'wallpaper_path');
}
notifyListeners();
}
ThemeData get themeData => ThemeData( ThemeData get themeData => ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: _themeMode == ThemeMode.dark brightness: _themeMode == ThemeMode.dark

View File

@ -1,4 +1,5 @@
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:chepuhagram/data/models/message_model.dart'; import 'package:chepuhagram/data/models/message_model.dart';
@ -9,34 +10,51 @@ class LocalDbService {
factory LocalDbService() => _instance; factory LocalDbService() => _instance;
LocalDbService._internal(); LocalDbService._internal();
static const int _dbVersion = 8;
Future<Database> get database async { Future<Database> get database async {
if (_database != null) return _database!; if (_database != null) return _database!;
_database = await _initDb(); _database = await _initDb();
return _database!; return _database!;
} }
Future<void> _createMessagesTable(Database db) async {
await db.execute('''
CREATE TABLE messages(
id INTEGER PRIMARY KEY,
sender_id INTEGER,
receiver_id INTEGER,
content TEXT,
timestamp TEXT,
delivered_at TEXT,
read_at TEXT,
reply_to_id INTEGER,
reply_to_text TEXT,
edited_at TEXT,
message_type TEXT DEFAULT 'text',
file_id TEXT,
encrypted_key TEXT,
file_name TEXT,
file_size INTEGER
)
''');
}
Future<Database> _initDb() async { Future<Database> _initDb() async {
String path = join(await getDatabasesPath(), 'chat_app.db'); String path = join(await getDatabasesPath(), 'chat_app.db');
return await openDatabase( return await openDatabase(
path, path,
version: 4, version: _dbVersion,
onCreate: (db, version) async { onCreate: (db, version) async {
await db.execute(''' await _createMessagesTable(db);
CREATE TABLE messages(
id INTEGER PRIMARY KEY,
sender_id INTEGER,
receiver_id INTEGER,
content TEXT,
timestamp TEXT,
delivered_at TEXT,
read_at TEXT,
reply_to_id INTEGER,
reply_to_text TEXT,
edited_at TEXT
)
''');
}, },
onUpgrade: (db, oldVersion, newVersion) async { onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 8) {
// v8: stop storing media bytes in SQLite; rebuild messages table.
await db.execute('DROP TABLE IF EXISTS messages');
await _createMessagesTable(db);
return;
}
if (oldVersion < 2) { if (oldVersion < 2) {
await db.execute('ALTER TABLE messages ADD COLUMN delivered_at TEXT'); await db.execute('ALTER TABLE messages ADD COLUMN delivered_at TEXT');
await db.execute('ALTER TABLE messages ADD COLUMN read_at TEXT'); await db.execute('ALTER TABLE messages ADD COLUMN read_at TEXT');
@ -52,27 +70,68 @@ class LocalDbService {
if (oldVersion < 4) { if (oldVersion < 4) {
await db.execute('ALTER TABLE messages ADD COLUMN edited_at TEXT'); await db.execute('ALTER TABLE messages ADD COLUMN edited_at TEXT');
} }
if (oldVersion < 5) {
try {
await db.execute(
'ALTER TABLE messages ADD COLUMN message_type TEXT',
);
} catch (e) {
print('message_type column already exists: $e');
}
try {
await db.execute('ALTER TABLE messages ADD COLUMN file_id TEXT');
} catch (e) {
print('file_id column already exists: $e');
}
}
if (oldVersion < 6) {
try {
await db.execute(
'ALTER TABLE messages ADD COLUMN encrypted_key TEXT',
);
} catch (e) {
print('encrypted_key column already exists: $e');
}
}
// old migrations kept for safety, but v8 rebuild returns early.
}, },
); );
} }
// Сохранение списка сообщений (из истории) Future<void> clearDatabase() async {
final db = await database;
await db.delete('messages');
}
Future<void> saveMessages(List<dynamic> messages) async { Future<void> saveMessages(List<dynamic> messages) async {
final db = await database; final db = await database;
final List<int> incomingIds = messages.map((msg) {
return (msg is MessageModel) ? msg.id! : (msg['id'] as int);
}).toList();
Batch batch = db.batch(); Batch batch = db.batch();
if (incomingIds.isNotEmpty) {
batch.delete('messages', where: 'id NOT IN (${incomingIds.join(',')})');
}
for (var msg in messages) { for (var msg in messages) {
if (msg is MessageModel) { if (msg is MessageModel) {
batch.insert('messages', { batch.insert('messages', {
'id': msg.id, 'id': msg.id,
'sender_id': msg.senderId, 'sender_id': msg.senderId,
'receiver_id': msg.receiverId, 'receiver_id': msg.receiverId,
'content': msg.text, // ВАЖНО: сохраняй зашифрованный текст! 'content': msg.text,
'timestamp': msg.createdAt.toIso8601String(), 'timestamp': msg.createdAt.toIso8601String(),
'delivered_at': null, 'delivered_at': null,
'read_at': null, 'read_at': null,
'reply_to_id': msg.replyToId, 'reply_to_id': msg.replyToId,
'reply_to_text': msg.replyToText, 'reply_to_text': msg.replyToText,
'edited_at': msg.editedAt?.toIso8601String(), 'edited_at': msg.editedAt?.toIso8601String(),
'message_type': msg.messageType.name,
'file_id': msg.fileId,
'encrypted_key': msg.encryptedFileKey,
'file_name': msg.fileName,
'file_size': msg.fileSize,
}, conflictAlgorithm: ConflictAlgorithm.replace); }, conflictAlgorithm: ConflictAlgorithm.replace);
} else { } else {
// Если это Map из API // Если это Map из API
@ -88,6 +147,11 @@ class LocalDbService {
'reply_to_id': msg['reply_to_id'], 'reply_to_id': msg['reply_to_id'],
'reply_to_text': msg['reply_to_text'], 'reply_to_text': msg['reply_to_text'],
'edited_at': msg['edited_at'], 'edited_at': msg['edited_at'],
'message_type': msg['message_type'] ?? 'text',
'file_id': msg['file_id'],
'encrypted_key': msg['encrypted_key'],
'file_name': msg['file_name'],
'file_size': msg['file_size'],
}, conflictAlgorithm: ConflictAlgorithm.replace); }, conflictAlgorithm: ConflictAlgorithm.replace);
} }
} }

View File

@ -5,14 +5,15 @@ import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_socket_channel/status.dart' as status; import 'package:web_socket_channel/status.dart' as status;
import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/io.dart';
import 'package:chepuhagram/core/constants.dart'; import 'package:chepuhagram/core/constants.dart';
import 'package:flutter/widgets.dart';
class SocketService { class SocketService with WidgetsBindingObserver {
static final SocketService _instance = SocketService._internal(); static final SocketService _instance = SocketService._internal();
factory SocketService() => _instance;
factory SocketService() { SocketService._internal() {
return _instance; WidgetsBinding.instance.addObserver(this);
} }
SocketService._internal();
WebSocketChannel? _channel; WebSocketChannel? _channel;
final StreamController<Map<String, dynamic>> _messageController = final StreamController<Map<String, dynamic>> _messageController =
@ -21,25 +22,52 @@ class SocketService {
// Поток, который будут слушать провайдеры // Поток, который будут слушать провайдеры
Stream<Map<String, dynamic>> get messages => _messageController.stream; Stream<Map<String, dynamic>> get messages => _messageController.stream;
bool allowConnect = true; // Флаг для контроля подключения
Timer? _connectTimer;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
allowConnect = true;
} else {
allowConnect = false;
disconnect();
}
}
Future<void> startConnect(ApiService apiService) async {
if (_connectTimer != null && _connectTimer!.isActive)
return; // Уже запущено
_connectTimer = Timer.periodic(const Duration(seconds: 15), (_) {
connect(apiService);
});
}
Future<void> connect(ApiService apiService) async { Future<void> connect(ApiService apiService) async {
final token = await apiService.getAccessToken(); final token = await apiService.getAccessToken();
if (_channel != null) return; // Уже подключены if (_channel != null) return; // Уже подключены
if (token == null || token.isEmpty) { if (token == null || token.isEmpty) {
print('❌ SocketService.connect: no access token, skipping connect'); throw Exception('Нет токена доступа. Пожалуйста, войдите в систему.');
return;
} }
if (!allowConnect) return; // Не разрешаем подключение
// В FastAPI эндпоинт обычно ожидает токен в URL или подзаголовке // print("✅ Токен получен, устанавливаем WebSocket соединение...");
final uri = Uri.parse("${AppConstants.wsUrl}/ws?token=$token");
//_channel = WebSocketChannel.connect(uri); startConnect(
apiService,
_channel = IOWebSocketChannel.connect( ); // Запускаем таймер на случай, если соединение не установится
uri,
connectTimeout: Duration(seconds: 10),
);
try { try {
// В FastAPI эндпоинт ожидает токен в URL-параметре
final uri = Uri.parse("${AppConstants.wsUrl}/ws?token=$token");
//_channel = WebSocketChannel.connect(uri);
_channel = IOWebSocketChannel.connect(
uri,
connectTimeout: Duration(seconds: 10),
);
await _channel!.ready; await _channel!.ready;
_channel!.stream.listen( _channel!.stream.listen(
(data) { (data) {
@ -52,10 +80,10 @@ class SocketService {
); );
} on TimeoutException catch (_) { } on TimeoutException catch (_) {
_channel = null; _channel = null;
throw Exception('timeout'); throw Exception('Превышено время ожидания. Пожалуйста, попробуйте позже.');
} catch (e) { } catch (e) {
_channel = null; _channel = null;
throw Exception("Ошибка подключения: $e"); throw Exception('Ошибка при подключении к WebSocket: $e');
} }
} }
@ -65,9 +93,15 @@ class SocketService {
} }
bool sendMessage(Map<String, dynamic> data, {int retryCnt = 0}) { bool sendMessage(Map<String, dynamic> data, {int retryCnt = 0}) {
const maxRetries = 5;
if (_channel == null) { if (_channel == null) {
//print("❌ ОШИБКА: Попытка отправить сообщение через NULL канал."); if (retryCnt < maxRetries) {
sendMessage(data, retryCnt: retryCnt + 1); // Schedule retry with exponential backoff
Future.delayed(
Duration(seconds: 1 << retryCnt),
() => sendMessage(data, retryCnt: retryCnt + 1),
);
}
return false; return false;
} }
try { try {
@ -92,5 +126,7 @@ class SocketService {
void disconnect() { void disconnect() {
_channel?.sink.close(status.normalClosure); _channel?.sink.close(status.normalClosure);
_channel = null; _channel = null;
_connectTimer?.cancel();
_connectTimer = null;
} }
} }

View File

@ -1,14 +1,28 @@
import '/core/constants.dart';
import 'package:chepuhagram/data/models/message_model.dart';
class Contact { class Contact {
final int id; final int id;
final String username; final String username;
final String name; String name;
final String surname; String surname;
final String? lastMessage; final String? lastMessage;
final String? avatarUrl; String? avatarFileId;
String? avatarUrl;
final DateTime? lastMessageTime; final DateTime? lastMessageTime;
final bool isOnline; final bool isOnline;
final int unreadCount; final int unreadCount;
final String? publicKey; String? publicKey;
final bool isLastMsgDecrypted;
final int? lastMessageId;
final MessageType? lastMessageType;
String? get effectiveAvatarUrl {
if (avatarFileId != null && avatarFileId!.isNotEmpty) {
return '${AppConstants.baseUrl}/media/$avatarFileId';
}
return avatarUrl;
}
Contact({ Contact({
required this.id, required this.id,
@ -16,11 +30,15 @@ class Contact {
required this.name, required this.name,
required this.surname, required this.surname,
this.lastMessage, this.lastMessage,
this.avatarFileId,
this.avatarUrl, this.avatarUrl,
this.lastMessageTime, this.lastMessageTime,
this.isOnline = false, this.isOnline = false,
this.unreadCount = 0, this.unreadCount = 0,
this.publicKey, this.publicKey,
this.isLastMsgDecrypted = false,
this.lastMessageId,
this.lastMessageType,
}); });
Contact copyWith({ Contact copyWith({
@ -29,11 +47,15 @@ class Contact {
String? name, String? name,
String? surname, String? surname,
String? lastMessage, String? lastMessage,
String? avatarFileId,
String? avatarUrl, String? avatarUrl,
DateTime? lastMessageTime, DateTime? lastMessageTime,
bool? isOnline, bool? isOnline,
int? unreadCount, int? unreadCount,
String? publicKey, String? publicKey,
bool? isLastMsgDecrypted,
int? lastMessageId,
MessageType? lastMessageType,
}) { }) {
return Contact( return Contact(
id: id ?? this.id, id: id ?? this.id,
@ -41,11 +63,15 @@ class Contact {
name: name ?? this.name, name: name ?? this.name,
surname: surname ?? this.surname, surname: surname ?? this.surname,
lastMessage: lastMessage ?? this.lastMessage, lastMessage: lastMessage ?? this.lastMessage,
avatarFileId: avatarFileId ?? this.avatarFileId,
avatarUrl: avatarUrl ?? this.avatarUrl, avatarUrl: avatarUrl ?? this.avatarUrl,
lastMessageTime: lastMessageTime ?? this.lastMessageTime, lastMessageTime: lastMessageTime ?? this.lastMessageTime,
isOnline: isOnline ?? this.isOnline, isOnline: isOnline ?? this.isOnline,
unreadCount: unreadCount ?? this.unreadCount, unreadCount: unreadCount ?? this.unreadCount,
publicKey: publicKey ?? this.publicKey, publicKey: publicKey ?? this.publicKey,
isLastMsgDecrypted: isLastMsgDecrypted ?? this.isLastMsgDecrypted,
lastMessageId: lastMessageId ?? this.lastMessageId,
lastMessageType: lastMessageType ?? this.lastMessageType,
); );
} }
@ -63,11 +89,15 @@ class Contact {
name: json['name'] ?? json['first_name'] ?? 'Unknown', name: json['name'] ?? json['first_name'] ?? 'Unknown',
surname: json['surname'] ?? json['last_name'] ?? 'Unknown', surname: json['surname'] ?? json['last_name'] ?? 'Unknown',
lastMessage: json['last_message'] ?? json['lastMessage'], lastMessage: json['last_message'] ?? json['lastMessage'],
avatarFileId: json['avatar_file_id'] ?? json['avatarFileId'],
avatarUrl: json['avatar_url'] ?? json['avatarUrl'], avatarUrl: json['avatar_url'] ?? json['avatarUrl'],
lastMessageTime: parseTime(json['last_message_time'] ?? json['lastMessageTime']), lastMessageTime: parseTime(json['last_message_time'] ?? json['lastMessageTime']),
isOnline: (json['is_online'] ?? json['isOnline']) == true, isOnline: (json['online'] ?? json['Online']) == true,
unreadCount: int.tryParse((json['unread_count'] ?? json['unreadCount'] ?? 0).toString()) ?? 0, unreadCount: int.tryParse((json['unread_count'] ?? json['unreadCount'] ?? 0).toString()) ?? 0,
publicKey: json['public_key'], publicKey: json['public_key'],
isLastMsgDecrypted: json['is_last_msg_decrypted'] ?? false,
lastMessageId: int.tryParse((json['last_message_id'] ?? json['lastMessageId'] ?? 0).toString()) ?? 0,
lastMessageType: MessageModel.parseMessageType(json['last_message_type'] ?? json['lastMessageType'] ?? 'text'),
); );
} }
} }

Binary file not shown.

View File

@ -1,6 +1,9 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'dart:io';
enum MessageStatus { sending, sent, delivered, read, failed } enum MessageStatus { encrypting, sending, sent, delivered, read, failed }
enum MessageType { text, image, video, file, videoNote, voiceNote }
class MessageModel { class MessageModel {
final int? id; // server id (null пока не подтверждено сервером) final int? id; // server id (null пока не подтверждено сервером)
@ -14,7 +17,12 @@ class MessageModel {
final int? replyToId; // ID сообщения, на которое отвечают final int? replyToId; // ID сообщения, на которое отвечают
final String? replyToText; // текст сообщения, на которое отвечают (для отображения) final String? replyToText; // текст сообщения, на которое отвечают (для отображения)
final DateTime? editedAt; final DateTime? editedAt;
final Uint8List? localFileBytes; final File? localFile;
final MessageType messageType;
final String? fileId;
final String? encryptedFileKey;
final String? fileName;
final int? fileSize;
MessageModel({ MessageModel({
this.id, this.id,
@ -28,7 +36,12 @@ class MessageModel {
this.replyToId, this.replyToId,
this.replyToText, this.replyToText,
this.editedAt, this.editedAt,
this.localFileBytes this.localFile,
this.messageType = MessageType.text,
this.fileId,
this.encryptedFileKey,
this.fileName,
this.fileSize
}); });
MessageModel copyWith({ MessageModel copyWith({
@ -43,7 +56,12 @@ class MessageModel {
int? replyToId, int? replyToId,
String? replyToText, String? replyToText,
DateTime? editedAt, DateTime? editedAt,
Uint8List? localFileBytes, File? localFile,
MessageType? messageType,
String? fileId,
String? encryptedFileKey,
String? fileName,
int? fileSize,
}) { }) {
return MessageModel( return MessageModel(
id: id ?? this.id, id: id ?? this.id,
@ -57,7 +75,12 @@ class MessageModel {
replyToId: replyToId ?? this.replyToId, replyToId: replyToId ?? this.replyToId,
replyToText: replyToText ?? this.replyToText, replyToText: replyToText ?? this.replyToText,
editedAt: editedAt ?? this.editedAt, editedAt: editedAt ?? this.editedAt,
localFileBytes: localFileBytes ?? this.localFileBytes, localFile: localFile ?? this.localFile,
messageType: messageType ?? this.messageType,
fileId: fileId ?? this.fileId,
encryptedFileKey: encryptedFileKey ?? this.encryptedFileKey,
fileName: fileName ?? this.fileName,
fileSize: fileSize ?? this.fileSize,
); );
} }
@ -65,6 +88,7 @@ class MessageModel {
final senderId = int.parse(json['sender_id'].toString()); final senderId = int.parse(json['sender_id'].toString());
final receiverId = int.parse((json['receiver_id'] ?? json['recipient_id']).toString()); final receiverId = int.parse((json['receiver_id'] ?? json['recipient_id']).toString());
final createdAtRaw = (json['created_at'] ?? json['timestamp']).toString(); final createdAtRaw = (json['created_at'] ?? json['timestamp']).toString();
final messageTypeStr = json['message_type']?.toString() ?? 'text';
return MessageModel( return MessageModel(
id: json['id'] == null ? null : int.tryParse(json['id'].toString()), id: json['id'] == null ? null : int.tryParse(json['id'].toString()),
@ -78,9 +102,52 @@ class MessageModel {
replyToId: json['reply_to_id'] == null ? null : int.tryParse(json['reply_to_id'].toString()), replyToId: json['reply_to_id'] == null ? null : int.tryParse(json['reply_to_id'].toString()),
replyToText: json['reply_to_text'] == null ? null : json['reply_to_text'].toString(), replyToText: json['reply_to_text'] == null ? null : json['reply_to_text'].toString(),
editedAt: json['edited_at'] == null ? null : DateTime.tryParse(json['edited_at'].toString()), editedAt: json['edited_at'] == null ? null : DateTime.tryParse(json['edited_at'].toString()),
messageType: MessageModel.parseMessageType(messageTypeStr),
fileId: json['file_id']?.toString(),
encryptedFileKey: json['encrypted_key']?.toString(),
fileName: json['file_name']?.toString(),
fileSize: json['file_size'] == null ? null : int.tryParse(json['file_size'].toString()),
); );
} }
static MessageType parseMessageType(String typeStr) {
switch (typeStr.toLowerCase()) {
case 'image':
return MessageType.image;
case 'video':
return MessageType.video;
case 'file':
return MessageType.file;
case 'video_note':
case 'videonote':
return MessageType.videoNote;
case 'voice_note':
case 'voicenote':
return MessageType.voiceNote;
case 'text':
default:
return MessageType.text;
}
}
static String getMediaPreview(MessageType type) {
switch (type) {
case MessageType.videoNote:
return '[Кружок]';
case MessageType.voiceNote:
return '[Голосовое]';
case MessageType.image:
return '[Фото]';
case MessageType.video:
return '[Видео]';
case MessageType.file:
return '[Файл]';
case MessageType.text:
return '';
}
}
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'id': id, 'id': id,
@ -93,6 +160,11 @@ class MessageModel {
'reply_to_id': replyToId, 'reply_to_id': replyToId,
'reply_to_text': replyToText, 'reply_to_text': replyToText,
'edited_at': editedAt?.toIso8601String(), 'edited_at': editedAt?.toIso8601String(),
'message_type': messageType.name,
'file_id': fileId,
'encrypted_key': encryptedFileKey,
'file_name': fileName,
'file_size': fileSize,
}; };
} }
} }

View File

@ -3,63 +3,155 @@ import 'package:http/http.dart' as http;
import '/core/constants.dart'; import '/core/constants.dart';
import '/data/models/contact_model.dart'; import '/data/models/contact_model.dart';
import '/domain/services/api_service.dart'; import '/domain/services/api_service.dart';
import 'package:flutter_http_cache/flutter_http_cache.dart';
class ContactRepository { class ContactRepository {
final http.Client _client = http.Client(); late final CachedHttpClient _client;
bool _isCacheInitialized = false;
final ApiService _apiService = ApiService(); final ApiService _apiService = ApiService();
Future<List<Contact>> fetchChatContacts() async { ContactRepository() {
final token = await _apiService.getAccessToken(); _initCachedClient();
if (token == null) { }
throw Exception('No access token');
}
final response = await _client.get( // Единая инициализация кэша для всех запросов репозитория
Uri.parse('${AppConstants.baseUrl}/users/chats'), void _initCachedClient() {
headers: { final cache = _apiService.cache;
'Authorization': 'Bearer $token', _client = CachedHttpClient(
'Content-Type': 'application/json', cache: cache,
}, defaultCachePolicy: CachePolicy.networkFirst,
); );
}
if (response.statusCode == 200) { Future<void> _ensureCacheReady() async {
final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes)); if (!_isCacheInitialized) {
return data.map((json) => Contact.fromJson(json)).toList(); await _apiService.cache.initialize();
} else { _isCacheInitialized = true;
throw Exception('Failed to load contacts');
} }
} }
Future<List<Contact>> fetchAllUsers() async { Future<List<Contact>> fetchChatContacts({bool forceRefresh = false}) async {
final token = await _apiService.getAccessToken(); final token = await _apiService.getAccessToken();
DateTime now = DateTime.now();
Duration offset = now.timeZoneOffset;
final Map<String, String> requestHeaders = {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
};
if (forceRefresh) {
requestHeaders['Cache-Control'] = 'no-cache';
}
await _ensureCacheReady();
try {
final response = await _client.get(
Uri.parse('${AppConstants.baseUrl}/users/chats'),
headers: requestHeaders,
);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
print(data);
return data.map((json) {
final contact = Contact.fromJson(json);
print(contact.lastMessageType);
if (contact.lastMessageTime != null) {
return contact.copyWith(
lastMessageTime: contact.lastMessageTime!.add(offset),
);
}
return contact;
}).toList();
} else {
throw Exception('Failed to load contacts');
}
} catch (e) {
print(
'⚠️ Ошибка сети при загрузке контактов: $e. Пробуем строгий кэш...',
);
// FALLBACK: Если сеть упала, принудительно создаем запрос с политикой cacheOnly
final offlineClient = CachedHttpClient(
cache: _apiService.cache,
defaultCachePolicy: CachePolicy.cacheOnly, // Читаем строго из кэша
);
try {
final response = await offlineClient.get(
Uri.parse('${AppConstants.baseUrl}/users/chats'),
headers: requestHeaders,
);
final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
return data.map((json) => Contact.fromJson(json)).toList();
} catch (cacheError) {
throw Exception('Нет доступа к сети. Проверте подключение к интернету.');
}
}
}
Future<List<Contact>> fetchAllUsers({bool forceRefresh = false}) async {
final token = await _apiService.getAccessToken();
DateTime now = DateTime.now();
Duration offset = now.timeZoneOffset;
if (token == null) { if (token == null) {
throw Exception('No access token'); throw Exception('No access token');
} }
final Map<String, String> requestHeaders = {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
};
if (forceRefresh) {
requestHeaders['Cache-Control'] = 'no-cache';
}
await _ensureCacheReady();
final response = await _client.get( final response = await _client.get(
Uri.parse('${AppConstants.baseUrl}/users/all'), Uri.parse('${AppConstants.baseUrl}/users/all'),
headers: { headers: requestHeaders,
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes)); final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
return data.map((json) => Contact.fromJson(json)).toList(); return data.map((json) {
final contact = Contact.fromJson(json);
if (contact.lastMessageTime != null) {
return contact.copyWith(
lastMessageTime: contact.lastMessageTime!.add(offset),
);
}
return contact;
}).toList();
} else { } else {
throw Exception('Failed to load contacts'); throw Exception('Failed to load contacts');
} }
} }
Future<Contact> fetchContactById(int userId) async { Future<Contact> fetchContactById(
int userId, {
bool forceRefresh = false,
}) async {
final token = await _apiService.getAccessToken(); final token = await _apiService.getAccessToken();
final Map<String, String> requestHeaders = {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
};
if (forceRefresh) {
requestHeaders['Cache-Control'] = 'no-cache';
}
await _ensureCacheReady();
final response = await _client.get( final response = await _client.get(
Uri.parse('${AppConstants.baseUrl}/users/$userId'), Uri.parse('${AppConstants.baseUrl}/users/$userId'),
headers: { headers: requestHeaders,
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
@ -69,4 +161,39 @@ class ContactRepository {
throw Exception('Не удалось загрузить данные контакта'); throw Exception('Не удалось загрузить данные контакта');
} }
} }
Future<List<Map<String, dynamic>>> getLastMessagesForContact(
int contactId, {
int limit = 2,
bool forceRefresh = false,
}) async {
final token = await _apiService.getAccessToken();
if (token == null) {
throw Exception('No access token');
}
final Map<String, String> requestHeaders = {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
};
if (forceRefresh) {
requestHeaders['Cache-Control'] = 'no-cache';
}
await _ensureCacheReady();
final response = await _client.get(
Uri.parse(
'${AppConstants.baseUrl}/messages/last?contact_id=$contactId&limit=$limit',
),
headers: requestHeaders,
);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
return data.map((item) => item as Map<String, dynamic>).toList();
} else {
throw Exception('Failed to load last messages');
}
}
} }

View File

@ -1,62 +1,265 @@
import 'dart:math';
import 'package:chepuhagram/data/models/contact_model.dart';
import 'package:jwt_decoder/jwt_decoder.dart'; import 'package:jwt_decoder/jwt_decoder.dart';
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:chepuhagram/core/constants.dart'; import 'package:chepuhagram/core/constants.dart';
import 'package:flutter_http_cache/flutter_http_cache.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'dart:convert'; import 'package:path_provider/path_provider.dart';
import 'dart:io';
class ApiService extends ChangeNotifier { class ApiService extends ChangeNotifier {
final _client = http.Client(); final _client = http.Client();
final _storage = const FlutterSecureStorage(); final _storage = const FlutterSecureStorage();
bool _isRefreshing = false;
bool _isCacheInitialized = false;
Future<String?> uploadMedia(List<int> bytes) async { final cache = HttpCache(
config: const CacheConfig(
maxMemorySize: 100 * 1024 * 1024, // 100MB
maxDiskSize: 500 * 1024 * 1024, // 500MB
),
);
Future<void> _ensureCacheReady() async {
if (!_isCacheInitialized) {
await cache.initialize();
_isCacheInitialized = true;
}
}
/// Получает данные пользователя (включая его публичный ключ E2EE) по username
Future<Contact?> getUserByUsername(String username) async {
try { try {
final token = getAccessToken(); // Подставляй свой эндпоинт, например: /users/by-username/
var request = http.MultipartRequest( final response = await Dio().get('/users/by-username/$username');
'POST',
Uri.parse('${AppConstants.baseUrl}/media/upload'),
);
request.headers.addAll({
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
});
// Добавляем файл в запрос
request.files.add(
http.MultipartFile.fromBytes(
'file',
bytes,
filename: 'media.enc', // Имя файла для сервера
),
);
// Добавь заголовки авторизации, если они у тебя есть (JWT и т.д.) if (response.statusCode == 200 && response.data != null) {
// request.headers.addAll({'Authorization': 'Bearer $token'}); // Парсим полученные данные в модель контакта.
// Убедись, что метод Contact.fromJson или Contact.fromMap корректно обрабатывает поле public_key
var streamedResponse = await request.send(); return Contact.fromJson(response.data);
var response = await http.Response.fromStream(streamedResponse);
if (response.statusCode == 200) {
// Предполагаем, что сервер возвращает JSON {"file_id": "..."}
final data = jsonDecode(response.body);
return data['file_id'];
} }
return null; return null;
} catch (e) { } catch (e) {
print("Ошибка API при загрузке: $e"); print("[ApiService] Ошибка при получении пользователя по username: $e");
return null;
}
}
Future<String?> copyMediaOnServer(String fileId, int receiverId) async {
try {
final token = await getAccessToken();
final response = await Dio().post(
'${AppConstants.baseUrl}/media/copy',
data: FormData.fromMap({'file_id': fileId, 'receiver_id': receiverId}),
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
if (response.statusCode == 200) {
return response.data['new_file_id'];
}
} catch (e) {
print("Ошибка копирования на сервере: $e");
}
return null;
}
Future<String?> uploadFile(
List<int> bytes, {
String purpose = 'media',
Function(double)? onProgress,
}) async {
final token = await getAccessToken();
final dio = Dio();
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(bytes, filename: 'media.enc'),
'purpose': purpose,
});
final response = await dio.post(
'${AppConstants.baseUrl}/media/v2/upload',
data: formData,
onSendProgress: (sent, total) {
onProgress?.call(sent / total);
},
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
return response.data['file_id'];
}
Future<(int?, String?)> getRemoteFileSizeAndName(String fileId) async {
try {
final token = await getAccessToken();
final url =
'${AppConstants.baseUrl}/media/size/$fileId'; // Скорректируй путь согласно роутеру
final response = await Dio().get(
url,
options: Options(
headers: {
'Authorization': 'Bearer $token', // Твой токен, если требуется
},
),
);
if (response.statusCode == 200 && response.data != null) {
// Извлекаем размер из JSON: {"file_id": "...", "size": 123456}
final intSize = response.data['size'] as int?;
String? fileName = response.data['file_name'] as String?;
if (fileName != null) {
fileName = Uri.decodeComponent(fileName);
print("Имя файла, полученное от сервера: $fileName");
}
debugPrint(
'Успешно получен размер файла через API-size: $intSize байт',
);
return (intSize, fileName);
}
} catch (e) {
debugPrint('Ошибка при получении размера файла через API-size: $e');
}
return (null, null);
}
Future<(http.ByteStream, String)> downloadFileAsStream(String fileId) async {
final token = await getAccessToken(); // Получаем JWT токен авторизации
final url = Uri.parse(
'${AppConstants.baseUrl}/media/$fileId',
); // Замените на ваш эндпоинт скачивания
final request = http.Request('GET', url);
request.headers.addAll({'Authorization': 'Bearer $token'});
// Отправляем запрос
final http.StreamedResponse response = await _client.send(request);
final contentDisposition = response.headers['content-disposition'];
String serverFileName = 'media.enc';
if (contentDisposition != null) {
final match = RegExp(
r"filename\*=UTF-8''(.+)",
).firstMatch(contentDisposition);
if (match != null) {
serverFileName = Uri.decodeComponent(match.group(1)!);
print("Имя файла, полученное от сервера: $serverFileName");
}
}
if (response.statusCode == 200) {
return (response.stream, serverFileName);
} else {
throw Exception(
'Ошибка скачивания файла: сервер вернул статус ${response.statusCode}',
);
}
}
Future<File?> downloadFile(String fileId, String filePath) async {
final token = await getAccessToken();
try {
final response = await Dio().download(
'${AppConstants.baseUrl}/media/$fileId',
filePath,
options: Options(
headers: {'Authorization': 'Bearer $token'},
validateStatus: (status) => status == 200,
),
);
if (response.statusCode == 200) {
return File(filePath);
}
} catch (e) {
debugPrint('Ошибка при скачивании: $e');
final file = File(filePath);
if (await file.exists()) await file.delete();
}
return null;
}
Future<String?> uploadFileStream(
Stream<List<int>> stream,
int sourceLength, {
String purpose = 'media',
void Function(int processed, int total)? onProgress,
String? fileName,
}) async {
try {
final token = await getAccessToken();
final dio = Dio();
print(
'[DEBUG] uploadFileStream: работаем через поточное чтение. Размер=$sourceLength bytes, purpose=$purpose',
);
// БЕЗОПАСНО ДЛЯ RAM: Передаем стрим напрямую в Dio. Память не забивается!
final formData = FormData.fromMap({
'file': MultipartFile.fromStream(
() => stream,
sourceLength, // Передаем точную длину стрима, это важно для прогресса!
filename: fileName,
),
'purpose': purpose,
});
final response = await dio.post(
'${AppConstants.baseUrl}/media/v2/upload',
data: formData,
onSendProgress: (sent, total) {
// Твой print(sent) теперь будет вызываться динамически по мере ухода байт в сеть!
if (total > 0) {
onProgress?.call(sent, total);
}
},
options: Options(
headers: {'Authorization': 'Bearer $token'},
validateStatus: (status) => status != null && status < 500,
),
);
print('[DEBUG] uploadFileStream response status=${response.statusCode}');
print('[DEBUG] uploadFileStream response data=${response.data}');
if (response.statusCode == 200 || response.statusCode == 201) {
final fileId = response.data['file_id']?.toString();
print('[DEBUG] uploadFileStream: успешно загружен, file_id=$fileId');
return fileId;
} else {
print(
'[ERROR] uploadFileStream: ошибка ${response.statusCode} - ${response.data}',
);
return null;
}
} catch (e) {
print('[ERROR] uploadFileStream exception: $e');
return null; return null;
} }
} }
Future<bool> refreshToken() async { Future<bool> refreshToken() async {
if (_isRefreshing) {
// Already refreshing, wait for completion or return true assuming it will succeed
return true;
}
_isRefreshing = true;
notifyListeners(); notifyListeners();
try { try {
final refreshToken = await _storage.read(key: 'refresh_token'); final refreshToken = await _storage.read(key: 'refresh_token');
final response = await _client.post( final response = await _client
Uri.parse('${AppConstants.baseUrl}/auth/refresh'), .post(
body: jsonEncode({'refresh_token': refreshToken}), Uri.parse('${AppConstants.baseUrl}/auth/refresh'),
headers: {'Content-Type': 'application/json'}, body: jsonEncode({'refresh_token': refreshToken}),
); headers: {'Content-Type': 'application/json'},
)
.timeout(Duration(seconds: 30));
final decodedResponse = final decodedResponse =
jsonDecode(utf8.decode(response.bodyBytes)) as Map; jsonDecode(utf8.decode(response.bodyBytes)) as Map;
@ -78,12 +281,21 @@ class ApiService extends ChangeNotifier {
} }
} catch (e) { } catch (e) {
notifyListeners(); notifyListeners();
rethrow; return false;
} finally {
_isRefreshing = false;
} }
} }
Future<String?> getAccessToken() async { Future<String?> getAccessToken() async {
String? token = await _storage.read(key: 'access_token'); String? token;
try {
token = await _storage.read(key: 'access_token');
} catch (_) {
throw Exception(
'Критическая ошибка инициализации внутренних библиотек.\n Приложение не может продолжить работу. \n Обратитесь к разработчику. \n Код ошибки: _apis_gat_1',
);
}
if (token != null) { if (token != null) {
bool isExpiredSoon = bool isExpiredSoon =
@ -104,25 +316,31 @@ class ApiService extends ChangeNotifier {
Future<bool> updateFcmToken(String fcmtoken) async { Future<bool> updateFcmToken(String fcmtoken) async {
notifyListeners(); notifyListeners();
try { try {
final token = await getAccessToken(); final token = await getAccessToken();
final response = await _client.post( if (token == null) return false; // Нет токена прерываем выполнение
Uri.parse('${AppConstants.baseUrl}/auth/update-fcm?token=$fcmtoken'),
headers: { final response = await _client
'Content-Type': 'application/json', .post(
'Authorization': 'Bearer $token', Uri.parse(
}, '${AppConstants.baseUrl}/auth/update-fcm?token=$fcmtoken',
); ),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
)
.timeout(const Duration(seconds: 10)); // Ограничиваем время ожидания
if (response.statusCode == 200) { if (response.statusCode == 200) {
return true; return true;
} else { } else {
print("Ошибка установки ключа: ${response.statusCode}"); print("Ошибка установки FCM ключа: ${response.statusCode}");
return false; return false;
} }
} catch (e) { } catch (e) {
rethrow; print(" Не удалось обновить FCM токен (нет сети): $e");
return false; // Возвращаем false вместо падения приложения
} finally { } finally {
notifyListeners(); notifyListeners();
} }
@ -157,7 +375,12 @@ class ApiService extends ChangeNotifier {
Future<Map<String, dynamic>> getMe() async { Future<Map<String, dynamic>> getMe() async {
final token = await getAccessToken(); final token = await getAccessToken();
final response = await _client.get( await cache.initialize();
final client = CachedHttpClient(
cache: cache,
defaultCachePolicy: CachePolicy.networkFirst,
);
final response = await client.get(
Uri.parse('${AppConstants.baseUrl}/users/me'), Uri.parse('${AppConstants.baseUrl}/users/me'),
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -206,9 +429,27 @@ class ApiService extends ChangeNotifier {
return response.statusCode == 200; return response.statusCode == 200;
} }
Future<List<dynamic>> getChatHistory(int contactId) async { Future<List<dynamic>> getChatHistory(
int contactId, {
bool forceRefresh = false,
}) async {
final token = await getAccessToken(); final token = await getAccessToken();
final response = await _client.get( await _ensureCacheReady();
final client = CachedHttpClient(
cache: cache,
defaultCachePolicy: CachePolicy.networkFirst,
);
final Map<String, String> requestHeaders = {
'Authorization': 'Bearer $token',
};
if (forceRefresh) {
requestHeaders['Cache-Control'] = 'no-cache';
}
final response = await client.get(
Uri.parse( Uri.parse(
'${AppConstants.baseUrl}/messages/history/${contactId.toString()}', '${AppConstants.baseUrl}/messages/history/${contactId.toString()}',
), ),
@ -217,7 +458,59 @@ class ApiService extends ChangeNotifier {
"Authorization": "Bearer $token", "Authorization": "Bearer $token",
}, },
); );
return jsonDecode(response.body) as List<dynamic>; return jsonDecode(utf8.decode(response.bodyBytes)) as List<dynamic>;
}
Future<Uint8List?> downloadMedia(
String fileId, {
void Function(int received, int total)? onProgress,
}) async {
try {
final token = await getAccessToken();
await _ensureCacheReady();
final client = CachedHttpClient(
cache: cache,
defaultCachePolicy: CachePolicy.networkFirst,
);
final uri = Uri.parse('${AppConstants.baseUrl}/media/$fileId');
if (onProgress == null) {
final response = await client.get(
uri,
headers: {'Authorization': 'Bearer $token'},
);
if (response.statusCode == 200) {
return response.bodyBytes;
}
print('?????? ???????? ?????: ${response.statusCode}');
return null;
}
final req = http.Request('GET', uri);
req.headers['Authorization'] = 'Bearer $token';
final streamed = await client.send(req);
final total =
int.tryParse(streamed.headers['content-length'] ?? '') ?? -1;
int received = 0;
final bytes = <int>[];
await for (final chunk in streamed.stream) {
bytes.addAll(chunk);
received += chunk.length;
onProgress(received, total);
}
if (streamed.statusCode == 200) {
return Uint8List.fromList(bytes);
}
print('?????? ???????? ?????: ${streamed.statusCode}');
return null;
} catch (e) {
print('?????? downloadMedia: $e');
return null;
}
} }
Future<Map<String, dynamic>> updateMe({ Future<Map<String, dynamic>> updateMe({
@ -258,7 +551,12 @@ class ApiService extends ChangeNotifier {
Future<Map<String, dynamic>> getUserById(int userId) async { Future<Map<String, dynamic>> getUserById(int userId) async {
final token = await getAccessToken(); final token = await getAccessToken();
final response = await _client.get( await _ensureCacheReady();
final client = CachedHttpClient(
cache: cache,
defaultCachePolicy: CachePolicy.networkFirst,
);
final response = await client.get(
Uri.parse('${AppConstants.baseUrl}/users/$userId'), Uri.parse('${AppConstants.baseUrl}/users/$userId'),
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -279,6 +577,7 @@ class ApiService extends ChangeNotifier {
bool? showAvatar, bool? showAvatar,
bool? showAbout, bool? showAbout,
bool? showUsername, bool? showUsername,
bool? showLastOnline,
}) async { }) async {
final token = await getAccessToken(); final token = await getAccessToken();
final response = await _client.put( final response = await _client.put(
@ -293,6 +592,7 @@ class ApiService extends ChangeNotifier {
if (showAvatar != null) 'show_avatar': showAvatar, if (showAvatar != null) 'show_avatar': showAvatar,
if (showAbout != null) 'show_about': showAbout, if (showAbout != null) 'show_about': showAbout,
if (showUsername != null) 'show_username': showUsername, if (showUsername != null) 'show_username': showUsername,
if (showLastOnline != null) 'show_last_online': showLastOnline,
}), }),
); );
@ -301,7 +601,13 @@ class ApiService extends ChangeNotifier {
Future<Map<String, dynamic>> getPrivacySettings() async { Future<Map<String, dynamic>> getPrivacySettings() async {
final token = await getAccessToken(); final token = await getAccessToken();
final response = await _client.get(
await _ensureCacheReady();
final client = CachedHttpClient(
cache: cache,
defaultCachePolicy: CachePolicy.networkFirst,
);
final response = await client.get(
Uri.parse('${AppConstants.baseUrl}/users/me/privacy'), Uri.parse('${AppConstants.baseUrl}/users/me/privacy'),
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -315,4 +621,74 @@ class ApiService extends ChangeNotifier {
} }
throw Exception('Не удалось получить настройки конфиденциальности'); throw Exception('Не удалось получить настройки конфиденциальности');
} }
Future<bool> updateAvatar(String fileId) async {
final token = await getAccessToken();
final response = await _client.put(
Uri.parse('${AppConstants.baseUrl}/users/me/avatar'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode({'avatar_file_id': fileId}),
);
return response.statusCode == 200;
}
Future<Map<String, dynamic>> enableTotp() async {
final token = await getAccessToken();
final response = await _client.post(
Uri.parse('${AppConstants.baseUrl}/auth/totp/enable'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
);
if (response.statusCode == 200) {
return jsonDecode(utf8.decode(response.bodyBytes))
as Map<String, dynamic>;
}
throw Exception(
(jsonDecode(response.body) as Map<String, dynamic>)['detail'] ??
'Failed to enable TOTP',
);
}
Future<bool> verifyTotp(String code) async {
final token = await getAccessToken();
final response = await _client.post(
Uri.parse('${AppConstants.baseUrl}/auth/totp/verify'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode({'code': code}),
);
return response.statusCode == 200;
}
Future<bool> disableTotp() async {
final token = await getAccessToken();
final response = await _client.post(
Uri.parse('${AppConstants.baseUrl}/auth/totp/disable'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
);
return response.statusCode == 200;
}
Future<bool> deleteAllMessages() async {
final token = await getAccessToken();
final response = await _client.delete(
Uri.parse('${AppConstants.baseUrl}/messages/all'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
);
return response.statusCode == 200;
}
} }

View File

@ -1,7 +1,12 @@
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart'; import 'package:cryptography/cryptography.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:chepuhagram/data/models/contact_model.dart';
import 'dart:async';
import 'package:pointycastle/export.dart' as pc;
import 'dart:io';
class CryptoService { class CryptoService {
final _storage = const FlutterSecureStorage(); final _storage = const FlutterSecureStorage();
@ -123,32 +128,377 @@ class CryptoService {
return base64Encode(nonce + encrypted.mac.bytes + encrypted.cipherText); return base64Encode(nonce + encrypted.mac.bytes + encrypted.cipherText);
} }
Future<(List<int>, String)?> encryptImage( static Future<String> decryptInIsolate(
List<int> fileBytes, String base64Data,
SecretKey sharedKey,
) async {
final data = base64Decode(base64Data);
final aesGcm = AesGcm.with256bits();
final nonce = data.sublist(0, 12);
final mac = data.sublist(12, 28);
final cipherText = data.sublist(28);
final decrypted = await aesGcm.decrypt(
SecretBox(cipherText, nonce: nonce, mac: Mac(mac)),
secretKey: sharedKey,
);
return utf8.decode(decrypted);
}
static Future<List<Contact>> bulkDecryptContacts(
Map<String, dynamic> data,
) async {
final List<Contact> contacts = data['contacts'];
final String privKey = data['privKey'];
final Map<int, SecretKey> cache = data['cache'];
final x25519 = X25519();
final aesGcm = AesGcm.with256bits();
final List<Contact> result = [];
// Вычисляем свою пару один раз
final myKeyPair = await x25519.newKeyPairFromSeed(base64Decode(privKey));
for (var contact in contacts) {
if (contact.lastMessage == null || contact.publicKey == null) {
result.add(contact);
continue;
}
try {
SecretKey sharedKey;
if (cache.containsKey(contact.id)) {
sharedKey = cache[contact.id]!;
} else {
final theirPubKey = SimplePublicKey(
base64Decode(contact.publicKey!),
type: KeyPairType.x25519,
);
sharedKey = await x25519.sharedSecretKey(
keyPair: myKeyPair,
remotePublicKey: theirPubKey,
);
}
// Дешифровка AES-GCM
final msgData = base64Decode(contact.lastMessage!);
final decrypted = await aesGcm.decrypt(
SecretBox(
msgData.sublist(28),
nonce: msgData.sublist(0, 12),
mac: Mac(msgData.sublist(12, 28)),
),
secretKey: sharedKey,
);
result.add(
contact.copyWith(
lastMessage: utf8.decode(decrypted),
isLastMsgDecrypted: true,
avatarFileId: contact.avatarFileId,
avatarUrl: contact.avatarUrl,
),
);
} catch (e) {
result.add(
contact.copyWith(
lastMessage: '[не удалось расшифровать: $e]',
isLastMsgDecrypted: true,
avatarFileId: contact.avatarFileId,
avatarUrl: contact.avatarUrl,
),
);
}
}
return result;
}
static Future<Map<int, List<int>>> computeSharedKeysTask(
Map<String, dynamic> params,
) async {
final Map<int, String> isolateKeysMap = params['keysMap'];
final String isolatePrivKey = params['privKey'];
final x25519 = X25519();
final Map<int, List<int>> result = {};
final myKeyPair = await x25519.newKeyPairFromSeed(
base64Decode(isolatePrivKey),
);
for (var entry in isolateKeysMap.entries) {
try {
final theirPubKey = SimplePublicKey(
base64Decode(entry.value),
type: KeyPairType.x25519,
);
final sharedKey = await x25519.sharedSecretKey(
keyPair: myKeyPair,
remotePublicKey: theirPubKey,
);
result[entry.key] = await sharedKey.extractBytes();
} catch (_) {
continue;
}
}
return result;
}
Future<(Stream<List<int>>, String)> encryptFileStream(
Stream<List<int>> fileStream,
SecretKey sharedKey, {
void Function(int processed, int total)? onProgress,
int? totalSize,
}) async {
// 1. Генерируем уникальный ключ для конкретного файла
final SecretKey fileKey = await aesGcm.newSecretKey();
final List<int> fileKeyBytes = await fileKey.extractBytes();
// 2. Шифруем этот ключ файла на общем ключе чата (sharedKey)
final keyNonce = aesGcm.newNonce();
final encryptedKeyBox = await aesGcm.encrypt(
fileKeyBytes,
secretKey: sharedKey,
nonce: keyNonce,
);
// Кодируем зашифрованный ключ в Base64 для сервера
final String encryptedKeyForServer = base64Encode(
encryptedKeyBox.concatenation(),
);
int processedBytes = 0;
final int total = totalSize ?? 0;
// 3. Создаем асинхронный генератор для поблочного шифрования самого файла
Stream<List<int>> processEncryption() async* {
final List<int> buffer = [];
const int chunkSize = 64 * 1024; // 64 KB
await for (final chunk in fileStream) {
buffer.addAll(chunk);
while (buffer.length >= chunkSize) {
final plainBlock = Uint8List.fromList(buffer.sublist(0, chunkSize));
buffer.removeRange(0, chunkSize);
final blockNonce = aesGcm.newNonce();
final secretBox = await aesGcm.encrypt(
plainBlock,
secretKey: fileKey,
nonce: blockNonce,
);
final payload = secretBox.concatenation();
final header = ByteData(4)..setUint32(0, payload.length);
yield header.buffer.asUint8List();
yield payload;
processedBytes += chunkSize;
if (onProgress != null) {
onProgress(processedBytes, total);
}
}
}
// Дозаписываем остаток файла (хвост), если он есть
if (buffer.isNotEmpty) {
final plainBlock = Uint8List.fromList(buffer);
final blockNonce = aesGcm.newNonce();
final secretBox = await aesGcm.encrypt(
plainBlock,
secretKey: fileKey,
nonce: blockNonce,
);
final payload = secretBox.concatenation();
final header = ByteData(4)..setUint32(0, payload.length);
yield header.buffer.asUint8List();
yield payload;
}
}
// Возвращаем кортеж (Record): очищенный зашифрованный поток данных и ключ для сервера
return (processEncryption(), encryptedKeyForServer);
}
Stream<List<int>> decryptFileStream(
Stream<List<int>> encryptedStream,
SecretKey sharedKey,
String encryptedFileKey, {
int? totalBytes,
void Function(int processed, int total)? onProgress,
}) async* {
try {
// 1. Дешифруем ключ файла с помощью общего ключа чата
final encryptedKeyBytes = base64Decode(encryptedFileKey);
final keySecretBox = SecretBox.fromConcatenation(
encryptedKeyBytes,
nonceLength: 12,
macLength: 16,
);
final fileKeyBytes = await aesGcm.decrypt(
keySecretBox,
secretKey: sharedKey,
);
final fileKey = SecretKey(fileKeyBytes);
final List<int> buffer = [];
int blocksDecrypted = 0;
int totalProcessedBytes = 0;
// 2. Потоковая дешифровка блоков файла
await for (final chunk in encryptedStream) {
buffer.addAll(chunk);
while (true) {
if (buffer.length < 4) break;
final headerBytes = Uint8List.fromList(buffer.sublist(0, 4));
final int payloadLength = ByteData.sublistView(
headerBytes,
).getUint32(0);
// Проверяем: если длина чанка подозрительно огромная (из-за неверного формата файла)
if (payloadLength > 500 * 1024 || payloadLength <= 0) {
print(
"ОШИБКА: Неверный заголовок длины чанка: $payloadLength байт. Возможно, файл зашифрован старым методом!",
);
throw Exception("Неверный формат зашифрованного блока");
}
if (buffer.length < 4 + payloadLength) break;
final encryptedBlockBytes = Uint8List.fromList(
buffer.sublist(4, 4 + payloadLength),
);
buffer.removeRange(0, 4 + payloadLength);
final blockSecretBox = SecretBox.fromConcatenation(
encryptedBlockBytes,
nonceLength: 12,
macLength: 16,
);
final decryptedBlock = await aesGcm.decrypt(
blockSecretBox,
secretKey: fileKey,
);
blocksDecrypted++;
if (blocksDecrypted % 10 == 0 || payloadLength < 64 * 1024) {
print(
"Дешифровано блоков: $blocksDecrypted. Текущий размер: ${decryptedBlock.length} байт. Всего обработано $totalProcessedBytes. Всего $totalBytes",
);
}
// Увеличиваем счетчик обработанных зашифрованных байт
totalProcessedBytes += 4 + payloadLength;
// Вызываем колбэк прогресса
if (onProgress != null) {
// Передаем, сколько байт обработано, и общий размер (если totalBytes null, передаем -1)
onProgress(totalProcessedBytes, totalBytes ?? -1);
}
yield decryptedBlock;
}
}
print(
"ПОТОК ДЕШИФРАЦИИ ЗАВЕРШЕН ПОЛНОСТЬЮ. Всего блоков: $blocksDecrypted",
);
} catch (e, stack) {
print("КРИТИЧЕСКАЯ ОШИБКА ВНУТРИ КРИПТОСТРИМА: $e");
print(stack);
rethrow;
}
}
Future<Uint8List?> decryptAesKey(
String encryptedKey,
SecretKey sharedKey, SecretKey sharedKey,
) async { ) async {
try { try {
final SecretKey fileSecretKey = await aesGcm.newSecretKey(); final keyBytes = base64Decode(encryptedKey);
final List<int> fileSecretKeyBytes = await fileSecretKey.extractBytes(); final nonce = keyBytes.sublist(0, 12);
final cipherText = keyBytes.sublist(12, keyBytes.length - 16);
final mac = keyBytes.sublist(keyBytes.length - 16);
final SecretBox secretBox = await aesGcm.encrypt( final decrypted = await aesGcm.decrypt(
fileBytes, SecretBox(cipherText, nonce: nonce, mac: Mac(mac)),
secretKey: fileSecretKey, secretKey: sharedKey,
); );
final List<int> dataToUpload = secretBox.concatenation(); return Uint8List.fromList(decrypted);
} catch (e) {
print('Ошибка дешифровки AES ключа: $e');
return null;
}
}
final encryptedKeyBox = await aesGcm.encrypt( Future<String?> encryptAesKey(List<int> keyBytes, SecretKey sharedKey) async {
fileSecretKeyBytes, try {
final encrypted = await aesGcm.encrypt(keyBytes, secretKey: sharedKey);
return base64Encode(encrypted.concatenation());
} catch (e) {
print('Ошибка шифрования AES ключа: $e');
return null;
}
}
Future<Uint8List?> decryptMedia(
List<int> encryptedData,
String encryptedKey,
SecretKey sharedKey,
) async {
try {
final keyBytes = base64Decode(encryptedKey);
final keyNonce = keyBytes.sublist(0, 12);
final keyCipher = keyBytes.sublist(12, keyBytes.length - 16);
final keyMac = keyBytes.sublist(keyBytes.length - 16);
final decryptedFileKey = await aesGcm.decrypt(
SecretBox(keyCipher, nonce: keyNonce, mac: Mac(keyMac)),
secretKey: sharedKey, secretKey: sharedKey,
); );
final String encryptedKeyForServer = base64Encode( final fileSecretKey = SecretKey(decryptedFileKey);
encryptedKeyBox.concatenation(), final nonce = encryptedData.sublist(0, 12);
final cipherText = encryptedData.sublist(12, encryptedData.length - 16);
final mac = encryptedData.sublist(encryptedData.length - 16);
final decryptedBytes = await aesGcm.decrypt(
SecretBox(cipherText, nonce: nonce, mac: Mac(mac)),
secretKey: fileSecretKey,
); );
return (dataToUpload, encryptedKeyForServer); return Uint8List.fromList(decryptedBytes);
} catch (e) { } catch (e) {
print("Ошибка шифрования медиа: $e"); print('Ошибка дешифровки медиа: $e');
return null;
}
}
Future<SecretKey?> _decryptFileKey(
String encryptedFileKey,
SecretKey sharedKey,
) async {
try {
final keyBytes = base64Decode(encryptedFileKey);
final nonce = keyBytes.sublist(0, 12);
final macBytes = keyBytes.sublist(keyBytes.length - 16);
final cipherText = keyBytes.sublist(12, keyBytes.length - 16);
final decrypted = await aesGcm.decrypt(
SecretBox(cipherText, nonce: nonce, mac: Mac(macBytes)),
secretKey: sharedKey,
);
return SecretKey(decrypted);
} catch (e) {
print('Error decrypting file key: $e');
return null; return null;
} }
} }
@ -184,4 +534,21 @@ class CryptoService {
Future<void> deletePrivateKey() async { Future<void> deletePrivateKey() async {
await _storage.delete(key: 'private_key'); await _storage.delete(key: 'private_key');
} }
SecretKey? _currentSharedKey;
// Метод для установки ключа (вызывается при входе в чат)
void setCurrentSharedKey(SecretKey key) {
_currentSharedKey = key;
}
// Тот самый метод, который ищет ChatScreen
Future<SecretKey> getSharedKey(String? chatId) async {
if (_currentSharedKey == null) {
// Если ключа нет, его нужно либо вычислить заново,
// либо выбросить ошибку. Для теста можно вернуть ошибку:
throw Exception("Shared key not initialized for chat $chatId");
}
return _currentSharedKey!;
}
} }

View File

@ -1,6 +1,9 @@
import 'package:chepuhagram/data/datasources/local_db_service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import '/core/constants.dart'; import '/core/constants.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:chepuhagram/domain/services/api_service.dart'; import 'package:chepuhagram/domain/services/api_service.dart';
@ -32,6 +35,12 @@ class AuthProvider extends ChangeNotifier {
String? _about; String? _about;
String? get about => _about; String? get about => _about;
String? _avatarPath;
String? get avatarPath => _avatarPath;
String? _avatarUrl;
String? get avatarUrl => _avatarUrl;
// Privacy settings // Privacy settings
bool? _showEmail; bool? _showEmail;
bool? get showEmail => _showEmail; bool? get showEmail => _showEmail;
@ -85,14 +94,23 @@ class AuthProvider extends ChangeNotifier {
SocketService get socketService => _socketService; SocketService get socketService => _socketService;
Future<bool> login(String username, String password) async { Future<bool> login(
String username,
String password, {
String? totpCode,
}) async {
_isLoading = true; _isLoading = true;
notifyListeners(); notifyListeners();
try { try {
final body = {'username': username, 'password': password};
if (totpCode != null) {
body['totp_code'] = totpCode;
}
final response = await _client.post( final response = await _client.post(
Uri.parse('${AppConstants.baseUrl}/auth/login'), Uri.parse('${AppConstants.baseUrl}/auth/login'),
body: {'username': username, 'password': password}, headers: {'Content-Type': 'application/json'},
body: jsonEncode(body),
); );
final decodedResponse = final decodedResponse =
@ -135,7 +153,12 @@ class AuthProvider extends ChangeNotifier {
Future<void> logout() async { Future<void> logout() async {
final mode = await _storage.read(key: 'theme_mode'); final mode = await _storage.read(key: 'theme_mode');
final color = await _storage.read(key: 'accent_color'); final color = await _storage.read(key: 'accent_color');
final wallpaper = await _storage.read(key: 'wallpaper_path');
final avatar = await _storage.read(key: 'avatar_path');
await _storage.deleteAll(); await _storage.deleteAll();
final prefs = await SharedPreferences.getInstance();
await prefs.clear();
await LocalDbService().clearDatabase();
_currentUserId = null; _currentUserId = null;
_username = null; _username = null;
_firstName = null; _firstName = null;
@ -143,17 +166,32 @@ class AuthProvider extends ChangeNotifier {
_phone = null; _phone = null;
_email = null; _email = null;
_about = null; _about = null;
_avatarPath = null;
_avatarUrl = null;
if (mode != null) { if (mode != null) {
await _storage.write(key: 'theme_mode', value: mode); await _storage.write(key: 'theme_mode', value: mode);
} }
if (color != null) { if (color != null) {
await _storage.write(key: 'accent_color', value: color); await _storage.write(key: 'accent_color', value: color);
} }
if (wallpaper != null) {
await _storage.write(key: 'wallpaper_path', value: wallpaper);
}
if (avatar != null) {
await _storage.write(key: 'avatar_path', value: avatar);
}
notifyListeners(); notifyListeners();
} }
Future<bool> tryAutoLogin() async { Future<bool> tryAutoLogin() async {
final token = await _apiService.getAccessToken(); String? token;
try {
token = await _apiService.getAccessToken();
} catch (e) {
throw Exception(
'$e+_aup_tal_1',
);
}
if (token == null) return false; if (token == null) return false;
// Загружаем currentUserId из хранилища // Загружаем currentUserId из хранилища
@ -253,6 +291,13 @@ class AuthProvider extends ChangeNotifier {
_phone = data['phone']?.toString(); _phone = data['phone']?.toString();
_email = data['email']?.toString(); _email = data['email']?.toString();
_about = data['about']?.toString(); _about = data['about']?.toString();
final avatarFileId = data['avatar_file_id']?.toString();
_avatarUrl = avatarFileId != null
? '${AppConstants.baseUrl}/media/$avatarFileId'
: null;
// Загружаем локальные настройки
_avatarPath = await _storage.read(key: 'avatar_path');
// Проверяем наличие публичного ключа на сервере // Проверяем наличие публичного ключа на сервере
_hasPublicKeyOnServer = _hasPublicKeyOnServer =
@ -312,4 +357,33 @@ class AuthProvider extends ChangeNotifier {
_needsKeyRecovery = false; _needsKeyRecovery = false;
notifyListeners(); notifyListeners();
} }
void updateAvatarPath(String? path) {
_avatarPath = path;
if (path != null) {
_storage.write(key: 'avatar_path', value: path);
} else {
_storage.delete(key: 'avatar_path');
}
notifyListeners();
}
Future<bool> updateAvatar(String path) async {
try {
final bytes = await File(path).readAsBytes();
final fileId = await _apiService.uploadFile(bytes, purpose: 'avatar');
if (fileId != null) {
final success = await _apiService.updateAvatar(fileId);
if (success) {
updateAvatarPath(path);
await refreshMe(); // Обновить данные профиля, включая avatarUrl
return true;
}
}
return false;
} catch (e) {
print('Ошибка обновления аватарки: $e');
return false;
}
}
} }

View File

@ -1,13 +1,18 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '/data/models/contact_model.dart'; import '/data/models/contact_model.dart';
import '/data/repositories/contact_repository.dart'; import '/data/repositories/contact_repository.dart';
import '/data/datasources/local_db_service.dart';
import '/domain/services/crypto_service.dart'; import '/domain/services/crypto_service.dart';
import 'dart:isolate';
import 'package:cryptography/cryptography.dart';
import 'package:flutter/foundation.dart';
import 'package:chepuhagram/data/models/message_model.dart';
class ContactProvider extends ChangeNotifier { class ContactProvider extends ChangeNotifier {
final ContactRepository _repository = ContactRepository(); final ContactRepository _repository = ContactRepository();
final LocalDbService _localDbService = LocalDbService(); final CryptoService _cryptoService;
final CryptoService _cryptoService = CryptoService();
ContactProvider(this._cryptoService);
final Map<int, SecretKey> _sharedKeysCache = {};
List<Contact> _contacts = []; List<Contact> _contacts = [];
List<Contact> _allContacts = []; List<Contact> _allContacts = [];
bool _isLoading = false; bool _isLoading = false;
@ -19,6 +24,11 @@ class ContactProvider extends ChangeNotifier {
List<Contact> get allContacts => _allContacts; List<Contact> get allContacts => _allContacts;
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
String? get error => _error; String? get error => _error;
Map<int, SecretKey> get sharedKeysCache => _sharedKeysCache;
void setSharedKey(int contactId, SecretKey key) {
_sharedKeysCache[contactId] = key;
}
void setCurrentUserId(int? id) { void setCurrentUserId(int? id) {
_currentUserId = id; _currentUserId = id;
@ -29,7 +39,7 @@ class ContactProvider extends ChangeNotifier {
return _currentUserId; return _currentUserId;
} }
Future<void> loadContacts() async { Future<void> loadContacts({bool enrichContacts = true}) async {
if (_isFirstLoad) { if (_isFirstLoad) {
_isFirstLoad = false; _isFirstLoad = false;
_isLoading = true; _isLoading = true;
@ -39,21 +49,27 @@ class ContactProvider extends ChangeNotifier {
try { try {
final allContacts = await _repository.fetchChatContacts(); final allContacts = await _repository.fetchChatContacts();
// Фильтруем: исключаем себя (для основного списка - только чаты) final userIdCopy = _currentUserId;
_contacts = allContacts _contacts = await Isolate.run(() {
.where((contact) => contact.id != _currentUserId) return allContacts
.toList(); .where((contact) => contact.id != userIdCopy)
.toList();
});
// Check if user changed during isolate execution
if (userIdCopy != _currentUserId) {
return; // Discard stale data
}
_allContacts = _contacts; _allContacts = _contacts;
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
// Обогащаем превью последним сообщением из локальной БД, не блокируя UI. if (enrichContacts) {
_enrichContactsWithLastMessages(); await _enrichContactsWithLastMessages();
}
} catch (e) { } catch (e) {
_error = e.toString(); _error = e.toString();
print("❌ ОШИБКА ПРИ ЗАГРУЗКЕ КОНТАКТОВ: $_error");
} finally { } finally {
// Если ошибка выходим из состояния загрузки тут.
// Если всё ок `_isLoading` уже сброшен выше, чтобы показать список быстрее.
if (_error != null) { if (_error != null) {
_isLoading = false; _isLoading = false;
} }
@ -61,7 +77,6 @@ class ContactProvider extends ChangeNotifier {
} }
} }
// Метод для получения всех контактов (исключая себя) для нового чата
Future<void> loadAllContactsForNewChat() async { Future<void> loadAllContactsForNewChat() async {
_isLoading = true; _isLoading = true;
_error = null; _error = null;
@ -69,7 +84,6 @@ class ContactProvider extends ChangeNotifier {
try { try {
final allContacts = await _repository.fetchAllUsers(); final allContacts = await _repository.fetchAllUsers();
// Фильтруем только исключение самого себя
_allContacts = allContacts _allContacts = allContacts
.where((contact) => contact.id != _currentUserId) .where((contact) => contact.id != _currentUserId)
.toList(); .toList();
@ -81,43 +95,200 @@ class ContactProvider extends ChangeNotifier {
} }
} }
Future<void> _enrichContactsWithLastMessages() async {
print("Начинаем обогащать контакты последними сообщениями из локальной БД... Для текущего пользователя ID: $_currentUserId");
final myId = _currentUserId;
if (myId == null) return;
print("Текущий пользователь ID: $myId");
final myPrivKey = await _cryptoService.getPrivateKey(); String _getMediaPreview(MessageType type) {
switch (type) {
final List<Contact> updated = List<Contact>.from(_contacts); case MessageType.videoNote:
return '[Кружок]';
for (int i = 0; i < updated.length; i++) { case MessageType.voiceNote:
final contact = updated[i]; return '[Голосовое]';
case MessageType.image:
// 1) Если сервер уже прислал lastMessage попробуем расшифровать превью. return '[Фото]';
print(contact.lastMessage); case MessageType.video:
if (contact.lastMessage != null && return '[Видео]';
contact.lastMessage!.isNotEmpty && case MessageType.file:
myPrivKey != null && return '[Файл]';
contact.publicKey != null) { case MessageType.text:
try { default:
final sharedSecret = await _cryptoService.deriveSharedSecret( return '';
myPrivKey,
contact.publicKey!,
);
final decrypted = await _cryptoService.decryptMessage(
contact.lastMessage!,
sharedSecret,
);
updated[i] = contact.copyWith(lastMessage: decrypted);
} catch (_) {
// Если расшифровать не удалось оставляем как есть, дальше попробуем локальную БД.
}
}
} }
}
_contacts = updated; Future<void> _enrichContactsWithLastMessages() async {
_allContacts = updated; final myPrivKeyBase64 = await _cryptoService.getPrivateKey();
notifyListeners(); if (myPrivKeyBase64 == null) return;
// Создаем локальные копии для передачи
final contactsToProcess = List<Contact>.from(_contacts);
final cacheCopy = Map<int, SecretKey>.from(_sharedKeysCache);
print('Avialable cache for contacts: ${cacheCopy.length}');
try {
final updatedContacts = await compute(CryptoService.bulkDecryptContacts, {
'contacts': contactsToProcess,
'privKey': myPrivKeyBase64,
'cache': cacheCopy,
});
for (var contact in updatedContacts) {
print(
'Decrypted contact: ${contact.name} ${contact.surname}, lastMessage: ${contact.lastMessage}, isDecrypted: ${contact.isLastMsgDecrypted}',
);
}
_contacts = updatedContacts;
notifyListeners();
} catch (e) {
print("Ошибка дешифровки: $e");
}
}
Future<void> updateContact(
int userId, {
String? lastMessage,
DateTime? lastMessageTime,
bool? isLastMsgDecrypted,
int? unreadCount,
}) async {
try {
final updatedContact = await _repository.fetchContactById(userId);
final index = _contacts.indexWhere((c) => c.id == userId);
if (index != -1) {
final existing = _contacts[index];
_contacts[index] = existing.copyWith(
username: updatedContact.username,
name: updatedContact.name,
surname: updatedContact.surname,
avatarUrl: updatedContact.avatarUrl,
avatarFileId: updatedContact.avatarFileId,
isOnline: updatedContact.isOnline,
publicKey: updatedContact.publicKey,
lastMessage: lastMessage ?? existing.lastMessage,
lastMessageTime: lastMessageTime ?? existing.lastMessageTime,
isLastMsgDecrypted: isLastMsgDecrypted ?? existing.isLastMsgDecrypted,
unreadCount: unreadCount ?? existing.unreadCount,
lastMessageId: updatedContact.lastMessageId,
);
print(
"Контакт ${updatedContact.name} ${updatedContact.surname} ${updatedContact.id} ${updatedContact.avatarFileId} ${updatedContact.avatarUrl} обновлен",
);
notifyListeners();
}
} catch (e) {
print("Error updating contact: $e");
}
}
Future<void> updateContactOnlineStatus(int userId, bool isOnline) async {
try {
final index = _contacts.indexWhere((c) => c.id == userId);
if (index != -1) {
final existing = _contacts[index];
_contacts[index] = existing.copyWith(
isOnline: isOnline,
username: existing.username,
name: existing.name,
surname: existing.surname,
avatarUrl: existing.avatarUrl,
avatarFileId: existing.avatarFileId,
publicKey: existing.publicKey,
);
print("Контакт ${existing.name} ${existing.surname} онлайн обновлен");
notifyListeners();
}
} catch (e) {
print("Error updating contact: $e");
}
}
Future<void> updateContactLastMessage(int contactId, {String? lastMessage, DateTime? lastMessageTime, bool? isLastMsgDecrypted, int? lastMessageId, bool isEdited = false}) async {
try {
final index = _contacts.indexWhere((c) => c.id == contactId);
if (index != -1) {
final existing = _contacts[index];
String displayMessage;
if (isEdited) {
final baseMessage = lastMessage ?? existing.lastMessage;
final rawMessage = baseMessage != null && baseMessage.isNotEmpty
? baseMessage
: 'Сообщение изменено';
displayMessage = rawMessage.endsWith('(изменено)')
? rawMessage
: '$rawMessage (изменено)';
} else {
displayMessage = lastMessage ?? existing.lastMessage ?? '';
}
_contacts[index] = existing.copyWith(
lastMessage: displayMessage.isNotEmpty ? displayMessage : null,
lastMessageTime: lastMessageTime,
isLastMsgDecrypted: isLastMsgDecrypted ?? existing.isLastMsgDecrypted,
lastMessageId: lastMessageId,
);
print("Последнее сообщение контакта ${existing.name} обновлено: $displayMessage");
notifyListeners();
}
} catch (e) {
print("Error updating contact last message: $e");
}
}
Future<void> refreshContactLastMessage(int contactId) async {
try {
// Получить предпоследнее сообщение из базы данных
final lastMessages = await _repository.getLastMessagesForContact(contactId, limit: 2);
if (lastMessages.isNotEmpty) {
final lastMsg = lastMessages.first;
final contact = _contacts.firstWhere((c) => c.id == contactId);
final messageId = int.tryParse(lastMsg['id'].toString());
final timestamp = DateTime.tryParse(lastMsg['timestamp']?.toString() ?? '');
final myPrivKeyBase64 = await _cryptoService.getPrivateKey();
if (myPrivKeyBase64 != null && contact.publicKey != null) {
try {
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKeyBase64,
contact.publicKey!,
);
final decryptedText = await _cryptoService.decryptMessage(
lastMsg['content'],
sharedSecret,
);
await updateContactLastMessage(
contactId,
lastMessage: decryptedText,
lastMessageTime: timestamp,
isLastMsgDecrypted: true,
lastMessageId: messageId,
);
} catch (e) {
print("Error decrypting last message: $e");
await updateContactLastMessage(
contactId,
lastMessage: lastMsg['content50'] ?? 'Зашифрованное сообщение',
lastMessageTime: timestamp,
isLastMsgDecrypted: false,
lastMessageId: messageId,
);
}
} else {
await updateContactLastMessage(
contactId,
lastMessage: lastMsg['content50'] ?? 'Зашифрованное сообщение',
lastMessageTime: timestamp,
isLastMsgDecrypted: false,
lastMessageId: messageId,
);
}
} else {
// Нет сообщений
await updateContactLastMessage(
contactId,
lastMessage: null,
lastMessageTime: null,
lastMessageId: null,
);
}
} catch (e) {
print("Error refreshing contact last message: $e");
}
} }
} }

View File

@ -1,5 +1,3 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'data/datasources/ws_client.dart'; import 'data/datasources/ws_client.dart';
import 'logic/auth_provider.dart'; import 'logic/auth_provider.dart';
import 'logic/contact_provider.dart'; import 'logic/contact_provider.dart';
@ -22,15 +20,12 @@ final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>(); final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
// Глобальная переменная для отслеживания текущего активного контакта в чате
int? currentActiveChatContactId; int? currentActiveChatContactId;
// Глобальная переменная для хранения начального сообщения (при запуске из уведомления)
RemoteMessage? initialMessage; RemoteMessage? initialMessage;
// Ключ для SharedPreferences // Ключ для SharedPreferences
const String _notificationLaunchKey = 'notification_launch_data'; const String _notificationLaunchKey = 'notification_launch_data';
// Защита от повторной обработки одного и того же payload при следующих запусках по иконке
const String _lastHandledNotificationLaunchPayloadKey = const String _lastHandledNotificationLaunchPayloadKey =
'notification_last_handled_payload'; 'notification_last_handled_payload';
@ -49,9 +44,6 @@ Future<void> _onSelectNotification(
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final canonicalPayload = jsonEncode(data); final canonicalPayload = jsonEncode(data);
// Важно: не сохраняем payload в SharedPreferences, если можем сразу перейти в чат.
// Иначе при следующем обычном запуске (по иконке) останется "хвост" и приложение
// будет снова автопереходить в чат.
if (context == null) { if (context == null) {
final lastHandled = prefs.getString( final lastHandled = prefs.getString(
_lastHandledNotificationLaunchPayloadKey, _lastHandledNotificationLaunchPayloadKey,
@ -70,7 +62,6 @@ Future<void> _onSelectNotification(
await prefs.remove(_notificationLaunchKey); await prefs.remove(_notificationLaunchKey);
} }
// Navigate to chat with this contact (if context is ready)
_navigateToChat(senderId); _navigateToChat(senderId);
} else { } else {
print( print(
@ -133,103 +124,113 @@ void _navigateToChat(int senderId) {
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(); try {
await Firebase.initializeApp();
// Проверяем, было ли приложение запущено из уведомления initialMessage = await FirebaseMessaging.instance.getInitialMessage();
// Добавляем небольшую задержку, чтобы Firebase полностью инициализировался print('Initial message from main() after delay: $initialMessage');
await Future.delayed(const Duration(milliseconds: 500)); // Сохраняем информацию в SharedPreferences для надежности
initialMessage = await FirebaseMessaging.instance.getInitialMessage(); final prefs = await SharedPreferences.getInstance();
print('Initial message from main() after delay: $initialMessage'); if (initialMessage != null) {
// Сохраняем информацию в SharedPreferences для надежности print('App launched from notification: ${initialMessage!.data}');
final prefs = await SharedPreferences.getInstance(); print('Message type: ${initialMessage!.data['type']}');
if (initialMessage != null) { print('Sender ID: ${initialMessage!.data['sender_id']}');
print('App launched from notification: ${initialMessage!.data}');
print('Message type: ${initialMessage!.data['type']}');
print('Sender ID: ${initialMessage!.data['sender_id']}');
final payloadString = jsonEncode(initialMessage!.data); final payloadString = jsonEncode(initialMessage!.data);
final lastHandled = prefs.getString( final lastHandled = prefs.getString(
_lastHandledNotificationLaunchPayloadKey,
);
if (lastHandled != payloadString) {
// Сохраняем данные уведомления
await prefs.setString(_notificationLaunchKey, payloadString);
await prefs.setString(
_lastHandledNotificationLaunchPayloadKey, _lastHandledNotificationLaunchPayloadKey,
payloadString,
); );
print('Saved notification data to SharedPreferences'); if (lastHandled != payloadString) {
} else { // Сохраняем данные уведомления
print('InitialMessage payload already handled earlier, skipping'); await prefs.setString(_notificationLaunchKey, payloadString);
} await prefs.setString(
} else {
print('No initial message - app launched normally');
// Очищаем сохраненные данные, если приложение запущено нормально
await prefs.remove(_notificationLaunchKey);
}
// Initialize local notifications
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
final InitializationSettings initializationSettings = InitializationSettings(
android: initializationSettingsAndroid,
);
await flutterLocalNotificationsPlugin.initialize(
initializationSettings,
onDidReceiveNotificationResponse: _onSelectNotification,
);
// Если приложение было запущено из локального уведомления, сохраним payload
final notificationAppLaunchDetails = await flutterLocalNotificationsPlugin
.getNotificationAppLaunchDetails();
if (notificationAppLaunchDetails?.didNotificationLaunchApp ?? false) {
final payload = notificationAppLaunchDetails?.notificationResponse?.payload;
print('App launched from local notification, payload: $payload');
if (payload != null && payload.isNotEmpty) {
try {
final lastHandled = prefs.getString(
_lastHandledNotificationLaunchPayloadKey, _lastHandledNotificationLaunchPayloadKey,
payloadString,
); );
if (lastHandled != payload) { print('Saved notification data to SharedPreferences');
final data = jsonDecode(payload); } else {
await prefs.setString(_notificationLaunchKey, jsonEncode(data)); print('InitialMessage payload already handled earlier, skipping');
await prefs.setString( }
} else {
print('No initial message - app launched normally');
// Очищаем сохраненные данные, если приложение запущено нормально
await prefs.remove(_notificationLaunchKey);
}
// Initialize local notifications
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
final InitializationSettings initializationSettings =
InitializationSettings(android: initializationSettingsAndroid);
await flutterLocalNotificationsPlugin.initialize(
initializationSettings,
onDidReceiveNotificationResponse: _onSelectNotification,
);
// Если приложение было запущено из локального уведомления, сохраним payload
final notificationAppLaunchDetails = await flutterLocalNotificationsPlugin
.getNotificationAppLaunchDetails();
if (notificationAppLaunchDetails?.didNotificationLaunchApp ?? false) {
final payload =
notificationAppLaunchDetails?.notificationResponse?.payload;
print('App launched from local notification, payload: $payload');
if (payload != null && payload.isNotEmpty) {
try {
final lastHandled = prefs.getString(
_lastHandledNotificationLaunchPayloadKey, _lastHandledNotificationLaunchPayloadKey,
payload,
); );
print('Saved local notification launch payload to SharedPreferences'); if (lastHandled != payload) {
} else { final data = jsonDecode(payload);
print('Local notification payload already handled earlier, skipping'); await prefs.setString(_notificationLaunchKey, jsonEncode(data));
await prefs.setString(
_lastHandledNotificationLaunchPayloadKey,
payload,
);
print(
'Saved local notification launch payload to SharedPreferences',
);
} else {
print(
'Local notification payload already handled earlier, skipping',
);
}
} catch (e) {
print('Failed to save notification launch payload: $e');
} }
} catch (e) {
print('Failed to save notification launch payload: $e');
} }
} }
// Create notification channel for Android 8+
const AndroidNotificationChannel channel = AndroidNotificationChannel(
'chat_id', // id
'Messages', // title
description: 'Chat messages notifications', // description
importance: Importance.high,
);
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(channel);
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
} catch (e) {
print('Уведосления не были инициальзированы: $e');
} }
// Create notification channel for Android 8+
const AndroidNotificationChannel channel = AndroidNotificationChannel(
'chat_id', // id
'Messages', // title
description: 'Chat messages notifications', // description
importance: Importance.high,
);
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(channel);
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
runApp( runApp(
MultiProvider( MultiProvider(
providers: [ providers: [
Provider(create: (_) => CryptoService()),
Provider(create: (_) => SocketService()),
ChangeNotifierProvider(create: (_) => AuthProvider()), ChangeNotifierProvider(create: (_) => AuthProvider()),
ChangeNotifierProvider(create: (_) => ThemeProvider()), ChangeNotifierProvider(create: (_) => ThemeProvider()),
ChangeNotifierProvider(create: (_) => ContactProvider()),
Provider(create: (_) => SocketService()), Provider(create: (_) => SocketService()),
ChangeNotifierProvider(
create: (context) => ContactProvider(context.read<CryptoService>()),
),
], ],
child: const MyApp(), child: const MyApp(),
), ),
@ -292,13 +293,42 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
notificationText = 'Failed to decrypt: ${e.toString()}'; notificationText = 'Failed to decrypt: ${e.toString()}';
} }
final senderId = int.tryParse(
message.data['sender_id']?.toString() ?? '',
);
// 4. Показываем локальное уведомление // 4. Показываем локальное уведомление
final String groupKey = 'ru.chepuhagram.app.$senderId';
await flutterLocalNotificationsPlugin.show(
senderId!,
'',
'',
NotificationDetails(
android: AndroidNotificationDetails(
'Messages',
'Новые сообщения',
groupKey: groupKey,
setAsGroupSummary: true,
importance: Importance.high,
priority: Priority.high,
groupAlertBehavior: GroupAlertBehavior.all,
),
),
);
await flutterLocalNotificationsPlugin.show( await flutterLocalNotificationsPlugin.show(
message.hashCode, message.hashCode,
message.data['username'] ?? 'Unknown', message.data['username'] ?? 'Unknown',
notificationText, notificationText,
const NotificationDetails( NotificationDetails(
android: AndroidNotificationDetails('chat_id', 'Messages'), android: AndroidNotificationDetails(
'chat_id',
'Messages',
groupKey: groupKey,
importance: Importance.high,
priority: Priority.high,
showWhen: true,
),
), ),
payload: jsonEncode({ payload: jsonEncode({
'type': 'enc_message', 'type': 'enc_message',

View File

@ -155,8 +155,8 @@ class _AccountSettingsScreenState extends State<AccountSettingsScreen> {
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'О себе', labelText: 'О себе',
), ),
minLines: 2, minLines: 1,
maxLines: 5, maxLines: 10,
), ),
], ],
), ),

View File

@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import '/core/theme_manager.dart';
import 'dart:io';
class AppearanceSettingsScreen extends StatefulWidget {
const AppearanceSettingsScreen({super.key});
@override
State<AppearanceSettingsScreen> createState() => _AppearanceSettingsScreenState();
}
class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
final ImagePicker _picker = ImagePicker();
Future<void> _pickWallpaper() async {
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
if (image != null) {
context.read<ThemeProvider>().updateWallpaper(image.path);
}
}
@override
Widget build(BuildContext context) {
final themeProv = context.watch<ThemeProvider>();
return Scaffold(
appBar: AppBar(title: const Text("Оформление")),
body: ListView(
children: [
// Ночной режим
SwitchListTile(
secondary: const Icon(Icons.dark_mode),
title: const Text("Ночной режим"),
value: themeProv.themeMode == ThemeMode.dark,
onChanged: (val) => themeProv.toggleTheme(val),
),
const Divider(),
// Выбор цвета акцента
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Icon(
Icons.palette_outlined,
color: Theme.of(context).colorScheme.onSurface,
),
const SizedBox(width: 10),
const Text("Цвет темы"),
const Spacer(),
_colorCircle(context, const Color(0xFF24A1DE), themeProv),
_colorCircle(context, const Color(0xFF3E8E7E), themeProv),
_colorCircle(context, const Color(0xFF8E3E7E), themeProv),
_colorCircle(context, const Color(0xFFFF9800), themeProv),
_colorCircle(context, const Color(0xFFF44336), themeProv),
],
),
],
),
),
const Divider(),
// Обои чата
ListTile(
leading: const Icon(Icons.wallpaper),
title: const Text('Обои чата'),
subtitle: const Text('Выбрать изображение из галереи'),
trailing: const Icon(Icons.chevron_right),
onTap: _pickWallpaper,
),
// Показать текущие обои, если есть
if (themeProv.wallpaperPath != null)
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Текущие обои:'),
const SizedBox(height: 8),
Container(
height: 150,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
image: DecorationImage(
image: FileImage(File(themeProv.wallpaperPath!)),
fit: BoxFit.cover,
),
),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () => themeProv.updateWallpaper(null),
child: const Text('Удалить обои'),
),
],
),
),
],
),
);
}
Widget _colorCircle(BuildContext context, Color color, ThemeProvider prov) {
bool isSelected = prov.accentColor == color;
return GestureDetector(
onTap: () => prov.updateAccentColor(color),
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: isSelected ? color : Colors.transparent,
width: 2,
),
),
child: CircleAvatar(backgroundColor: color, radius: 15),
),
);
}
}

View File

@ -0,0 +1,412 @@
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'dart:async';
import 'media_preview_screen.dart';
class CameraScreen extends StatefulWidget {
const CameraScreen({super.key});
@override
State<CameraScreen> createState() => _CameraScreenState();
}
enum FlashModeType { off, autoCapture, alwaysCapture, torch }
class _CameraScreenState extends State<CameraScreen> {
CameraController? _controller;
List<CameraDescription> _cameras = [];
int _cameraIndex = 0;
bool _isRecording = false;
FlashModeType _flashMode = FlashModeType.off;
double _minZoom = 1.0;
double _maxZoom = 1.0;
double _currentZoom = 1.0;
bool _showZoomSlider = false;
Future<void>? _initFuture;
@override
void initState() {
super.initState();
_initFuture = _init();
}
Future<void> _init() async {
_cameras = await availableCameras();
await _initCamera();
}
Future<void> _initCamera() async {
final camera = _cameras[_cameraIndex];
final controller = CameraController(
camera,
ResolutionPreset.high,
enableAudio: true,
);
await controller.initialize();
_minZoom = await controller.getMinZoomLevel();
_maxZoom = await controller.getMaxZoomLevel();
_currentZoom = _minZoom;
if (!mounted) return;
setState(() {
_controller = controller;
});
}
Future<void> _switchCamera() async {
if (_cameras.length < 2) return;
await _controller?.dispose();
_cameraIndex = (_cameraIndex + 1) % _cameras.length;
setState(() => _controller = null);
await _initCamera();
}
Future<void> _cycleFlashMode() async {
if (_controller == null) return;
switch (_flashMode) {
case FlashModeType.off:
_flashMode = FlashModeType.autoCapture;
await _controller!.setFlashMode(FlashMode.off);
break;
case FlashModeType.autoCapture:
_flashMode = FlashModeType.alwaysCapture;
await _controller!.setFlashMode(FlashMode.off);
break;
case FlashModeType.alwaysCapture:
_flashMode = FlashModeType.torch;
await _controller!.setFlashMode(FlashMode.torch);
break;
case FlashModeType.torch:
_flashMode = FlashModeType.off;
await _controller!.setFlashMode(FlashMode.off);
break;
}
setState(() {});
}
Future<void> _takePhoto() async {
if (_controller == null) return;
bool usedTorch = false;
if (_flashMode == FlashModeType.alwaysCapture) {
await _controller!.setFlashMode(FlashMode.torch);
usedTorch = true;
await Future.delayed(const Duration(milliseconds: 120));
}
if (_flashMode == FlashModeType.autoCapture) {
await _controller!.setFlashMode(FlashMode.torch);
usedTorch = true;
await Future.delayed(const Duration(milliseconds: 120));
}
final file = await _controller!.takePicture();
if (usedTorch) {
await _controller!.setFlashMode(FlashMode.off);
}
WidgetsBinding.instance.addPostFrameCallback((_) async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MediaPreviewScreen(path: file.path, isVideo: false),
),
);
if (result == true && mounted) {
Navigator.pop(context, (file, 'image'));
}
});
}
bool usedTorch = false;
Future<void> _startVideo() async {
if (_controller == null || _isRecording) return;
if (_flashMode == FlashModeType.alwaysCapture) {
await _controller!.setFlashMode(FlashMode.torch);
usedTorch = true;
await Future.delayed(const Duration(milliseconds: 120));
}
if (_flashMode == FlashModeType.autoCapture) {
await _controller!.setFlashMode(FlashMode.torch);
usedTorch = true;
await Future.delayed(const Duration(milliseconds: 120));
}
await _controller!.startVideoRecording();
setState(() => _isRecording = true);
}
Future<void> _stopVideo() async {
if (_controller == null || !_isRecording) return;
if (usedTorch) {
await _controller!.setFlashMode(FlashMode.off);
}
final file = await _controller!.stopVideoRecording();
setState(() => _isRecording = false);
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MediaPreviewScreen(path: file.path, isVideo: true),
),
);
if (result == true && mounted) {
Navigator.pop(context, (file, 'video'));
}
}
Future<void> _setZoom(double zoom) async {
if (_controller == null) return;
final clamped = zoom.clamp(_minZoom, _maxZoom);
await _controller!.setZoomLevel(clamped);
setState(() {
_currentZoom = clamped;
});
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: FutureBuilder(
future: _initFuture,
builder: (context, snapshot) {
if (_controller == null || !_controller!.value.isInitialized) {
return const Center(child: CircularProgressIndicator());
}
return Stack(
children: [
// 📷 Camera preview (full screen, Telegram style crop)
Positioned.fill(
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width: _controller!.value.previewSize!.height,
height: _controller!.value.previewSize!.width,
child: GestureDetector(
onScaleStart: (_) {
setState(() {
_showZoomSlider = true;
});
},
onScaleUpdate: (details) {
final zoom = (_currentZoom * details.scale).clamp(
_minZoom,
_maxZoom,
);
_setZoom(zoom);
},
child: CameraPreview(_controller!),
),
),
),
),
// 🌑 top gradient (Telegram feel)
Positioned(
top: 0,
left: 0,
right: 0,
height: 120,
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black87, Colors.transparent],
),
),
),
),
// 🔘 top controls
Positioned(
top: 50,
left: 20,
right: 20,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Flash (left)
IconButton(
onPressed: _cycleFlashMode,
icon: Icon(switch (_flashMode) {
FlashModeType.off => Icons.flash_off,
FlashModeType.autoCapture => Icons.flash_auto,
FlashModeType.alwaysCapture => Icons.flash_on,
FlashModeType.torch => Icons.highlight,
}, color: Colors.white),
),
// Camera switch (right)
IconButton(
onPressed: _switchCamera,
icon: const Icon(Icons.cameraswitch, color: Colors.white),
),
],
),
),
// 🔘 capture button (center bottom)
Positioned(
bottom: 90,
left: 0,
right: 0,
child: Column(
children: [
GestureDetector(
onTap: _takePhoto,
onLongPressStart: (_) => _startVideo(),
onLongPressEnd: (_) => _stopVideo(),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: _isRecording ? 80 : 72,
height: _isRecording ? 80 : 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _isRecording ? Colors.red : Colors.white,
border: Border.all(color: Colors.white, width: 4),
),
),
),
const SizedBox(height: 16),
const Text(
"Нажмите для фото, удерживайте для съемки",
style: TextStyle(color: Colors.white70, fontSize: 13),
),
],
),
),
// 🔴 recording indicator
if (_isRecording)
const Positioned(
top: 50,
left: 0,
right: 0,
child: Center(
child: Text(
"REC",
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
),
if (_showZoomSlider)
Positioned(
bottom: 200,
left: 20,
right: 20,
child: Center(
child: Container(
child: Row(
children: [
GestureDetector(
onTap: () {
final newZoom = (_currentZoom - 0.5).clamp(
_minZoom,
_maxZoom,
);
_setZoom(newZoom);
},
child: const Text(
'',
style: TextStyle(
color: Colors.white,
fontSize: 18,
),
),
),
const SizedBox(width: 8),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 2,
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white24,
thumbColor: Colors.white,
overlayColor: Colors.white24,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 6,
),
),
child: Slider(
value: _currentZoom,
min: _minZoom,
max: _maxZoom,
onChanged: (value) {
_setZoom(value);
},
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: () {
final newZoom = (_currentZoom + 0.5).clamp(
_minZoom,
_maxZoom,
);
_setZoom(newZoom);
},
child: const Text(
'+',
style: TextStyle(
color: Colors.white,
fontSize: 18,
),
),
),
],
),
),
),
),
],
);
},
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:chepuhagram/core/constants.dart'; import 'package:chepuhagram/core/constants.dart';
import 'package:chepuhagram/domain/services/aPI_service.dart'; import 'package:chepuhagram/domain/services/api_service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../widgets/contact_tile.dart'; import '../widgets/contact_tile.dart';
@ -8,6 +8,7 @@ import '../screens/settings_screen.dart';
import '../screens/new_chat_screen.dart'; import '../screens/new_chat_screen.dart';
import '../screens/chat_screen.dart'; import '../screens/chat_screen.dart';
import '/logic/contact_provider.dart'; import '/logic/contact_provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '/logic/auth_provider.dart'; import '/logic/auth_provider.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
@ -20,8 +21,8 @@ import 'package:package_info_plus/package_info_plus.dart';
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:open_filex/open_filex.dart'; import 'package:open_filex/open_filex.dart';
import '/data/datasources/ws_client.dart';
class ContactsScreen extends StatefulWidget { class ContactsScreen extends StatefulWidget {
final int? targetChatId; final int? targetChatId;
@ -37,15 +38,22 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
StreamSubscription<dynamic>? _socketSubscription; StreamSubscription<dynamic>? _socketSubscription;
bool _isDownloading = false; bool _isDownloading = false;
double _downloadProgress = 0.0; double _downloadProgress = 0.0;
int _downloadedBytes = 0;
int _downloadTotalBytes = 0;
int _apkFileSizeBytes = 0;
CancelToken? _cancelToken = CancelToken(); CancelToken? _cancelToken = CancelToken();
String? _latestApkUrl; String? _latestApkUrl;
bool _showUpdateBanner = false; bool _showUpdateBanner = false;
bool _contactsLoaded = false;
Timer? _contactLoadTimer;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
print('ContactsScreen initState, targetChatId: ${widget.targetChatId}'); print('ContactsScreen initState, targetChatId: ${widget.targetChatId}');
_setupPushNotifications(); _setupPushNotifications();
final socketService = Provider.of<SocketService>(context, listen: false);
_socketSubscription = socketService.messages.listen(_handleIncomingMessage);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final authProvider = context.read<AuthProvider>(); final authProvider = context.read<AuthProvider>();
final contactProvider = context.read<ContactProvider>(); final contactProvider = context.read<ContactProvider>();
@ -55,19 +63,38 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
'Setting current user ID in ContactProvider: ${authProvider.currentUserId}', 'Setting current user ID in ContactProvider: ${authProvider.currentUserId}',
); );
contactProvider.setCurrentUserId(authProvider.currentUserId); contactProvider.setCurrentUserId(authProvider.currentUserId);
contactProvider.loadContacts().then((_) { _startContactsLoadTimer();
print('Contacts loaded, checking targetChatId: ${widget.targetChatId}');
// После загрузки контактов проверить, нужно ли перейти к чату
if (widget.targetChatId != null) {
_navigateToTargetChat();
} else {
_checkSavedNotificationTarget();
}
});
}); });
}
Future<void> _startContactsLoadTimer() async {
if (_contactLoadTimer != null && _contactLoadTimer!.isActive) return;
_contactLoadTimer = Timer(const Duration(seconds: 2), () {
_initContacts();
});
}
Future<void> _initContacts() async {
if (_contactsLoaded) return; // Предотвращаем повторную загрузку
final contactProvider = context.read<ContactProvider>();
// Ждем завершения загрузки контактов
await contactProvider.loadContacts();
print('Contacts loaded, checking targetChatId: ${widget.targetChatId}');
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_checkAppUpdate(); _checkAppUpdate();
}); });
// Дальнейшая логика выполнится только после того, как loadContacts завершится
if (widget.targetChatId != null) {
_navigateToTargetChat();
} else {
_checkSavedNotificationTarget();
}
_contactLoadTimer?.cancel();
_contactLoadTimer = null;
_contactsLoaded = true;
} }
@override @override
@ -174,6 +201,14 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
_showUpdateBanner = true; _showUpdateBanner = true;
_latestApkUrl = data['apk_url']; _latestApkUrl = data['apk_url'];
}); });
if (_latestApkUrl != null) {
final size = await _fetchApkSize(_latestApkUrl!);
if (mounted) {
setState(() {
_apkFileSizeBytes = size;
});
}
}
} }
} }
} catch (e) { } catch (e) {
@ -200,12 +235,7 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
}); });
// Listen for foreground messages // Listen for foreground messages
FirebaseMessaging.onMessage.listen((RemoteMessage message) { FirebaseMessaging.onMessage.listen(_handleIncomingMessage);
print('Foreground message received: ${message.data}');
if (message.data['type'] == 'enc_message') {
_handleIncomingMessage(message);
}
});
// Handle notification tap when app was terminated/backgrounded // Handle notification tap when app was terminated/backgrounded
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) { FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
@ -260,7 +290,99 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
} }
} }
Future<void> _handleIncomingMessage(RemoteMessage message) async { Future<void> _handleIncomingMessage(dynamic data) async {
if (data is RemoteMessage) {
// FCM message
await _handleFCMMessage(data);
} else if (data is Map<String, dynamic>) {
// WebSocket message
print('WebSocket message received: $data');
if (data['type'] == 'user_updated') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId != null) {
final contactProvider = context.read<ContactProvider>();
contactProvider.updateContact(userId);
}
}
if (data['type'] == 'user_online') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId != null) {
final contactProvider = context.read<ContactProvider>();
contactProvider.updateContactOnlineStatus(userId, true);
}
}
if (data['type'] == 'user_offline') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId != null) {
final contactProvider = context.read<ContactProvider>();
contactProvider.updateContactOnlineStatus(userId, false);
}
}
if (data['type'] == 'message_edited') {
final messageId = int.tryParse(data['message_id']?.toString() ?? '');
final senderId = int.tryParse(data['sender_id']?.toString() ?? '');
if (messageId != null && senderId != null) {
final contactProvider = context.read<ContactProvider>();
final contact = contactProvider.contacts
.where((c) => c.id == senderId)
.firstOrNull;
if (contact != null) {
final editedAt = DateTime.tryParse(
data['edited_at']?.toString() ?? '',
);
// Дефолтные значения на случай ошибки расшифровки
String lastMessageText = contact.lastMessage ?? '';
bool isDecrypted = false;
final myPrivKey = await CryptoService().getPrivateKey();
if (myPrivKey != null && contact.publicKey != null) {
try {
final sharedSecret = await CryptoService().deriveSharedSecret(
myPrivKey,
contact.publicKey!,
);
lastMessageText = await CryptoService().decryptMessage(
data['content']?.toString() ?? '',
sharedSecret,
);
isDecrypted = true;
} catch (e) {
print('Error decrypting edited message for contacts list: $e');
}
}
// Единая точка обновления состояния
await contactProvider.updateContactLastMessage(
contact.id,
lastMessage: lastMessageText,
lastMessageTime: editedAt,
isLastMsgDecrypted: isDecrypted,
lastMessageId: messageId,
isEdited: true,
);
}
}
}
if (data['type'] == 'message_deleted') {
final messageId = int.tryParse(data['message_id']?.toString() ?? '');
if (messageId != null) {
final contactProvider = context.read<ContactProvider>();
final contactIndex = contactProvider.contacts.indexWhere(
(c) => c.lastMessageId == messageId,
);
if (contactIndex != -1) {
final contactId = contactProvider.contacts[contactIndex].id;
await contactProvider.refreshContactLastMessage(contactId);
}
}
}
}
}
Future<void> _handleFCMMessage(RemoteMessage message) async {
try { try {
// Проверяем, не находимся ли мы уже в чате с отправителем // Проверяем, не находимся ли мы уже в чате с отправителем
final senderId = int.tryParse( final senderId = int.tryParse(
@ -273,8 +395,8 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
// Ensure notification channel exists // Ensure notification channel exists
const AndroidNotificationChannel channel = AndroidNotificationChannel( const AndroidNotificationChannel channel = AndroidNotificationChannel(
'chat_id',
'Messages', 'Messages',
'Новые сообщения',
description: 'Chat messages notifications', description: 'Chat messages notifications',
importance: Importance.high, importance: Importance.high,
); );
@ -301,13 +423,53 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
sharedSecret, sharedSecret,
); );
if (senderId == null) return;
final String groupKey = 'ru.chepuhagram.app.$senderId';
final prefs = await SharedPreferences.getInstance();
final String? firstName = prefs.getString(
'firstname_${message.data['sender_id']}',
);
final String? lastName = prefs.getString(
'lastname_${message.data['sender_id']}',
);
final String localFullName = '${firstName ?? ''} ${lastName ?? ''}'
.trim();
final String title = localFullName.isNotEmpty
? localFullName
: (message.data['username'] ?? 'Unknown');
// Show local notification // Show local notification
await flutterLocalNotificationsPlugin.show(
senderId,
'',
'',
NotificationDetails(
android: AndroidNotificationDetails(
'Messages',
'Новые сообщения',
groupKey: groupKey,
setAsGroupSummary: true,
importance: Importance.high,
priority: Priority.high,
groupAlertBehavior: GroupAlertBehavior.all,
),
),
);
await flutterLocalNotificationsPlugin.show( await flutterLocalNotificationsPlugin.show(
message.hashCode, message.hashCode,
message.data['username'] ?? 'Unknown', title,
decryptedText, decryptedText,
const NotificationDetails( NotificationDetails(
android: AndroidNotificationDetails('chat_id', 'Messages'), android: AndroidNotificationDetails(
'Messages',
'Новые сообщения',
groupKey: groupKey,
importance: Importance.high,
priority: Priority.high,
showWhen: true,
),
), ),
payload: jsonEncode({ payload: jsonEncode({
'type': 'enc_message', 'type': 'enc_message',
@ -318,17 +480,34 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
); );
if (message.data['type'] == 'enc_message') { if (message.data['type'] == 'enc_message') {
print('Received private message FCM, updating contact $senderId');
final contactProvider = context.read<ContactProvider>(); final contactProvider = context.read<ContactProvider>();
contactProvider.loadContacts(); contactProvider.updateContact(
senderId,
lastMessage: decryptedText,
lastMessageTime: DateTime.tryParse(
message.data['timestamp'] ?? DateTime.now().toIso8601String(),
),
isLastMsgDecrypted: true,
unreadCount: message.data['unread_count'] != null
? int.tryParse(message.data['unread_count'].toString()) ?? 1
: null,
);
} }
} catch (e) { } catch (e) {
print('Error processing foreground message: $e'); print('Error processing foreground FCM message: $e');
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final double fabBottomPadding = _showUpdateBanner ? 120.0 : 16.0; double bannerHeight = 0.0;
if (_showUpdateBanner) {
bannerHeight = _isDownloading ? 152.0 : 96.0;
}
final double fabBottomPadding = _showUpdateBanner
? (bannerHeight + 20.0)
: 16.0;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(
@ -347,7 +526,15 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (contactProvider.error != null) { if (contactProvider.error != null) {
return Center(child: Text('Error: ${contactProvider.error}')); return Center(
child: Text(
'${contactProvider.error?.replaceAll('Exception: ', '')}',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
);
} }
return ListView.separated( return ListView.separated(
itemCount: contactProvider.contacts.length, itemCount: contactProvider.contacts.length,
@ -386,7 +573,7 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
], ],
), ),
floatingActionButton: AnimatedPadding( floatingActionButton: AnimatedPadding(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 100),
curve: Curves.easeInOut, curve: Curves.easeInOut,
padding: EdgeInsets.only(bottom: fabBottomPadding), padding: EdgeInsets.only(bottom: fabBottomPadding),
child: FloatingActionButton( child: FloatingActionButton(
@ -431,15 +618,30 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
), ),
), ),
currentAccountPicture: CircleAvatar( currentAccountPicture: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.onSurface, backgroundColor:
child: Text( authProvider.avatarUrl == null &&
initials.isEmpty ? 'U' : initials, authProvider.avatarPath == null
style: TextStyle( ? Theme.of(context).colorScheme.onSurface
fontSize: 20, : null,
fontWeight: FontWeight.bold, backgroundImage: authProvider.avatarUrl != null
color: Theme.of(context).colorScheme.primaryContainer, ? CachedNetworkImageProvider(authProvider.avatarUrl!)
), : authProvider.avatarPath != null
), ? FileImage(File(authProvider.avatarPath!))
: null,
child:
(authProvider.avatarUrl == null &&
authProvider.avatarPath == null)
? Text(
initials.isEmpty ? 'U' : initials,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(
context,
).colorScheme.primaryContainer,
),
)
: null,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.inversePrimary, color: Theme.of(context).colorScheme.inversePrimary,
@ -488,16 +690,24 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
} }
try { try {
setState(() {
_downloadProgress = 0.0;
_downloadedBytes = 0;
_downloadTotalBytes = 0;
});
// Скачиваем файл «в лоб» // Скачиваем файл «в лоб»
await Dio().download( await Dio().download(
_latestApkUrl!, _latestApkUrl!,
path, path,
cancelToken: _cancelToken, cancelToken: _cancelToken,
onReceiveProgress: (rec, total) { onReceiveProgress: (rec, total) {
if (total != -1) { if (mounted) {
if (mounted) { setState(() {
setState(() => _downloadProgress = rec / total); _downloadedBytes = rec;
} _downloadTotalBytes = total > 0 ? total : 0;
_downloadProgress = total > 0 ? rec / total : 0.0;
});
} }
}, },
); );
@ -515,17 +725,34 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
print("Ошибка: $e"); print("Ошибка: $e");
} finally { } finally {
if (mounted) { if (mounted) {
setState(() => _isDownloading = false); setState(() {
_isDownloading = false;
_downloadProgress = 0.0;
_downloadedBytes = 0;
_downloadTotalBytes = 0;
});
} }
} }
} }
void _cancelDownload() { Future<int> _fetchApkSize(String url) async {
_cancelToken?.cancel("Отменено"); try {
setState(() { final response = await http.head(Uri.parse(url));
_isDownloading = false; final lengthHeader = response.headers['content-length'];
_downloadProgress = 0.0; if (lengthHeader == null) return 0;
}); return int.tryParse(lengthHeader) ?? 0;
} catch (_) {
return 0;
}
}
String _formatBytes(int bytes) {
if (bytes <= 0) return '0 B';
const kb = 1024;
const mb = kb * 1024;
if (bytes < kb) return '$bytes B';
if (bytes < mb) return '${(bytes / kb).toStringAsFixed(1)} KB';
return '${(bytes / mb).toStringAsFixed(1)} MB';
} }
Widget _buildUpdateBanner() { Widget _buildUpdateBanner() {
@ -564,7 +791,9 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
child: Text( child: Text(
_isDownloading _isDownloading
? 'Скачивание ${(_downloadProgress * 100).toStringAsFixed(0)}%' ? 'Скачивание ${(_downloadProgress * 100).toStringAsFixed(0)}%'
: "Доступно новое обновление!", : _apkFileSizeBytes > 0
? 'Доступно новое обновление: ${_formatBytes(_apkFileSizeBytes)}'
: 'Доступно новое обновление!',
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -581,6 +810,8 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
_isDownloading = false; _isDownloading = false;
_cancelToken = null; // Обязательно обнуляем токен! _cancelToken = null; // Обязательно обнуляем токен!
_downloadProgress = 0.0; _downloadProgress = 0.0;
_downloadedBytes = 0;
_downloadTotalBytes = 0;
}); });
} else { } else {
// Если не качаем запускаем // Если не качаем запускаем
@ -617,6 +848,14 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
color: Colors.white, color: Colors.white,
backgroundColor: Colors.white24, backgroundColor: Colors.white24,
), ),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: Text(
'${_formatBytes(_downloadedBytes)} из ${_formatBytes(_downloadTotalBytes)}',
style: const TextStyle(color: Colors.white70, fontSize: 14),
),
),
], ],
], ],
), ),

View File

@ -0,0 +1,300 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '/core/constants.dart';
import '/data/models/message_model.dart';
import '/data/models/contact_model.dart';
import '/logic/contact_provider.dart';
import '/domain/services/api_service.dart';
class ForwardContactPickerScreen extends StatefulWidget {
final MessageModel message;
const ForwardContactPickerScreen({
super.key,
required this.message,
});
@override
State<ForwardContactPickerScreen> createState() => _ForwardContactPickerScreenState();
}
class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen> {
Contact? _selectedContact;
bool _isInitLoading = true;
SharedPreferences? _prefs;
String? token;
@override
void initState() {
super.initState();
_loadActiveChats();
}
Future<void> _loadActiveChats() async {
try {
final contactProvider = context.read<ContactProvider>();
await contactProvider.loadContacts();
final apiService = ApiService();
final accessToken = await apiService.getAccessToken();
final shared = await SharedPreferences.getInstance();
if (mounted) {
setState(() {
_prefs = shared;
token = accessToken;
});
}
} catch (e) {
debugPrint("Ошибка при загрузке данных для пересылки: $e");
} finally {
if (mounted) {
setState(() {
_isInitLoading = false;
});
}
}
}
String _getDisplayName(Contact contact) {
if (_prefs == null) return contact.name;
final id = contact.id;
final savedName = _prefs!.getString('firstname_$id');
if (savedName != null && savedName.isNotEmpty) {
return savedName;
}
return contact.name;
}
String _formatTime(DateTime? time) {
if (time == null) return '';
final localTime = time.toLocal();
final hour = localTime.hour.toString().padLeft(2, '0');
final minute = localTime.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}
@override
Widget build(BuildContext context) {
final contactProvider = context.watch<ContactProvider>();
final contacts = contactProvider.contacts;
final isLoading = _isInitLoading || contactProvider.isLoading;
final primaryColor = Theme.of(context).colorScheme.primary;
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => Navigator.of(context).pop(),
),
title: const Text(
'Переслать...',
style: TextStyle(fontWeight: FontWeight.w600),
),
actions: [
AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _selectedContact != null ? 1.0 : 0.4,
child: TextButton(
onPressed: _selectedContact != null
? () => Navigator.of(context).pop(_selectedContact)
: null,
child: const Text(
'Продолжить',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
const SizedBox(width: 8),
],
),
body: () {
if (isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (contactProvider.error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text(
'Ошибка: ${contactProvider.error}',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey),
),
),
);
}
if (contacts.isEmpty) {
return const Center(
child: Text(
'Нет активных чатов для пересылки.',
style: TextStyle(color: Colors.grey, fontSize: 15),
),
);
}
return ListView.builder(
itemCount: contacts.length,
itemBuilder: (context, index) {
final contact = contacts[index];
final isSelected = _selectedContact?.id == contact.id;
// Логика формирования текста сообщения (1-в-1 как в твоем ContactTile)
final bool isDecrypted = contact.isLastMsgDecrypted ?? false;
final String subtitleText = isDecrypted
? (contact.lastMessage == null
? "Нет сообщений"
: "${contact.lastMessageType != null ? MessageModel.getMediaPreview(contact.lastMessageType!) : ''} ${contact.lastMessage}".trim())
: (contact.lastMessage != null
? "Ожидание дешифровки..."
: "Нет сообщений");
// Логика формирования URL аватарки
final avatarUrl = contact.effectiveAvatarUrl;
final bool hasAvatar = avatarUrl != null && avatarUrl.isNotEmpty;
return InkWell(
onTap: () {
setState(() {
if (isSelected) {
_selectedContact = null;
} else {
_selectedContact = contact;
}
});
},
child: Container(
color: isSelected ? primaryColor.withOpacity(0.08) : Colors.transparent,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
// 1. АВАТАРКА
leading: Stack(
children: [
if (hasAvatar)
CircleAvatar(
radius: 24,
backgroundColor: Colors.grey[200],
child: ClipOval(
child: CachedNetworkImage(
imageUrl: avatarUrl,
width: 48,
height: 48,
fit: BoxFit.cover,
httpHeaders: token != null ? {'Authorization': 'Bearer $token'} : null,
placeholder: (context, url) => const CircularProgressIndicator(strokeWidth: 2),
errorWidget: (context, url, error) => CircleAvatar(
radius: 24,
backgroundColor: primaryColor.withOpacity(0.1),
child: Text(
_getDisplayName(contact).isNotEmpty ? _getDisplayName(contact)[0].toUpperCase() : '?',
style: TextStyle(color: primaryColor, fontWeight: FontWeight.bold),
),
),
),
),
)
else
CircleAvatar(
radius: 24,
backgroundColor: primaryColor.withOpacity(0.1),
child: Text(
_getDisplayName(contact).isNotEmpty ? _getDisplayName(contact)[0].toUpperCase() : '?',
style: TextStyle(color: primaryColor, fontWeight: FontWeight.bold),
),
),
if (contact.isOnline == true)
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: Border.all(color: Theme.of(context).scaffoldBackgroundColor, width: 2),
),
),
),
],
),
// 2. ИМЯ
title: Text(
_getDisplayName(contact),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
),
// 3. ПОСЛЕДНЕЕ СООБЩЕНИЕ
subtitle: Text(
subtitleText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey),
),
// 4. ПРАВАЯ ЧАСТЬ (Анимация переключения Время <-> Галочка)
trailing: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (Widget child, Animation<double> animation) {
return ScaleTransition(scale: animation, child: child);
},
child: isSelected
? Container(
key: const ValueKey('checkmark'),
width: 24,
height: 24,
decoration: BoxDecoration(
color: primaryColor,
shape: BoxShape.circle,
),
child: const Icon(Icons.check_rounded, color: Colors.white, size: 16),
)
: Column(
key: const ValueKey('time_and_badge'),
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(contact.lastMessageTime),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
if (contact.unreadCount > 0) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: primaryColor.withAlpha((0.5 * 255).round()),
shape: BoxShape.circle,
),
child: Text(
'${contact.unreadCount}',
style: const TextStyle(color: Colors.white, fontSize: 10),
),
),
],
],
),
),
),
),
);
},
);
}(),
);
}
}

View File

@ -31,6 +31,15 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
try { try {
final authProvider = context.read<AuthProvider>(); final authProvider = context.read<AuthProvider>();
// Удаляем все сообщения пользователя
try {
final api = ApiService();
await api.deleteAllMessages();
} catch (e) {
print('Ошибка при удалении сообщений: $e');
// Продолжаем даже если удаление сообщений не удалось
}
// Удаляем старые ключи и создаем новые // Удаляем старые ключи и создаем новые
await authProvider.resetKeys(); await authProvider.resetKeys();

View File

@ -16,6 +16,9 @@ class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController(); final _usernameController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
final _totpController = TextEditingController();
bool _showTotpField = false;
String? _errorMessage;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -85,6 +88,36 @@ class _LoginScreenState extends State<LoginScreen> {
validator: (value) => validator: (value) =>
value!.length < 6 ? "Минимум 6 символов" : null, value!.length < 6 ? "Минимум 6 символов" : null,
), ),
const SizedBox(height: 16),
// Поле TOTP, если требуется
if (_showTotpField)
TextFormField(
controller: _totpController,
decoration: InputDecoration(
labelText: "TOTP код",
prefixIcon: const Icon(Icons.security),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
fillColor: Theme.of(context).colorScheme.primary,
iconColor: Theme.of(context).colorScheme.primary,
hoverColor: Theme.of(context).colorScheme.primary,
focusColor: Theme.of(context).colorScheme.primary,
),
validator: (value) => value!.isEmpty ? "Введите TOTP код" : null,
),
if (_showTotpField) const SizedBox(height: 16),
// Сообщение об ошибке
if (_errorMessage != null)
Text(
_errorMessage!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
textAlign: TextAlign.center,
),
if (_errorMessage != null) const SizedBox(height: 16),
const SizedBox(height: 24), const SizedBox(height: 24),
// Кнопка Входа // Кнопка Входа
@ -120,6 +153,7 @@ class _LoginScreenState extends State<LoginScreen> {
final success = await authProvider.login( final success = await authProvider.login(
_usernameController.text, _usernameController.text,
_passwordController.text, _passwordController.text,
totpCode: _showTotpField ? _totpController.text : null,
); );
if (success && mounted) { if (success && mounted) {
await authProvider.initRealtime(); await authProvider.initRealtime();
@ -146,9 +180,25 @@ class _LoginScreenState extends State<LoginScreen> {
} }
} }
} catch (e) { } catch (e) {
ScaffoldMessenger.of(context).showSnackBar( final error = e.toString().replaceAll('Exception: ', '');
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))), if (error.contains('TOTP код требуется')) {
); setState(() {
_showTotpField = true;
_errorMessage = error;
});
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error)),
);
}
} }
} }
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
_totpController.dispose();
super.dispose();
}
} }

View File

@ -0,0 +1,235 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import 'package:flutter/services.dart';
import 'dart:math';
import 'package:open_filex/open_filex.dart';
class MediaPreviewScreen extends StatefulWidget {
final String path;
final bool isVideo;
const MediaPreviewScreen({
super.key,
required this.path,
required this.isVideo,
});
@override
State<MediaPreviewScreen> createState() => _MediaPreviewScreenState();
}
class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
VideoPlayerController? _videoController;
bool _isPlaying = true;
String? _videoInitError;
@override
void initState() {
super.initState();
if (widget.isVideo) {
_videoController = VideoPlayerController.file(File(widget.path))
..initialize()
.then((_) {
_videoInitError = null;
if (!mounted) return;
setState(() {});
_videoController!.setLooping(false);
_videoController!.play();
})
.catchError((e) {
_videoInitError = e.toString();
_videoController?.dispose().catchError((_) {});
_videoController = null;
if (mounted) setState(() {});
});
_videoController!.addListener(() {
if (mounted) setState(() {});
});
}
}
@override
void dispose() {
_videoController?.dispose();
super.dispose();
}
String _formatDuration(Duration d) {
String two(int n) => n.toString().padLeft(2, '0');
final m = two(d.inMinutes.remainder(60));
final s = two(d.inSeconds.remainder(60));
return "$m:$s";
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: null,
body: Stack(
children: [
// MEDIA
Center(
child: widget.isVideo
? (_videoInitError != null)
? _buildVideoInitErrorFallback()
: (_videoController != null &&
_videoController!.value.isInitialized)
? Stack(
alignment: Alignment.center,
children: [
AspectRatio(
aspectRatio: _videoController!.value.aspectRatio,
child: VideoPlayer(_videoController!),
),
// overlay controls
Positioned(
bottom: 40,
left: 16,
right: 16,
child: _buildVideoControls(),
),
],
)
: const CircularProgressIndicator()
: Image.file(File(widget.path)),
),
// BOTTOM ACTIONS (как Telegram)
Positioned(
bottom: 40,
left: 16,
right: 16,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// переснять
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white10,
),
onPressed: () {
Navigator.pop(context, false);
},
icon: const Icon(Icons.refresh),
label: const Text("Переснять"),
),
// отправить
ElevatedButton.icon(
onPressed: () {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.pop(context, true);
});
},
icon: const Icon(Icons.send),
label: const Text("Отправить"),
),
],
),
),
],
),
);
}
Widget _buildVideoControls() {
final c = _videoController!;
final duration = c.value.duration;
final position = c.value.position;
final posMs = position.inMilliseconds.toDouble();
final maxMs = duration.inMilliseconds
.toDouble()
.clamp(1, double.infinity)
.toDouble();
return Container(
child: Row(
children: [
// / слева
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
icon: Icon(
c.value.isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.white,
size: 26,
),
onPressed: () {
setState(() {
if (c.value.isPlaying) {
c.pause();
_isPlaying = false;
} else {
c.play();
_isPlaying = true;
}
});
},
),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 2, // ТОНКИЙ как в Telegram
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white24,
thumbColor: Colors.white,
overlayColor: Colors.transparent,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5),
),
child: Slider(
value: posMs.clamp(0, maxMs).toDouble(),
min: 0,
max: maxMs,
onChanged: (v) {
c.seekTo(Duration(milliseconds: v.toInt()));
},
),
),
),
const SizedBox(width: 8),
Text(
"${_formatDuration(position)} / ${_formatDuration(duration)}",
style: const TextStyle(color: Colors.white, fontSize: 12),
),
],
),
);
}
Widget _buildVideoInitErrorFallback() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.play_disabled, color: Colors.white70, size: 48),
const SizedBox(height: 10),
const Text(
'Видео не воспроизводится на этом устройстве',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white70),
),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: () async {
try {
await OpenFilex.open(widget.path);
} catch (_) {}
},
icon: const Icon(Icons.open_in_new, color: Colors.white70),
label: const Text(
'Открыть внешним плеером',
style: TextStyle(color: Colors.white70),
),
),
],
);
}
}

View File

@ -0,0 +1,330 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:video_player/video_player.dart';
import 'package:open_filex/open_filex.dart';
class MediaItem {
final String path;
final bool isVideo;
MediaItem({
required this.path,
required this.isVideo,
});
}
class MediaViewer extends StatefulWidget {
final List<MediaItem> items;
final int initialIndex;
const MediaViewer({
super.key,
required this.items,
this.initialIndex = 0,
});
@override
State<MediaViewer> createState() => _MediaViewerState();
}
class _MediaViewerState extends State<MediaViewer> {
late PageController _pageController;
VideoPlayerController? _videoController;
String? _videoInitError;
int _index = 0;
bool _uiVisible = true;
bool _isLandscape = false;
@override
void initState() {
super.initState();
_index = widget.initialIndex;
_pageController = PageController(initialPage: _index);
// 1. Скрываем строку состояния и панель навигации при входе в плеер
_hideSystemUI();
_initVideoIfNeeded(_index);
}
Future<void> _initVideoIfNeeded(int index) async {
_videoController?.removeListener(_videoListener);
_videoController?.dispose();
_videoController = null;
_videoInitError = null;
final item = widget.items[index];
if (!item.isVideo) return;
final controller = VideoPlayerController.file(File(item.path));
_videoController = controller;
try {
await controller.initialize();
_videoController!.addListener(_videoListener);
controller.setLooping(false);
controller.play();
_videoInitError = null;
} catch (e) {
_videoInitError = e.toString();
_videoController?.removeListener(_videoListener);
await _videoController?.dispose().catchError((_) {});
_videoController = null;
} finally {
if (mounted) setState(() {});
}
}
void _videoListener() {
if (mounted) {
setState(() {});
}
}
// Метод скрытия системного UI (Status bar и Navigation bar)
void _hideSystemUI() {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
}
// Метод показа системного UI при выходе из полноэкранного режима
void _showSystemUI() {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values, // Возвращает статус-бар и нижний бар
);
}
void _toggleOrientation() {
setState(() {
_isLandscape = !_isLandscape;
});
if (_isLandscape) {
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
} else {
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
}
}
@override
void dispose() {
_videoController?.removeListener(_videoListener);
_videoController?.dispose();
_pageController.dispose();
// 2. Обязательно возвращаем системный UI и портретный режим при выходе
_showSystemUI();
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
super.dispose();
}
void _toggleUI() {
setState(() => _uiVisible = !_uiVisible);
}
String _format(Duration d) {
String two(int n) => n.toString().padLeft(2, '0');
return "${two(d.inMinutes.remainder(60))}:${two(d.inSeconds.remainder(60))}";
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
// SafeArea гарантирует, что даже если системные бары скрыты/показаны,
// интерактивные элементы интерфейса (кнопки закрытия, плеер)
// никогда не уйдут под физические вырезы экрана (челку, скругления)
body: SafeArea(
child: GestureDetector(
onTap: _toggleUI,
behavior: HitTestBehavior.opaque,
child: Stack(
children: [
// MEDIA PAGES (Контент растягивается на весь экран)
Positioned.fill(
child: PageView.builder(
controller: _pageController,
onPageChanged: (i) async {
setState(() => _index = i);
await _initVideoIfNeeded(i);
},
itemCount: widget.items.length,
itemBuilder: (context, i) {
final item = widget.items[i];
if (item.isVideo) {
if (_videoInitError != null) {
return _buildVideoInitErrorFallback(item.path);
}
if (_videoController == null ||
!_videoController!.value.isInitialized) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
return Center(
child: AspectRatio(
aspectRatio: _videoController!.value.aspectRatio,
child: VideoPlayer(_videoController!),
),
);
}
return Center(
child: InteractiveViewer(
maxScale: 4.0,
child: Image.file(
File(item.path),
fit: BoxFit.contain,
),
),
);
},
),
),
// TOP BAR (Кнопки управления сверху)
if (_uiVisible)
Positioned(
top: 10, // Маленький фиксированный отступ, т.к. SafeArea уже защищает сверху
left: 16,
right: 16,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.close, color: Colors.white, size: 28),
onPressed: () => Navigator.pop(context),
),
IconButton(
icon: Icon(
Icons.screen_rotation,
color: Colors.white,
size: 26,
),
onPressed: _toggleOrientation,
),
],
),
),
// VIDEO CONTROLS (Нижняя панель управления видео)
if (_uiVisible &&
widget.items[_index].isVideo &&
_videoController != null &&
_videoController!.value.isInitialized)
Positioned(
bottom: 10, // Прижато к низу безопасной зоны SafeArea
left: 16,
right: 16,
child: _buildVideoControls(),
),
],
),
),
),
);
}
Widget _buildVideoControls() {
final c = _videoController!;
final pos = c.value.position;
final dur = c.value.duration;
final posMs = pos.inMilliseconds.toDouble();
final maxMs = dur.inMilliseconds.toDouble().clamp(1, double.infinity).toDouble();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(30),
),
child: Row(
children: [
IconButton(
icon: Icon(
c.value.isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.white,
size: 28,
),
onPressed: () {
setState(() {
c.value.isPlaying ? c.pause() : c.play();
});
},
),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 4,
activeTrackColor: Colors.white60,
inactiveTrackColor: Colors.white30,
thumbColor: Colors.white,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayColor: Colors.transparent,
),
child: Slider(
value: posMs.clamp(0, maxMs),
min: 0,
max: maxMs,
onChanged: (v) {
c.seekTo(Duration(milliseconds: v.toInt()));
},
),
),
),
const SizedBox(width: 8),
Text(
"${_format(pos)} / ${_format(dur)}",
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
),
const SizedBox(width: 8),
],
),
);
}
Widget _buildVideoInitErrorFallback(String path) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.play_disabled, color: Colors.white70, size: 56),
const SizedBox(height: 10),
const Text(
'Видео не воспроизводится на этом устройстве',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white70),
),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: () async {
try {
await OpenFilex.open(path);
} catch (_) {}
},
icon: const Icon(Icons.open_in_new, color: Colors.white70),
label: const Text(
'Открыть внешним плеером',
style: TextStyle(color: Colors.white70),
),
),
],
),
);
}
}

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '/data/models/contact_model.dart';
import '/logic/contact_provider.dart'; import '/logic/contact_provider.dart';
import '/logic/auth_provider.dart'; import '/logic/auth_provider.dart';
import 'chat_screen.dart'; import 'chat_screen.dart';

View File

@ -14,13 +14,13 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
static const _showPhoneKey = 'privacy_show_phone'; static const _showPhoneKey = 'privacy_show_phone';
static const _showAvatarKey = 'privacy_show_avatar'; static const _showAvatarKey = 'privacy_show_avatar';
static const _showAboutKey = 'privacy_show_about'; static const _showAboutKey = 'privacy_show_about';
static const _showUsernameKey = 'privacy_show_username'; static const _showLastOnlineKey = 'privacy_show_last_online';
bool _showEmail = true; bool _showEmail = true;
bool _showPhone = true; bool _showPhone = true;
bool _showAvatar = true; bool _showAvatar = true;
bool _showAbout = true; bool _showAbout = true;
bool _showUsername = true; bool _showLastOnline = true;
bool _isSaving = false; bool _isSaving = false;
@override @override
@ -37,7 +37,7 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
_showPhone = prefs.getBool(_showPhoneKey) ?? true; _showPhone = prefs.getBool(_showPhoneKey) ?? true;
_showAvatar = prefs.getBool(_showAvatarKey) ?? true; _showAvatar = prefs.getBool(_showAvatarKey) ?? true;
_showAbout = prefs.getBool(_showAboutKey) ?? true; _showAbout = prefs.getBool(_showAboutKey) ?? true;
_showUsername = prefs.getBool(_showUsernameKey) ?? true; _showLastOnline = prefs.getBool(_showLastOnlineKey) ?? true;
}); });
} }
@ -50,14 +50,14 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
_showPhone = data['show_phone'] ?? true; _showPhone = data['show_phone'] ?? true;
_showAvatar = data['show_avatar'] ?? true; _showAvatar = data['show_avatar'] ?? true;
_showAbout = data['show_about'] ?? true; _showAbout = data['show_about'] ?? true;
_showUsername = data['show_username'] ?? true; _showLastOnline = data['show_last_online'] ?? true;
}); });
// Сохраняем локально для быстрого доступа // Сохраняем локально для быстрого доступа
await _savePreference(_showEmailKey, _showEmail); await _savePreference(_showEmailKey, _showEmail);
await _savePreference(_showPhoneKey, _showPhone); await _savePreference(_showPhoneKey, _showPhone);
await _savePreference(_showAvatarKey, _showAvatar); await _savePreference(_showAvatarKey, _showAvatar);
await _savePreference(_showAboutKey, _showAbout); await _savePreference(_showAboutKey, _showAbout);
await _savePreference(_showUsernameKey, _showUsername); await _savePreference(_showLastOnlineKey, _showLastOnline);
} catch (e) { } catch (e) {
// Если не удалось загрузить с сервера, используем локальные настройки // Если не удалось загрузить с сервера, используем локальные настройки
print('Ошибка загрузки настроек с сервера: $e'); print('Ошибка загрузки настроек с сервера: $e');
@ -81,7 +81,7 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
showPhone: _showPhone, showPhone: _showPhone,
showAvatar: _showAvatar, showAvatar: _showAvatar,
showAbout: _showAbout, showAbout: _showAbout,
showUsername: _showUsername, showLastOnline: _showLastOnline,
); );
if (success) { if (success) {
@ -90,7 +90,7 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
await _savePreference(_showPhoneKey, _showPhone); await _savePreference(_showPhoneKey, _showPhone);
await _savePreference(_showAvatarKey, _showAvatar); await _savePreference(_showAvatarKey, _showAvatar);
await _savePreference(_showAboutKey, _showAbout); await _savePreference(_showAboutKey, _showAbout);
await _savePreference(_showUsernameKey, _showUsername); await _savePreference(_showLastOnlineKey, _showLastOnline);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -143,13 +143,6 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
children: [ children: [
const Text('Настройки видимости', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const Text('Настройки видимости', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12), const SizedBox(height: 12),
SwitchListTile(
title: const Text('Показывать имя пользователя (@username)'),
value: _showUsername,
onChanged: (value) {
setState(() => _showUsername = value);
},
),
SwitchListTile( SwitchListTile(
title: const Text('Показывать почту другим'), title: const Text('Показывать почту другим'),
value: _showEmail, value: _showEmail,
@ -178,6 +171,13 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
setState(() => _showAbout = value); setState(() => _showAbout = value);
}, },
), ),
SwitchListTile(
title: const Text('Показывать последний онлайн'),
value: _showLastOnline,
onChanged: (value) {
setState(() => _showLastOnline = value);
},
),
const SizedBox(height: 24), const SizedBox(height: 24),
const Text( const Text(
'Эти настройки влияют на то, какую информацию о вас видят другие пользователи приложения.', 'Эти настройки влияют на то, какую информацию о вас видят другие пользователи приложения.',

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:local_auth/local_auth.dart'; import 'package:local_auth/local_auth.dart';
import 'package:flutter/services.dart';
import 'package:chepuhagram/domain/services/api_service.dart'; import 'package:chepuhagram/domain/services/api_service.dart';
import 'package:chepuhagram/domain/services/crypto_service.dart'; import 'package:chepuhagram/domain/services/crypto_service.dart';
import 'dart:convert';
class SecuritySettingsScreen extends StatefulWidget { class SecuritySettingsScreen extends StatefulWidget {
const SecuritySettingsScreen({super.key}); const SecuritySettingsScreen({super.key});
@ -13,7 +15,7 @@ class SecuritySettingsScreen extends StatefulWidget {
class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> { class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
final _passwordFormKey = GlobalKey<FormState>(); final _passwordFormKey = GlobalKey<FormState>();
final _encryptionFormKey = GlobalKey<FormState>(); final _encryptionFormKey = GlobalKey<FormState>();
final _totpFormKey = GlobalKey<FormState>(); //final _totpFormKey = GlobalKey<FormState>();
final _currentPasswordController = TextEditingController(); final _currentPasswordController = TextEditingController();
final _newPasswordController = TextEditingController(); final _newPasswordController = TextEditingController();
@ -28,11 +30,15 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
bool _isSavingPassword = false; bool _isSavingPassword = false;
bool _isSavingEncryption = false; bool _isSavingEncryption = false;
bool _isSavingTotp = false; bool _isSavingTotp = false;
bool _isTotpEnabled = false;
String? _totpSecret;
String? _totpQrCode;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_checkBiometricSupport(); _checkBiometricSupport();
_loadTotpStatus();
} }
@override @override
@ -53,7 +59,8 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
final availableBiometrics = await _localAuth.getAvailableBiometrics(); final availableBiometrics = await _localAuth.getAvailableBiometrics();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_isBiometricAvailable = canCheckBiometrics && isSupported && availableBiometrics.isNotEmpty; _isBiometricAvailable =
canCheckBiometrics && isSupported && availableBiometrics.isNotEmpty;
}); });
} catch (_) { } catch (_) {
if (!mounted) return; if (!mounted) return;
@ -63,6 +70,24 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
} }
} }
Future<void> _loadTotpStatus() async {
try {
final api = ApiService();
final userData = await api.getMe();
print('TOTP status from getMe: ${userData['totp_enabled']}');
if (!mounted) return;
setState(() {
_isTotpEnabled = userData['totp_enabled'] ?? false;
});
print('TOTP status set to: $_isTotpEnabled');
} catch (e) {
print('Error loading TOTP status: $e');
// Ignore errors, assume TOTP is disabled
if (!mounted) return;
setState(() => _isTotpEnabled = false);
}
}
Future<bool> _authenticateBiometric() async { Future<bool> _authenticateBiometric() async {
try { try {
return await _localAuth.authenticate( return await _localAuth.authenticate(
@ -96,9 +121,9 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
} }
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
const SnackBar(content: Text('Пароль успешно изменён')), context,
); ).showSnackBar(const SnackBar(content: Text('Пароль успешно изменён')));
_currentPasswordController.clear(); _currentPasswordController.clear();
_newPasswordController.clear(); _newPasswordController.clear();
_confirmPasswordController.clear(); _confirmPasswordController.clear();
@ -143,7 +168,8 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
} else { } else {
final api = ApiService(); final api = ApiService();
final userData = await api.getMe(); final userData = await api.getMe();
final encryptedPrivateKey = userData['encrypted_private_key']?.toString(); final encryptedPrivateKey = userData['encrypted_private_key']
?.toString();
if (encryptedPrivateKey == null || encryptedPrivateKey.isEmpty) { if (encryptedPrivateKey == null || encryptedPrivateKey.isEmpty) {
throw Exception('Зашифрованный ключ не найден на сервере.'); throw Exception('Зашифрованный ключ не найден на сервере.');
@ -156,12 +182,12 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
await cryptoService.savePrivateKey(privateKeyBase64); await cryptoService.savePrivateKey(privateKeyBase64);
} }
final updatedEncryptedPrivateKey = await cryptoService.encryptPrivateKeyWithPassword( final updatedEncryptedPrivateKey = await cryptoService
privateKeyBase64, .encryptPrivateKeyWithPassword(privateKeyBase64, newPassword);
newPassword,
);
final success = await ApiService().updateEncryptedPrivateKey(updatedEncryptedPrivateKey); final success = await ApiService().updateEncryptedPrivateKey(
updatedEncryptedPrivateKey,
);
if (!success) { if (!success) {
throw Exception('Не удалось обновить пароль шифрования на сервере.'); throw Exception('Не удалось обновить пароль шифрования на сервере.');
} }
@ -185,12 +211,221 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
} }
Future<void> _setupTotp() async { Future<void> _setupTotp() async {
if (_isTotpEnabled) {
// Показываем диалог с опциями
_showTotpOptionsDialog();
} else {
// Enable TOTP
setState(() => _isSavingTotp = true);
try {
final api = ApiService();
final data = await api.enableTotp();
setState(() {
_totpSecret = data['secret'];
_totpQrCode = data['qr_code'];
});
// Show dialog to scan QR and enter code
_showTotpSetupDialog();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
);
} finally {
setState(() => _isSavingTotp = false);
}
}
}
void _showTotpOptionsDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('TOTP'),
content: const Text('TOTP включён. Выберите действие:'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Отмена'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
_reissueTotp();
},
child: const Text('Перевыпустить ключ'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
_disableTotp();
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
),
child: const Text('Отключить TOTP'),
),
],
),
);
}
Future<void> _reissueTotp() async {
setState(() => _isSavingTotp = true); setState(() => _isSavingTotp = true);
await Future.delayed(const Duration(milliseconds: 500)); try {
if (!mounted) return; final api = ApiService();
setState(() => _isSavingTotp = false); final data = await api.enableTotp();
ScaffoldMessenger.of(context).showSnackBar( setState(() {
const SnackBar(content: Text('TOTP пока не подключён на сервере')), _totpSecret = data['secret'];
_totpQrCode = data['qr_code'];
});
// Show dialog to scan QR and enter code
_showTotpSetupDialog(isReissue: true);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
);
} finally {
setState(() => _isSavingTotp = false);
}
}
Future<void> _disableTotp() async {
setState(() => _isSavingTotp = true);
try {
final api = ApiService();
final success = await api.disableTotp();
if (success) {
setState(() {
_isTotpEnabled = false;
_totpSecret = null;
_totpQrCode = null;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('TOTP отключён')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
);
} finally {
setState(() => _isSavingTotp = false);
}
}
void _showTotpSetupDialog({bool isReissue = false}) {
final codeController = TextEditingController();
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text(isReissue ? 'Перевыпуск ключа TOTP' : 'Настройка TOTP'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(isReissue
? 'Отсканируйте новый QR-код в приложении аутентификатора:'
: 'Отсканируйте QR-код в приложении аутентификатора:'),
const SizedBox(height: 16),
if (_totpQrCode != null)
Builder(
builder: (context) {
final base64String = _totpQrCode!.split(',').last;
final bytes = base64Decode(base64String);
return Image.memory(bytes, width: 200, height: 200);
},
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Text(
'Ключ: ${_totpSecret ?? ''}',
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.copy, size: 18),
onPressed: () {
if (_totpSecret != null) {
Clipboard.setData(ClipboardData(text: _totpSecret!));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ключ скопирован')),
);
}
},
tooltip: 'Скопировать ключ',
),
],
),
const SizedBox(height: 16),
TextField(
controller: codeController,
decoration: const InputDecoration(
labelText: 'Введите код из приложения',
helperText: 'Обычно это 6 цифр',
),
keyboardType: TextInputType.number,
),
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
setState(() {
_totpSecret = null;
_totpQrCode = null;
});
},
child: const Text('Отмена'),
),
ElevatedButton(
onPressed: () async {
final code = codeController.text.trim();
if (code.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Введите код')),
);
return;
}
try {
final api = ApiService();
final success = await api.verifyTotp(code);
if (success) {
Navigator.of(context).pop();
setState(() {
_isTotpEnabled = true;
_totpSecret = null;
_totpQrCode = null;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(isReissue ? 'Ключ перевыпущен' : 'TOTP включён')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Неверный код')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
);
}
},
child: const Text('Подтвердить'),
),
],
),
); );
} }
@ -210,7 +445,10 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
const Text('Смена пароля аккаунта', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const Text(
'Смена пароля аккаунта',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12), const SizedBox(height: 12),
Form( Form(
key: _passwordFormKey, key: _passwordFormKey,
@ -218,10 +456,13 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
children: [ children: [
TextFormField( TextFormField(
controller: _currentPasswordController, controller: _currentPasswordController,
decoration: const InputDecoration(labelText: 'Текущий пароль'), decoration: const InputDecoration(
labelText: 'Текущий пароль',
),
obscureText: true, obscureText: true,
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) return 'Введите текущий пароль'; if (value == null || value.isEmpty)
return 'Введите текущий пароль';
return null; return null;
}, },
), ),
@ -231,7 +472,8 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
decoration: const InputDecoration(labelText: 'Новый пароль'), decoration: const InputDecoration(labelText: 'Новый пароль'),
obscureText: true, obscureText: true,
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) return 'Введите новый пароль'; if (value == null || value.isEmpty)
return 'Введите новый пароль';
if (value.length < 6) return 'Пароль слишком короткий'; if (value.length < 6) return 'Пароль слишком короткий';
return null; return null;
}, },
@ -239,23 +481,31 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
TextFormField( TextFormField(
controller: _confirmPasswordController, controller: _confirmPasswordController,
decoration: const InputDecoration(labelText: 'Повторите пароль'), decoration: const InputDecoration(
labelText: 'Повторите пароль',
),
obscureText: true, obscureText: true,
validator: (value) { validator: (value) {
if (value != _newPasswordController.text) return 'Пароли не совпадают'; if (value != _newPasswordController.text)
return 'Пароли не совпадают';
return null; return null;
}, },
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
ElevatedButton( ElevatedButton(
onPressed: _isSavingPassword ? null : _savePassword, onPressed: _isSavingPassword ? null : _savePassword,
child: _isSavingPassword ? const CircularProgressIndicator(color: Colors.white) : const Text('Сохранить пароль'), child: _isSavingPassword
? const CircularProgressIndicator(color: Colors.white)
: const Text('Сохранить пароль'),
), ),
], ],
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
const Text('Пароль шифрования сообщений', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const Text(
'Пароль шифрования сообщений',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12), const SizedBox(height: 12),
Form( Form(
key: _encryptionFormKey, key: _encryptionFormKey,
@ -275,39 +525,54 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
TextFormField( TextFormField(
controller: _newEncryptPasswordController, controller: _newEncryptPasswordController,
decoration: const InputDecoration(labelText: 'Новый пароль шифрования'), decoration: const InputDecoration(
labelText: 'Новый пароль шифрования',
),
obscureText: true, obscureText: true,
validator: (value) { validator: (value) {
if (value == null || value.length < 6) return 'Пароль слишком короткий'; if (value == null || value.length < 6)
return 'Пароль слишком короткий';
return null; return null;
}, },
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextFormField( TextFormField(
controller: _confirmEncryptPasswordController, controller: _confirmEncryptPasswordController,
decoration: const InputDecoration(labelText: 'Повторите новый пароль'), decoration: const InputDecoration(
labelText: 'Повторите новый пароль',
),
obscureText: true, obscureText: true,
validator: (value) { validator: (value) {
if (value != _newEncryptPasswordController.text) return 'Пароли не совпадают'; if (value != _newEncryptPasswordController.text)
return 'Пароли не совпадают';
return null; return null;
}, },
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
ElevatedButton( ElevatedButton(
onPressed: _isSavingEncryption ? null : _saveEncryptionPassword, onPressed: _isSavingEncryption
child: _isSavingEncryption ? const CircularProgressIndicator(color: Colors.white) : const Text('Сохранить пароль шифрования'), ? null
: _saveEncryptionPassword,
child: _isSavingEncryption
? const CircularProgressIndicator(color: Colors.white)
: const Text('Сохранить пароль шифрования'),
), ),
], ],
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
const Text('TOTP', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const Text(
'TOTP',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12), const SizedBox(height: 12),
const Text('Настройка одноразового кода (TOTP) пока не подключена на сервере.'), Text(_isTotpEnabled ? 'TOTP включён' : 'TOTP отключён'),
const SizedBox(height: 12), const SizedBox(height: 12),
ElevatedButton( ElevatedButton(
onPressed: _isSavingTotp ? null : _setupTotp, onPressed: _isSavingTotp ? null : _setupTotp,
child: _isSavingTotp ? const CircularProgressIndicator(color: Colors.white) : const Text('Установить TOTP код'), child: _isSavingTotp
? const CircularProgressIndicator(color: Colors.white)
: Text(_isTotpEnabled ? 'Отключить TOTP' : 'Включить TOTP'),
), ),
], ],
), ),

View File

@ -1,11 +1,13 @@
import 'package:chepuhagram/presentation/screens/account_settings_screen.dart'; import 'package:chepuhagram/presentation/screens/account_settings_screen.dart';
import 'package:chepuhagram/presentation/screens/login_screen.dart'; import 'package:chepuhagram/presentation/screens/login_screen.dart';
import 'package:chepuhagram/presentation/screens/privacy_settings_menu_screen.dart'; import 'package:chepuhagram/presentation/screens/privacy_settings_menu_screen.dart';
import 'package:chepuhagram/presentation/screens/appearance_settings_screen.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '/logic/auth_provider.dart'; import '/logic/auth_provider.dart';
import '/core/theme_manager.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@ -16,6 +18,7 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
String? versionCode; String? versionCode;
final ImagePicker _picker = ImagePicker();
@override @override
void initState() { void initState() {
@ -32,14 +35,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
} }
Future<void> _pickAvatar() async {
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
if (image != null) {
final success = await context.read<AuthProvider>().updateAvatar(image.path);
if (!success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ошибка загрузки аватарки')),
);
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final themeProv = context.watch<ThemeProvider>();
final authProv = context.watch<AuthProvider>(); final authProv = context.watch<AuthProvider>();
final accountEmail = authProv.email?.isNotEmpty == true final accountUsername = authProv.username?.isNotEmpty == true
? authProv.email!
: authProv.username?.isNotEmpty == true
? '@${authProv.username!}' ? '@${authProv.username!}'
: 'Не указано'; : 'Не указано';
@ -64,16 +76,54 @@ class _SettingsScreenState extends State<SettingsScreen> {
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
), ),
accountEmail: Text( accountEmail: Text(
accountEmail, accountUsername,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
), ),
currentAccountPicture: CircleAvatar( currentAccountPicture: GestureDetector(
child: Text( onTap: _pickAvatar,
initials.isEmpty ? 'U' : initials, child: SizedBox(
style: TextStyle( width: 80,
fontSize: 20, height: 80,
fontWeight: FontWeight.bold, child: Stack(
color: Theme.of(context).colorScheme.onSurface, children: [
authProv.avatarUrl != null
? CircleAvatar(
radius: 40,
backgroundImage: NetworkImage(authProv.avatarUrl!),
)
: authProv.avatarPath != null
? CircleAvatar(
radius: 40,
backgroundImage: FileImage(File(authProv.avatarPath!)),
)
: CircleAvatar(
radius: 40,
child: Text(
initials.isEmpty ? 'U' : initials,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
Positioned(
bottom: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Icons.camera_alt,
size: 16,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
],
), ),
), ),
), ),
@ -111,40 +161,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
}, },
), ),
const Divider(), const Divider(),
ListTile(
SwitchListTile( leading: const Icon(Icons.palette),
secondary: const Icon(Icons.dark_mode), title: const Text('Оформление'),
title: const Text("Ночной режим"), subtitle: const Text('Тема, цвета, обои'),
value: themeProv.themeMode == ThemeMode.dark, trailing: const Icon(Icons.chevron_right),
onChanged: (val) => themeProv.toggleTheme(val), onTap: () {
), Navigator.push(
context,
// Выбор цвета акцента MaterialPageRoute(
Padding( builder: (_) => const AppearanceSettingsScreen(),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Icon(
Icons.palette_outlined,
color: Theme.of(context).colorScheme.onSurface,
),
SizedBox(width: 10),
const Text("Цвет темы"),
Spacer(),
_colorCircle(context, const Color(0xFF24A1DE), themeProv),
_colorCircle(context, const Color(0xFF3E8E7E), themeProv),
_colorCircle(context, const Color(0xFF8E3E7E), themeProv),
_colorCircle(context, const Color(0xFFFF9800), themeProv),
_colorCircle(context, const Color(0xFFF44336), themeProv),
],
), ),
], );
), },
), ),
const Divider(),
const Divider(), const Divider(),
@ -183,22 +214,4 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
); );
} }
Widget _colorCircle(BuildContext context, Color color, ThemeProvider prov) {
bool isSelected = prov.accentColor == color;
return GestureDetector(
onTap: () => prov.updateAccentColor(color),
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: isSelected ? color : Colors.transparent,
width: 2,
),
),
child: CircleAvatar(backgroundColor: color, radius: 15),
),
);
}
} }

View File

@ -1,9 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../../logic/auth_provider.dart'; import '../../logic/auth_provider.dart';
import '../../logic/contact_provider.dart'; import '../../logic/contact_provider.dart';
import 'login_screen.dart'; import 'login_screen.dart';
@ -15,6 +12,9 @@ import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:chepuhagram/main.dart'; import 'package:chepuhagram/main.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:chepuhagram/domain/services/crypto_service.dart';
import 'package:cryptography/cryptography.dart';
import 'package:flutter/foundation.dart';
class SplashScreen extends StatefulWidget { class SplashScreen extends StatefulWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});
@ -29,6 +29,8 @@ class _SplashScreenState extends State<SplashScreen> {
// Ключ для SharedPreferences // Ключ для SharedPreferences
static const String _notificationLaunchKey = 'notification_launch_data'; static const String _notificationLaunchKey = 'notification_launch_data';
static const String _contactPublicKey = 'contact_public_key_';
static const String _contactSharedKey = 'contact_shared_key_';
@override @override
void initState() { void initState() {
@ -65,7 +67,16 @@ class _SplashScreenState extends State<SplashScreen> {
// 2. Пытаемся выполнить автологин // 2. Пытаемся выполнить автологин
final authProvider = context.read<AuthProvider>(); final authProvider = context.read<AuthProvider>();
final isLoggedIn = await authProvider.tryAutoLogin(); bool? isLoggedIn;
try {
isLoggedIn = await authProvider.tryAutoLogin();
} catch (e) {
setState(() {
connectError =
'$e+_sps_init_1'.replaceAll('Exception: ', '');
});
return;
}
if (!mounted) return; if (!mounted) return;
bool connected = false; bool connected = false;
@ -117,12 +128,58 @@ class _SplashScreenState extends State<SplashScreen> {
// Проверяем, было ли приложение запущено из уведомления // Проверяем, было ли приложение запущено из уведомления
int? targetChatId = int? targetChatId =
_targetChatId; // Сначала проверяем из onMessageOpenedApp _targetChatId; // Сначала проверяем из onMessageOpenedApp
// Если не установлено, проверяем SharedPreferences
if (targetChatId == null) { if (targetChatId == null) {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final savedData = prefs.getString(_notificationLaunchKey); final savedData = prefs.getString(_notificationLaunchKey);
try {
final contactProvider = context.read<ContactProvider>();
contactProvider.setCurrentUserId(authProvider.currentUserId);
await contactProvider.loadContacts(enrichContacts: false);
final myPrivKeyBase64 = await context
.read<CryptoService>()
.getPrivateKey();
if (myPrivKeyBase64 != null) {
final Map<int, String> keysToCompute = {};
for (var c in contactProvider.contacts) {
final savedKeyHex = prefs.getString(
'$_contactSharedKey${c.id}',
);
final savedPubKey = prefs.getString(
'$_contactPublicKey${c.id}',
);
if (savedKeyHex != null && savedPubKey == c.publicKey) {
final bytes = base64Decode(savedKeyHex);
contactProvider.setSharedKey(c.id, SecretKey(bytes));
} else if (c.publicKey != null) {
keysToCompute[c.id] = c.publicKey!;
}
}
print(
'Contacts with keys for isolate: ${keysToCompute.keys.toList()}',
);
final String privKey = myPrivKeyBase64;
final computedKeys = await compute(
CryptoService.computeSharedKeysTask,
{'keysMap': keysToCompute, 'privKey': privKey},
);
computedKeys.forEach((id, bytes) {
contactProvider.setSharedKey(id, SecretKey(bytes));
prefs.setString('$_contactSharedKey$id', base64Encode(bytes));
prefs.setString('$_contactPublicKey$id', keysToCompute[id]!);
});
}
} catch (e) {
print("Ошибка при загрузке контактов или вычислении ключей: $e");
}
// Если не установлено, проверяем SharedPreferences
if (savedData != null) { if (savedData != null) {
try { try {
final data = jsonDecode(savedData) as Map<String, dynamic>; final data = jsonDecode(savedData) as Map<String, dynamic>;
@ -178,7 +235,7 @@ class _SplashScreenState extends State<SplashScreen> {
try { try {
final contactProvider = context.read<ContactProvider>(); final contactProvider = context.read<ContactProvider>();
contactProvider.setCurrentUserId(authProvider.currentUserId); contactProvider.setCurrentUserId(authProvider.currentUserId);
await contactProvider.loadContacts(); await contactProvider.loadContacts(enrichContacts: false);
final contact = contactProvider.contacts.firstWhere( final contact = contactProvider.contacts.firstWhere(
(c) => c.id == targetChatId, (c) => c.id == targetChatId,

View File

@ -1,5 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chepuhagram/domain/services/api_service.dart'; import 'package:chepuhagram/domain/services/api_service.dart';
import 'package:chepuhagram/data/datasources/ws_client.dart';
import 'package:provider/provider.dart';
import '/core/constants.dart';
import 'package:cached_network_image/cached_network_image.dart';
class UserProfileScreen extends StatefulWidget { class UserProfileScreen extends StatefulWidget {
final int userId; final int userId;
@ -19,19 +25,48 @@ class UserProfileScreen extends StatefulWidget {
class _UserProfileScreenState extends State<UserProfileScreen> { class _UserProfileScreenState extends State<UserProfileScreen> {
Map<String, dynamic>? _userData; Map<String, dynamic>? _userData;
StreamSubscription<dynamic>? _socketSubscription;
bool _isLoading = true; bool _isLoading = true;
String? _error; String? _error;
Duration? offset;
Timer? _onlineTimer;
String? firstName;
String? lastName;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadUserData(); _loadUserData();
startOnlineUpdates();
DateTime now = DateTime.now();
offset = now.timeZoneOffset;
final socketService = Provider.of<SocketService>(context, listen: false);
_socketSubscription = socketService.messages.listen(_handleIncomingMessage);
}
void startOnlineUpdates() {
_onlineTimer = Timer.periodic(const Duration(minutes: 1), (_) {
_loadUserData();
});
} }
Future<void> _loadUserData() async { Future<void> _loadUserData() async {
_error = null;
_isLoading = true;
try { try {
final api = ApiService(); final api = ApiService();
final data = await api.getUserById(widget.userId); final data = await api.getUserById(widget.userId);
final prefs = await SharedPreferences.getInstance();
firstName = prefs.containsKey('firstname_${widget.userId}')
? prefs.getString('firstname_${widget.userId}')
: null;
lastName = prefs.containsKey('lastname_${widget.userId}')
? prefs.getString('lastname_${widget.userId}')
: null;
if (mounted) { if (mounted) {
setState(() { setState(() {
_userData = data; _userData = data;
@ -41,44 +76,66 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
setState(() { setState(() {
_error = e.toString().replaceAll('Exception: ', ''); if (e.toString().contains('SocketFailed')) {
_error =
'Ошибка соединения с сервером. Проверьте интернет соединение.';
} else {
_error = e.toString().replaceAll('Exception: ', '');
}
_isLoading = false; _isLoading = false;
}); });
} }
Future.delayed(Duration(seconds: 2), () {
_loadUserData();
});
} }
} }
@override
void dispose() {
_onlineTimer?.cancel();
_socketSubscription?.cancel();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: const Text('Информация о пользователе')),
title: const Text('Информация о пользователе'),
),
body: _isLoading body: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _error != null : _error != null
? Center( ? Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red), const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16), const SizedBox(height: 16),
Text(_error!, textAlign: TextAlign.center), Text(_error!, textAlign: TextAlign.center),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: _loadUserData, onPressed: _loadUserData,
child: const Text('Повторить'), child: const Text('Повторить'),
),
],
), ),
) ],
: _buildUserInfo(), ),
)
: _buildUserInfo(),
); );
} }
Widget _buildUserInfo() { Widget _buildUserInfo() {
if (_userData == null) return const SizedBox.shrink(); if (_userData == null) return const SizedBox.shrink();
final String displayFN = firstName ?? _userData?['first_name'] ?? '';
final String displayLN = lastName ?? _userData?['last_name'] ?? '';
final String username = _userData?['username'] ?? '';
final rawAvatarUrl = _userData?['avatar_url']?.toString();
final avatarUrl = rawAvatarUrl != null && rawAvatarUrl.startsWith('/')
? '${AppConstants.baseUrl}$rawAvatarUrl'
: rawAvatarUrl;
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
@ -87,38 +144,84 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
child: CircleAvatar( child: CircleAvatar(
radius: 50, radius: 50,
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1), backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1),
child: Text( backgroundImage:
(_userData!['first_name'] != null && _userData!['first_name'].isNotEmpty && (avatarUrl != null && _userData?['show_avatar'] == true)
_userData!['last_name'] != null && _userData!['last_name'].isNotEmpty) ? CachedNetworkImageProvider(avatarUrl)
? '${_userData!['first_name'][0]}${_userData!['last_name'][0]}'.toUpperCase() : null,
: (_userData!['first_name'] != null && _userData!['first_name'].isNotEmpty) child: (avatarUrl == null || _userData?['show_avatar'] != true)
? _userData!['first_name'][0].toUpperCase() ? Text(
: (_userData!['username'] != null && _userData!['username'].isNotEmpty) (displayFN.isNotEmpty && displayLN.isNotEmpty)
? _userData!['username'][0].toUpperCase() ? '${displayFN[0]}${displayLN[0]}'.toUpperCase()
: '?', : (displayFN.isNotEmpty)
style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold), ? displayFN[0].toUpperCase()
), : (username.isNotEmpty)
? username[0].toUpperCase()
: '?',
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
),
)
: null,
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Name // Name
if ((_userData!['first_name'] != null && _userData!['first_name'].isNotEmpty) || GestureDetector(
(_userData!['last_name'] != null && _userData!['last_name'].isNotEmpty)) onTap: () => {_editUserName(displayFN, displayLN)},
Text( child: Row(
'${_userData!['first_name'] ?? ''} ${_userData!['last_name'] ?? ''}'.trim(), children: [
style: Theme.of(context).textTheme.headlineSmall, const Spacer(),
textAlign: TextAlign.center, if ((displayFN.isNotEmpty) || (displayLN.isNotEmpty))
Text(
'$displayFN $displayLN'.trim(),
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(width: 5),
Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurface),
const Spacer(),
],
), ),
),
const SizedBox(height: 8), const SizedBox(height: 8),
// Username // Username
if (_userData!['username'] != null && _userData!['username'].isNotEmpty) if (_userData!['username'] != null && _userData!['username'].isNotEmpty)
Text( Text(
'@${_userData!['username']}', '@${_userData!['username']}',
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(
color: Colors.grey[600], context,
), ).textTheme.bodyLarge?.copyWith(color: Colors.grey[600]),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
// Last online status
if (_userData!['online'] == true)
const Text(
'Онлайн',
style: TextStyle(fontSize: 12, color: Colors.greenAccent),
textAlign: TextAlign.center,
)
else if (_userData!['last_online'] != null &&
DateTime.tryParse(_userData!['last_online']) != null)
Text(
'Был(а) в сети ${_formatLastOnline(DateTime.tryParse(_userData!['last_online'])!.add(offset != null ? offset! : Duration.zero))}',
style: const TextStyle(
fontSize: 12,
color: Color.fromARGB(255, 161, 161, 161),
),
textAlign: TextAlign.center,
)
else
const Text(
'Был(а) недавно',
style: TextStyle(
fontSize: 12,
color: Color.fromARGB(255, 161, 161, 161),
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
@ -128,7 +231,11 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
// Public Key (if available) // Public Key (if available)
if (_userData!['public_key'] != null) if (_userData!['public_key'] != null)
_buildInfoTile('Публичный ключ', _userData!['public_key'], maxLines: 3), _buildInfoTile(
'Публичный ключ',
_userData!['public_key'],
maxLines: 3,
),
// About // About
if (_userData!['about'] != null && _userData!['about'].isNotEmpty) if (_userData!['about'] != null && _userData!['about'].isNotEmpty)
@ -143,9 +250,12 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
_buildInfoTile('Почта', _userData!['email']), _buildInfoTile('Почта', _userData!['email']),
const SizedBox(height: 16), const SizedBox(height: 16),
if ((_userData!['username'] == null || _userData!['username'].isEmpty) && if ((_userData!['username'] == null ||
(_userData!['first_name'] == null || _userData!['first_name'].isEmpty) && _userData!['username'].isEmpty) &&
(_userData!['last_name'] == null || _userData!['last_name'].isEmpty) && (_userData!['first_name'] == null ||
_userData!['first_name'].isEmpty) &&
(_userData!['last_name'] == null ||
_userData!['last_name'].isEmpty) &&
(_userData!['about'] == null || _userData!['about'].isEmpty) && (_userData!['about'] == null || _userData!['about'].isEmpty) &&
(_userData!['phone'] == null || _userData!['phone'].isEmpty) && (_userData!['phone'] == null || _userData!['phone'].isEmpty) &&
(_userData!['email'] == null || _userData!['email'].isEmpty)) (_userData!['email'] == null || _userData!['email'].isEmpty))
@ -158,6 +268,134 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
); );
} }
Future<void> _editUserName(String firstname, String lastname) async {
final firstnameController = TextEditingController(text: firstname);
final lastnameController = TextEditingController(text: lastname);
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Изменить имя пользователя'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: firstnameController,
minLines: 1,
maxLines: 5,
autofocus: true,
decoration: const InputDecoration(hintText: 'Имя'),
textCapitalization: TextCapitalization.words,
),
const SizedBox(height: 8),
TextField(
controller: lastnameController,
minLines: 1,
maxLines: 5,
decoration: const InputDecoration(hintText: 'Фамилия'),
textCapitalization: TextCapitalization.words,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Сбрость'),
),
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Сохранить'),
),
],
),
);
final prefs = await SharedPreferences.getInstance();
if (result == true) {
if (firstname != firstnameController.text) {
prefs.setString('firstname_${widget.userId}', firstnameController.text);
}
if (lastname != lastnameController.text) {
prefs.setString('lastname_${widget.userId}', lastnameController.text);
}
if (mounted) {
setState(() {});
}
_loadUserData();
} else {
prefs.remove('firstname_${widget.userId}');
prefs.remove('lastname_${widget.userId}');
if (mounted) {
setState(() {});
}
_loadUserData();
}
}
void _handleIncomingMessage(Map<String, dynamic> data) async {
if (data['type'] == 'user_online') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId == widget.userId) {
if (mounted) {
setState(() {
_userData = _userData?..['online'] = true;
});
}
}
}
if (data['type'] == 'user_offline') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId == widget.userId) {
setState(() {
_userData = _userData?..['online'] = false;
_userData = _userData
?..['last_online'] = DateTime.now().toIso8601String();
});
}
}
if (data['type'] == 'user_updated') {
print('User updated message received, refreshing contact list');
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId != null && userId == widget.userId) {
_loadUserData();
}
}
}
String _formatLastOnline(DateTime lastOnline) {
final now = DateTime.now();
final difference = now.difference(lastOnline);
if (difference.inSeconds < 60) {
return 'только что';
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes} минут${_pluralize(difference.inMinutes, "у", "ы", "")} назад';
} else if (difference.inHours < 24) {
return '${difference.inHours} час${_pluralize(difference.inHours, "", "а", "ов")} назад';
} else if (difference.inDays < 7) {
return '${difference.inDays} ${_pluralize(difference.inDays, "день", "дня", "дней")} назад';
} else if (difference.inDays < 30) {
final weeks = (difference.inDays / 7).floor();
return '$weeks ${_pluralize(weeks, "неделю", "недели", "недель")} назад';
} else {
return 'давно';
}
}
String _pluralize(int count, String form1, String form2, String form5) {
final mod10 = count % 10;
final mod100 = count % 100;
if (mod10 == 1 && mod100 != 11) {
return form1;
} else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) {
return form2;
} else {
return form5;
}
}
Widget _buildInfoTile(String label, String value, {int maxLines = 1}) { Widget _buildInfoTile(String label, String value, {int maxLines = 1}) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),

View File

@ -1,78 +1,177 @@
import 'package:chepuhagram/domain/services/aPI_service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '/data/models/contact_model.dart'; import '/data/models/contact_model.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:chepuhagram/data/models/message_model.dart';
class ContactTile extends StatelessWidget { class ContactTile extends StatefulWidget {
final Contact contact; final Contact contact;
final VoidCallback? onTap; final VoidCallback? onTap;
const ContactTile({super.key, required this.contact, this.onTap}); ContactTile({super.key, required this.contact, this.onTap});
@override
State<ContactTile> createState() => _ContactTileState();
}
class _ContactTileState extends State<ContactTile> {
SharedPreferences? _prefs;
String? token;
@override
void initState() {
super.initState();
_initPrefs();
}
Future<void> _initPrefs() async {
final apiService = ApiService();
final accessToken = await apiService.getAccessToken();
final shared = await SharedPreferences.getInstance();
if (mounted) {
setState(() {
_prefs = shared;
token = accessToken;
});
}
}
String get displayName { String get displayName {
final full = '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}'.trim(); if (_prefs == null) return widget.contact.name;
final id = widget.contact.id;
final savedName = _prefs!.getString('firstname_$id');
final savedSurname = _prefs!.getString('lastname_$id');
final name = savedName ?? widget.contact.name;
final surname = savedSurname ?? widget.contact.surname;
final full =
'${name != 'Unknown' ? name : ''} ${surname != 'Unknown' ? surname : ''}'
.trim();
if (full.isNotEmpty) return full; if (full.isNotEmpty) return full;
if ((contact.username != 'Unknown' ? contact.username : '').isNotEmpty) return contact.username!; if (widget.contact.username != 'Unknown') return widget.contact.username;
return 'User'; return 'User';
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final primary = Theme.of(context).colorScheme.primary; final primary = Theme.of(context).colorScheme.primary;
final username = widget.contact.username; //
final username = contact.username; final initials =
final initials = (displayName.isNotEmpty ? displayName : (username != 'Unknown' ? username : 'U')) (displayName.isNotEmpty
.trim() ? displayName
.split(RegExp(r'\s+')) : (username != 'Unknown' ? username : 'U'))
.where((p) => p.isNotEmpty) .trim()
.take(2) .split(RegExp(r'\s+'))
.map((p) => p[0].toUpperCase()) .where((p) => p.isNotEmpty)
.join(); .take(2)
.map((p) => p[0].toUpperCase())
.join(); //
debugPrint(
'=== CONTACT DEBUG: ${widget.contact.name} -> URL: ${widget.contact.effectiveAvatarUrl}',
);
return ListTile( return ListTile(
onTap: onTap, onTap: widget.onTap, //
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), contentPadding: EdgeInsets.symmetric(
leading: CircleAvatar( horizontal: 16,
radius: 28, vertical: 4,
backgroundColor: primary.withAlpha((0.1 * 255).round()), ), //
child: Text( // Переписываем ведущий виджет (аватарку)
initials, leading: SizedBox(
style: TextStyle( width: 56, // Соответствует радиусу 28 * 2
color: Theme.of(context).colorScheme.primary, height: 56,
fontWeight: FontWeight.bold, child:
), widget.contact.effectiveAvatarUrl !=
), null //
? CachedNetworkImage(
imageUrl: widget.contact.effectiveAvatarUrl!, //
// Передаем токен для FastAPI, чтобы сервер разрешил скачивание файла
httpHeaders: {
if (token != null) 'Authorization': 'Bearer $token',
},
imageBuilder: (context, imageProvider) => Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
image: DecorationImage(
image: imageProvider,
fit: BoxFit.cover,
),
),
),
// Пока картинка качается показываем цветной круг с инициалами
placeholder: (context, url) => CircleAvatar(
radius: 28,
backgroundColor: primary.withAlpha((0.1 * 255).round()),
child: Text(
initials,
style: TextStyle(
color: primary,
fontWeight: FontWeight.bold,
),
),
),
// Ошибка 401, 404 или упал интернет? Без паники, плавно вернем инициалы
errorWidget: (context, url, error) => CircleAvatar(
radius: 28,
backgroundColor: primary.withAlpha((0.1 * 255).round()),
child: Text(
initials,
style: TextStyle(
color: primary,
fontWeight: FontWeight.bold,
),
),
),
)
: CircleAvatar(
radius: 28,
backgroundColor: primary.withAlpha((0.1 * 255).round()), //
child: Text(
initials,
style: TextStyle(color: primary, fontWeight: FontWeight.bold),
),
),
), ),
title: Text( title: Text(
contact.name, displayName, //
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), //
), ),
subtitle: Text( subtitle: Text(
contact.lastMessage ?? "Нет сообщений", widget.contact.isLastMsgDecrypted
? widget.contact.lastMessage == null
? "Нет сообщений"
: "${widget.contact.lastMessageType != null ? MessageModel.getMediaPreview(widget.contact.lastMessageType!) : ''} ${widget.contact.lastMessage}"
: (widget.contact.lastMessage != null
? "Ожидание дешифровки..."
: "Нет сообщений"),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey), style: TextStyle(color: Colors.grey), //
), ),
trailing: Column( trailing: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( Text(
_formatTime(contact.lastMessageTime), _formatTime(widget.contact.lastMessageTime), //
style: const TextStyle( style: TextStyle(color: Colors.grey, fontSize: 12), //
color: Colors.grey,
fontSize: 12,
),
), ),
const SizedBox(height: 4), SizedBox(height: 4), //
if (contact.unreadCount > 0) if (widget.contact.unreadCount > 0) //
Container( Container(
padding: const EdgeInsets.all(6), padding: EdgeInsets.all(6), //
decoration: BoxDecoration( decoration: BoxDecoration(
color: primary.withAlpha((0.5 * 255).round()), color: primary.withAlpha((0.5 * 255).round()), //
shape: BoxShape.circle, shape: BoxShape.circle, //
), ),
child: Text( child: Text(
'${contact.unreadCount}', '${widget.contact.unreadCount}', //
style: const TextStyle(color: Colors.white, fontSize: 10), style: TextStyle(color: Colors.white, fontSize: 10), //
), ),
), ),
], ],

File diff suppressed because it is too large Load Diff

View File

@ -6,17 +6,25 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <audioplayers_linux/audioplayers_linux_plugin.h>
#include <file_selector_linux/file_selector_plugin.h> #include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h> #include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <record_linux/record_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h> #include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar); file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) record_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
record_linux_plugin_register_with_registrar(record_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View File

@ -3,8 +3,10 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
file_selector_linux file_selector_linux
flutter_secure_storage_linux flutter_secure_storage_linux
record_linux
url_launcher_linux url_launcher_linux
) )

View File

@ -5,6 +5,9 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import audioplayers_darwin
import ffmpeg_kit_flutter_new_min_gpl
import file_picker
import file_selector_macos import file_selector_macos
import firebase_analytics import firebase_analytics
import firebase_core import firebase_core
@ -12,14 +15,22 @@ import firebase_messaging
import flutter_image_compress_macos import flutter_image_compress_macos
import flutter_local_notifications import flutter_local_notifications
import flutter_secure_storage_darwin import flutter_secure_storage_darwin
import gal
import local_auth_darwin import local_auth_darwin
import package_info_plus import package_info_plus
import path_provider_foundation import path_provider_foundation
import photo_manager
import record_macos
import shared_preferences_foundation import shared_preferences_foundation
import sqflite_darwin import sqflite_darwin
import url_launcher_macos import url_launcher_macos
import video_compress
import video_player_avfoundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
FFmpegKitFlutterPlugin.register(with: registry.registrar(forPlugin: "FFmpegKitFlutterPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAnalyticsPlugin")) FLTFirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAnalyticsPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
@ -27,10 +38,15 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin")) FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
PhotoManagerPlugin.register(with: registry.registrar(forPlugin: "PhotoManagerPlugin"))
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
VideoCompressPlugin.register(with: registry.registrar(forPlugin: "VideoCompressPlugin"))
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
} }

View File

@ -9,6 +9,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.35" version: "1.3.35"
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -25,6 +33,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.1" version: "2.13.1"
audioplayers:
dependency: "direct main"
description:
name: audioplayers
sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70
url: "https://pub.dev"
source: hosted
version: "6.6.0"
audioplayers_android:
dependency: transitive
description:
name: audioplayers_android
sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605"
url: "https://pub.dev"
source: hosted
version: "5.2.1"
audioplayers_darwin:
dependency: transitive
description:
name: audioplayers_darwin
sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91
url: "https://pub.dev"
source: hosted
version: "6.4.0"
audioplayers_linux:
dependency: transitive
description:
name: audioplayers_linux
sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001
url: "https://pub.dev"
source: hosted
version: "4.2.1"
audioplayers_platform_interface:
dependency: transitive
description:
name: audioplayers_platform_interface
sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656"
url: "https://pub.dev"
source: hosted
version: "7.1.1"
audioplayers_web:
dependency: transitive
description:
name: audioplayers_web
sha256: faa8fa6587f996a6f604433b53af44c57a1407d4fe8dff5766cf63d6875e8de9
url: "https://pub.dev"
source: hosted
version: "5.2.0"
audioplayers_windows:
dependency: transitive
description:
name: audioplayers_windows
sha256: bafff2b38b6f6d331887558ba6e0a01c9c208d9dbb3ad0005234db065122a734
url: "https://pub.dev"
source: hosted
version: "4.3.0"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@ -33,14 +97,94 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
cached_network_image:
dependency: "direct main"
description:
name: cached_network_image
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
cached_network_image_platform_interface:
dependency: transitive
description:
name: cached_network_image_platform_interface
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
cached_network_image_web:
dependency: transitive
description:
name: cached_network_image_web
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
camera:
dependency: "direct main"
description:
name: camera
sha256: "4142a19a38e388d3bab444227636610ba88982e36dff4552d5191a86f65dc437"
url: "https://pub.dev"
source: hosted
version: "0.11.4"
camera_android_camerax:
dependency: transitive
description:
name: camera_android_camerax
sha256: "8516fe308bc341a5067fb1a48edff0ddfa57c0d3cdcc9dbe7ceca3ba119e2577"
url: "https://pub.dev"
source: hosted
version: "0.6.30"
camera_avfoundation:
dependency: transitive
description:
name: camera_avfoundation
sha256: "11b4aee2f5e5e038982e152b4a342c749b414aa27857899d20f4323e94cb5f0b"
url: "https://pub.dev"
source: hosted
version: "0.9.23+2"
camera_platform_interface:
dependency: transitive
description:
name: camera_platform_interface
sha256: "7ac852d77699acee79f0d438b793feee26721841e50973576419ff5c6d95e9b7"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
camera_web:
dependency: transitive
description:
name: camera_web
sha256: "57f49a635c8bf249d07fb95eb693d7e4dda6796dedb3777f9127fb54847beba7"
url: "https://pub.dev"
source: hosted
version: "0.3.5+3"
characters: characters:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@ -57,6 +201,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
convert:
dependency: "direct main"
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
cross_file: cross_file:
dependency: transitive dependency: transitive
description: description:
@ -81,6 +233,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.9.0" version: "2.9.0"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@ -113,6 +273,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
extended_image:
dependency: transitive
description:
name: extended_image
sha256: f6cbb1d798f51262ed1a3d93b4f1f2aa0d76128df39af18ecb77fa740f88b2e0
url: "https://pub.dev"
source: hosted
version: "10.0.1"
extended_image_library:
dependency: transitive
description:
name: extended_image_library
sha256: "1f9a24d3a00c2633891c6a7b5cab2807999eb2d5b597e5133b63f49d113811fe"
url: "https://pub.dev"
source: hosted
version: "5.0.1"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -129,6 +305,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
ffmpeg_kit_flutter_new_min_gpl:
dependency: "direct main"
description:
name: ffmpeg_kit_flutter_new_min_gpl
sha256: "7009b1a8a75188b4f8c13ba4bbc399c8e57b13bab9ee172f4a5583774d850efd"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
ffmpeg_kit_flutter_platform_interface:
dependency: transitive
description:
name: ffmpeg_kit_flutter_platform_interface
sha256: addf046ae44e190ad0101b2fde2ad909a3cd08a2a109f6106d2f7048b7abedee
url: "https://pub.dev"
source: hosted
version: "0.2.1"
file: file:
dependency: transitive dependency: transitive
description: description:
@ -137,6 +329,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387
url: "https://pub.dev"
source: hosted
version: "11.0.2"
file_selector_linux: file_selector_linux:
dependency: transitive dependency: transitive
description: description:
@ -241,11 +441,35 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.5.18" version: "3.5.18"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_cache_manager:
dependency: "direct main"
description:
name: flutter_cache_manager
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_http_cache:
dependency: "direct main"
description:
name: flutter_http_cache
sha256: "2227f5694d730622d6dad580b0e4fdfec6b5884868148101d13c61a09661fa78"
url: "https://pub.dev"
source: hosted
version: "0.0.3"
flutter_image_compress: flutter_image_compress:
dependency: "direct main" dependency: "direct main"
description: description:
@ -294,6 +518,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.5" version: "0.1.5"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_linkify: flutter_linkify:
dependency: "direct main" dependency: "direct main"
description: description:
@ -400,6 +632,22 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
gal:
dependency: "direct main"
description:
name: gal
sha256: "969598f986789127fd407a750413249e1352116d4c2be66e81837ffeeaafdfee"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@ -408,6 +656,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.6.0" version: "1.6.0"
http_client_helper:
dependency: transitive
description:
name: http_client_helper
sha256: "8a9127650734da86b5c73760de2b404494c968a3fd55602045ffec789dac3cb1"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
http_parser: http_parser:
dependency: transitive dependency: transitive
description: description:
@ -416,14 +672,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
image_picker: image_picker:
dependency: "direct main" dependency: "direct main"
description: description:
name: image_picker name: image_picker
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.1" version: "1.2.2"
image_picker_android: image_picker_android:
dependency: transitive dependency: transitive
description: description:
@ -512,6 +776,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.7" version: "0.6.7"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
url: "https://pub.dev"
source: hosted
version: "4.12.0"
jwt_decoder: jwt_decoder:
dependency: "direct main" dependency: "direct main"
description: description:
@ -604,26 +876,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.17" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.1" version: "0.13.0"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.18.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@ -640,6 +912,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
octo_image:
dependency: transitive
description:
name: octo_image
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
open_filex: open_filex:
dependency: "direct main" dependency: "direct main"
description: description:
@ -681,7 +961,7 @@ packages:
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_provider: path_provider:
dependency: transitive dependency: "direct main"
description: description:
name: path_provider name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@ -728,6 +1008,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
url: "https://pub.dev"
source: hosted
version: "12.0.1"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
url: "https://pub.dev"
source: hosted
version: "13.0.1"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
url: "https://pub.dev"
source: hosted
version: "9.4.7"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
url: "https://pub.dev"
source: hosted
version: "0.1.3+5"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
url: "https://pub.dev"
source: hosted
version: "4.3.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
@ -736,6 +1064,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.2" version: "7.0.2"
photo_manager:
dependency: "direct main"
description:
name: photo_manager
sha256: fb3bc8ea653370f88742b3baa304700107c83d12748aa58b2b9f2ed3ef15e6c2
url: "https://pub.dev"
source: hosted
version: "3.9.0"
photo_manager_image_provider:
dependency: transitive
description:
name: photo_manager_image_provider
sha256: b6015b67b32f345f57cf32c126f871bced2501236c405aafaefa885f7c821e4f
url: "https://pub.dev"
source: hosted
version: "2.2.0"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@ -752,6 +1096,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
pointycastle:
dependency: "direct main"
description:
name: pointycastle
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
url: "https://pub.dev"
source: hosted
version: "3.9.1"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
@ -760,6 +1120,78 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5+1" version: "6.1.5+1"
record:
dependency: "direct main"
description:
name: record
sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277
url: "https://pub.dev"
source: hosted
version: "6.2.0"
record_android:
dependency: transitive
description:
name: record_android
sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1"
url: "https://pub.dev"
source: hosted
version: "1.5.1"
record_ios:
dependency: transitive
description:
name: record_ios
sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
record_linux:
dependency: transitive
description:
name: record_linux
sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7
url: "https://pub.dev"
source: hosted
version: "1.3.0"
record_macos:
dependency: transitive
description:
name: record_macos
sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
record_platform_interface:
dependency: transitive
description:
name: record_platform_interface
sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
record_web:
dependency: transitive
description:
name: record_web
sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
record_windows:
dependency: transitive
description:
name: record_windows
sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78"
url: "https://pub.dev"
source: hosted
version: "1.0.7"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@ -885,6 +1317,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner: string_scanner:
dependency: transitive dependency: transitive
description: description:
@ -913,10 +1353,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7" version: "0.7.11"
timezone: timezone:
dependency: transitive dependency: transitive
description: description:
@ -997,6 +1437,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.5" version: "3.1.5"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@ -1005,6 +1453,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
video_compress:
dependency: "direct main"
description:
name: video_compress
sha256: "31bc5cdb9a02ba666456e5e1907393c28e6e0e972980d7d8d619a7beda0d4f20"
url: "https://pub.dev"
source: hosted
version: "3.1.4"
video_player:
dependency: "direct main"
description:
name: video_player
sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
video_player_android:
dependency: transitive
description:
name: video_player_android
sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0"
url: "https://pub.dev"
source: hosted
version: "2.9.5"
video_player_avfoundation:
dependency: transitive
description:
name: video_player_avfoundation
sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e
url: "https://pub.dev"
source: hosted
version: "2.9.4"
video_player_platform_interface:
dependency: transitive
description:
name: video_player_platform_interface
sha256: "16eaed5268c571c31840dc58ef8da5f0cd4db2a98490c3b8f1cf70122546c6e0"
url: "https://pub.dev"
source: hosted
version: "6.7.0"
video_player_web:
dependency: transitive
description:
name: video_player_web
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
video_thumbnail:
dependency: "direct main"
description:
name: video_thumbnail
sha256: "181a0c205b353918954a881f53a3441476b9e301641688a581e0c13f00dc588b"
url: "https://pub.dev"
source: hosted
version: "0.5.6"
visibility_detector:
dependency: "direct main"
description:
name: visibility_detector
sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420
url: "https://pub.dev"
source: hosted
version: "0.4.0+2"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
@ -1037,6 +1549,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.3"
wechat_assets_picker:
dependency: "direct main"
description:
name: wechat_assets_picker
sha256: c307e50394c1e6dfcd5c4701e84efb549fce71444fedcf2e671c50d809b3e2a1
url: "https://pub.dev"
source: hosted
version: "9.8.0"
wechat_picker_library:
dependency: transitive
description:
name: wechat_picker_library
sha256: "5cb61b9aa935b60da5b043f8446fbb9c5077419f20ccc4856bf444aec4f44bc1"
url: "https://pub.dev"
source: hosted
version: "1.0.7"
win32: win32:
dependency: transitive dependency: transitive
description: description:
@ -1061,6 +1589,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.6.1" version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks: sdks:
dart: ">=3.10.0 <4.0.0" dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.0" flutter: ">=3.38.0"

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 2.0.0+1 version: 2.0.2+1
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0
@ -50,11 +50,30 @@ dependencies:
shared_preferences: ^2.5.5 shared_preferences: ^2.5.5
flutter_linkify: ^6.0.0 flutter_linkify: ^6.0.0
url_launcher: ^6.3.2 url_launcher: ^6.3.2
image_picker: ^1.0.4 gal: ^2.3.2
flutter_image_compress: ^2.1.0 flutter_image_compress: ^2.1.0
dio: ^5.9.2 dio: ^5.9.2
package_info_plus: ^9.0.1 package_info_plus: ^9.0.1
open_filex: ^4.3.2 open_filex: ^4.7.0
convert: ^3.1.2
cached_network_image: ^3.3.1
flutter_cache_manager: ^3.0.2
path_provider: ^2.1.3
file_picker: ^11.0.2
video_compress: ^3.1.0
video_player: ^2.11.1
flutter_http_cache: ^0.0.3
image_picker: ^1.2.2
permission_handler: ^12.0.1
wechat_assets_picker: ^9.0.0
photo_manager: ^3.0.0
camera: ^0.11.0
pointycastle: ^3.9.1
visibility_detector: ^0.4.0+2
video_thumbnail: ^0.5.3
record: ^6.2.0
audioplayers: ^6.6.0
ffmpeg_kit_flutter_new_min_gpl: ^2.1.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@ -67,6 +86,14 @@ dev_dependencies:
# rules and activating additional ones. # rules and activating additional ones.
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
flutter_launcher_icons: "^0.14.0"
flutter_launcher_icons:
android: "launcher_icon"
ios: true
image_path: "assets/images/icon.png"
remove_alpha_channel_ios: true
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

View File

@ -6,6 +6,11 @@ from app.api import schemas
from app.db import models from app.db import models
from jose import JWTError, jwt from jose import JWTError, jwt
from app.core.security import get_current_user from app.core.security import get_current_user
import pyotp
import qrcode
import base64
from io import BytesIO
from fastapi.responses import StreamingResponse
# бд # бд
@ -61,17 +66,26 @@ async def register(password: str):
@authRouter.post("/login") @authRouter.post("/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): async def login(data: schemas.LoginRequest, db: Session = Depends(get_db)):
user = db.query(models.User).filter( print(f"Login attempt: username={data.username}, totp_code provided={bool(data.totp_code)}")
models.User.username == form_data.username).first()
if not user or not security.verify_password(form_data.password, user.hashed_password): user = db.query(models.User).filter(
models.User.username == data.username).first()
if not user or not security.verify_password(data.password, user.hashed_password):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверный логин или пароль", detail="Неверный логин или пароль",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
if user.totp_secret:
if not data.totp_code:
raise HTTPException(status_code=400, detail="TOTP код требуется")
totp = pyotp.TOTP(user.totp_secret)
if not totp.verify(data.totp_code):
raise HTTPException(status_code=400, detail="Неверный TOTP код")
access_token = security.create_access_token(data={"sub": str(user.id)}) access_token = security.create_access_token(data={"sub": str(user.id)})
refresh_token = security.create_refresh_token(data={"sub": str(user.id)}) refresh_token = security.create_refresh_token(data={"sub": str(user.id)})
return { return {
@ -82,6 +96,73 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session =
} }
@authRouter.post("/totp/enable")
async def enable_totp(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
# Загружаем свежую копию user из БД
user = db.query(models.User).filter(models.User.id == current_user.id).first()
if not user:
raise HTTPException(status_code=400, detail="Пользователь не найден")
#if user.totp_secret:
#raise HTTPException(status_code=400, detail="TOTP уже включен")
secret = pyotp.random_base32()
user.totp_temp_secret = secret
db.commit()
print(f"TOTP enabled for user {user.id}, secret saved")
# Генерировать QR
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(name=user.username, issuer_name="Chepuhagram")
img = qrcode.make(uri)
buf = BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
qr_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
qr_data_url = f"data:image/png;base64,{qr_base64}"
return {"secret": secret, "qr_code": qr_data_url}
@authRouter.post("/totp/verify")
async def verify_totp(data: schemas.TOTPVerifyRequest, current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
# Загружаем свежую копию user из БД
user = db.query(models.User).filter(models.User.id == current_user.id).first()
if not user:
raise HTTPException(status_code=400, detail="Пользователь не найден")
if not user.totp_temp_secret:
raise HTTPException(status_code=400, detail="TOTP не включен")
try:
totp = pyotp.TOTP(user.totp_temp_secret)
code_str = str(data.code).strip()
is_valid = totp.verify(code_str)
print(f"TOTP verify: user_id={user.id}, code={code_str}, secret_set={bool(user.totp_temp_secret)}, valid={is_valid}")
if is_valid:
user.totp_secret = user.totp_temp_secret
user.totp_temp_secret = None
db.commit()
return {"status": "ok"}
else:
raise HTTPException(status_code=400, detail="Неверный код")
except HTTPException:
raise
except Exception as e:
print(f"TOTP verify error: {str(e)}")
raise HTTPException(status_code=500, detail=f"Ошибка верификации: {str(e)}")
@authRouter.post("/totp/disable")
async def disable_totp(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
user = db.query(models.User).filter(models.User.id == current_user.id).first()
if user:
user.totp_secret = None
db.commit()
return {"status": "ok"}
@authRouter.post("/refresh") @authRouter.post("/refresh")
async def refresh_token(data: schemas.RefreshRequest): async def refresh_token(data: schemas.RefreshRequest):
try: try:

View File

@ -1,53 +1,584 @@
from fastapi import FastAPI, Depends, HTTPException, status, APIRouter, File, UploadFile import shutil
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi import Depends, FastAPI, HTTPException, status, APIRouter, File, UploadFile, Request, Form
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core import security from sqlalchemy.sql import func
from app.api import schemas
from app.db import models
from jose import JWTError, jwt
from app.core.security import get_current_user from app.core.security import get_current_user
from app.db import models
from app.core.config import config
import os import os
import re
import uuid import uuid
# бд import urllib.request
import urllib.error
from io import BytesIO
import asyncio
def get_db(): def _ensure_directory(path: str):
if not os.path.exists(path):
os.makedirs(path, exist_ok=True)
UPLOAD_FOLDER = 'uploads'
def _parse_multipart_body(body: bytes):
try:
if not body.startswith(b"--"):
return None
boundary, _ = body.split(b"\r\n", 1)
parts = body.split(boundary)
for part in parts:
if not part or part in (b"--", b"--\r\n"):
continue
part = part.strip(b"\r\n")
if not part:
continue
headers, _, content = part.partition(b"\r\n\r\n")
if not headers or content is None:
continue
disposition_match = re.search(
br'Content-Disposition:\s*form-data;\s*name="([^"]+)"(?:;\s*filename="([^"]+)")?',
headers,
re.IGNORECASE,
)
if not disposition_match:
continue
field_name = disposition_match.group(
1).decode('utf-8', errors='ignore')
filename = disposition_match.group(2)
if field_name != 'file':
continue
filename = filename.decode(
'utf-8', errors='ignore') if filename else 'upload.bin'
content_type_match = re.search(
br'Content-Type:\s*([\w\-\/]+)', headers, re.IGNORECASE)
content_type = (
content_type_match.group(1).decode('utf-8', errors='ignore')
if content_type_match
else 'application/octet-stream'
)
return filename, content.rstrip(b'\r\n'), content_type
except Exception:
return None
return None
async def _get_upload_file(request: Request, uploaded_file: UploadFile | None):
if uploaded_file is not None:
return uploaded_file
raw_body = await request.body()
parsed = _parse_multipart_body(raw_body)
if parsed is None:
return None
filename, content, content_type = parsed
return UploadFile(filename=filename, file=BytesIO(content), content_type=content_type)
def _encode_multipart_formdata(fields, files):
boundary = uuid.uuid4().hex
body = BytesIO()
for name, value in fields.items():
body.write(f"--{boundary}\r\n".encode('utf-8'))
body.write(
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode('utf-8'))
body.write(str(value).encode('utf-8'))
body.write(b"\r\n")
for field_name, filename, content_type, file_bytes in files:
body.write(f"--{boundary}\r\n".encode('utf-8'))
body.write(
f'Content-Disposition: form-data; name="{field_name}"; filename="{filename}"\r\n'.encode(
'utf-8')
)
body.write(f"Content-Type: {content_type}\r\n\r\n".encode('utf-8'))
body.write(file_bytes)
body.write(b"\r\n")
body.write(f"--{boundary}--\r\n".encode('utf-8'))
return body.getvalue(), boundary
def _get_cloud_cache_size_bytes(db: Session) -> int:
total = db.query(func.sum(models.CloudMediaItem.size_bytes)).filter(
models.CloudMediaItem.status.in_(['pending', 'sending']),
models.CloudMediaItem.is_avatar == 0,
).scalar()
return int(total or 0)
def _find_local_media_path(file_id: str) -> str | None:
candidates = [
os.path.join(config.CLOUD_MEDIA_CACHE_FOLDER, f"{file_id}.enc"),
os.path.join('uploads', f"{file_id}.enc"),
os.path.join(config.HOME_MEDIA_FOLDER, f"{file_id}.enc"),
]
for path in candidates:
if os.path.exists(path):
return path
return None
def _stream_response_from_remote(url: str):
try:
request = urllib.request.Request(url)
response = urllib.request.urlopen(request, timeout=45)
except urllib.error.HTTPError as exc:
if exc.code == 404:
raise HTTPException(status_code=404, detail='File not found')
raise HTTPException(
status_code=502, detail=f'Error fetching media from home server: {exc.code}')
except Exception as exc:
raise HTTPException(
status_code=502, detail=f'Could not reach home server: {exc}')
headers = {k.lower(): v for k, v in response.getheaders()}
content_type = headers.get('content-type', 'application/octet-stream')
return StreamingResponse(
iter(lambda: response.read(8192), b""),
media_type=content_type,
headers={
'Content-Disposition': headers.get('content-disposition', f'attachment; filename="{os.path.basename(url)}"')
},
)
def _post_file_to_home(item: models.CloudMediaItem) -> tuple[bool, str]:
file_path = os.path.join(
config.CLOUD_MEDIA_CACHE_FOLDER, item.local_filename)
if not os.path.exists(file_path):
return False, 'Local cache file not found'
with open(file_path, 'rb') as f:
content = f.read()
fields = {
'owner_id': item.owner_id or '',
'cloud_file_id': item.file_id,
'original_filename': item.original_filename or item.local_filename,
}
files = [
('file', item.original_filename or item.local_filename,
item.content_type or 'application/octet-stream', content),
]
body, boundary = _encode_multipart_formdata(fields, files)
request = urllib.request.Request(
f"{config.HOME_SERVER_URL}/media/receive",
data=body,
headers={
'Content-Type': f'multipart/form-data; boundary={boundary}',
'X-Media-Forwarding-Secret': config.MEDIA_FORWARDING_SECRET,
},
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
if response.status == 200:
return True, ''
return False, f'Home server returned {response.status}'
except urllib.error.HTTPError as exc:
body = exc.read().decode(errors='ignore')
return False, f'Home server HTTP error {exc.code}: {body}'
except Exception as exc:
return False, str(exc)
def _cleanup_home_quota(db: Session, owner_id: int | None):
if owner_id is None:
return
total = db.query(func.sum(models.HomeMediaFile.size_bytes)).filter(
models.HomeMediaFile.owner_id == owner_id
).scalar() or 0
total = int(total)
if total <= config.HOME_USER_QUOTA_BYTES:
return
files = db.query(models.HomeMediaFile).filter(
models.HomeMediaFile.owner_id == owner_id
).order_by(models.HomeMediaFile.created_at.asc()).all()
for file_record in files:
if total <= config.HOME_USER_QUOTA_BYTES:
break
path = os.path.join(config.HOME_MEDIA_FOLDER,
file_record.storage_filename)
if os.path.exists(path):
os.remove(path)
total -= file_record.size_bytes
db.delete(file_record)
db.commit()
def _cleanup_all_home_storage():
db = models.SessionLocal() db = models.SessionLocal()
try: try:
yield db owner_ids = db.query(models.HomeMediaFile.owner_id).filter(
models.HomeMediaFile.owner_id.isnot(None)).distinct().all()
for owner_id_tuple in owner_ids:
_cleanup_home_quota(db, owner_id_tuple[0])
finally: finally:
db.close() db.close()
async def forward_pending_media_loop():
while True:
if config.SERVER_ROLE != 'cloud':
await asyncio.sleep(10)
continue
db = models.SessionLocal()
try:
total_cache = _get_cloud_cache_size_bytes(db)
if total_cache >= config.CLOUD_CACHE_MAX_BYTES:
await asyncio.sleep(config.MEDIA_FORWARD_INTERVAL_SECONDS)
continue
pending_items = db.query(models.CloudMediaItem).filter(
models.CloudMediaItem.status == 'pending',
models.CloudMediaItem.is_avatar == 0,
).order_by(models.CloudMediaItem.created_at.asc()).limit(5).all()
for item in pending_items:
item.status = 'sending'
item.attempts += 1
db.commit()
success, error = _post_file_to_home(item)
if success:
item.status = 'sent'
item.sent_at = func.now()
item.error_message = None
db.commit()
cache_path = os.path.join(
config.CLOUD_MEDIA_CACHE_FOLDER, item.local_filename)
if os.path.exists(cache_path):
os.remove(cache_path)
else:
item.status = 'failed'
item.error_message = error
db.commit()
except Exception:
pass
finally:
db.close()
await asyncio.sleep(config.MEDIA_FORWARD_INTERVAL_SECONDS)
async def home_storage_maintenance_loop():
while True:
if config.SERVER_ROLE != 'home':
await asyncio.sleep(10)
continue
_cleanup_all_home_storage()
await asyncio.sleep(600)
mediaRouter = APIRouter( mediaRouter = APIRouter(
prefix="/media", prefix='/media',
tags=[], tags=['media'],
) )
UPLOAD_FOLDER = 'uploads'
if not os.path.exists(UPLOAD_FOLDER): _ensure_directory(UPLOAD_FOLDER)
os.makedirs(UPLOAD_FOLDER) _ensure_directory(config.CLOUD_MEDIA_CACHE_FOLDER)
_ensure_directory(config.HOME_MEDIA_FOLDER)
@mediaRouter.post('/upload') @mediaRouter.post('/upload')
async def upload_file(file: UploadFile = File(...)): async def upload_file(
# Проверяем, есть ли файл в запросе request: Request,
if not file.filename: file: UploadFile = File(None),
raise HTTPException(status_code=400, detail="No selected file") ):
uploaded_file = await _get_upload_file(request, file)
if uploaded_file is None or not uploaded_file.filename:
raise HTTPException(status_code=400, detail='No selected file')
# Генерируем уникальное имя, чтобы файлы не перезаписывались content = await uploaded_file.read()
file_id = str(uuid.uuid4()) if len(content) > config.MEDIA_UPLOAD_MAX_BYTES:
raise HTTPException(
status_code=400, detail=f'File too large (max {config.MEDIA_UPLOAD_MAX_BYTES} bytes)')
file_id = uuid.uuid4().hex
filename = f"{file_id}.enc" filename = f"{file_id}.enc"
file_path = os.path.join(UPLOAD_FOLDER, filename) file_path = os.path.join(UPLOAD_FOLDER, filename)
with open(file_path, 'wb') as f:
# Сохраняем
with open(file_path, "wb") as f:
content = await file.read()
f.write(content) f.write(content)
print(f"Файл сохранен: {file_path}")
return { return {
"status": "ok", 'status': 'ok',
"file_id": file_id 'file_id': file_id,
} }
@mediaRouter.post('/v2/upload')
async def upload_file_v2(
request: Request,
file: UploadFile = File(None),
purpose: str = Form('media'),
current_user: models.User = Depends(get_current_user),
):
if config.SERVER_ROLE != 'cloud':
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Upload endpoint is available only on cloud server')
uploaded_file = await _get_upload_file(request, file)
if uploaded_file is None or not uploaded_file.filename:
raise HTTPException(status_code=400, detail='No selected file')
content = await uploaded_file.read()
if len(content) > config.MEDIA_UPLOAD_MAX_BYTES:
raise HTTPException(
status_code=400, detail=f'File too large (max {config.MEDIA_UPLOAD_MAX_BYTES} bytes)')
db = models.SessionLocal()
try:
cache_size = _get_cloud_cache_size_bytes(db)
is_avatar = purpose == 'avatar'
if cache_size >= config.CLOUD_CACHE_MAX_BYTES and not is_avatar:
raise HTTPException(
status_code=503,
detail='Cloud media cache is full; new uploads are temporarily paused until pending files are forwarded.',
)
file_id = uuid.uuid4().hex
local_filename = f"{file_id}.enc"
storage_path = os.path.join(
config.CLOUD_MEDIA_CACHE_FOLDER, local_filename)
with open(storage_path, 'wb') as f:
f.write(content)
item = models.CloudMediaItem(
file_id=file_id,
owner_id=current_user.id,
original_filename=uploaded_file.filename,
content_type=uploaded_file.content_type or 'application/octet-stream',
local_filename=local_filename,
size_bytes=len(content),
status='avatar' if is_avatar else 'pending',
is_avatar=1 if is_avatar else 0,
)
db.add(item)
db.commit()
finally:
db.close()
return {'status': 'ok', 'file_id': file_id}
@mediaRouter.post('/receive')
async def receive_media(
request: Request,
file: UploadFile = File(None),
owner_id: int | None = Form(None),
cloud_file_id: str | None = Form(None),
original_filename: str | None = Form(None),
):
secret = request.headers.get('X-Media-Forwarding-Secret')
if secret != config.MEDIA_FORWARDING_SECRET:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid forwarding secret')
uploaded_file = await _get_upload_file(request, file)
if uploaded_file is None or not uploaded_file.filename:
raise HTTPException(status_code=400, detail='No selected file')
content = await uploaded_file.read()
if len(content) > config.MEDIA_UPLOAD_MAX_BYTES:
raise HTTPException(
status_code=400, detail=f'File too large (max {config.MEDIA_UPLOAD_MAX_BYTES} bytes)')
file_id = cloud_file_id or uuid.uuid4().hex
storage_filename = f"{file_id}.enc"
file_path = os.path.join(config.HOME_MEDIA_FOLDER, storage_filename)
with open(file_path, 'wb') as f:
f.write(content)
db = models.SessionLocal()
try:
home_record = models.HomeMediaFile(
file_id=file_id,
owner_id=owner_id,
original_filename=original_filename or uploaded_file.filename,
content_type=uploaded_file.content_type or 'application/octet-stream',
storage_filename=storage_filename,
size_bytes=len(content),
)
db.add(home_record)
db.commit()
_cleanup_home_quota(db, owner_id)
finally:
db.close()
return {'status': 'ok', 'file_id': file_id}
@mediaRouter.get('/size/{file_id}')
async def get_file_size(file_id: str):
db = models.SessionLocal()
db_file = None
try:
db_file = db.query(models.HomeMediaFile).filter(
models.HomeMediaFile.file_id == file_id).first()
finally:
db.close()
# 1. Проверяем наличие файла локально на этом сервере
local_path = _find_local_media_path(file_id)
if local_path and os.path.exists(local_path):
file_size = os.path.getsize(local_path)
filename = db_file.original_filename if db_file else f"file_{file_id}"
content_type = db_file.content_type if db_file else 'application/octet-stream'
encoded_filename = urllib.parse.quote(filename)
return {"file_id": file_id, "size": file_size, "file_name": encoded_filename, "content_type": content_type}
# 2. Если роль сервера 'cloud', запрашиваем размер у домашнего сервера
if config.SERVER_ROLE == 'cloud':
remote_url = f"{config.HOME_SERVER_URL}/media/size/{file_id}"
try:
# Выполняем синхронный легковесный подзапрос к домашнему серверу в треде,
# чтобы не блокировать асинхронный цикл FastAPI (по аналогии с деплоем стримов)
def _fetch_remote_size():
req = urllib.request.Request(remote_url, method='GET')
with urllib.request.urlopen(req, timeout=5.0) as response:
if response.status == 200:
import json
return json.loads(response.read().decode('utf-8'))
return None
remote_data = await asyncio.to_thread(_fetch_remote_size)
if remote_data:
return remote_data
except urllib.error.HTTPError as e:
if e.code == 404:
raise HTTPException(
status_code=404, detail='File not found on home server')
raise HTTPException(status_code=e.code, detail='Home server error')
except Exception as e:
print(f"Ошибка подключения к домашнему серверу: {e}")
raise HTTPException(
status_code=502, detail='Home server is unavailable')
# 3. Если файл не найден ни локально, ни на удаленном сервере
raise HTTPException(status_code=404, detail='File not found')
@mediaRouter.get('/{file_id}')
async def get_file(file_id: str):
db = models.SessionLocal()
db_file = None
try:
db_file = db.query(models.HomeMediaFile).filter(
models.HomeMediaFile.file_id == file_id).first()
finally:
db.close()
local_path = _find_local_media_path(file_id)
if local_path:
filename = db_file.original_filename if db_file else f"file_{file_id}"
content_type = db_file.content_type if db_file else 'application/octet-stream'
encoded_filename = urllib.parse.quote(filename)
headers = {
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
}
return FileResponse(
local_path,
media_type=content_type,
headers=headers
)
if config.SERVER_ROLE == 'cloud':
return _stream_response_from_remote(f"{config.HOME_SERVER_URL}/media/{file_id}")
raise HTTPException(status_code=404, detail='File not found')
@mediaRouter.post('/copy_internal')
async def copy_file_internal(
request: Request,
file_id: str = Form(...),
owner_id: int = Form(...), # ID нового владельца (получателя)
):
# Проверка секрета
secret = request.headers.get('X-Media-Forwarding-Secret')
if secret != config.MEDIA_FORWARDING_SECRET:
raise HTTPException(status_code=401, detail='Unauthorized')
# 1. Находим файл
source_path = _find_local_media_path(file_id)
if not source_path:
raise HTTPException(status_code=404, detail='Source file not found')
# 2. Создаем новый ID и путь
new_file_id = uuid.uuid4().hex
new_storage_filename = f"{new_file_id}.enc"
dest_path = os.path.join(config.HOME_MEDIA_FOLDER, new_storage_filename)
# 3. Физическое копирование
shutil.copyfile(source_path, dest_path)
# 4. Обновляем БД
db = models.SessionLocal()
try:
old_record = db.query(models.HomeMediaFile).filter(
models.HomeMediaFile.file_id == file_id).first()
new_record = models.HomeMediaFile(
file_id=new_file_id,
owner_id=owner_id,
original_filename=old_record.original_filename if old_record else "copy.enc",
content_type=old_record.content_type if old_record else 'application/octet-stream',
storage_filename=new_storage_filename,
size_bytes=os.path.getsize(dest_path),
)
db.add(new_record)
db.commit()
finally:
db.close()
return {"status": "ok", "new_file_id": new_file_id}
@mediaRouter.post('/copy')
async def copy(
file_id: str = Form(...),
current_user: models.User = Depends(get_current_user),
):
if config.SERVER_ROLE != 'cloud':
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Upload endpoint is available only on cloud server')
# Делаем запрос к домашнему серверу
url = f"{config.HOME_SERVER_URL}/media/copy_internal"
# Используем FormData для передачи параметров на домашний сервер
body_data = f"file_id={file_id}&owner_id={current_user.id}".encode('utf-8')
request = urllib.request.Request(
url,
data=body_data,
headers={
'X-Media-Forwarding-Secret': config.MEDIA_FORWARDING_SECRET,
'Content-Type': 'application/x-www-form-urlencoded'
},
method='POST'
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
if response.status == 200:
import json
return json.loads(response.read().decode('utf-8'))
except Exception as e:
raise HTTPException(
status_code=502, detail=f'Failed to copy on home server: {e}')
raise HTTPException(status_code=500, detail='Copying failed')

View File

@ -31,6 +31,38 @@ async def get_chat_history(
(models.Message.sender_id == current_user.id) & (models.Message.receiver_id == contact_id) | (models.Message.sender_id == current_user.id) & (models.Message.receiver_id == contact_id) |
(models.Message.sender_id == contact_id) & (models.Message.receiver_id == current_user.id) (models.Message.sender_id == contact_id) & (models.Message.receiver_id == current_user.id)
).order_by(models.Message.timestamp.desc()).limit(limit).all() ).order_by(models.Message.timestamp.desc()).limit(limit).all()
print(
f"DEBUG get_chat_history: user={current_user.id}, contact={contact_id}, count={len(messages)}, ids={[m.id for m in messages]}",
)
return jsonable_encoder(messages)
@messagesRouter.get("/last")
async def get_last_messages(
contact_id: int,
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db),
limit: int = 2
):
messages = db.query(models.Message).filter(
(models.Message.sender_id == current_user.id) & (models.Message.receiver_id == contact_id) |
(models.Message.sender_id == contact_id) & (models.Message.receiver_id == current_user.id)
).order_by(models.Message.timestamp.desc()).limit(limit).all()
return jsonable_encoder(messages) return jsonable_encoder(messages)
@messagesRouter.delete("/all")
async def delete_all_messages(
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Удалить все сообщения пользователя"""
# Удаляем все сообщения, где пользователь либо отправитель, либо получатель
db.query(models.Message).filter(
(models.Message.sender_id == current_user.id) | (models.Message.receiver_id == current_user.id)
).delete()
db.commit()
return {"status": "ok", "detail": "Все сообщения удалены"}

View File

@ -1,14 +1,19 @@
from fastapi import Depends, APIRouter, HTTPException, Depends import os
from fastapi import Depends, APIRouter, HTTPException, Depends, Request, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db import models from app.db import models
from app.core.security import get_current_user from app.core.security import get_current_user
from app.api import schemas from app.api import schemas
from app.core.config import config
from sqlalchemy import or_, and_, exists from sqlalchemy import or_, and_, exists
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from app.websocket import connection_manager
# бд # бд
def get_db(): def get_db():
db = models.SessionLocal() db = models.SessionLocal()
try: try:
@ -17,6 +22,29 @@ def get_db():
db.close() db.close()
def _delete_old_avatar_file(file_id: str, db: Session):
upload_path = os.path.join('uploads', f"{file_id}.enc")
if os.path.exists(upload_path):
try:
os.remove(upload_path)
except OSError:
pass
cloud_item = db.query(models.CloudMediaItem).filter(
models.CloudMediaItem.file_id == file_id,
).all()
for item in cloud_item:
cloud_path = os.path.join(
config.CLOUD_MEDIA_CACHE_FOLDER, item.local_filename)
if os.path.exists(cloud_path):
try:
os.remove(cloud_path)
except OSError:
pass
db.delete(item)
db.commit()
usersRouter = APIRouter( usersRouter = APIRouter(
prefix="/users", prefix="/users",
tags=[], tags=[],
@ -37,6 +65,8 @@ async def read_users_me(current_user: models.User = Depends(get_current_user)):
"about": current_user.about, "about": current_user.about,
"public_key": current_user.public_key, "public_key": current_user.public_key,
"encrypted_private_key": current_user.encrypted_private_key, "encrypted_private_key": current_user.encrypted_private_key,
"avatar_file_id": current_user.avatar_file_id,
"totp_enabled": bool(current_user.totp_secret != None),
} }
@ -69,6 +99,7 @@ async def update_users_me(
status_code=400, detail="phone/email already in use") status_code=400, detail="phone/email already in use")
db.refresh(user_to_update) db.refresh(user_to_update)
await connection_manager.manager.broadcast({'type': 'user_updated', 'user_id': current_user.id})
return { return {
"status": "ok", "status": "ok",
"user": { "user": {
@ -100,6 +131,7 @@ async def update_encrypted_private_key(
status_code=500, detail="Не удалось сохранить ключ шифрования") status_code=500, detail="Не удалось сохранить ключ шифрования")
db.refresh(user_to_update) db.refresh(user_to_update)
await connection_manager.manager.broadcast({'type': 'user_updated', 'user_id': current_user.id})
return {"status": "ok"} return {"status": "ok"}
@ -144,9 +176,10 @@ async def update_privacy_settings(
user_to_update.show_avatar = 1 if data.show_avatar else 0 user_to_update.show_avatar = 1 if data.show_avatar else 0
if data.show_about is not None: if data.show_about is not None:
user_to_update.show_about = 1 if data.show_about else 0 user_to_update.show_about = 1 if data.show_about else 0
if data.show_username is not None: # Настройка show_username удалена, всегда сохраняем 1
user_to_update.show_username = 1 if data.show_username else 0 user_to_update.show_username = 1
if data.show_last_online is not None:
user_to_update.show_last_online = 1 if data.show_last_online else 0
try: try:
db.commit() db.commit()
except Exception: except Exception:
@ -155,6 +188,7 @@ async def update_privacy_settings(
status_code=500, detail="Не удалось сохранить настройки конфиденциальности") status_code=500, detail="Не удалось сохранить настройки конфиденциальности")
db.refresh(user_to_update) db.refresh(user_to_update)
await connection_manager.manager.broadcast({'type': 'user_updated', 'user_id': current_user.id})
return {"status": "ok"} return {"status": "ok"}
@ -168,18 +202,28 @@ async def get_privacy_settings(current_user: models.User = Depends(get_current_u
"show_phone": bool(current_user.show_phone), "show_phone": bool(current_user.show_phone),
"show_avatar": bool(current_user.show_avatar), "show_avatar": bool(current_user.show_avatar),
"show_about": bool(current_user.show_about), "show_about": bool(current_user.show_about),
"show_username": bool(current_user.show_username), "show_username": True, # Настройка show_username удалена, всегда возвращаем True
"show_last_online": bool(current_user.show_last_online),
} }
@usersRouter.get("/all") @usersRouter.get("/all")
async def read_users_all(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)): async def read_users_all(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
users = db.query(models.User).all() users = db.query(models.User).all()
return [{"id": user.id, "username": user.username, "name": f"{user.first_name} {user.last_name or ''}".strip(), "public_key": user.public_key} for user in users] users_for_return = []
if current_user.id >= 100:
for user in users:
if not 1 < int(user.id) < 100:
users_for_return.append(user)
else:
users_for_return = users
return [{"id": user.id, "username": user.username, "name": f"{user.first_name} {user.last_name or ''}".strip(), "public_key": user.public_key} for user in users_for_return]
@usersRouter.get("/chats") @usersRouter.get("/chats")
async def read_users_chats( async def read_users_chats(
request: Request,
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@ -190,7 +234,6 @@ async def read_users_chats(
Клиент должен расшифровать превью локально. Клиент должен расшифровать превью локально.
""" """
users = ( users = (
db.query(models.User) db.query(models.User)
.filter(models.User.id != current_user.id) .filter(models.User.id != current_user.id)
@ -241,9 +284,14 @@ async def read_users_chats(
"username": user.username, "username": user.username,
"name": f"{user.first_name} {user.last_name or ''}".strip(), "name": f"{user.first_name} {user.last_name or ''}".strip(),
"public_key": user.public_key, "public_key": user.public_key,
"avatar_file_id": user.avatar_file_id if (user.show_avatar or current_user.id == 1) else None,
"avatar_url": str(request.url_for("get_file", file_id=user.avatar_file_id)) if (user.show_avatar or current_user.id == 1) and user.avatar_file_id else None,
"last_message": last_msg.content if last_msg else None, "last_message": last_msg.content if last_msg else None,
"last_message_time": (last_msg.timestamp.isoformat() if last_msg and last_msg.timestamp else None), "last_message_time": (last_msg.timestamp.isoformat() if last_msg and last_msg.timestamp else None),
"unread_count": unread_count, "unread_count": unread_count,
"online": str(user.id) in connection_manager.manager.active_connections,
"last_message_id": last_msg.id if last_msg else None,
"last_message_type": last_msg.message_type if last_msg else None,
} }
) )
@ -251,9 +299,55 @@ async def read_users_chats(
return result return result
@usersRouter.get("/by-username/{username}", response_model=schemas.UserContactResponse)
def get_user_by_username(username: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user)):
user = db.query(models.User).filter(models.User.username == username).first()
if not user:
raise HTTPException(status_code=404, detail="Пользователь не найден")
profile_data = {
"id": user.id,
"public_key": user.public_key,
}
profile_data["first_name"] = user.first_name
profile_data["last_name"] = user.last_name
profile_data["username"] = user.username
if user.show_avatar or current_user.id == 1:
profile_data["avatar_url"] = str(request.url_for(
"get_file", file_id=user.avatar_file_id)) if (user.show_avatar or current_user.id == 1) and user.avatar_file_id else None
profile_data["show_avatar"] = bool(user.show_avatar)
profile_data["totp_enabled"] = bool(user.totp_secret)
if user.show_about or current_user.id == 1:
profile_data["about"] = user.about
if user.show_phone or current_user.id == 1:
profile_data["phone"] = user.phone
if user.show_email or current_user.id == 1:
profile_data["email"] = user.email
if str(user.id) in connection_manager.manager.active_connections:
profile_data["online"] = True
else:
profile_data["online"] = False
if user.show_last_online or current_user.id == 1:
profile_data["last_online"] = user.last_online.isoformat(
) if user.last_online else None
return profile_data
@usersRouter.get("/{user_id}", response_model=schemas.UserProfile) @usersRouter.get("/{user_id}", response_model=schemas.UserProfile)
def get_user_by_id( def get_user_by_id(
user_id: int, user_id: int,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user) current_user: models.User = Depends(get_current_user)
): ):
@ -271,22 +365,57 @@ def get_user_by_id(
"public_key": user.public_key, "public_key": user.public_key,
} }
# Проверяем настройки конфиденциальности profile_data["first_name"] = user.first_name
if user.show_username: profile_data["last_name"] = user.last_name
profile_data["username"] = user.username profile_data["username"] = user.username
if user.show_avatar: if user.show_avatar or current_user.id == 1:
# Для аватара пока просто передаем имя, клиент сам сгенерирует аватар profile_data["avatar_url"] = str(request.url_for(
profile_data["first_name"] = user.first_name "get_file", file_id=user.avatar_file_id)) if (user.show_avatar or current_user.id == 1) and user.avatar_file_id else None
profile_data["last_name"] = user.last_name
if user.show_about: profile_data["show_avatar"] = bool(user.show_avatar)
profile_data["totp_enabled"] = bool(user.totp_secret)
if user.show_about or current_user.id == 1:
profile_data["about"] = user.about profile_data["about"] = user.about
if user.show_phone: if user.show_phone or current_user.id == 1:
profile_data["phone"] = user.phone profile_data["phone"] = user.phone
if user.show_email: if user.show_email or current_user.id == 1:
profile_data["email"] = user.email profile_data["email"] = user.email
if str(user.id) in connection_manager.manager.active_connections:
profile_data["online"] = True
else:
profile_data["online"] = False
if user.show_last_online or current_user.id == 1:
profile_data["last_online"] = user.last_online.isoformat(
) if user.last_online else None
return profile_data return profile_data
@usersRouter.put("/me/avatar")
async def update_user_avatar(
data: dict,
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db),
):
user_to_update = db.merge(current_user)
avatar_file_id = data.get("avatar_file_id")
if avatar_file_id:
old_avatar_file_id = user_to_update.avatar_file_id
if old_avatar_file_id and old_avatar_file_id != avatar_file_id:
_delete_old_avatar_file(old_avatar_file_id, db)
user_to_update.avatar_file_id = avatar_file_id
db.commit()
print(
f"Пользователь {user_to_update.id} обновил аватар: {avatar_file_id}")
else:
raise HTTPException(
status_code=400, detail="avatar_file_id is required")
await connection_manager.manager.broadcast({'type': 'user_updated', 'user_id': current_user.id})
return {"message": "Avatar updated"}

View File

@ -7,6 +7,11 @@ class SetPublicKey(BaseModel):
class RefreshRequest(BaseModel): class RefreshRequest(BaseModel):
refresh_token: str refresh_token: str
class LoginRequest(BaseModel):
username: str
password: str
totp_code: Optional[str] = None
class SetupAccount(BaseModel): class SetupAccount(BaseModel):
first_name: str first_name: str
last_name: str last_name: str
@ -45,6 +50,10 @@ class UpdatePrivacySettings(BaseModel):
show_avatar: Optional[bool] = None show_avatar: Optional[bool] = None
show_about: Optional[bool] = None show_about: Optional[bool] = None
show_username: Optional[bool] = None show_username: Optional[bool] = None
show_last_online: Optional[bool] = None
class TOTPVerifyRequest(BaseModel):
code: str
class UserProfile(BaseModel): class UserProfile(BaseModel):
id: int id: int
@ -54,6 +63,20 @@ class UserProfile(BaseModel):
about: Optional[str] = None about: Optional[str] = None
phone: Optional[str] = None phone: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
avatar_url: Optional[str] = None
public_key: Optional[str] = None
online: bool = False
last_online: Optional[str] = None
show_avatar: bool = False
totp_enabled: bool = False
class Config:
from_attributes = True
class UserContactResponse(BaseModel):
id: str
name: str
username: str
public_key: Optional[str] = None public_key: Optional[str] = None
class Config: class Config:

View File

@ -0,0 +1,34 @@
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
# Database
DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:///./chepuhagram.db")
# Security
JWT_KEY: str = os.getenv("JWT_KEY", "")
if not JWT_KEY:
raise RuntimeError("JWT_KEY environment variable not set")
# Firebase
FIREBASE_CREDENTIALS_PATH: str = os.getenv("FIREBASE_CREDENTIALS_PATH", "chepuhagram-6ca5d-firebase-adminsdk-fbsvc-cf8a5ad2f3.json")
# Server
HOST: str = os.getenv("HOST", "0.0.0.0")
PORT: int = int(os.getenv("PORT", "8000"))
SERVER_ROLE: str = os.getenv("SERVER_ROLE", "cloud").lower()
HOME_SERVER_URL: str = os.getenv("HOME_SERVER_URL", "http://home-server.local:8000")
MEDIA_FORWARDING_SECRET: str = os.getenv("MEDIA_FORWARDING_SECRET", "changeme")
CLOUD_MEDIA_CACHE_FOLDER: str = os.getenv("CLOUD_MEDIA_CACHE_FOLDER", "cloud_media_cache")
HOME_MEDIA_FOLDER: str = os.getenv("HOME_MEDIA_FOLDER", "home_media_store")
CLOUD_CACHE_MAX_BYTES: int = int(os.getenv("CLOUD_CACHE_MAX_BYTES", str(5 * 1024 * 1024 * 1024)))
HOME_USER_QUOTA_BYTES: int = int(os.getenv("HOME_USER_QUOTA_BYTES", str(10 * 1024 * 1024 * 1024)))
MEDIA_UPLOAD_MAX_BYTES: int = int(os.getenv("MEDIA_UPLOAD_MAX_BYTES", str(100 * 1024 * 1024)))
MEDIA_FORWARD_INTERVAL_SECONDS: int = int(os.getenv("MEDIA_FORWARD_INTERVAL_SECONDS", "12"))
# CORS
ALLOWED_ORIGINS: list = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000").split(",")
config = Config()

View File

@ -10,7 +10,10 @@ from jose import JWTError, jwt
import os import os
import bcrypt import bcrypt
load_dotenv() load_dotenv()
SECRET_KEY = os.getenv("JWT_KEY").strip() SECRET_KEY = os.getenv("JWT_KEY")
if not SECRET_KEY:
raise RuntimeError("JWT_KEY environment variable not set")
SECRET_KEY = SECRET_KEY.strip()
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30 ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 60 REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 60

View File

@ -4,12 +4,12 @@ from sqlalchemy.orm import sessionmaker
from sqlalchemy import Column, Integer, Text, ForeignKey, DateTime from sqlalchemy import Column, Integer, Text, ForeignKey, DateTime
from sqlalchemy.sql import func from sqlalchemy.sql import func
from sqlalchemy import text from sqlalchemy import text
from app.core.config import config
SQLALCHEMY_DATABASE_URL = "sqlite:///./chepuhagram.db" SQLALCHEMY_DATABASE_URL = config.DATABASE_URL
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base() Base = declarative_base()
Base.metadata.create_all(bind=engine)
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
@ -21,10 +21,12 @@ class User(Base):
phone = Column(String(20), unique=True, nullable=True) phone = Column(String(20), unique=True, nullable=True)
email = Column(String(255), unique=True, nullable=True) email = Column(String(255), unique=True, nullable=True)
totp_secret = Column(String(32), nullable=True) totp_secret = Column(String(32), nullable=True)
totp_temp_secret = Column(String(32), nullable=True) # Temporary secret until verified
hashed_password = Column(String) hashed_password = Column(String)
public_key = Column(String, nullable=True) public_key = Column(String, nullable=True)
encrypted_private_key = Column(String, nullable=True) encrypted_private_key = Column(String, nullable=True)
fcm_token = Column(String, nullable=True) fcm_token = Column(String, nullable=True)
avatar_file_id = Column(String, nullable=True)
# Privacy settings # Privacy settings
show_email = Column(Integer, nullable=False, server_default="1") # 1 = true, 0 = false show_email = Column(Integer, nullable=False, server_default="1") # 1 = true, 0 = false
@ -32,6 +34,8 @@ class User(Base):
show_avatar = Column(Integer, nullable=False, server_default="1") show_avatar = Column(Integer, nullable=False, server_default="1")
show_about = Column(Integer, nullable=False, server_default="1") show_about = Column(Integer, nullable=False, server_default="1")
show_username = Column(Integer, nullable=False, server_default="1") show_username = Column(Integer, nullable=False, server_default="1")
show_last_online = Column(Integer, nullable=False, server_default="1")
last_online = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class Message(Base): class Message(Base):
__tablename__ = "messages" __tablename__ = "messages"
@ -45,6 +49,38 @@ class Message(Base):
reply_to_id = Column(Integer, ForeignKey("messages.id"), nullable=True) reply_to_id = Column(Integer, ForeignKey("messages.id"), nullable=True)
reply_to_text = Column(Text, nullable=True) reply_to_text = Column(Text, nullable=True)
edited_at = Column(DateTime(timezone=True), nullable=True) edited_at = Column(DateTime(timezone=True), nullable=True)
message_type = Column(String, nullable=False, server_default="text")
file_id = Column(String, nullable=True)
encrypted_key = Column(String, nullable=True)
class CloudMediaItem(Base):
__tablename__ = "cloud_media_items"
id = Column(Integer, primary_key=True, index=True)
file_id = Column(String, unique=True, nullable=False, index=True)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=True)
original_filename = Column(String, nullable=True)
content_type = Column(String, nullable=True)
local_filename = Column(String, nullable=False)
size_bytes = Column(Integer, nullable=False)
status = Column(String, nullable=False, server_default="pending")
is_avatar = Column(Integer, nullable=False, server_default="0")
attempts = Column(Integer, nullable=False, server_default="0")
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
sent_at = Column(DateTime(timezone=True), nullable=True)
error_message = Column(Text, nullable=True)
class HomeMediaFile(Base):
__tablename__ = "home_media_files"
id = Column(Integer, primary_key=True, index=True)
file_id = Column(String, unique=True, nullable=False, index=True)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=True)
original_filename = Column(String, nullable=True)
content_type = Column(String, nullable=True)
storage_filename = Column(String, nullable=False)
size_bytes = Column(Integer, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@ -66,6 +102,12 @@ def _ensure_sqlite_message_columns():
conn.execute(text("ALTER TABLE messages ADD COLUMN reply_to_text TEXT")) conn.execute(text("ALTER TABLE messages ADD COLUMN reply_to_text TEXT"))
if "edited_at" not in existing: if "edited_at" not in existing:
conn.execute(text("ALTER TABLE messages ADD COLUMN edited_at DATETIME")) conn.execute(text("ALTER TABLE messages ADD COLUMN edited_at DATETIME"))
if "message_type" not in existing:
conn.execute(text("ALTER TABLE messages ADD COLUMN message_type VARCHAR(32) DEFAULT 'text' NOT NULL"))
if "file_id" not in existing:
conn.execute(text("ALTER TABLE messages ADD COLUMN file_id VARCHAR(255)"))
if "encrypted_key" not in existing:
conn.execute(text("ALTER TABLE messages ADD COLUMN encrypted_key VARCHAR(1024)"))
conn.commit() conn.commit()
@ -93,6 +135,15 @@ def _ensure_sqlite_user_columns():
conn.execute(text("ALTER TABLE users ADD COLUMN show_about INTEGER DEFAULT 1")) conn.execute(text("ALTER TABLE users ADD COLUMN show_about INTEGER DEFAULT 1"))
if "show_username" not in existing: if "show_username" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN show_username INTEGER DEFAULT 1")) conn.execute(text("ALTER TABLE users ADD COLUMN show_username INTEGER DEFAULT 1"))
if "show_last_online" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN show_last_online INTEGER DEFAULT 1"))
if "last_online" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN last_online DATETIME"))
conn.execute(text("UPDATE users SET last_online = datetime('now')"))
if "avatar_file_id" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN avatar_file_id VARCHAR(255)"))
if "totp_temp_secret" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN totp_temp_secret VARCHAR(32)"))
conn.commit() conn.commit()

View File

@ -1,14 +1,15 @@
from fastapi import HTTPException, status, APIRouter, WebSocket, WebSocketDisconnect, Query, Depends from fastapi import HTTPException, status, APIRouter, WebSocket, WebSocketDisconnect, Query, Depends
from app.core.security import test_token from app.core.security import test_token
from typing import Dict from typing import Dict
from datetime import datetime from datetime import datetime, timezone
import json import json
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db import models from app.db import models
from firebase_admin import messaging, credentials, exceptions from firebase_admin import messaging, credentials, exceptions
import firebase_admin import firebase_admin
from app.core.config import config
cred = credentials.Certificate("chepuhagram-6ca5d-firebase-adminsdk-fbsvc-cf8a5ad2f3.json") cred = credentials.Certificate(config.FIREBASE_CREDENTIALS_PATH)
firebase_admin.initialize_app(cred) firebase_admin.initialize_app(cred)
# бд # бд
@ -40,6 +41,14 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
print("ПОДКЛЮЧЕНИЕ") print("ПОДКЛЮЧЕНИЕ")
await manager.connect(user_id, websocket) await manager.connect(user_id, websocket)
print("ПОДКЛЮЧЕНО") print("ПОДКЛЮЧЕНО")
db.query(models.User).filter(models.User.id == user_id).update({"last_online": datetime.now(timezone.utc)},
synchronize_session="fetch")
db.commit()
await manager.broadcast({
"type": "user_online",
"user_id": user_id,
})
try: try:
while True: while True:
print("ОЖИДАНИЕ СООБЩЕНИЙ") print("ОЖИДАНИЕ СООБЩЕНИЙ")
@ -47,13 +56,25 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
message_data = json.loads(data) message_data = json.loads(data)
print(f"DEBUG: Получены данные: {message_data}") print(f"DEBUG: Получены данные: {message_data}")
db.query(models.User).filter(models.User.id == user_id).update({"last_online": datetime.now(timezone.utc)},
synchronize_session="fetch")
db.commit()
if message_data.get("type") == "private_message": if message_data.get("type") == "private_message":
user = db.query(models.User).filter(models.User.id == user_id).first() user = db.query(models.User).filter(
models.User.id == user_id).first()
receiver_id = message_data.get("receiver_id") receiver_id = message_data.get("receiver_id")
temp_id = message_data.get("temp_id") temp_id = message_data.get("temp_id")
content = message_data.get("content") content = message_data.get("content")
content50 = message_data.get("content50") content50 = message_data.get("content50")
message_type = message_data.get("message_type") or "text"
file_id = message_data.get("file_id")
encrypted_key = message_data.get("encrypted_key")
print(
f"DEBUG private_message payload: temp_id={temp_id}, receiver_id={receiver_id}, message_type={message_type}, file_id={file_id}, encrypted_key_present={encrypted_key is not None}",
)
if receiver_id is None or content is None: if receiver_id is None or content is None:
await websocket.send_json({ await websocket.send_json({
@ -75,6 +96,9 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
sender_id=user_id, sender_id=user_id,
receiver_id=receiver_id, receiver_id=receiver_id,
content=content, content=content,
message_type=message_type,
file_id=file_id,
encrypted_key=encrypted_key,
reply_to_id=message_data.get("reply_to_id"), reply_to_id=message_data.get("reply_to_id"),
reply_to_text=message_data.get("reply_to_text") reply_to_text=message_data.get("reply_to_text")
) )
@ -82,15 +106,19 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
db.commit() db.commit()
db.refresh(new_msg) db.refresh(new_msg)
# ACK отправителю: сервер принял и сохранил сообщение (нужно для статусов клиента). print(
f"DEBUG saved message: id={new_msg.id}, sender={new_msg.sender_id}, receiver={new_msg.receiver_id}, message_type={new_msg.message_type}, file_id={new_msg.file_id}, encrypted_key_present={new_msg.encrypted_key is not None}",
)
# ACK отправителю: сервер принял и сохранил сообщение.
await manager.send_personal_message({ await manager.send_personal_message({
"type": "message_sent", "type": "message_sent",
"temp_id": temp_id, "temp_id": temp_id,
"server_id": new_msg.id, "server_id": new_msg.id,
"timestamp": (new_msg.timestamp or datetime.now()).isoformat(), "timestamp": (new_msg.timestamp or datetime.utcnow()).isoformat(),
}, str(user_id)) }, str(user_id))
# Если получатель оффлайн — отправим пуш (если есть токен и ключи). # отправим пуш.
if user.public_key: if user.public_key:
receiver = db.query(models.User).filter( receiver = db.query(models.User).filter(
models.User.id == receiver_id).first() models.User.id == receiver_id).first()
@ -101,7 +129,12 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
user.first_name, user.first_name,
user.public_key, user.public_key,
content50 if content50 else content, content50 if content50 else content,
datetime.now(), datetime.utcnow(),
unread_count=db.query(models.Message).filter(
models.Message.receiver_id == receiver_id,
models.Message.read_at == None
).count(),
message_id=new_msg.id,
) )
# Формируем пакет для получателя # Формируем пакет для получателя
outgoing_message = { outgoing_message = {
@ -110,18 +143,26 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
"sender_id": user_id, "sender_id": user_id,
"receiver_id": receiver_id, "receiver_id": receiver_id,
"content": message_data.get("content"), "content": message_data.get("content"),
"timestamp": (new_msg.timestamp or datetime.now()).isoformat(), "message_type": message_type,
"file_id": file_id,
"encrypted_key": message_data.get("encrypted_key"),
"timestamp": (new_msg.timestamp or datetime.utcnow()).isoformat(),
"reply_to_id": new_msg.reply_to_id, "reply_to_id": new_msg.reply_to_id,
"reply_to_text": new_msg.reply_to_text, "reply_to_text": new_msg.reply_to_text,
} }
print(
f"DEBUG outgoing_message: id={outgoing_message['id']}, receiver_id={outgoing_message['receiver_id']}, file_id={outgoing_message['file_id']}, encrypted_key_present={outgoing_message['encrypted_key'] is not None}",
)
# Пересылаем получателю, если он в сети # Пересылаем получателю, если он в сети
sent_to_receiver = await manager.send_personal_message(outgoing_message, str(receiver_id)) sent_to_receiver = await manager.send_personal_message(outgoing_message, str(receiver_id))
print(f"DEBUG send_personal_message returned: {sent_to_receiver}")
# Если сообщение реально ушло по сокету получателю — отмечаем delivered_at. # Если сообщение реально ушло по сокету получателю — отмечаем delivered_at.
if sent_to_receiver: if sent_to_receiver:
try: try:
delivered_at = datetime.now() delivered_at = datetime.utcnow()
new_msg.delivered_at = delivered_at new_msg.delivered_at = delivered_at
db.add(new_msg) db.add(new_msg)
db.commit() db.commit()
@ -150,12 +191,13 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
"detail": "message_id must be int", "detail": "message_id must be int",
}) })
continue continue
msg = db.query(models.Message).filter(models.Message.id == message_id).first() msg = db.query(models.Message).filter(
models.Message.id == message_id).first()
if msg is None or msg.sender_id != user_id: if msg is None or msg.sender_id != user_id:
continue continue
try: try:
msg.content = content msg.content = content
msg.edited_at = datetime.now() msg.edited_at = datetime.utcnow()
db.add(msg) db.add(msg)
db.commit() db.commit()
except Exception: except Exception:
@ -164,6 +206,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
event = { event = {
"type": "message_edited", "type": "message_edited",
"message_id": msg.id, "message_id": msg.id,
"sender_id": msg.sender_id,
"content": msg.content, "content": msg.content,
"edited_at": msg.edited_at.isoformat() if msg.edited_at else None, "edited_at": msg.edited_at.isoformat() if msg.edited_at else None,
} }
@ -186,7 +229,8 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
"detail": "message_id must be int", "detail": "message_id must be int",
}) })
continue continue
msg = db.query(models.Message).filter(models.Message.id == message_id).first() msg = db.query(models.Message).filter(
models.Message.id == message_id).first()
if msg is None or msg.sender_id != user_id: if msg is None or msg.sender_id != user_id:
continue continue
receiver_id = msg.receiver_id receiver_id = msg.receiver_id
@ -210,7 +254,8 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
msg = db.query(models.Message).filter(models.Message.id == message_id).first() msg = db.query(models.Message).filter(
models.Message.id == message_id).first()
if msg is None: if msg is None:
continue continue
@ -220,7 +265,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
# Сохраняем read_at в БД # Сохраняем read_at в БД
try: try:
read_at = datetime.now() read_at = datetime.utcnow()
msg.read_at = read_at msg.read_at = read_at
db.add(msg) db.add(msg)
db.commit() db.commit()
@ -231,16 +276,50 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
await manager.send_personal_message({ await manager.send_personal_message({
"type": "message_read", "type": "message_read",
"message_id": message_id, "message_id": message_id,
"timestamp": read_at.isoformat() if 'read_at' in locals() else datetime.now().isoformat(), "timestamp": read_at.isoformat() if 'read_at' in locals() else datetime.utcnow().isoformat(),
}, str(sender_id)) }, str(sender_id))
elif message_data.get("type") == "typing":
receiver_id = message_data.get("receiver_id")
if receiver_id is None:
continue
try:
receiver_id = int(receiver_id)
except (TypeError, ValueError):
continue
await manager.send_personal_message({
"type": "typing",
"sender_id": user_id,
}, str(receiver_id))
elif message_data.get("type") == "stop_typing":
receiver_id = message_data.get("receiver_id")
if receiver_id is None:
continue
try:
receiver_id = int(receiver_id)
except (TypeError, ValueError):
continue
await manager.send_personal_message({
"type": "stop_typing",
"sender_id": user_id,
}, str(receiver_id))
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
finally: finally:
manager.disconnect(user_id) manager.disconnect(user_id)
db.query(models.User).filter(models.User.id == user_id).update(
{"last_online": datetime.now(timezone.utc)}, synchronize_session="fetch")
db.commit()
print("ОТКЛЮЧЕНИЕ")
await manager.broadcast({
"type": "user_offline",
"user_id": user_id,
})
def send_fcm_notification(token, user_id, username, public_key, encrypted_text, timestamp): def send_fcm_notification(token, user_id, username, public_key, encrypted_text, timestamp, unread_count='1', message_id='0'):
print(f"DEBUG: Отправляем FCM уведомление пользователю {user_id} с токеном {token}") print(
f"DEBUG: Отправляем FCM уведомление пользователю {user_id} с токеном {token}")
message = messaging.Message( message = messaging.Message(
data={ data={
"type": "enc_message", "type": "enc_message",
@ -249,6 +328,8 @@ def send_fcm_notification(token, user_id, username, public_key, encrypted_text,
"public_key": public_key, "public_key": public_key,
"content": encrypted_text, # Зашифрованный текст "content": encrypted_text, # Зашифрованный текст
"timestamp": timestamp.isoformat(), "timestamp": timestamp.isoformat(),
"unread_count": str(unread_count),
"message_id": str(message_id),
}, },
android=messaging.AndroidConfig( android=messaging.AndroidConfig(
priority='high', priority='high',

Binary file not shown.

View File

@ -4,6 +4,9 @@ from app.api.endpoints import users, auth, messages, media
from app.websocket.connection_manager import wsRouter from app.websocket.connection_manager import wsRouter
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import os import os
import asyncio
from app.db import models
from app.core.config import config
app = FastAPI() app = FastAPI()
@ -15,7 +18,7 @@ app.include_router(wsRouter)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=config.ALLOWED_ORIGINS,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@ -25,7 +28,7 @@ app.add_middleware(
@app.get("/check-update") @app.get("/check-update")
async def check_update(): async def check_update():
return { return {
"latest_version": "2.0.0", "latest_version": "2.0.1",
"apk_url": "https://api.chepuhagram.ru/get-update", "apk_url": "https://api.chepuhagram.ru/get-update",
"force_update": False "force_update": False
} }
@ -47,11 +50,41 @@ async def head_image():
if not os.path.exists(file_path): if not os.path.exists(file_path):
return {"error": "Файл не найден"} return {"error": "Файл не найден"}
return FileResponse( return FileResponse(path=file_path, filename="chepuhagram-release.apk",
path=file_path, media_type="application/vnd.android.package-archive",)
filename="chepuhagram-release.apk",
media_type="application/vnd.android.package-archive"
) @app.on_event("startup")
async def startup_event():
asyncio.create_task(cleanup_uploads())
if config.SERVER_ROLE == 'cloud':
asyncio.create_task(media.forward_pending_media_loop())
elif config.SERVER_ROLE == 'home':
asyncio.create_task(media.home_storage_maintenance_loop())
async def cleanup_uploads():
while True:
try:
db = models.SessionLocal()
# Получить все используемые file_id из avatar_file_id
file_ids = db.query(models.User.avatar_file_id).filter(models.User.avatar_file_id.isnot(None)).all()
used_files = set(f[0] for f in file_ids)
db.close()
# Проверить файлы в uploads
uploads_dir = 'uploads'
if os.path.exists(uploads_dir):
for filename in os.listdir(uploads_dir):
if filename.endswith('.enc'):
file_id = filename[:-4] # убрать .enc
if file_id not in used_files:
file_path = os.path.join(uploads_dir, filename)
os.remove(file_path)
print(f"Удален неиспользуемый файл: {file_path}")
except Exception as e:
print(f"Ошибка в cleanup: {e}")
await asyncio.sleep(300) # каждые 5 минут
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn

View File

@ -4,3 +4,5 @@ sqlalchemy
passlib[bcrypt] passlib[bcrypt]
python-jose[cryptography] python-jose[cryptography]
python-multipart python-multipart
pyotp
qrcode[pil]

View File

@ -6,21 +6,33 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <audioplayers_windows/audioplayers_windows_plugin.h>
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
#include <firebase_core/firebase_core_plugin_c_api.h> #include <firebase_core/firebase_core_plugin_c_api.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h> #include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <gal/gal_plugin_c_api.h>
#include <local_auth_windows/local_auth_plugin.h> #include <local_auth_windows/local_auth_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <record_windows/record_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h> #include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
AudioplayersWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
FileSelectorWindowsRegisterWithRegistrar( FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows")); registry->GetRegistrarForPlugin("FileSelectorWindows"));
FirebaseCorePluginCApiRegisterWithRegistrar( FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar( FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
GalPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GalPluginCApi"));
LocalAuthPluginRegisterWithRegistrar( LocalAuthPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("LocalAuthPlugin")); registry->GetRegistrarForPlugin("LocalAuthPlugin"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
RecordWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar( UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows")); registry->GetRegistrarForPlugin("UrlLauncherWindows"));
} }

View File

@ -3,10 +3,14 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_windows
file_selector_windows file_selector_windows
firebase_core firebase_core
flutter_secure_storage_windows flutter_secure_storage_windows
gal
local_auth_windows local_auth_windows
permission_handler_windows
record_windows
url_launcher_windows url_launcher_windows
) )