From d116fbde438332c5d526f06e06ab41274b359947 Mon Sep 17 00:00:00 2001 From: Artur Date: Mon, 22 Jun 2026 19:07:21 +0500 Subject: [PATCH] 22-06-2026+19-07 --- lib/core/constants.dart | 7 +- lib/data/datasources/ws_client.dart | 49 +- lib/domain/services/webrtc_service.dart | 407 ++++++++++++--- lib/main.dart | 158 +++++- lib/presentation/screens/call_screen.dart | 484 ++++++++++++++++-- lib/presentation/screens/chat_screen.dart | 47 +- lib/presentation/screens/contacts_screen.dart | 38 ++ .../screens/privacy_settings_screen.dart | 28 + srv/app/websocket/connection_manager.py | 49 +- 9 files changed, 1118 insertions(+), 149 deletions(-) diff --git a/lib/core/constants.dart b/lib/core/constants.dart index f25435d..179a5e8 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -1,5 +1,10 @@ class AppConstants { - //static const baseUrl = '192.168.0.180:8000'; static const baseUrl = 'https://api.chepuhagram.ru'; static const wsUrl = 'wss://api.chepuhagram.ru'; + + // TURN Server Configuration + static const turnUrl = 'turns:turn.chepuhagram.ru:5349'; + static const stunUrl = 'stuns:turn.chepuhagram.ru:5349'; + static const turnUsername = 'chepuha_user'; + static const turnCredential = 'byybg3456bFYTfY8ytFb323yTfYBtF'; } diff --git a/lib/data/datasources/ws_client.dart b/lib/data/datasources/ws_client.dart index 899d1ee..e7cd53b 100644 --- a/lib/data/datasources/ws_client.dart +++ b/lib/data/datasources/ws_client.dart @@ -1,12 +1,15 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:chepuhagram/domain/services/api_service.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/status.dart' as status; import 'package:web_socket_channel/io.dart'; import 'package:chepuhagram/core/constants.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:chepuhagram/domain/services/webrtc_service.dart'; +import 'package:chepuhagram/main.dart'; +import 'package:chepuhagram/presentation/screens/call_screen.dart'; class SocketService with WidgetsBindingObserver { static final SocketService _instance = SocketService._internal(); @@ -14,6 +17,7 @@ class SocketService with WidgetsBindingObserver { SocketService._internal() { WidgetsBinding.instance.addObserver(this); + _initMessageListener(); } WebSocketChannel? _channel; @@ -36,8 +40,47 @@ class SocketService with WidgetsBindingObserver { void _initMessageListener() { messages.listen((data) { - if (data['type'] == 'call_accepted') { - WebRTCService().handleOffer(data['call_id'], data['sdp']); + final type = data['type']; + print("SocketService: Получено сигнальное сообщение: $type"); + if (type == 'call_init') { + final callId = data['call_id']?.toString() ?? ''; + final callerId = int.tryParse(data['caller_id']?.toString() ?? '') ?? 0; + final callerUsername = data['caller_username']?.toString() ?? 'Пользователь'; + + if (Platform.isAndroid || Platform.isIOS) { + showIncomingCallKit( + callId: callId, + callerName: callerUsername, + callerId: callerId, + ); + } else { + final context = navigatorKey.currentContext; + if (context != null) { + if (CallScreen.currentActiveCallId != null) { + print("SocketService: A call is already active (${CallScreen.currentActiveCallId}), skipping push for $callId"); + } else { + CallScreen.currentActiveCallId = callId; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => CallScreen( + callId: callId, + isIncoming: true, + callerName: callerUsername, + targetUserId: callerId, + ), + ), + ); + } + } + } + } else if (type == 'call_accepted' || + type == 'offer' || + type == 'answer' || + type == 'ice_candidate' || + type == 'hangup' || + type == 'decline' || + type == 'video_toggle') { + WebRTCService().handleSignalingMessage(data); } }); } diff --git a/lib/domain/services/webrtc_service.dart b/lib/domain/services/webrtc_service.dart index c2b64cf..50b17c7 100644 --- a/lib/domain/services/webrtc_service.dart +++ b/lib/domain/services/webrtc_service.dart @@ -1,104 +1,379 @@ import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:chepuhagram/data/datasources/ws_client.dart'; +import 'package:chepuhagram/core/constants.dart'; +import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class WebRTCService { + static final WebRTCService _instance = WebRTCService._internal(); + factory WebRTCService() => _instance; + WebRTCService._internal(); + RTCPeerConnection? _peerConnection; MediaStream? _localStream; + Function()? onCallEnded; + Function(bool)? onRemoteVideoToggled; + int? _targetUserId; + int? _myUserId; + bool _isInitiator = false; + final List _remoteIceCandidatesQueue = []; + bool _isReadyForSignaling = false; + final List> _incomingSignalingQueue = []; + bool isCallAcceptedLocally = false; + bool _tracksAdded = false; - // Конфигурация STUN-серверов (Google STUN) + // Конфигурация STUN и TURN-серверов final Map _config = { "iceServers": [ {"urls": "stun:stun.l.google.com:19302"}, {"urls": "stun:stun1.l.google.com:19302"}, - ] + {"urls": "stun:stun2.l.google.com:19302"}, + { + "urls": AppConstants.turnUrl, + "username": AppConstants.turnUsername, + "credential": AppConstants.turnCredential, + }, + ], }; - /// Инициализация PeerConnection - Future initPeerConnection(String callId, Function(MediaStream) onRemoteStream) async { - _peerConnection = await createPeerConnection(_config); + /// Локальный стрим (камера + микрофон) + Future startLocalStream( + Function(MediaStream) onLocalStreamCallback, { + bool videoDefaultEnabled = false, + }) async { + try { + _localStream?.getTracks().forEach((track) => track.stop()); + _localStream?.dispose(); - // Слушаем удаленный поток - _peerConnection!.onAddStream = (stream) { - onRemoteStream(stream); - }; - - // Отправляем ICE-кандидаты на сервер через SocketService - _peerConnection!.onIceCandidate = (candidate) { - SocketService().sendMessage({ - "type": "ice_candidate", - "call_id": callId, - "candidate": candidate.toMap(), + _localStream = await navigator.mediaDevices.getUserMedia({ + 'audio': true, + 'video': { + 'mandatory': { + 'minWidth': '1280', + 'minHeight': '720', + 'minFrameRate': '30', + }, + 'facingMode': 'user', + 'optional': [], + }, + }); + // Выключаем видео по умолчанию + _localStream!.getVideoTracks().forEach((track) { + track.enabled = videoDefaultEnabled; }); - }; - // Получаем локальный поток (микрофон + камера) - _localStream = await navigator.mediaDevices.getUserMedia({ - 'audio': true, - 'video': true, - }); + // Если PeerConnection уже инициализирован, сразу же добавляем локальные треки + if (_peerConnection != null && !_tracksAdded) { + _localStream!.getTracks().forEach((track) { + _peerConnection!.addTrack(track, _localStream!); + }); + _tracksAdded = true; + } - // Добавляем треки в соединение - _localStream!.getTracks().forEach((track) { - _peerConnection!.addTrack(track, _localStream!); - }); + onLocalStreamCallback(_localStream!); + } catch (e) { + print("Ошибка получения локального стрима: $e"); + } } - Future handleOffer(String callId, String remoteSdp) async { - // 1. Инициализируем соединение, если оно еще не создано - if (_peerConnection == null) { - await initPeerConnection(callId, (stream) { - // Здесь можно добавить callback для отрисовки видео, если нужно - print("Remote stream received"); - }); + /// Инициализация PeerConnection + Future initPeerConnection( + String callId, + int targetUserId, + Function(MediaStream) onRemoteStream, [ + int? myUserId, + bool isInitiator = false, + ]) async { + _targetUserId = targetUserId; + _isInitiator = isInitiator; + if (myUserId != null) { + _myUserId = myUserId; + } + try { + final prefs = await SharedPreferences.getInstance(); + final alwaysRelay = prefs.getBool('privacy_always_relay_calls') ?? false; + + final Map currentConfig = { + "iceServers": alwaysRelay + ? [ + { + "urls": AppConstants.turnUrl, + "username": AppConstants.turnUsername, + "credential": AppConstants.turnCredential, + }, + ] + : [ + {"urls": "stun:stun.l.google.com:19302"}, + {"urls": "stun:stun1.l.google.com:19302"}, + {"urls": "stun:stun2.l.google.com:19302"}, + {"urls": AppConstants.stunUrl}, + { + "urls": AppConstants.turnUrl, + "username": AppConstants.turnUsername, + "credential": AppConstants.turnCredential, + }, + ], + if (alwaysRelay) "iceTransportPolicy": "relay", + }; + + _peerConnection = await createPeerConnection(currentConfig); + + // Слушаем удаленный поток + _peerConnection!.onAddStream = (stream) { + onRemoteStream(stream); + }; + + // Отправляем ICE-кандидаты на сервер через SocketService + _peerConnection!.onIceCandidate = (candidate) { + if (_targetUserId != null) { + SocketService().sendMessage({ + "type": "ice_candidate", + "call_id": callId, + "receiver_id": _targetUserId, + "sender_id": _myUserId, + "candidate": candidate.toMap(), + }); + } + }; + + // Добавляем треки в соединение + if (_localStream != null && !_tracksAdded) { + _localStream!.getTracks().forEach((track) { + _peerConnection!.addTrack(track, _localStream!); + }); + _tracksAdded = true; + } + + // Дрейним очередь сигналов, так как PeerConnection теперь готов! + _drainSignalingQueue(); + } catch (e) { + print("Ошибка инициализации PeerConnection: $e"); + } } - // 2. Создаем ответ (это вызывает ваш метод createAnswer) - await createAnswer(callId, remoteSdp); -} + /// Обработка сигнальных сообщений + void handleSignalingMessage(Map data) async { + final type = data['type']; + final needsPeerConnection = + (type == 'offer' || type == 'answer' || type == 'ice_candidate'); - /// Создание Offer (вызывает инициатор звонка) - Future createOffer(String callId) async { - RTCSessionDescription offer = await _peerConnection!.createOffer(); - await _peerConnection!.setLocalDescription(offer); - - SocketService().sendMessage({ - "type": "offer", - "call_id": callId, - "sdp": offer.sdp, - }); + if (!_isReadyForSignaling || + (needsPeerConnection && _peerConnection == null)) { + _incomingSignalingQueue.add(data); + print( + "WebRTCService: Queueing message: $type (ready: $_isReadyForSignaling, pc: ${_peerConnection != null})", + ); + return; + } + + final callId = data['call_id']; + print("WebRTCService: Получено сигнальное сообщение: $type"); + + if (type == 'call_accepted') { + if (_isInitiator) { + await createOffer(callId); + } else { + // Мы не инициатор, значит звонок принят на другом нашем устройстве! + if (!isCallAcceptedLocally) { + print( + "WebRTCService: Call accepted on other device. Ending call locally.", + ); + onCallEnded?.call(); + FlutterCallkitIncoming.endAllCalls(); + } + } + } else if (type == 'offer') { + final senderId = int.tryParse(data['sender_id']?.toString() ?? '') ?? 0; + if (senderId != 0) { + _targetUserId = senderId; + } + final sdp = data['sdp']?.toString() ?? ''; + await handleOffer(callId, sdp); + } else if (type == 'answer') { + final sdp = data['sdp']?.toString() ?? ''; + await _peerConnection?.setRemoteDescription( + RTCSessionDescription(sdp, 'answer'), + ); + await _processRemoteIceCandidatesQueue(); + } else if (type == 'ice_candidate') { + final candidate = data['candidate']; + if (candidate != null) { + await addRemoteIceCandidate(Map.from(candidate)); + } + } else if (type == 'video_toggle') { + final enabled = data['video_enabled'] == true; + onRemoteVideoToggled?.call(enabled); + } else if (type == 'hangup' || type == 'decline') { + if (onCallEnded != null) { + onCallEnded?.call(); + } else { + print( + "WebRTCService: Call declined or hung up on other device. Dismissing CallKit.", + ); + FlutterCallkitIncoming.endAllCalls(); + } + } } - /// Создание Answer (вызывает получатель звонка) - Future createAnswer(String callId, String remoteSdp) async { - await _peerConnection!.setRemoteDescription( - RTCSessionDescription(remoteSdp, 'offer'), - ); - - RTCSessionDescription answer = await _peerConnection!.createAnswer(); - await _peerConnection!.setLocalDescription(answer); + /// Создание Offer (вызывает инициатор звонка после call_accepted) + Future createOffer(String callId, [int? targetUserId]) async { + final tId = targetUserId ?? _targetUserId; + if (_peerConnection == null || tId == null || tId == 0) return; + try { + RTCSessionDescription offer = await _peerConnection!.createOffer(); + await _peerConnection!.setLocalDescription(offer); - SocketService().sendMessage({ - "type": "answer", - "call_id": callId, - "sdp": answer.sdp, - }); + SocketService().sendMessage({ + "type": "offer", + "call_id": callId, + "receiver_id": tId, + "sender_id": _myUserId, + "sdp": offer.sdp, + }); + } catch (e) { + print("Ошибка при создании offer: $e"); + } + } + + /// Обработка Offer и отправка Answer + Future handleOffer( + String callId, + String remoteSdp, [ + int? targetUserId, + ]) async { + final tId = targetUserId ?? _targetUserId; + if (_peerConnection == null || tId == null || tId == 0) return; + try { + await _peerConnection!.setRemoteDescription( + RTCSessionDescription(remoteSdp, 'offer'), + ); + await _processRemoteIceCandidatesQueue(); + + RTCSessionDescription answer = await _peerConnection!.createAnswer(); + await _peerConnection!.setLocalDescription(answer); + + SocketService().sendMessage({ + "type": "answer", + "call_id": callId, + "receiver_id": tId, + "sender_id": _myUserId, + "sdp": answer.sdp, + }); + } catch (e) { + print("Ошибка при обработке offer и создании answer: $e"); + } } /// Обработка ICE кандидатов от удаленного собеседника Future addRemoteIceCandidate(Map candidateMap) async { - await _peerConnection!.addCandidate( - RTCIceCandidate( - candidateMap['candidate'], - candidateMap['sdpMid'], - candidateMap['sdpMLineIndex'], - ), + if (_peerConnection == null) return; + final candidate = RTCIceCandidate( + candidateMap['candidate'], + candidateMap['sdpMid'], + candidateMap['sdpMLineIndex'], ); + + final remoteDesc = await _peerConnection!.getRemoteDescription(); + if (remoteDesc == null) { + _remoteIceCandidatesQueue.add(candidate); + print( + "WebRTCService: Remote description is not set yet. Queued ICE candidate.", + ); + return; + } + + try { + await _peerConnection!.addCandidate(candidate); + } catch (e) { + print("Ошибка добавления удаленного ICE кандидата: $e"); + } + } + + /// Процесс добавления сохраненных в очереди ICE кандидатов + Future _processRemoteIceCandidatesQueue() async { + if (_peerConnection == null) return; + print( + "WebRTCService: Processing queued remote ICE candidates (${_remoteIceCandidatesQueue.length})", + ); + for (var candidate in _remoteIceCandidatesQueue) { + try { + await _peerConnection!.addCandidate(candidate); + } catch (e) { + print("Ошибка добавления удаленного ICE кандидата из очереди: $e"); + } + } + _remoteIceCandidatesQueue.clear(); + } + + /// Включение/выключение звука + void toggleMute(bool isMuted) { + _localStream?.getAudioTracks().forEach((track) { + track.enabled = !isMuted; + }); + } + + /// Включение/выключение видео + void toggleVideo(bool isVideoOff) { + _localStream?.getVideoTracks().forEach((track) { + track.enabled = !isVideoOff; + }); + } + + /// Переключение вывода звука (динамик громкой связи / разговорный динамик) + void toggleSpeakerphone(bool isSpeakerOn) { + try { + Helper.setSpeakerphoneOn(isSpeakerOn); + print("WebRTCService: Громкая связь переключена в: $isSpeakerOn"); + } catch (e) { + print("Ошибка при переключении динамика: $e"); + } + } + + /// Переключение камеры + Future switchCamera() async { + if (_localStream != null) { + final videoTrack = _localStream!.getVideoTracks().firstOrNull; + if (videoTrack != null) { + await Helper.switchCamera(videoTrack); + } + } + } + + /// Пометить WebRTCService как готовый для обмена сообщениями и обработать накопившуюся очередь + void setReadyForSignaling() { + _isReadyForSignaling = true; + print("WebRTCService: Ready for signaling."); + _drainSignalingQueue(); + } + + void _drainSignalingQueue() { + if (!_isReadyForSignaling) return; + print( + "WebRTCService: Draining signaling queue of size: ${_incomingSignalingQueue.length}", + ); + + final List> queueCopy = List.from( + _incomingSignalingQueue, + ); + _incomingSignalingQueue.clear(); + + for (var data in queueCopy) { + handleSignalingMessage(data); + } } /// Очистка ресурсов void dispose() { + print("WebRTCService: Очистка ресурсов WebRTC"); + _tracksAdded = false; + _remoteIceCandidatesQueue.clear(); + _incomingSignalingQueue.clear(); + _isReadyForSignaling = false; + isCallAcceptedLocally = false; _localStream?.getTracks().forEach((track) => track.stop()); _localStream?.dispose(); + _localStream = null; _peerConnection?.close(); + _peerConnection = null; } -} \ No newline at end of file +} diff --git a/lib/main.dart b/lib/main.dart index f62dc10..46f109e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'package:chepuhagram/data/datasources/ws_client.dart'; import 'package:chepuhagram/logic/auth_provider.dart'; +import 'package:chepuhagram/domain/services/api_service.dart'; import 'package:chepuhagram/logic/contact_provider.dart'; import 'package:chepuhagram/core/theme_manager.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; @@ -8,6 +9,7 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:path_provider/path_provider.dart'; import 'package:chepuhagram/domain/services/crypto_service.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'dart:convert'; import 'package:chepuhagram/data/datasources/local_db_service.dart'; @@ -271,6 +273,56 @@ void main() async { ); } +Future showIncomingCallKit({ + required String callId, + required String callerName, + required int callerId, +}) async { + final params = CallKitParams( + id: callId, + nameCaller: callerName, + appName: 'Chepuhagram', + avatar: '', + handle: callerName, + type: 0, // 0 - audio, 1 - video + duration: 30000, + textAccept: 'Принять', + textDecline: 'Отклонить', + missedCallNotification: const NotificationParams( + showNotification: true, + subtitle: 'Пропущенный звонок', + ), + extra: { + 'callerId': callerId, + }, + android: const AndroidParams( + isCustomNotification: true, + isShowLogo: false, + ringtonePath: 'system_ringtone_default', + backgroundColor: '#0F0F12', + actionColor: '#4CAF50', + textColor: '#ffffff', + incomingCallNotificationChannelName: 'Входящие звонки', + ), + ios: const IOSParams( + handleType: 'generic', + supportsVideo: false, + maximumCallGroups: 1, + maximumCallsPerCallGroup: 1, + audioSessionMode: 'default', + audioSessionActive: true, + audioSessionPreferredSampleRate: 44100.0, + audioSessionPreferredIOBufferDuration: 0.005, + supportsDTMF: false, + supportsHolding: false, + supportsGrouping: false, + supportsUngrouping: false, + ringtonePath: 'system_ringtone_default', + ), + ); + await FlutterCallkitIncoming.showCallkitIncoming(params); +} + void initCallkitListener() { if (!(Platform.isAndroid || Platform.isIOS)) { print('Skipping CallKit event listener on non-mobile platform.'); @@ -281,10 +333,12 @@ void initCallkitListener() { FlutterCallkitIncoming.onEvent.listen((event) { if (event == null) return; + final callerId = int.tryParse(event.body['extra']?['callerId']?.toString() ?? '') ?? + int.tryParse(event.body['callerId']?.toString() ?? '') ?? 0; + switch (event.event) { case Event.actionCallIncoming: // Звонок получен, но CallKit уже показал экран. - // Здесь можно логировать или обновить статус в БД. print("Incoming call: ${event.body['id']}"); break; @@ -294,31 +348,76 @@ void initCallkitListener() { case Event.actionCallAccept: // ПОЛЬЗОВАТЕЛЬ НАЖАЛ "ПРИНЯТЬ" - // 1. Уведомляем сервер (чтобы другая сторона узнала о начале звонка) - SocketService().sendMessage({ - "type": "call_accept", - "call_id": event.body['id'], - }); + Future(() async { + final context = navigatorKey.currentContext; + var myId = 0; + if (context != null) { + try { + myId = Provider.of(context, listen: false).currentUserId ?? 0; + } catch (_) {} + } + if (myId == 0) { + final storage = FlutterSecureStorage(); + final userIdStr = await storage.read(key: 'user_id'); + myId = int.tryParse(userIdStr ?? '') ?? 0; + } - // 2. Переходим на экран звонка - navigatorKey.currentState?.push( - MaterialPageRoute( - builder: (_) => CallScreen( - callId: event.body['id'], - isIncoming: true, - callerName: event.body['nameCaller'] ?? 'Unknown', - onAccept: () {}, - onHangup: () => _handleHangupGlobal(event.body['id']), - ), - ), - ); + final socket = SocketService(); + if (!socket.isConnected()) { + print("CallKit action: Socket is not connected, connecting..."); + try { + await socket.connect(ApiService()); + } catch (e) { + print("CallKit action error connecting socket: $e"); + } + } + + // 1. Уведомляем сервер (чтобы другая сторона узнала о начале звонка) + socket.sendMessage({ + "type": "call_accepted", + "call_id": event.body['id'], + "receiver_id": callerId, + "sender_id": myId, + }); + + // 2. Переходим на экран звонка + final callId = event.body['id']?.toString() ?? ''; + if (CallScreen.currentActiveCallId != null) { + print("CallKit action: A call is already active (${CallScreen.currentActiveCallId}), skipping push for $callId"); + } else { + CallScreen.currentActiveCallId = callId; + navigatorKey.currentState?.push( + MaterialPageRoute( + builder: (_) => CallScreen( + callId: callId, + isIncoming: true, + callerName: event.body['nameCaller'] ?? 'Unknown', + targetUserId: callerId, + startAccepted: true, + ), + ), + ); + } + }); break; case Event.actionCallDecline: // ПОЛЬЗОВАТЕЛЬ НАЖАЛ "ОТКЛОНИТЬ" - SocketService().sendMessage({ - "type": "decline", - "call_id": event.body['id'], + Future(() async { + final socket = SocketService(); + if (!socket.isConnected()) { + print("CallKit action: Socket is not connected for decline, connecting..."); + try { + await socket.connect(ApiService()); + } catch (e) { + print("CallKit action error connecting socket: $e"); + } + } + socket.sendMessage({ + "type": "decline", + "call_id": event.body['id'], + "receiver_id": callerId, + }); }); break; @@ -504,8 +603,23 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { } catch (e) { print('Error processing background message: $e'); } + } else if (message.data['type'] == 'call_init') { + try { + final callId = message.data['call_id']?.toString() ?? ''; + final callerName = message.data['caller_username']?.toString() ?? 'Пользователь'; + final callerId = int.tryParse(message.data['caller_id']?.toString() ?? '') ?? 0; + + print("Фоновый звонок через FCM: callId=$callId, callerName=$callerName, callerId=$callerId"); + await showIncomingCallKit( + callId: callId, + callerName: callerName, + callerId: callerId, + ); + } catch (e) { + print("Error showing incoming call kit in background: $e"); + } } else { - print('Message type is not enc_message: ${message.data['type']}'); + print('Message type is not recognized: ${message.data['type']}'); } } diff --git a/lib/presentation/screens/call_screen.dart b/lib/presentation/screens/call_screen.dart index 9c5c4b2..c54a5bc 100644 --- a/lib/presentation/screens/call_screen.dart +++ b/lib/presentation/screens/call_screen.dart @@ -1,90 +1,506 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:chepuhagram/domain/services/webrtc_service.dart'; +import 'package:chepuhagram/data/datasources/ws_client.dart'; +import 'package:provider/provider.dart'; +import 'package:chepuhagram/logic/auth_provider.dart'; +import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; +import 'package:chepuhagram/domain/services/api_service.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; class CallScreen extends StatefulWidget { final String callId; final bool isIncoming; final String callerName; - final VoidCallback onAccept; - final VoidCallback onHangup; + final int targetUserId; + final bool startAccepted; + + static String? currentActiveCallId; const CallScreen({ super.key, required this.callId, required this.isIncoming, required this.callerName, - required this.onAccept, - required this.onHangup, + required this.targetUserId, + this.startAccepted = false, }); @override State createState() => _CallScreenState(); } -class _CallScreenState extends State { - // Рендереры для видеопотока +class _CallScreenState extends State with TickerProviderStateMixin { final RTCVideoRenderer _localRenderer = RTCVideoRenderer(); final RTCVideoRenderer _remoteRenderer = RTCVideoRenderer(); - + bool _isAccepted = false; + bool _isMuted = false; + bool _isVideoOff = true; + bool _isSpeakerOn = false; + bool _isRemoteVideoOn = false; + bool _renderersInitialized = false; + bool _isPopped = false; + + Timer? _durationTimer; + int _durationSeconds = 0; + + // Анимация пульсации для экрана вызова + late AnimationController _pulseController; + late Animation _pulseAnimation; @override void initState() { super.initState(); - _localRenderer.initialize(); - _remoteRenderer.initialize(); + CallScreen.currentActiveCallId = widget.callId; + _isAccepted = widget.startAccepted; + _initRenderers(); + _setupPulseAnimation(); + _setupWebRTC(); + } + + Future _initRenderers() async { + await _localRenderer.initialize(); + await _remoteRenderer.initialize(); + if (mounted) { + setState(() { + _renderersInitialized = true; + }); + } + } + + void _setupPulseAnimation() { + _pulseController = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + )..repeat(); + _pulseAnimation = Tween(begin: 1.0, end: 1.6).animate( + CurvedAnimation(parent: _pulseController, curve: Curves.easeOut), + ); + } + + void _setupWebRTC() async { + // 1. Устанавливаем обработчик завершения звонка + WebRTCService().onCallEnded = () { + if (mounted && !_isPopped) { + _isPopped = true; + if (CallScreen.currentActiveCallId == widget.callId) { + CallScreen.currentActiveCallId = null; + } + _stopTimer(); + FlutterCallkitIncoming.endAllCalls(); + Navigator.of(context).pop(); + } + }; + + // Слушаем изменение состояния камеры собеседника + WebRTCService().onRemoteVideoToggled = (enabled) { + if (mounted) { + setState(() { + _isRemoteVideoOn = enabled; + }); + } + }; + + // 2. Если звонок исходящий или входящий и уже принят через CallKit, + // сразу же создаем/инициализируем PeerConnection, чтобы быть готовыми принимать входящие сигналы (offer и т.д.) + if (!widget.isIncoming || widget.startAccepted) { + if (widget.startAccepted || !widget.isIncoming) { + WebRTCService().isCallAcceptedLocally = true; + } + await _initConnection(); + } + + // 3. Запускаем локальный стрим (камера открывается параллельно/следом) + await WebRTCService().startLocalStream((localStream) { + if (mounted) { + setState(() { + _localRenderer.srcObject = localStream; + }); + } + }); + + // 4. Помечаем WebRTCService как готовый для обмена сигналами + WebRTCService().setReadyForSignaling(); + } + + Future _initConnection() async { + var myId = context.read().currentUserId ?? 0; + if (myId == 0) { + final storage = FlutterSecureStorage(); + final userIdStr = await storage.read(key: 'user_id'); + myId = int.tryParse(userIdStr ?? '') ?? 0; + } + + final socket = SocketService(); + if (!socket.isConnected()) { + print("CallScreen: Socket not connected during init, connecting..."); + try { + await socket.connect(ApiService()); + } catch (e) { + print("CallScreen socket connection error: $e"); + } + } + + await WebRTCService().initPeerConnection( + widget.callId, + widget.targetUserId, + (remoteStream) { + if (mounted) { + setState(() { + _remoteRenderer.srcObject = remoteStream; + _isAccepted = true; + }); + _startTimer(); + } + }, + myId, + !widget.isIncoming, + ); + } + + void _startTimer() { + _durationTimer?.cancel(); + _durationSeconds = 0; + _durationTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (mounted) { + setState(() { + _durationSeconds++; + }); + } + }); + } + + void _stopTimer() { + _durationTimer?.cancel(); + } + + String _formatDuration(int seconds) { + final int minutes = seconds ~/ 60; + final int remainingSeconds = seconds % 60; + return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}'; + } + + void _handleAccept() async { + setState(() { + _isAccepted = true; + }); + WebRTCService().isCallAcceptedLocally = true; + final myId = context.read().currentUserId ?? 0; + // Сообщаем вызывающему, что вызов принят + SocketService().sendMessage({ + "type": "call_accepted", + "call_id": widget.callId, + "receiver_id": widget.targetUserId, + "sender_id": myId, + }); + // Инициализируем WebRTC-соединение + await _initConnection(); + _startTimer(); + } + + void _handleDecline() { + SocketService().sendMessage({ + "type": "decline", + "call_id": widget.callId, + "receiver_id": widget.targetUserId, + }); + _cleanupAndPop(); + } + + void _handleHangup() { + SocketService().sendMessage({ + "type": "hangup", + "call_id": widget.callId, + "receiver_id": widget.targetUserId, + }); + _cleanupAndPop(); + } + + void _cleanupAndPop() { + if (_isPopped) return; + _isPopped = true; + if (CallScreen.currentActiveCallId == widget.callId) { + CallScreen.currentActiveCallId = null; + } + _stopTimer(); + WebRTCService().dispose(); + FlutterCallkitIncoming.endAllCalls(); + if (mounted) { + Navigator.of(context).pop(); + } + } + + void _toggleMute() { + setState(() { + _isMuted = !_isMuted; + }); + WebRTCService().toggleMute(_isMuted); + } + + void _toggleVideo() { + setState(() { + _isVideoOff = !_isVideoOff; + }); + WebRTCService().toggleVideo(_isVideoOff); + + // Уведомляем собеседника о переключении нашей камеры + final myId = context.read().currentUserId ?? 0; + SocketService().sendMessage({ + "type": "video_toggle", + "call_id": widget.callId, + "receiver_id": widget.targetUserId, + "sender_id": myId, + "video_enabled": !_isVideoOff, + }); + } + + void _toggleSpeaker() { + setState(() { + _isSpeakerOn = !_isSpeakerOn; + }); + WebRTCService().toggleSpeakerphone(_isSpeakerOn); + } + + void _switchCamera() async { + await WebRTCService().switchCamera(); } @override void dispose() { + _stopTimer(); + _pulseController.dispose(); + _localRenderer.srcObject = null; + _remoteRenderer.srcObject = null; _localRenderer.dispose(); _remoteRenderer.dispose(); + WebRTCService().onCallEnded = null; + WebRTCService().onRemoteVideoToggled = null; + WebRTCService().dispose(); + if (CallScreen.currentActiveCallId == widget.callId) { + CallScreen.currentActiveCallId = null; + } super.dispose(); } @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final initials = widget.callerName.isNotEmpty + ? widget.callerName.split(RegExp(r'\s+')).take(2).map((p) => p[0].toUpperCase()).join() + : '?'; + return Scaffold( - backgroundColor: Colors.black, + backgroundColor: const Color(0xFF0F0F12), body: Stack( children: [ - // Основной контент (Видео или Аватар) + // 1. Задний фон / Удаленное видео Positioned.fill( - child: _isAccepted - ? RTCVideoView(_remoteRenderer, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover) - : Center(child: Text(widget.callerName, style: const TextStyle(color: Colors.white, fontSize: 24))), + child: _renderersInitialized && _isAccepted && _isRemoteVideoOn && _remoteRenderer.srcObject != null + ? RTCVideoView( + _remoteRenderer, + objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, + ) + : _buildWaitingScreen(initials), ), - // Панель управления + // 2. Плавающее локальное видео (картинка в картинке) после ответа + if (_renderersInitialized && _isAccepted && !_isVideoOff && _localRenderer.srcObject != null) + Positioned( + top: MediaQuery.of(context).padding.top + 16, + right: 16, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Container( + width: 110, + height: 160, + color: Colors.black26, + child: RTCVideoView( + _localRenderer, + mirror: true, + objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, + ), + ), + ), + ), + + // 3. Статус звонка и таймер Positioned( - bottom: 50, + top: MediaQuery.of(context).padding.top + 80, left: 0, right: 0, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + child: Column( children: [ - FloatingActionButton( - backgroundColor: Colors.red, - onPressed: () { - widget.onHangup(); - Navigator.of(context).pop(); - }, - child: const Icon(Icons.call_end, color: Colors.white), - ), - if (widget.isIncoming && !_isAccepted) - FloatingActionButton( - backgroundColor: Colors.green, - onPressed: () { - setState(() => _isAccepted = true); - widget.onAccept(); - }, - child: const Icon(Icons.call, color: Colors.white), + Text( + widget.callerName, + style: const TextStyle( + color: Colors.white, + fontSize: 28, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, ), + ), + const SizedBox(height: 8), + Text( + _isAccepted + ? _formatDuration(_durationSeconds) + : (widget.isIncoming ? 'Входящий звонок...' : 'Вызов...'), + style: TextStyle( + color: Colors.white.withOpacity(0.7), + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), ], ), ), + + // 4. Панель управления звонком + Positioned( + bottom: 40, + left: 20, + right: 20, + child: _buildControlsBar(colorScheme), + ), ], ), ); } + + Widget _buildWaitingScreen(String initials) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF1E1E24), Color(0xFF0F0F12)], + ), + ), + child: Center( + child: Stack( + alignment: Alignment.center, + children: [ + if (!_isAccepted) + AnimatedBuilder( + animation: _pulseAnimation, + builder: (context, child) { + return Container( + width: 140 * _pulseAnimation.value, + height: 140 * _pulseAnimation.value, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.blueAccent.withOpacity(1.0 - (_pulseAnimation.value - 1.0) / 0.6), + ), + ); + }, + ), + Container( + width: 130, + height: 130, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [Colors.blueAccent, Colors.indigoAccent], + ), + ), + child: Center( + child: Text( + initials, + style: const TextStyle( + fontSize: 48, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildControlsBar(ColorScheme colorScheme) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.4), + borderRadius: BorderRadius.circular(30), + border: Border.all(color: Colors.white.withOpacity(0.08)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Кнопка звука + _buildCircleButton( + icon: _isMuted ? Icons.mic_off : Icons.mic, + isActive: !_isMuted, + onPressed: _toggleMute, + ), + // Кнопка динамика + _buildCircleButton( + icon: _isSpeakerOn ? Icons.volume_up : Icons.volume_down, + isActive: _isSpeakerOn, + onPressed: _toggleSpeaker, + ), + // Кнопка камеры + _buildCircleButton( + icon: _isVideoOff ? Icons.videocam_off : Icons.videocam, + isActive: !_isVideoOff, + onPressed: _toggleVideo, + ), + // Поворот камеры + _buildCircleButton( + icon: Icons.flip_camera_ios, + isActive: true, + onPressed: _switchCamera, + ), + const SizedBox(width: 8), + + // Кнопка Отклонить/Принять или Завершить + if (widget.isIncoming && !_isAccepted) ...[ + FloatingActionButton( + heroTag: 'decline_btn', + backgroundColor: Colors.redAccent, + onPressed: _handleDecline, + child: const Icon(Icons.call_end, color: Colors.white, size: 28), + ), + FloatingActionButton( + heroTag: 'accept_btn', + backgroundColor: Colors.greenAccent, + onPressed: _handleAccept, + child: const Icon(Icons.call, color: Colors.white, size: 28), + ), + ] else ...[ + FloatingActionButton( + heroTag: 'hangup_btn', + backgroundColor: Colors.redAccent, + onPressed: _handleHangup, + child: const Icon(Icons.call_end, color: Colors.white, size: 28), + ), + ], + ], + ), + ); + } + + Widget _buildCircleButton({ + required IconData icon, + required bool isActive, + required VoidCallback onPressed, + }) { + return Container( + width: 50, + height: 50, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive ? Colors.white.withOpacity(0.12) : Colors.red.withOpacity(0.2), + ), + child: IconButton( + icon: Icon(icon, color: isActive ? Colors.white : Colors.redAccent, size: 24), + onPressed: onPressed, + ), + ); + } } \ No newline at end of file diff --git a/lib/presentation/screens/chat_screen.dart b/lib/presentation/screens/chat_screen.dart index 334c678..ef16ad1 100644 --- a/lib/presentation/screens/chat_screen.dart +++ b/lib/presentation/screens/chat_screen.dart @@ -14,6 +14,7 @@ import 'package:chepuhagram/data/datasources/ws_client.dart'; import 'package:provider/provider.dart'; import 'package:flutter/rendering.dart'; import '/logic/contact_provider.dart'; +import 'package:chepuhagram/logic/auth_provider.dart'; import '../../domain/services/api_service.dart'; import 'dart:math'; import 'package:chepuhagram/data/datasources/local_db_service.dart'; @@ -841,6 +842,15 @@ class _ChatScreenState extends State with RouteAware { ); }, ), + actions: [ + if (_currentContact.id != 0) + IconButton( + icon: const Icon(Icons.phone_rounded), + tooltip: 'Позвонить', + onPressed: () => _initiateCall(context), + ), + const SizedBox(width: 8), + ], ), body: Stack( children: [ @@ -1315,10 +1325,12 @@ class _ChatScreenState extends State with RouteAware { } void _initiateCall(BuildContext context) { + final myUsername = context.read().username ?? 'Пользователь'; // Отправляем сигнал на сервер SocketService().sendMessage({ "type": "call_init", "receiver_id": widget.contact.id, + "caller_username": myUsername, }); } @@ -2948,25 +2960,24 @@ class _ChatScreenState extends State with RouteAware { Duration offset = now.timeZoneOffset; if (data['type'] == 'call_created') { final String serverCallId = data['call_id']; - final String targetName = data['receiver_name'] ?? "Пользователь"; - // Переходим на экран звонка, используя ID от сервера - navigatorKey.currentState?.push( - MaterialPageRoute( - builder: (_) => CallScreen( - callId: serverCallId, - isIncoming: false, // Мы инициатор - callerName: targetName, - onAccept: () async {}, - onHangup: () { - // Отправляем сигнал отмены на сервер - SocketService().sendMessage({ - "type": "hangup", - "call_id": serverCallId, - }); - }, + final String displayName = '${widget.contact.name} ${widget.contact.surname}'.trim(); + final String targetName = displayName.isNotEmpty ? displayName : widget.contact.username; + + if (CallScreen.currentActiveCallId != null) { + print("ChatScreen: A call is already active (${CallScreen.currentActiveCallId}), skipping push for $serverCallId"); + } else { + CallScreen.currentActiveCallId = serverCallId; + navigatorKey.currentState?.push( + MaterialPageRoute( + builder: (_) => CallScreen( + callId: serverCallId, + isIncoming: false, // Мы инициатор + callerName: targetName, + targetUserId: widget.contact.id, + ), ), - ), - ); + ); + } } if (data['type'] == 'all_chat_read') { diff --git a/lib/presentation/screens/contacts_screen.dart b/lib/presentation/screens/contacts_screen.dart index 90a4e3b..fadc032 100644 --- a/lib/presentation/screens/contacts_screen.dart +++ b/lib/presentation/screens/contacts_screen.dart @@ -6,6 +6,7 @@ import 'package:provider/provider.dart'; import '../screens/settings_screen.dart'; import '../screens/new_chat_screen.dart'; import '../screens/chat_screen.dart'; +import 'call_screen.dart'; import 'my_profile_screen.dart'; import '/logic/contact_provider.dart'; import '/logic/auth_provider.dart'; @@ -1693,6 +1694,43 @@ class _ContactsScreenState extends State with RouteAware { Future _handleIncomingMessage(dynamic data) async { if (data is RemoteMessage) { + if (data.data['type'] == 'call_init') { + try { + final callId = data.data['call_id']?.toString() ?? ''; + final callerName = data.data['caller_username']?.toString() ?? 'Пользователь'; + final callerId = int.tryParse(data.data['caller_id']?.toString() ?? '') ?? 0; + print("Входящий звонок через FCM в фокусе: callId=$callId"); + if (Platform.isAndroid || Platform.isIOS) { + await showIncomingCallKit( + callId: callId, + callerName: callerName, + callerId: callerId, + ); + } else { + final context = navigatorKey.currentContext; + if (context != null) { + if (CallScreen.currentActiveCallId != null) { + print("ContactsScreen: A call is already active (${CallScreen.currentActiveCallId}), skipping push for $callId"); + } else { + CallScreen.currentActiveCallId = callId; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => CallScreen( + callId: callId, + isIncoming: true, + callerName: callerName, + targetUserId: callerId, + ), + ), + ); + } + } + } + } catch (e) { + print("Error showing incoming call kit: $e"); + } + return; + } await _handleFCMMessage(data); } else if (data is Map) { print('WebSocket message received in ContactsScreen: $data'); diff --git a/lib/presentation/screens/privacy_settings_screen.dart b/lib/presentation/screens/privacy_settings_screen.dart index d616603..55c7067 100644 --- a/lib/presentation/screens/privacy_settings_screen.dart +++ b/lib/presentation/screens/privacy_settings_screen.dart @@ -15,12 +15,14 @@ class _PrivacySettingsScreenState extends State { static const _showAvatarKey = 'privacy_show_avatar'; static const _showAboutKey = 'privacy_show_about'; static const _showLastOnlineKey = 'privacy_show_last_online'; + static const _alwaysRelayCallsKey = 'privacy_always_relay_calls'; bool _showEmail = true; bool _showPhone = true; bool _showAvatar = true; bool _showAbout = true; bool _showLastOnline = true; + bool _alwaysRelayCalls = false; bool _isSaving = false; @override @@ -38,6 +40,7 @@ class _PrivacySettingsScreenState extends State { _showAvatar = prefs.getBool(_showAvatarKey) ?? true; _showAbout = prefs.getBool(_showAboutKey) ?? true; _showLastOnline = prefs.getBool(_showLastOnlineKey) ?? true; + _alwaysRelayCalls = prefs.getBool(_alwaysRelayCallsKey) ?? false; }); } @@ -87,6 +90,7 @@ class _PrivacySettingsScreenState extends State { await _savePreference(_showAvatarKey, _showAvatar); await _savePreference(_showAboutKey, _showAbout); await _savePreference(_showLastOnlineKey, _showLastOnline); + await _savePreference(_alwaysRelayCallsKey, _alwaysRelayCalls); } } catch (e) { if (mounted) { @@ -145,6 +149,30 @@ class _PrivacySettingsScreenState extends State { ), ), const SizedBox(height: 24), + const Padding( + padding: EdgeInsets.only(left: 8.0, bottom: 12), + child: Text('Звонки:', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, letterSpacing: 0.5)), + ), + Container( + decoration: BoxDecoration( + color: colorScheme.surfaceVariant.withOpacity(0.2), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.1)), + ), + child: _buildSwitchTile('Всегда направлять звонки через сервер', _alwaysRelayCalls, (v) { + setState(() => _alwaysRelayCalls = v); + _savePreference(_alwaysRelayCallsKey, v); + }), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + 'При включении звонки будут направляться только через TURN сервер. Это скрывает ваш IP-адрес от собеседника, но может ухудшить качество связи.', + style: TextStyle(color: colorScheme.outline, fontSize: 12, height: 1.4), + ), + ), + const SizedBox(height: 24), Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( diff --git a/srv/app/websocket/connection_manager.py b/srv/app/websocket/connection_manager.py index 0bdb2ab..bb07d63 100644 --- a/srv/app/websocket/connection_manager.py +++ b/srv/app/websocket/connection_manager.py @@ -449,20 +449,59 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db: if message_data.get("type") == "call_init": receiver_id = message_data.get("receiver_id") call_id = str(uuid.uuid4()) + caller_username = message_data.get("caller_username") + call_data = { "type": "call_init", "call_id": call_id, - "caller_username": message_data.get("caller_username"), + "caller_username": caller_username, "caller_id": str(user_id) } - sent = await manager.send_personal_message(call_data, str(receiver_id)) - if sent: - await manager.send_personal_message({"type": "call_created", "call_id": call_id}, str(user_id)) + + # 1. Отправляем в WebSocket + await manager.send_personal_message(call_data, str(receiver_id)) + + # 2. Отправляем высокоприоритетный пуш через FCM + try: + res = await db.execute( + select(models.User.fcm_token) + .where(models.User.id == receiver_id) + ) + receiver_fcm = res.scalar() + if receiver_fcm: + fcm_message = messaging.Message( + token=receiver_fcm, + data={ + "type": "call_init", + "call_id": call_id, + "caller_username": str(caller_username), + "caller_id": str(user_id), + }, + android=messaging.AndroidConfig(priority="high"), + apns=messaging.APNSConfig( + payload=messaging.APNSPayload( + aps=messaging.Aps(sound="default", content_available=True) + ) + ) + ) + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, messaging.send, fcm_message) + print(f"DEBUG: FCM Call Push sent to user {receiver_id}") + except Exception as ex: + print(f"DEBUG: Error sending FCM Call Push: {ex}") + + # 3. Подтверждаем инициатору + await manager.send_personal_message({"type": "call_created", "call_id": call_id}, str(user_id)) - elif message_data.get("type") in ["offer", "answer", "ice_candidate", "hangup", "decline", "call_accepted"]: + elif message_data.get("type") in ["offer", "answer", "ice_candidate", "video_toggle"]: receiver_id = message_data.get("receiver_id") await manager.send_personal_message(message_data, str(receiver_id)) + elif message_data.get("type") in ["hangup", "decline", "call_accepted"]: + receiver_id = message_data.get("receiver_id") + await manager.send_personal_message(message_data, str(receiver_id)) + await manager.send_personal_message(message_data, str(user_id)) + except WebSocketDisconnect: pass finally: