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 )