diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 4df7405..f73cb16 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -49,4 +49,13 @@ dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
implementation(platform("com.google.firebase:firebase-bom:34.12.0"))
implementation("com.google.firebase:firebase-messaging")
+}
+
+configurations.all {
+ resolutionStrategy.eachDependency {
+ if (requested.group == "com.arthenica" && requested.name.startsWith("ffmpeg-kit")) {
+ useVersion("6.0.3")
+ because("Фикс падения сборки на версии 6.0.3+2-LTS")
+ }
+ }
}
\ No newline at end of file
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index b3e97d2..7bb8dc9 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -4,14 +4,16 @@
-
-
+
+
+
+
+
+
+
PLAYER_STATE: initState() для пути: ${widget.videoPath}');
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ _initVideoWithDelay();
+ });
+ InlineVideoNotePlayer.activeVideoPathNotifier.addListener(
+ _onActiveVideoChanged,
+ );
+ }
+
+ void _initVideoWithDelay() async {
+ if (widget.videoPath.isEmpty) return;
+ await Future.delayed(const Duration(milliseconds: 200));
+
+ // Извлекаем msgId из пути или передаем из MessageBubble.
+ // Если у тебя в InlineVideoNotePlayer есть доступ к msgId, лучше использовать его.
+ // Для примера вытащим цифры из хэша файла или сделаем случайный сдвиг:
+ final int stableSalt =
+ widget.videoPath.hashCode % 6; // Получим число от 0 до 5
+
+ // Каждое видео получит свой уникальный сдвиг: 0мс, 150мс, 300мс, 450мс и т.д.
+ final int delayMs = 150 * stableSalt;
+
+ debugPrint(
+ '--> PLAYER_STATE: Планируем запуск плеера с задержкой $delayMs мс для ${widget.videoPath.split('/').last}',
+ );
+
+ _delayFuture = Future.delayed(Duration(milliseconds: delayMs)).then((
+ _,
+ ) async {
+ // КРИТИЧЕСКИ ВАЖНО: проверяем, жив ли еще виджет на экране после паузы
+ if (!mounted) return;
+ _initVideo();
+ });
+ }
+
+ @override
+ void dispose() {
+ debugPrint(
+ ' --> PLAYER_STATE: dispose() вызыван для пути: ${widget.videoPath}',
+ );
+
+ InlineVideoNotePlayer.activeVideoPathNotifier.removeListener(
+ _onActiveVideoChanged,
+ );
+ _controller?.removeListener(_videoListener);
+ _controller?.dispose();
+ super.dispose();
+ }
+
+ @override
+ void didUpdateWidget(covariant InlineVideoNotePlayer oldWidget) {
+ super.didUpdateWidget(oldWidget);
+
+ debugPrint(
+ ' --> PLAYER_STATE: didUpdateWidget. Старый путь="${oldWidget.videoPath}", Новый путь="${widget.videoPath}"',
+ );
+
+ // 1. Если новый путь пустой, НЕ уничтожаем старый рабочий контроллер!
+ // Скорее всего, это временный лаг обновления состояния в ListView.
+ if (widget.videoPath.isEmpty && oldWidget.videoPath.isNotEmpty) {
+ debugPrint(
+ ' --> PLAYER_STATE: Новый путь пустой. Игнорируем сброс плеера.',
+ );
+ return;
+ }
+
+ // 2. Если пути формально отличаются, но плеер уже инициализирован и успешно играет,
+ // а разница лишь в динамическом префиксе дешифрованного файла (одно и то же видео)
+ if (oldWidget.videoPath != widget.videoPath) {
+ // Защита: Если старый файл существовал и новый существует, и они имеют одинаковый размер
+ // (или мы просто доверяем текущему плееру), не нужно дергать нативный контроллер.
+ if (_controller != null && _controller!.value.isInitialized) {
+ final oldFile = File(oldWidget.videoPath);
+ final newFile = File(widget.videoPath);
+
+ if (oldFile.existsSync() &&
+ newFile.existsSync() &&
+ oldFile.lengthSync() == newFile.lengthSync()) {
+ debugPrint(
+ ' --> PLAYER_STATE: Пути разные, но файлы идентичны по размеру. Не пересоздаем.',
+ );
+ return;
+ }
+ }
+
+ debugPrint(
+ ' --> PLAYER_STATE: Путь изменился на валидный! Пересоздаем контроллер.',
+ );
+ if (_controller != null) {
+ _controller!.removeListener(_videoListener);
+ final oldController = _controller!;
+ _controller = null;
+ oldController.dispose().catchError(
+ (e) => debugPrint('Error disposing vc: $e'),
+ );
+ }
+ _initVideo();
+ }
+ }
+
+ void _initVideo() {
+ if (widget.videoPath.isEmpty) {
+ debugPrint(' --> PLAYER_INIT: Отмена инициализации. Путь пустой.');
+ return;
+ }
+
+ final file = File(widget.videoPath);
+ if (!file.existsSync()) {
+ debugPrint(
+ ' --> PLAYER_INIT: Отмена инициализации. Файла физически НЕТ на диске по пути: ${widget.videoPath}',
+ );
+ return;
+ }
+
+ debugPrint(
+ ' --> PLAYER_INIT: Начинаем VideoPlayerController.file(). Исходный файл существует.',
+ );
+
+ if (_isInitializing) return;
+ _isInitializing = true;
+ _initError = null;
+
+ _controller = VideoPlayerController.file(file)
+ ..initialize()
+ .then((_) {
+ debugPrint(
+ ' --> PLAYER_INIT: СУПЕР! Контроллер успешно инициализирован для ${widget.videoPath}',
+ );
+ _isInitializing = false;
+ _initError = null;
+ if (mounted) setState(() {});
+ })
+ .catchError((e) {
+ _isInitializing = false;
+ _initError = e.toString();
+ final oldController = _controller;
+ _controller = null;
+ oldController?.removeListener(_videoListener);
+ oldController?.dispose().catchError((_) {});
+ if (mounted) setState(() {});
+ debugPrint(
+ ' --> PLAYER_INIT_ERROR: Фатальный сбой VideoPlayer: $e',
+ );
+ });
+
+ _controller?.addListener(_videoListener);
+ }
+
+ void _videoListener() {
+ if (!mounted || _controller == null) return;
+
+ final isPlaying = _controller!.value.isPlaying;
+ final isInitialized = _controller!.value.isInitialized;
+
+ if (isInitialized && _wasPlaying && !isPlaying) {
+ _isExpanded = false;
+
+ if (InlineVideoNotePlayer.activeVideoPathNotifier.value ==
+ widget.videoPath) {
+ InlineVideoNotePlayer.activeVideoPathNotifier.value = null;
+ }
+ }
+
+ _wasPlaying = isPlaying;
+
+ setState(() {});
+ }
+
+ void _onActiveVideoChanged() {
+ if (!mounted || _controller == null) return;
+ final activePath = InlineVideoNotePlayer.activeVideoPathNotifier.value;
+
+ if (activePath != widget.videoPath) {
+ setState(() {
+ if (_controller!.value.isPlaying) {
+ _controller!.pause();
+ }
+ _isExpanded = false;
+ });
+ }
+ }
+
+ void _togglePlay() {
+ if (_controller == null || !_controller!.value.isInitialized) return;
+
+ setState(() {
+ if (_controller!.value.isPlaying) {
+ _controller!.pause();
+ _isExpanded = false;
+ if (InlineVideoNotePlayer.activeVideoPathNotifier.value ==
+ widget.videoPath) {
+ InlineVideoNotePlayer.activeVideoPathNotifier.value = null;
+ }
+ } else {
+ InlineVideoNotePlayer.activeVideoPathNotifier.value = widget.videoPath;
+ _controller!.play();
+ _controller!.setLooping(true);
+ _isExpanded = true;
+ }
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final double size = _isExpanded ? 260.0 : 160.0;
+ final bool isInitialized =
+ _controller != null && _controller!.value.isInitialized;
+ final bool hasInitError = _initError != null;
+
+ double progress = 0.0;
+ if (isInitialized) {
+ final duration = _controller!.value.duration.inMilliseconds;
+ final position = _controller!.value.position.inMilliseconds;
+ progress = duration > 0 ? (position / duration) : 0.0;
+ }
+
+ return AnimatedContainer(
+ duration: const Duration(milliseconds: 180),
+ curve: Curves.easeOut,
+ width: size,
+ height: size,
+ decoration: const BoxDecoration(
+ shape: BoxShape.circle,
+ color: Colors
+ .black12, // Даем легкую подложку вместо прозрачности, чтобы круг было видно
+ ),
+ child: ClipOval(
+ child: Stack(
+ alignment: Alignment.center,
+ children: [
+ if (hasInitError)
+ _InlineVideoInitErrorFallback(videoPath: widget.videoPath)
+ else if (isInitialized)
+ GestureDetector(
+ onTap: _togglePlay,
+ child: SizedBox(
+ width: size,
+ height: size,
+ child: FittedBox(
+ fit: BoxFit.cover,
+ child: SizedBox(
+ width: _controller!.value.size.width,
+ height: _controller!.value.size.height,
+ child: VideoPlayer(_controller!),
+ ),
+ ),
+ ),
+ )
+ else
+ // Красивый лоадер, пока файл скачивается или обрабатывается
+ const Center(
+ child: SizedBox(
+ width: 30,
+ height: 30,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ valueColor: AlwaysStoppedAnimation(Colors.white70),
+ ),
+ ),
+ ),
+
+ if (isInitialized && !hasInitError)
+ Positioned.fill(
+ child: IgnorePointer(
+ child: CustomPaint(
+ painter: CircleProgressPainter(
+ progress: progress,
+ progressColor: Colors.white,
+ backgroundColor: Colors.white30,
+ strokeWidth: 4.0,
+ ),
+ ),
+ ),
+ ),
+
+ if (isInitialized && !_controller!.value.isPlaying && !hasInitError)
+ IgnorePointer(
+ child: Container(
+ color: Colors.black26,
+ alignment: Alignment.center,
+ child: const Icon(
+ Icons.play_arrow,
+ size: 40,
+ color: Colors.white,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _InlineVideoInitErrorFallback extends StatelessWidget {
+ final String videoPath;
+
+ const _InlineVideoInitErrorFallback({required this.videoPath});
+
+ @override
+ Widget build(BuildContext context) {
+ return Material(
+ color: Colors.black12,
+ child: InkWell(
+ onTap: () async {
+ try {
+ await OpenFilex.open(videoPath);
+ } catch (e) {
+ debugPrint(' --> PLAYER_FALLBACK_ERROR: $e');
+ }
+ },
+ child: const Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(Icons.play_disabled, color: Colors.white70, size: 40),
+ SizedBox(height: 8),
+ Text(
+ 'Видео не воспроизводится\n Нажмите, чтобы открыть внешним плеером',
+ textAlign: TextAlign.center,
+ style: TextStyle(color: Colors.white70, fontSize: 12),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class CircleProgressPainter extends CustomPainter {
+ final double progress;
+ final Color progressColor;
+ final Color backgroundColor;
+ final double strokeWidth;
+
+ CircleProgressPainter({
+ required this.progress,
+ required this.progressColor,
+ required this.backgroundColor,
+ required this.strokeWidth,
+ });
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final Offset center = Offset(size.width / 2, size.height / 2);
+ final double radius = (size.width - strokeWidth) / 2;
+
+ final Paint backgroundPaint = Paint()
+ ..color = backgroundColor
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = strokeWidth;
+
+ canvas.drawCircle(center, radius, backgroundPaint);
+
+ final Paint progressPaint = Paint()
+ ..color = progressColor
+ ..style = PaintingStyle.stroke
+ ..strokeCap = StrokeCap.round
+ ..strokeWidth = strokeWidth;
+
+ double startAngle = -math.pi / 2;
+ double sweepAngle = 2 * math.pi * progress;
+
+ canvas.drawArc(
+ Rect.fromCircle(center: center, radius: radius),
+ startAngle,
+ sweepAngle,
+ false,
+ progressPaint,
+ );
+ }
+
+ @override
+ bool shouldRepaint(covariant CircleProgressPainter oldDelegate) {
+ return oldDelegate.progress != progress ||
+ oldDelegate.progressColor != progressColor ||
+ oldDelegate.backgroundColor != backgroundColor;
+ }
+}
+
+// ==========================================
+// ВСТРОЕННЫЙ ПЛЕЕР ДЛЯ ГОЛОСОВЫХ СООБЩЕНИЙ
+// ==========================================
+class InlineVoiceNotePlayer extends StatefulWidget {
+ final String audioPath;
+ const InlineVoiceNotePlayer({super.key, required this.audioPath});
+
+ @override
+ State createState() => _InlineVoiceNotePlayerState();
+}
+
+class _InlineVoiceNotePlayerState extends State {
+ final AudioPlayer _audioPlayer = AudioPlayer();
+ bool _isPlaying = false;
+ bool _isInitializing = false;
+ Duration _duration = Duration.zero;
+ Duration _position = Duration.zero;
+ bool _sourceInitialized = false;
+ Timer? _fileWatchTimer;
+
+ bool get _hasValidDuration => _duration.inMilliseconds > 0;
+
+ @override
+ void initState() {
+ super.initState();
+ _initAudioListeners();
+ _checkAndSetupSource();
+ _startFileAvailabilityPolling();
+ }
+
+ // Вынесем проверку в отдельный метод
+ void _checkAndSetupSource() {
+ if (widget.audioPath.isEmpty || _sourceInitialized || _isInitializing)
+ return;
+
+ final file = File(widget.audioPath);
+ if (!file.existsSync()) {
+ debugPrint('[AUDIO] Файл пока отсутствует на диске, ждем обновления...');
+ return;
+ }
+
+ _isInitializing = true;
+ if (mounted) {
+ setState(() {});
+ }
+
+ _setupSource(widget.audioPath).whenComplete(() {
+ if (!mounted) {
+ _isInitializing = false;
+ return;
+ }
+ setState(() {
+ _isInitializing = false;
+ });
+ });
+ }
+
+ void _startFileAvailabilityPolling() {
+ _fileWatchTimer?.cancel();
+ if (widget.audioPath.isEmpty) return;
+
+ final file = File(widget.audioPath);
+ if (file.existsSync()) {
+ if (!_sourceInitialized && !_isInitializing) {
+ _checkAndSetupSource();
+ }
+ return;
+ }
+
+ _fileWatchTimer = Timer.periodic(const Duration(milliseconds: 250), (
+ timer,
+ ) {
+ if (!mounted) {
+ timer.cancel();
+ return;
+ }
+ if (widget.audioPath.isEmpty) {
+ timer.cancel();
+ return;
+ }
+ final file = File(widget.audioPath);
+ if (file.existsSync()) {
+ timer.cancel();
+ if (mounted) {
+ setState(() {});
+ _checkAndSetupSource();
+ }
+ }
+ });
+ }
+
+ Future _setupSource(String path) async {
+ try {
+ await _audioPlayer.stop();
+ await _audioPlayer.setSource(DeviceFileSource(path));
+
+ if (!mounted) return;
+ setState(() {
+ _sourceInitialized = true;
+ });
+
+ final d = await _audioPlayer.getDuration();
+ if (!mounted) return;
+ if (d != null && d.inMilliseconds > 0) {
+ setState(() {
+ _duration = d;
+ });
+ }
+
+ debugPrint('[AUDIO] Источник успешно установлен для: $path');
+ } catch (e) {
+ debugPrint('[AUDIO ERROR] Ошибка установки источника: $e');
+ }
+ }
+
+ @override
+ void didUpdateWidget(covariant InlineVoiceNotePlayer oldWidget) {
+ super.didUpdateWidget(oldWidget);
+
+ final bool pathChanged = oldWidget.audioPath != widget.audioPath;
+ final bool fileJustAppeared =
+ widget.audioPath.isNotEmpty &&
+ !_sourceInitialized &&
+ File(widget.audioPath).existsSync();
+
+ if (pathChanged) {
+ _sourceInitialized = false;
+ _position = Duration.zero;
+ _duration = Duration.zero;
+ _fileWatchTimer?.cancel();
+ }
+
+ if (pathChanged || fileJustAppeared) {
+ debugPrint('[AUDIO_UPDATE] Реактивное обновление источника звука.');
+ _checkAndSetupSource();
+ _startFileAvailabilityPolling();
+ if (_isFileAvailable && !_sourceInitialized && mounted) {
+ setState(() {});
+ }
+ }
+ }
+
+ void _initAudioListeners() {
+ _audioPlayer.onPlayerStateChanged.listen((state) {
+ if (mounted) {
+ setState(() {
+ _isPlaying = state == PlayerState.playing;
+ if (state == PlayerState.stopped || state == PlayerState.completed) {
+ _position = Duration.zero;
+ }
+ });
+ }
+ });
+
+ _audioPlayer.onDurationChanged.listen((newDuration) {
+ // ЗАЩИТА ОТ ДЕРГАНИЯ: игнорируем пустые или некорректные ивенты от движка
+ if (mounted && newDuration.inMilliseconds > 0) {
+ setState(() {
+ _duration = newDuration;
+ });
+ }
+ });
+
+ _audioPlayer.onPositionChanged.listen((newPosition) {
+ if (!mounted) return;
+
+ // СЛЕПАЯ ЗОНА (150мс): сглаживаем рывок ползунка в самом начале воспроизведения
+ if (_isPlaying && newPosition.inMilliseconds < 150) return;
+
+ setState(() {
+ if (_duration.inMilliseconds > 0 && newPosition > _duration) {
+ _position = _duration;
+ } else {
+ _position = newPosition;
+ }
+ });
+ });
+ }
+
+ void _togglePlay() async {
+ // Блокируем клик, если файл физически еще не скачан
+ if (widget.audioPath.isEmpty || !File(widget.audioPath).existsSync())
+ return;
+
+ if (!_sourceInitialized) {
+ _checkAndSetupSource();
+ }
+
+ if (_isPlaying) {
+ await _audioPlayer.pause();
+ } else {
+ await _audioPlayer.play(DeviceFileSource(widget.audioPath));
+ }
+ }
+
+ bool get _isFileAvailable =>
+ widget.audioPath.isNotEmpty && File(widget.audioPath).existsSync();
+
+ @override
+ void dispose() {
+ _fileWatchTimer?.cancel();
+ _audioPlayer.dispose();
+ super.dispose();
+ }
+
+ String _formatDuration(Duration duration) {
+ final String minutes = duration.inMinutes.toString();
+ final String seconds = (duration.inSeconds % 60).toString().padLeft(2, '0');
+ return "$minutes:$seconds";
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final bool fileAvailable = _isFileAvailable;
+ final bool hasDuration = _hasValidDuration;
+
+ final bool isReady = fileAvailable && _sourceInitialized && hasDuration;
+ final String statusText;
+ if (!fileAvailable) {
+ statusText = 'Загрузка...';
+ } else if (!_sourceInitialized || !hasDuration) {
+ statusText = 'Подготовка...';
+ } else {
+ statusText =
+ "${_formatDuration(_position)} / ${_formatDuration(_duration)}";
+ }
+
+ final double durationMs = _duration.inMilliseconds.toDouble();
+ final double positionMs = _position.inMilliseconds.toDouble();
+ final bool canSeek = hasDuration;
+ final double safeMax = durationMs > 0 ? durationMs : 1.0;
+ final double safeValue = durationMs > 0
+ ? positionMs.clamp(0.0, safeMax)
+ : 0.0;
+
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
+ width: 240,
+ decoration: BoxDecoration(
+ color: Colors.black12,
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ IconButton(
+ icon: Icon(
+ _isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
+ ),
+ iconSize: 36,
+ color: fileAvailable ? Colors.white : Colors.white38,
+ onPressed: fileAvailable ? _togglePlay : null,
+ ),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ SliderTheme(
+ data: SliderTheme.of(context).copyWith(
+ trackHeight: 3,
+ padding: EdgeInsets.zero,
+ thumbShape: const RoundSliderThumbShape(
+ enabledThumbRadius: 5,
+ elevation: 0,
+ ),
+ overlayShape: const RoundSliderOverlayShape(
+ overlayRadius: 8,
+ ),
+ ),
+ child: Container(
+ height: 20,
+ alignment: Alignment.center,
+ child: Slider(
+ activeColor: isReady ? Colors.white : Colors.white38,
+ inactiveColor: Colors.white60,
+ thumbColor: isReady ? Colors.white : Colors.white24,
+ min: 0.0,
+ max: safeMax,
+ value: safeValue,
+ onChanged: canSeek
+ ? (value) async {
+ await _audioPlayer.seek(
+ Duration(milliseconds: value.toInt()),
+ );
+ }
+ : null,
+ ),
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 4,
+ vertical: 2,
+ ),
+ child: Text(
+ statusText,
+ style: const TextStyle(
+ fontSize: 11,
+ color: Colors.white70,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
}
}
diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc
index 3ccd551..6f62215 100644
--- a/linux/flutter/generated_plugin_registrant.cc
+++ b/linux/flutter/generated_plugin_registrant.cc
@@ -6,17 +6,25 @@
#include "generated_plugin_registrant.h"
+#include
#include
#include
+#include
#include
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 =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
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 =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake
index fbedf4a..c0e31b7 100644
--- a/linux/flutter/generated_plugins.cmake
+++ b/linux/flutter/generated_plugins.cmake
@@ -3,8 +3,10 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
+ audioplayers_linux
file_selector_linux
flutter_secure_storage_linux
+ record_linux
url_launcher_linux
)
diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift
index 66c0d33..76220df 100644
--- a/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -5,6 +5,9 @@
import FlutterMacOS
import Foundation
+import audioplayers_darwin
+import ffmpeg_kit_flutter_new_min_gpl
+import file_picker
import file_selector_macos
import firebase_analytics
import firebase_core
@@ -16,11 +19,18 @@ import gal
import local_auth_darwin
import package_info_plus
import path_provider_foundation
+import photo_manager
+import record_macos
import shared_preferences_foundation
import sqflite_darwin
import url_launcher_macos
+import video_compress
+import video_player_avfoundation
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"))
FLTFirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAnalyticsPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
@@ -32,7 +42,11 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
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"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
+ VideoCompressPlugin.register(with: registry.registrar(forPlugin: "VideoCompressPlugin"))
+ FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
}
diff --git a/pubspec.lock b/pubspec.lock
index 4cad2c8..9ed6682 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -9,6 +9,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.35"
+ archive:
+ dependency: transitive
+ description:
+ name: archive
+ sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.0.9"
args:
dependency: transitive
description:
@@ -25,6 +33,62 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -57,14 +121,70 @@ packages:
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:
dependency: transitive
description:
name: characters
- sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
+ sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
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:
dependency: transitive
description:
@@ -113,6 +233,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.9.0"
+ csslib:
+ dependency: transitive
+ description:
+ name: csslib
+ sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.2"
cupertino_icons:
dependency: "direct main"
description:
@@ -145,6 +273,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -161,6 +305,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -169,6 +329,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -294,6 +462,14 @@ packages:
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:
dependency: "direct main"
description:
@@ -342,6 +518,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:
@@ -456,6 +640,14 @@ packages:
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:
dependency: "direct main"
description:
@@ -464,6 +656,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -472,14 +672,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
+ image:
+ dependency: transitive
+ description:
+ name: image
+ sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.8.0"
image_picker:
dependency: "direct main"
description:
name: image_picker
- sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
+ sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac"
url: "https://pub.dev"
source: hosted
- version: "1.2.1"
+ version: "1.2.2"
image_picker_android:
dependency: transitive
description:
@@ -568,6 +776,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:
@@ -660,26 +876,26 @@ packages:
dependency: transitive
description:
name: matcher
- sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
+ sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
- version: "0.12.17"
+ version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
- sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
+ sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
- version: "0.11.1"
+ version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
- sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
+ sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
- version: "1.17.0"
+ version: "1.18.0"
mime:
dependency: transitive
description:
@@ -792,6 +1008,54 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -800,6 +1064,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -816,6 +1096,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:
@@ -824,6 +1120,70 @@ packages:
url: "https://pub.dev"
source: hosted
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:
@@ -957,6 +1317,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -985,10 +1353,10 @@ packages:
dependency: transitive
description:
name: test_api
- sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
+ sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
- version: "0.7.7"
+ version: "0.7.11"
timezone:
dependency: transitive
description:
@@ -1085,6 +1453,70 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -1117,6 +1549,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -1141,6 +1589,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.6.1"
+ yaml:
+ dependency: transitive
+ description:
+ name: yaml
+ sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.3"
sdks:
dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.0"
diff --git a/pubspec.yaml b/pubspec.yaml
index aa50479..10a94ab 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -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
# 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.
-version: 2.0.1+1
+version: 2.0.2+1
environment:
sdk: ^3.10.0
@@ -50,16 +50,30 @@ dependencies:
shared_preferences: ^2.5.5
flutter_linkify: ^6.0.0
url_launcher: ^6.3.2
- image_picker: ^1.0.4
gal: ^2.3.2
flutter_image_compress: ^2.1.0
dio: ^5.9.2
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:
flutter_test:
@@ -71,6 +85,14 @@ dev_dependencies:
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
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
# following page: https://dart.dev/tools/pub/pubspec
diff --git a/srv/8971f17fd63b416b964620c9bd89701a.png b/srv/8971f17fd63b416b964620c9bd89701a.png
new file mode 100644
index 0000000..9147a48
Binary files /dev/null and b/srv/8971f17fd63b416b964620c9bd89701a.png differ
diff --git a/srv/app/api/endpoints/media.py b/srv/app/api/endpoints/media.py
index 7ce8a00..dddc09e 100644
--- a/srv/app/api/endpoints/media.py
+++ b/srv/app/api/endpoints/media.py
@@ -1,3 +1,4 @@
+import shutil
from fastapi import Depends, FastAPI, HTTPException, status, APIRouter, File, UploadFile, Request, Form
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.orm import Session
@@ -49,13 +50,16 @@ def _parse_multipart_body(body: bytes):
if not disposition_match:
continue
- field_name = disposition_match.group(1).decode('utf-8', errors='ignore')
+ 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)
+ 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
@@ -86,14 +90,16 @@ def _encode_multipart_formdata(fields, files):
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(
+ 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')
+ 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)
@@ -130,9 +136,11 @@ def _stream_response_from_remote(url: str):
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}')
+ 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}')
+ 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')
@@ -146,7 +154,8 @@ def _stream_response_from_remote(url: str):
def _post_file_to_home(item: models.CloudMediaItem) -> tuple[bool, str]:
- file_path = os.path.join(config.CLOUD_MEDIA_CACHE_FOLDER, item.local_filename)
+ 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'
@@ -159,7 +168,8 @@ def _post_file_to_home(item: models.CloudMediaItem) -> tuple[bool, str]:
'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),
+ ('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(
@@ -201,7 +211,8 @@ def _cleanup_home_quota(db: Session, owner_id: int | None):
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)
+ 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
@@ -212,7 +223,8 @@ def _cleanup_home_quota(db: Session, owner_id: int | None):
def _cleanup_all_home_storage():
db = models.SessionLocal()
try:
- owner_ids = db.query(models.HomeMediaFile.owner_id).filter(models.HomeMediaFile.owner_id.isnot(None)).distinct().all()
+ 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:
@@ -248,7 +260,8 @@ async def forward_pending_media_loop():
item.sent_at = func.now()
item.error_message = None
db.commit()
- cache_path = os.path.join(config.CLOUD_MEDIA_CACHE_FOLDER, item.local_filename)
+ cache_path = os.path.join(
+ config.CLOUD_MEDIA_CACHE_FOLDER, item.local_filename)
if os.path.exists(cache_path):
os.remove(cache_path)
else:
@@ -293,7 +306,8 @@ async def upload_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)')
+ 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"
@@ -315,7 +329,8 @@ async def upload_file_v2(
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')
+ 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:
@@ -323,7 +338,8 @@ async def upload_file_v2(
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)')
+ raise HTTPException(
+ status_code=400, detail=f'File too large (max {config.MEDIA_UPLOAD_MAX_BYTES} bytes)')
db = models.SessionLocal()
try:
@@ -337,7 +353,8 @@ async def upload_file_v2(
file_id = uuid.uuid4().hex
local_filename = f"{file_id}.enc"
- storage_path = os.path.join(config.CLOUD_MEDIA_CACHE_FOLDER, local_filename)
+ storage_path = os.path.join(
+ config.CLOUD_MEDIA_CACHE_FOLDER, local_filename)
with open(storage_path, 'wb') as f:
f.write(content)
@@ -369,7 +386,8 @@ async def receive_media(
):
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')
+ 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:
@@ -377,7 +395,8 @@ async def receive_media(
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)')
+ 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"
@@ -404,13 +423,162 @@ async def receive_media(
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:
- return FileResponse(local_path, media_type='application/octet-stream')
+ 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')
diff --git a/srv/app/api/endpoints/messages.py b/srv/app/api/endpoints/messages.py
index f32d6b5..08e0af7 100644
--- a/srv/app/api/endpoints/messages.py
+++ b/srv/app/api/endpoints/messages.py
@@ -37,6 +37,21 @@ async def get_chat_history(
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)
+
+
@messagesRouter.delete("/all")
async def delete_all_messages(
current_user: models.User = Depends(get_current_user),
diff --git a/srv/app/api/endpoints/users.py b/srv/app/api/endpoints/users.py
index 1e3d5be..313bd33 100644
--- a/srv/app/api/endpoints/users.py
+++ b/srv/app/api/endpoints/users.py
@@ -1,7 +1,7 @@
import os
-from fastapi import Depends, APIRouter, HTTPException, Depends, Request
+from fastapi import Depends, APIRouter, HTTPException, Depends, Request, status
from sqlalchemy.orm import Session
from app.db import models
from app.core.security import get_current_user
@@ -34,7 +34,8 @@ def _delete_old_avatar_file(file_id: str, db: Session):
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)
+ cloud_path = os.path.join(
+ config.CLOUD_MEDIA_CACHE_FOLDER, item.local_filename)
if os.path.exists(cloud_path):
try:
os.remove(cloud_path)
@@ -175,8 +176,8 @@ async def update_privacy_settings(
user_to_update.show_avatar = 1 if data.show_avatar else 0
if data.show_about is not None:
user_to_update.show_about = 1 if data.show_about else 0
- if data.show_username is not None:
- user_to_update.show_username = 1 if data.show_username else 0
+ # Настройка show_username удалена, всегда сохраняем 1
+ 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:
@@ -201,11 +202,12 @@ async def get_privacy_settings(current_user: models.User = Depends(get_current_u
"show_phone": bool(current_user.show_phone),
"show_avatar": bool(current_user.show_avatar),
"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")
async def read_users_all(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
users = db.query(models.User).all()
@@ -217,7 +219,7 @@ async def read_users_all(current_user: models.User = Depends(get_current_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")
async def read_users_chats(
@@ -282,11 +284,14 @@ async def read_users_chats(
"username": user.username,
"name": f"{user.first_name} {user.last_name or ''}".strip(),
"public_key": user.public_key,
- "avatar_file_id": user.avatar_file_id if user.show_avatar else None,
- "avatar_url": str(request.url_for("get_file", file_id=user.avatar_file_id)) if user.show_avatar and user.avatar_file_id else None,
+ "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_time": (last_msg.timestamp.isoformat() if last_msg and last_msg.timestamp else None),
"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,
}
)
@@ -294,6 +299,51 @@ async def read_users_chats(
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)
def get_user_by_id(
user_id: int,
@@ -317,33 +367,30 @@ def get_user_by_id(
profile_data["first_name"] = user.first_name
profile_data["last_name"] = user.last_name
+ profile_data["username"] = user.username
- # Проверяем настройки конфиденциальности
- if user.show_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(
- "get_file", file_id=user.avatar_file_id)) if user.avatar_file_id else None
+ "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:
+ if user.show_about or current_user.id == 1:
profile_data["about"] = user.about
- if user.show_phone:
+ if user.show_phone or current_user.id == 1:
profile_data["phone"] = user.phone
- if user.show_email:
+ 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:
+ if user.show_last_online or current_user.id == 1:
profile_data["last_online"] = user.last_online.isoformat(
) if user.last_online else None
diff --git a/srv/app/api/schemas.py b/srv/app/api/schemas.py
index 19b2bbc..1185d8d 100644
--- a/srv/app/api/schemas.py
+++ b/srv/app/api/schemas.py
@@ -72,3 +72,12 @@ class UserProfile(BaseModel):
class Config:
from_attributes = True
+
+class UserContactResponse(BaseModel):
+ id: str
+ name: str
+ username: str
+ public_key: Optional[str] = None
+
+ class Config:
+ from_attributes = True
\ No newline at end of file
diff --git a/srv/app/websocket/connection_manager.py b/srv/app/websocket/connection_manager.py
index 348e19c..8f225e2 100644
--- a/srv/app/websocket/connection_manager.py
+++ b/srv/app/websocket/connection_manager.py
@@ -110,15 +110,15 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
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 отправителю: сервер принял и сохранил сообщение (нужно для статусов клиента).
+ # ACK отправителю: сервер принял и сохранил сообщение.
await manager.send_personal_message({
"type": "message_sent",
"temp_id": temp_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))
- # Если получатель оффлайн — отправим пуш (если есть токен и ключи).
+ # отправим пуш.
if user.public_key:
receiver = db.query(models.User).filter(
models.User.id == receiver_id).first()
@@ -129,7 +129,12 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
user.first_name,
user.public_key,
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 = {
@@ -141,7 +146,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
"message_type": message_type,
"file_id": file_id,
"encrypted_key": message_data.get("encrypted_key"),
- "timestamp": (new_msg.timestamp or datetime.now()).isoformat(),
+ "timestamp": (new_msg.timestamp or datetime.utcnow()).isoformat(),
"reply_to_id": new_msg.reply_to_id,
"reply_to_text": new_msg.reply_to_text,
}
@@ -157,7 +162,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
# Если сообщение реально ушло по сокету получателю — отмечаем delivered_at.
if sent_to_receiver:
try:
- delivered_at = datetime.now()
+ delivered_at = datetime.utcnow()
new_msg.delivered_at = delivered_at
db.add(new_msg)
db.commit()
@@ -192,7 +197,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
continue
try:
msg.content = content
- msg.edited_at = datetime.now()
+ msg.edited_at = datetime.utcnow()
db.add(msg)
db.commit()
except Exception:
@@ -201,6 +206,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
event = {
"type": "message_edited",
"message_id": msg.id,
+ "sender_id": msg.sender_id,
"content": msg.content,
"edited_at": msg.edited_at.isoformat() if msg.edited_at else None,
}
@@ -259,7 +265,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
# Сохраняем read_at в БД
try:
- read_at = datetime.now()
+ read_at = datetime.utcnow()
msg.read_at = read_at
db.add(msg)
db.commit()
@@ -270,7 +276,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
await manager.send_personal_message({
"type": "message_read",
"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))
elif message_data.get("type") == "typing":
receiver_id = message_data.get("receiver_id")
@@ -311,7 +317,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
})
-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}")
message = messaging.Message(
@@ -322,6 +328,8 @@ def send_fcm_notification(token, user_id, username, public_key, encrypted_text,
"public_key": public_key,
"content": encrypted_text, # Зашифрованный текст
"timestamp": timestamp.isoformat(),
+ "unread_count": str(unread_count),
+ "message_id": str(message_id),
},
android=messaging.AndroidConfig(
priority='high',
diff --git a/srv/ca5c897d07a84334933ad99beab4e63c.png b/srv/ca5c897d07a84334933ad99beab4e63c.png
new file mode 100644
index 0000000..5377185
Binary files /dev/null and b/srv/ca5c897d07a84334933ad99beab4e63c.png differ
diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc
index 05102ec..faa71ca 100644
--- a/windows/flutter/generated_plugin_registrant.cc
+++ b/windows/flutter/generated_plugin_registrant.cc
@@ -6,14 +6,19 @@
#include "generated_plugin_registrant.h"
+#include
#include
#include
#include
#include
#include
+#include
+#include
#include
void RegisterPlugins(flutter::PluginRegistry* registry) {
+ AudioplayersWindowsPluginRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FirebaseCorePluginCApiRegisterWithRegistrar(
@@ -24,6 +29,10 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("GalPluginCApi"));
LocalAuthPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
+ PermissionHandlerWindowsPluginRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
+ RecordWindowsPluginCApiRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}
diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake
index 6b6e227..fa538b4 100644
--- a/windows/flutter/generated_plugins.cmake
+++ b/windows/flutter/generated_plugins.cmake
@@ -3,11 +3,14 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
+ audioplayers_windows
file_selector_windows
firebase_core
flutter_secure_storage_windows
gal
local_auth_windows
+ permission_handler_windows
+ record_windows
url_launcher_windows
)