diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index d59795b..30d2ab7 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,15 @@ - + + + + + + + Void + ) { + guard type == .voIP else { + completion() + return + } + let data = flutter_callkit_incoming.Data( + id: UUID().uuidString, nameCaller: "Šepot", handle: "Šepot", type: 0) + data.appName = "Šepot" + data.duration = 45000 + data.extra = ["push": true] + data.handleType = "generic" + data.supportsVideo = true + data.maximumCallGroups = 1 + data.maximumCallsPerCallGroup = 1 + data.supportsHolding = false + data.supportsDTMF = false + data.includesCallsInRecents = false + SwiftFlutterCallkitIncomingPlugin.sharedInstance?.showCallkitIncoming(data, fromPushKit: true) { + completion() + } + // Běží-li Dart, ať se hned připojí a vyzvedne nabídku z fronty. + apnsChannel?.invokeMethod("onWake", arguments: nil) + } + + // MARK: - CallKit + // Rozhodnutí (přijmout/odmítnout) řeší Dart přes události pluginu; + // tady jen potvrdit akci CallKitu, jinak by ji systém po čase zrušil. + + func onAccept(_ call: Call, _ action: CXAnswerCallAction) { + action.fulfill() + } + + func onDecline(_ call: Call, _ action: CXEndCallAction) { + action.fulfill() + } + + func onEnd(_ call: Call, _ action: CXEndCallAction) { + action.fulfill() + } + + func onTimeOut(_ call: Call) {} + + func didActivateAudioSession(_ audioSession: AVAudioSession) { + RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession) + RTCAudioSession.sharedInstance().isAudioEnabled = true + } + + func didDeactivateAudioSession(_ audioSession: AVAudioSession) { + RTCAudioSession.sharedInstance().audioSessionDidDeactivate(audioSession) + RTCAudioSession.sharedInstance().isAudioEnabled = false + } + + func providerDidReset() {} + // APNs vydal device token → předáme Dartu (ten ho pošle relayi). override func application( _ application: UIApplication, - didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Foundation.Data ) { let token = deviceToken.map { String(format: "%02x", $0) }.joined() pendingToken = token diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist index 63ee80d..d33d868 100644 --- a/app/ios/Runner/Info.plist +++ b/app/ios/Runner/Info.plist @@ -5,9 +5,11 @@ UIBackgroundModes remote-notification + audio + voip NSCameraUsageDescription - The camera is used to scan contacts' QR codes and to take photos for attachments. + The camera is used to scan contacts' QR codes, take photos for attachments and for video calls. NSPhotoLibraryUsageDescription The photo library is used to pick photos to send in a conversation. NSFaceIDUsageDescription @@ -66,6 +68,6 @@ UIStatusBarHidden NSMicrophoneUsageDescription - Šepot needs the microphone to record voice messages. + Šepot needs the microphone for voice messages and calls. diff --git a/app/ios/Runner/cs.lproj/InfoPlist.strings b/app/ios/Runner/cs.lproj/InfoPlist.strings index 9f89c89..88ae01b 100644 --- a/app/ios/Runner/cs.lproj/InfoPlist.strings +++ b/app/ios/Runner/cs.lproj/InfoPlist.strings @@ -1,5 +1,5 @@ /* Šepot — texty oprávnění iOS (čeština) */ -"NSCameraUsageDescription" = "Kamera slouží ke skenování QR kódů kontaktů a focení příloh."; +"NSCameraUsageDescription" = "Kamera slouží ke skenování QR kódů kontaktů, focení příloh a videohovorům."; "NSPhotoLibraryUsageDescription" = "Galerie slouží k výběru fotek pro odeslání v konverzaci."; "NSFaceIDUsageDescription" = "Face ID odemyká aplikaci místo PINu."; -"NSMicrophoneUsageDescription" = "Šepot potřebuje mikrofon pro nahrávání hlasových zpráv."; +"NSMicrophoneUsageDescription" = "Šepot potřebuje mikrofon pro hlasové zprávy a hovory."; diff --git a/app/ios/Runner/de.lproj/InfoPlist.strings b/app/ios/Runner/de.lproj/InfoPlist.strings index ff47597..bee7b0b 100644 --- a/app/ios/Runner/de.lproj/InfoPlist.strings +++ b/app/ios/Runner/de.lproj/InfoPlist.strings @@ -1,5 +1,5 @@ /* Šepot — iOS-Berechtigungstexte (Deutsch) */ -"NSCameraUsageDescription" = "Die Kamera dient zum Scannen der QR-Codes deiner Kontakte und zum Aufnehmen von Fotos für Anhänge."; +"NSCameraUsageDescription" = "Die Kamera dient zum Scannen der QR-Codes deiner Kontakte, zum Aufnehmen von Fotos für Anhänge und für Videoanrufe."; "NSPhotoLibraryUsageDescription" = "Die Fotomediathek dient zum Auswählen von Fotos, die du in einer Unterhaltung sendest."; "NSFaceIDUsageDescription" = "Face ID entsperrt die App anstelle der PIN."; -"NSMicrophoneUsageDescription" = "Šepot benötigt das Mikrofon zum Aufnehmen von Sprachnachrichten."; +"NSMicrophoneUsageDescription" = "Šepot benötigt das Mikrofon für Sprachnachrichten und Anrufe."; diff --git a/app/ios/Runner/en.lproj/InfoPlist.strings b/app/ios/Runner/en.lproj/InfoPlist.strings index 214936d..c51e5d3 100644 --- a/app/ios/Runner/en.lproj/InfoPlist.strings +++ b/app/ios/Runner/en.lproj/InfoPlist.strings @@ -1,5 +1,5 @@ /* Šepot — iOS permission strings (English) */ -"NSCameraUsageDescription" = "The camera is used to scan contacts' QR codes and to take photos for attachments."; +"NSCameraUsageDescription" = "The camera is used to scan contacts' QR codes, take photos for attachments and for video calls."; "NSPhotoLibraryUsageDescription" = "The photo library is used to pick photos to send in a conversation."; "NSFaceIDUsageDescription" = "Face ID unlocks the app instead of your PIN."; -"NSMicrophoneUsageDescription" = "Šepot needs the microphone to record voice messages."; +"NSMicrophoneUsageDescription" = "Šepot needs the microphone for voice messages and calls."; diff --git a/app/ios/Runner/it.lproj/InfoPlist.strings b/app/ios/Runner/it.lproj/InfoPlist.strings index 70cb5fe..ee2283b 100644 --- a/app/ios/Runner/it.lproj/InfoPlist.strings +++ b/app/ios/Runner/it.lproj/InfoPlist.strings @@ -1,5 +1,5 @@ /* Šepot — testi delle autorizzazioni iOS (italiano) */ -"NSCameraUsageDescription" = "La fotocamera serve a scansionare i codici QR dei contatti e a scattare foto per gli allegati."; +"NSCameraUsageDescription" = "La fotocamera serve a scansionare i codici QR dei contatti, a scattare foto per gli allegati e per le videochiamate."; "NSPhotoLibraryUsageDescription" = "La libreria foto serve a scegliere le foto da inviare in una conversazione."; "NSFaceIDUsageDescription" = "Face ID sblocca l'app al posto del PIN."; -"NSMicrophoneUsageDescription" = "Šepot ha bisogno del microfono per registrare i messaggi vocali."; +"NSMicrophoneUsageDescription" = "Šepot ha bisogno del microfono per i messaggi vocali e le chiamate."; diff --git a/app/ios/Runner/ru.lproj/InfoPlist.strings b/app/ios/Runner/ru.lproj/InfoPlist.strings index 369734c..3e4b84e 100644 --- a/app/ios/Runner/ru.lproj/InfoPlist.strings +++ b/app/ios/Runner/ru.lproj/InfoPlist.strings @@ -1,5 +1,5 @@ /* Šepot — тексты разрешений iOS (русский) */ -"NSCameraUsageDescription" = "Камера используется для сканирования QR-кодов контактов и съёмки фотографий для вложений."; +"NSCameraUsageDescription" = "Камера используется для сканирования QR-кодов контактов, съёмки фотографий для вложений и видеозвонков."; "NSPhotoLibraryUsageDescription" = "Галерея используется для выбора фотографий, которые вы отправляете в чате."; "NSFaceIDUsageDescription" = "Face ID разблокирует приложение вместо PIN-кода."; -"NSMicrophoneUsageDescription" = "Для записи голосовых сообщений приложению Šepot нужен доступ к микрофону."; +"NSMicrophoneUsageDescription" = "Приложению Šepot нужен доступ к микрофону для голосовых сообщений и звонков."; diff --git a/app/lib/core/calls/call_controller.dart b/app/lib/core/calls/call_controller.dart new file mode 100644 index 0000000..990f6c5 --- /dev/null +++ b/app/lib/core/calls/call_controller.dart @@ -0,0 +1,512 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart' hide MessageType; +import 'package:sepot/core/calls/call_ringer.dart'; +import 'package:sepot/models/call_log.dart'; +import 'package:sepot/models/contact.dart'; +import 'package:sepot/models/message.dart'; +import 'package:sepot/providers/conversations_provider.dart'; +import 'package:sepot/providers/identity_provider.dart'; +import 'package:sepot/providers/messaging_provider.dart'; +import 'package:sepot/providers/push_provider.dart'; +import 'package:sepot/providers/storage_provider.dart'; +import 'package:uuid/uuid.dart'; + +/// Fáze hovoru. +enum CallPhase { idle, outgoing, incoming, connecting, active, ended } + +class CallState { + const CallState({ + this.phase = CallPhase.idle, + this.callId, + this.contactId, + this.contactName = '', + this.video = false, + this.outgoing = false, + this.connectedAt, + this.muted = false, + this.speaker = false, + this.cameraOff = false, + this.outcome, + }); + + final CallPhase phase; + final String? callId; + final String? contactId; + final String contactName; + final bool video; + final bool outgoing; + final DateTime? connectedAt; + final bool muted; + final bool speaker; + final bool cameraOff; + + /// Proč hovor skončil (jen ve fázi [CallPhase.ended]). + final CallOutcome? outcome; + + bool get busy => phase != CallPhase.idle && phase != CallPhase.ended; + + CallState copyWith({ + CallPhase? phase, + DateTime? connectedAt, + bool? muted, + bool? speaker, + bool? cameraOff, + CallOutcome? outcome, + }) => + CallState( + phase: phase ?? this.phase, + callId: callId, + contactId: contactId, + contactName: contactName, + video: video, + outgoing: outgoing, + connectedAt: connectedAt ?? this.connectedAt, + muted: muted ?? this.muted, + speaker: speaker ?? this.speaker, + cameraOff: cameraOff ?? this.cameraOff, + outcome: outcome ?? this.outcome, + ); +} + +/// Řídí jeden hovor 1:1: WebRTC spojení, signalizaci přes E2E kanál +/// Šepotu a záznam výsledku do chatu. Druhý souběžný hovor se odmítne +/// jako `busy`. +class CallController extends Notifier { + /// Nabídka starší než tohle už nezvoní — jen se zapíše jako zmeškaná. + /// Stejná mez jako expirace VoIP pushe na relayi. + static const ringTimeout = Duration(seconds: 45); + + /// ICE bez trickle: nabídka jde až po sběru kandidátů, nejdéle po této + /// době (jinak by pomalé rozhraní zdrželo zvonění). + static const _iceGatherTimeout = Duration(seconds: 3); + + /// Jak dlouho je vidět obrazovka „Hovor skončil“, než se zavře. + static const endedLinger = Duration(seconds: 2); + + RTCPeerConnection? _pc; + MediaStream? _localStream; + String? _pendingOfferSdp; + Contact? _peer; + Timer? _ringTimer; + + /// Id systémového zvonění (CallKit / Android) k běžícímu příchozímu + /// hovoru; null = zvoní jen obrazovka appky. + String? _ringId; + + final localRenderer = RTCVideoRenderer(); + final remoteRenderer = RTCVideoRenderer(); + bool _renderersReady = false; + + @override + CallState build() { + CallRinger.listen( + onAccept: _onRingAccept, + onDecline: _onRingDecline, + onEnded: _onRingEnded, + onVoipToken: () => ref.read(pushServiceProvider).refreshVoipToken(), + ); + ref.onDispose(() { + unawaited(_cleanup()); + localRenderer.dispose(); + remoteRenderer.dispose(); + }); + return const CallState(); + } + + // ── Odchozí ──────────────────────────────────────────────────────── + + Future startCall(Contact contact, {required bool video}) async { + if (state.busy) return; + _peer = contact; + state = CallState( + phase: CallPhase.outgoing, + callId: const Uuid().v4(), + contactId: contact.id, + contactName: contact.displayName, + video: video, + outgoing: true, + speaker: video, + ); + try { + await _openMedia(video: video); + final pc = await _createPeerConnection(); + final offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + final sdp = await _gatheredSdp(pc); + await ref.read(messagingServiceProvider).sendCallSignal( + contact, + {'cid': state.callId, 'k': 'offer', 'sdp': sdp, 'vid': video}, + ring: true, + ); + _ringTimer = Timer(ringTimeout, () { + if (state.phase == CallPhase.outgoing) { + unawaited(_hangup(CallOutcome.unanswered, notify: true)); + } + }); + } catch (_) { + await _finish(CallOutcome.failed); + } + } + + // ── Příchozí ─────────────────────────────────────────────────────── + + /// Obsluha příchozí signalizace z [MessagingService.onCallSignal]. + Future onSignal(Contact contact, Map signal) async { + final cid = signal['cid']; + final kind = signal['k']; + if (cid is! String || kind is! String) return; + + if (kind == 'offer') { + await _onOffer(contact, cid, signal); + return; + } + // Ostatní patří jen k právě běžícímu hovoru s tímtéž člověkem. + if (cid != state.callId || contact.id != state.contactId) return; + switch (kind) { + case 'answer': + final sdp = signal['sdp']; + if (sdp is! String || state.phase != CallPhase.outgoing) return; + _ringTimer?.cancel(); + state = state.copyWith(phase: CallPhase.connecting); + await _pc?.setRemoteDescription(RTCSessionDescription(sdp, 'answer')); + case 'hangup': + final answered = state.connectedAt != null; + await _finish(answered + ? CallOutcome.completed + : state.outgoing + ? CallOutcome.rejected + : CallOutcome.missed); + case 'reject': + await _finish(CallOutcome.rejected); + case 'busy': + await _finish(CallOutcome.busy); + } + } + + Future _onOffer( + Contact contact, String cid, Map signal) async { + final sdp = signal['sdp']; + if (sdp is! String) return; + final video = signal['vid'] == true; + final sentAt = DateTime.fromMillisecondsSinceEpoch( + (signal['ts'] as num?)?.toInt() ?? 0); + // Pozdě doručená nabídka (telefon byl mimo signál) — nezvonit. + if (DateTime.now().difference(sentAt) > ringTimeout) { + await _log(contact.id, + CallLog(outcome: CallOutcome.missed, outgoing: false, video: video), + at: sentAt); + return; + } + if (state.busy) { + if (cid != state.callId) { + await ref + .read(messagingServiceProvider) + .sendCallSignal(contact, {'cid': cid, 'k': 'busy'}); + } + return; + } + _peer = contact; + _pendingOfferSdp = sdp; + state = CallState( + phase: CallPhase.incoming, + callId: cid, + contactId: contact.id, + contactName: contact.displayName, + video: video, + speaker: video, + ); + _ringTimer = Timer(ringTimeout, () { + if (state.phase == CallPhase.incoming) { + unawaited(_finish(CallOutcome.missed)); + } + }); + await _attachSystemRing(cid, contact.displayName, video); + } + + /// Spáruje hovor se systémovým zvoněním. Zvonění z budíčku relaye už + /// běží (možná ho uživatel mezitím přijal na zamčené obrazovce); jinak + /// se zvonění spustí, jen když appka není v popředí — v popředí stačí + /// vlastní obrazovka hovoru. + Future _attachSystemRing(String cid, String name, bool video) async { + final pushRing = await CallRinger.pendingPushRing(); + if (pushRing != null) { + _ringId = pushRing.id; + if (pushRing.accepted) { + await accept(); + } else if (CallRinger.declinedEarly.remove(pushRing.id)) { + await reject(); + } + return; + } + final foreground = + WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed; + if (!foreground) { + _ringId = cid; + await CallRinger.ringFor(callId: cid, name: name, video: video); + } + } + + void _onRingAccept(String id) { + if (id == _ringId && state.phase == CallPhase.incoming) { + unawaited(accept()); + } + } + + void _onRingDecline(String id) { + if (id == _ringId && state.phase == CallPhase.incoming) { + unawaited(reject()); + } else if (!state.busy) { + CallRinger.declinedEarly.add(id); + } + } + + /// Zavěšení ze systémového rozhraní (zamčená obrazovka, sluchátka). + void _onRingEnded(String id) { + if (id == _ringId && state.busy) unawaited(hangUp()); + } + + Future accept() async { + final peer = _peer; + final offer = _pendingOfferSdp; + if (state.phase != CallPhase.incoming || peer == null || offer == null) { + return; + } + _ringTimer?.cancel(); + state = state.copyWith(phase: CallPhase.connecting); + try { + await _openMedia(video: state.video); + final pc = await _createPeerConnection(); + await pc.setRemoteDescription(RTCSessionDescription(offer, 'offer')); + final answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + final sdp = await _gatheredSdp(pc); + await ref.read(messagingServiceProvider).sendCallSignal( + peer, {'cid': state.callId, 'k': 'answer', 'sdp': sdp}); + } catch (_) { + await _hangup(CallOutcome.failed, notify: true); + } + } + + Future reject() async { + if (state.phase != CallPhase.incoming) return; + await _hangup(CallOutcome.rejected, notify: true, kind: 'reject'); + } + + /// Zavěšení v jakékoli fázi (vytáčení, spojování, hovor). + Future hangUp() async { + if (!state.busy) return; + final outcome = state.connectedAt != null + ? CallOutcome.completed + : state.outgoing + ? CallOutcome.unanswered + : CallOutcome.rejected; + await _hangup(outcome, notify: true); + } + + // ── Ovládání během hovoru ────────────────────────────────────────── + + void toggleMute() { + final muted = !state.muted; + for (final t in _localStream?.getAudioTracks() ?? const []) { + t.enabled = !muted; + } + state = state.copyWith(muted: muted); + } + + Future toggleSpeaker() async { + final speaker = !state.speaker; + await Helper.setSpeakerphoneOn(speaker); + state = state.copyWith(speaker: speaker); + } + + void toggleCamera() { + final off = !state.cameraOff; + for (final t in _localStream?.getVideoTracks() ?? const []) { + t.enabled = !off; + } + state = state.copyWith(cameraOff: off); + } + + Future switchCamera() async { + final track = _localStream?.getVideoTracks().firstOrNull; + if (track != null) await Helper.switchCamera(track); + } + + // ── WebRTC ───────────────────────────────────────────────────────── + + Future _openMedia({required bool video}) async { + if (!_renderersReady) { + await localRenderer.initialize(); + await remoteRenderer.initialize(); + _renderersReady = true; + } + final stream = await navigator.mediaDevices.getUserMedia({ + 'audio': true, + 'video': video ? {'facingMode': 'user'} : false, + }); + _localStream = stream; + localRenderer.srcObject = video ? stream : null; + await Helper.setSpeakerphoneOn(video); + } + + Future _createPeerConnection() async { + final pc = await createPeerConnection({ + 'iceServers': await _iceServers(), + 'sdpSemantics': 'unified-plan', + }); + final local = _localStream; + for (final track in local?.getTracks() ?? const []) { + await pc.addTrack(track, local!); + } + pc.onTrack = (event) { + if (event.streams.isNotEmpty) { + remoteRenderer.srcObject = event.streams.first; + } + }; + pc.onConnectionState = (s) { + switch (s) { + case RTCPeerConnectionState.RTCPeerConnectionStateConnected: + if (state.connectedAt == null) { + state = state.copyWith( + phase: CallPhase.active, connectedAt: DateTime.now()); + final ring = _ringId; + if (ring != null) unawaited(CallRinger.connected(ring)); + } + case RTCPeerConnectionState.RTCPeerConnectionStateFailed: + unawaited(_hangup( + state.connectedAt != null + ? CallOutcome.completed + : CallOutcome.failed, + notify: true)); + default: + break; + } + }; + _pc = pc; + return pc; + } + + /// STUN i TURN běží na stejném serveru; bez údajů z relaye aspoň STUN + /// (přímé spojení, když obě strany nejsou za symetrickým NATem). + Future>> _iceServers() async { + final turn = await ref.read(messagingServiceProvider).turnCredentials(); + if (turn == null || turn.urls.isEmpty) return const []; + return [ + { + 'urls': turn.urls, + 'username': turn.username, + 'credential': turn.credential, + }, + ]; + } + + /// SDP po sběru ICE kandidátů (non-trickle). + Future _gatheredSdp(RTCPeerConnection pc) async { + final done = Completer(); + pc.onIceGatheringState = (s) { + if (s == RTCIceGatheringState.RTCIceGatheringStateComplete && + !done.isCompleted) { + done.complete(); + } + }; + if (pc.iceGatheringState == + RTCIceGatheringState.RTCIceGatheringStateComplete) { + done.complete(); + } + await done.future.timeout(_iceGatherTimeout, onTimeout: () {}); + final local = await pc.getLocalDescription(); + final sdp = local?.sdp; + if (sdp == null) throw StateError('no local description'); + return sdp; + } + + // ── Konec hovoru ─────────────────────────────────────────────────── + + Future _hangup(CallOutcome outcome, + {required bool notify, String kind = 'hangup'}) async { + final peer = _peer; + final cid = state.callId; + if (notify && peer != null && cid != null) { + try { + await ref + .read(messagingServiceProvider) + .sendCallSignal(peer, {'cid': cid, 'k': kind}); + } catch (_) { + // Protistrana zavěšení nedostala — její hovor skončí sám, až + // spojení vypadne (ICE failed) nebo vyprší zvonění. + } + } + await _finish(outcome); + } + + Future _finish(CallOutcome outcome) async { + if (state.phase == CallPhase.idle || state.phase == CallPhase.ended) { + return; + } + final connectedAt = state.connectedAt; + final contactId = state.contactId; + final log = CallLog( + outcome: outcome, + outgoing: state.outgoing, + video: state.video, + durationSeconds: connectedAt == null + ? 0 + : DateTime.now().difference(connectedAt).inSeconds, + ); + state = state.copyWith(phase: CallPhase.ended, outcome: outcome); + await _cleanup(); + if (contactId != null) await _log(contactId, log); + Timer(endedLinger, () { + if (state.phase == CallPhase.ended) state = const CallState(); + }); + } + + Future _cleanup() async { + _ringTimer?.cancel(); + _ringTimer = null; + final ring = _ringId; + _ringId = null; + // Konec zvonění vyvolá událost „ukončeno“ — to už je stav mimo hovor, + // takže ji _onRingEnded ignoruje. + if (ring != null) unawaited(CallRinger.end(ring)); + _pendingOfferSdp = null; + _peer = null; + for (final t in _localStream?.getTracks() ?? const []) { + await t.stop(); + } + await _localStream?.dispose(); + _localStream = null; + await _pc?.close(); + _pc = null; + if (_renderersReady) { + localRenderer.srcObject = null; + remoteRenderer.srcObject = null; + } + } + + /// Řádek „Hovor · 3:12“ do chatu. Jen lokálně — nic se neposílá. + Future _log(String contactId, CallLog log, {DateTime? at}) async { + final myId = ref.read(identityProvider).valueOrNull?.id; + if (myId == null) return; + final message = Message( + id: const Uuid().v4(), + senderId: log.outgoing ? myId : contactId, + recipientId: log.outgoing ? contactId : myId, + type: MessageType.text, + timestamp: at ?? DateTime.now(), + // Záznam hovoru není nepřečtená zpráva — kromě zmeškaného. + isRead: log.outcome != CallOutcome.missed, + isDelivered: true, + callLog: log, + ); + await ref.read(storageProvider).addMessage(contactId, message); + ref.invalidate(conversationsProvider); + ref.invalidate(conversationProvider(contactId)); + } +} + +final callProvider = + NotifierProvider(CallController.new); diff --git a/app/lib/core/calls/call_ringer.dart b/app/lib/core/calls/call_ringer.dart new file mode 100644 index 0000000..0a8421b --- /dev/null +++ b/app/lib/core/calls/call_ringer.dart @@ -0,0 +1,147 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter_callkit_incoming/entities/entities.dart'; +import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; +import 'package:uuid/uuid.dart'; + +/// Systémové zvonění (CallKit na iOS, celoobrazovkové na Androidu) pro +/// hovory, které přijdou, když appka není v popředí. +/// +/// Budíček z relaye nenese, kdo volá — relay to neví. Zvonění z budíčku +/// proto ukáže „Šepot“ a označí se `extra.push`; až appka dešifruje +/// nabídku, [CallController] ho spáruje s hovorem podle toho. +class CallRinger { + CallRinger._(); + + static const _appName = 'Šepot'; + + /// Zvonění vzniklé z budíčku, ke kterému ještě nedorazila nabídka. + static const pushMarker = 'push'; + + /// Jak dlouho zvoní — stejné jako expirace budíčku na relayi. + static const ringMillis = 45000; + + static StreamSubscription? _sub; + + /// Id zvonění, která uživatel odmítl dřív, než dorazila nabídka. + /// (Přijetí si pamatuje samo zvonění — `isAccepted` v [pendingPushRing].) + static final declinedEarly = {}; + + static void listen({ + required void Function(String id) onAccept, + required void Function(String id) onDecline, + required void Function(String id) onEnded, + required void Function() onVoipToken, + }) { + _sub ??= FlutterCallkitIncoming.onEvent.listen((event) { + switch (event) { + case CallEventActionCallAccept(:final callKitParams): + onAccept(callKitParams.id); + case CallEventActionCallDecline(:final callKitParams): + onDecline(callKitParams.id); + case CallEventActionCallEnded(:final callKitParams): + onEnded(callKitParams.id); + case CallEventActionDidUpdateDevicePushTokenVoip(): + onVoipToken(); + default: + break; + } + }); + } + + static Future voipToken() async { + final token = await FlutterCallkitIncoming.getDevicePushTokenVoIP(); + return token != null && token.isNotEmpty ? token : null; + } + + /// Zvonění pro dešifrovanou nabídku (appka běží na pozadí). + static Future ringFor({ + required String callId, + required String name, + required bool video, + }) => + FlutterCallkitIncoming.showCallkitIncoming( + _params(id: callId, name: name, video: video)); + + /// Zvonění z budíčku relaye — jméno ani typ hovoru ještě neznáme. + static Future ringFromPush() => FlutterCallkitIncoming + .showCallkitIncoming(_params( + id: const Uuid().v4(), + name: _appName, + video: false, + extra: {pushMarker: true})); + + /// Zvonění z budíčku, které čeká na nabídku (nejnovější), a jestli ho + /// uživatel už přijal — typicky na zamčené obrazovce, než appka naběhla. + static Future<({String id, bool accepted})?> pendingPushRing() async { + final calls = await FlutterCallkitIncoming.activeCalls(); + for (final call in calls.reversed) { + if (call.extra?[pushMarker] == true) { + return (id: call.id, accepted: call.isAccepted); + } + } + return null; + } + + static Future connected(String id) => + FlutterCallkitIncoming.setCallConnected(id); + + static Future end(String id) => FlutterCallkitIncoming.endCall(id); + + /// Texty zvonění. Zvonění z budíčku běží v izolátu na pozadí bez + /// lokalizace appky, proto vlastní malý slovník podle jazyka systému. + static const _texts = { + 'cs': ('Přijmout', 'Odmítnout', 'Příchozí hovory'), + 'de': ('Annehmen', 'Ablehnen', 'Eingehende Anrufe'), + 'it': ('Accetta', 'Rifiuta', 'Chiamate in arrivo'), + 'ru': ('Принять', 'Отклонить', 'Входящие звонки'), + 'en': ('Accept', 'Decline', 'Incoming calls'), + }; + + static (String, String, String) get _localTexts => + _texts[PlatformDispatcher.instance.locale.languageCode] ?? + _texts['en']!; + + static CallKitParams _params({ + required String id, + required String name, + required bool video, + Map? extra, + }) => + CallKitParams( + id: id, + nameCaller: name, + appName: _appName, + handle: _appName, + type: video ? 1 : 0, + duration: ringMillis, + extra: extra ?? const {}, + missedCallNotification: const NotificationParams( + showNotification: false, + ), + android: AndroidParams( + isCustomNotification: true, + isShowLogo: false, + ringtonePath: 'system_ringtone_default', + backgroundColor: '#0B0C12', + actionColor: '#7FAE8C', + textColor: '#F0EEE8', + incomingCallNotificationChannelName: _localTexts.$3, + textAccept: _localTexts.$1, + textDecline: _localTexts.$2, + isShowFullLockedScreen: true, + ), + ios: const IOSParams( + handleType: 'generic', + supportsVideo: true, + maximumCallGroups: 1, + maximumCallsPerCallGroup: 1, + supportsDTMF: false, + supportsHolding: false, + supportsGrouping: false, + supportsUngrouping: false, + ringtonePath: 'system_ringtone_default', + ), + ); +} diff --git a/app/lib/core/push/push_bootstrap.dart b/app/lib/core/push/push_bootstrap.dart index b7e816a..dbb31ce 100644 --- a/app/lib/core/push/push_bootstrap.dart +++ b/app/lib/core/push/push_bootstrap.dart @@ -20,6 +20,7 @@ import 'dart:io' show Platform; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:sepot/core/calls/call_ringer.dart'; import 'package:sepot/core/push/apns_ios.dart'; import 'package:sepot/core/push/push_notifier.dart'; import 'package:sepot/core/push/push_service.dart'; @@ -28,8 +29,14 @@ import 'package:unifiedpush/unifiedpush.dart'; /// FCM budíček se zabitou appkou — plugin spouští handler ve vlastním /// background isolate. Stejný model jako UnifiedPush killed-state: /// DB je zamčená, nic nejde dešifrovat → jen generická notifikace. +/// Budíček hovoru (`k=call`) místo ní rovnou zazvoní; kdo +/// volá, appka zjistí až po odemčení z nabídky ve frontě. @pragma('vm:entry-point') Future sepotFcmBackgroundWake(RemoteMessage message) async { + if (message.data['k'] == 'call') { + await CallRinger.ringFromPush(); + return; + } await Firebase.initializeApp(); await PushNotifier.showWakeNotification(); } @@ -52,8 +59,11 @@ class PushBootstrap { /// Režim pushů žije v OS keystore (NE v SQLCipher DB) — musí být /// čitelný před unlockem, aby main() věděl, zda registrovat FCM. - static Future readMode() async => - await _store.read(key: _modeKey) ?? 'off'; + static Future readMode() async => await readStoredMode() ?? 'off'; + + /// Uložený režim, nebo null, když ho uživatel ani appka ještě nevybrali + /// — to odlišuje čerstvou instalaci od vědomého „vypnuto". + static Future readStoredMode() => _store.read(key: _modeKey); static Future writeMode(String mode) => _store.write(key: _modeKey, value: mode); diff --git a/app/lib/core/push/push_service.dart b/app/lib/core/push/push_service.dart index 9032d39..b6ea2d5 100644 --- a/app/lib/core/push/push_service.dart +++ b/app/lib/core/push/push_service.dart @@ -13,8 +13,11 @@ /// killed-state startu `--unifiedpush-bg` bez UI); tahle třída je /// most bootstrap ↔ messaging + API pro nastavení. /// -/// FCM „fast mode" (opt-in) je druhá cesta (viz [setModeFcm]); default je -/// degooglovaný UnifiedPush. +/// Výchozí režim na Androidu vybírá [restoreMode] při prvním startu: s +/// UnifiedPush distributorem UnifiedPush, jinak FCM. Bez budíčku Android +/// appku na pozadí uspí a zprávy čekají, než ji člověk otevře — to je pro +/// messenger horší než token u Googlu (obsah ani odesílatel budíčkem nejde). +/// Uživatel může kdykoli přepnout v Nastavení. library; import 'dart:async'; @@ -24,6 +27,7 @@ import 'dart:typed_data'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:sepot/core/calls/call_ringer.dart'; import 'package:sepot/core/push/apns_ios.dart'; import 'package:sepot/core/push/push_bootstrap.dart'; import 'package:sepot/core/push/push_notifier.dart'; @@ -80,11 +84,25 @@ class PushService { } } - void _registerApnsToken(String token) { - _messaging.registerPush(_registration( + String? _apnsToken; + + /// APNs token pro zprávy + VoIP token pro hovory v jedné registraci + /// (relay drží obojí u zařízení). + Future _registerApnsToken(String token) async { + _apnsToken = token; + final voip = await CallRinger.voipToken(); + final reg = _registration( pb.RegisterPush_Platform.PLATFORM_APNS, Uint8List.fromList(utf8.encode(token)), - )); + ); + if (voip != null) reg.voipToken = utf8.encode(voip); + _messaging.registerPush(reg); + } + + /// PushKit vydal (nový) VoIP token — přeregistrovat, pokud už APNs běží. + Future refreshVoipToken() async { + final token = _apnsToken; + if (token != null) await _registerApnsToken(token); } /// iOS nativní push (APNs) — zapne registraci u Apple; token přijde @@ -166,8 +184,8 @@ class PushService { return ok; } - /// FCM fast mode — OPT-IN: registrační token zařízení jde Googlu - /// (obsah budíčků nikdy, ťuk je konstantní `{"v":1}`). + /// FCM: registrační token zařízení jde Googlu (obsah budíčků nikdy, + /// ťuk je konstantní `{"v":1}`). Future setModeFcm() async { await init(); try { @@ -222,6 +240,10 @@ class PushService { } return; } + if (await PushBootstrap.readStoredMode() == null) { + await _chooseDefaultMode(); + return; + } switch (await currentMode()) { case 'up': await enable(); @@ -235,4 +257,23 @@ class PushService { await init(); // aspoň attach (killed-state notifikace) } } + + /// První start na Androidu (nebo instalace z doby, kdy byl push + /// opt-in a nikdo ho nezapnul). Kdo má nainstalovaný UnifiedPush + /// distributor, vybral si degooglovanou cestu sám — dostane ji. + /// Ostatním zapneme FCM. Telefon bez Google služeb token nedostane; + /// pak zapíšeme „off", ať se to nezkouší při každém startu — seznam + /// chatů na chybějící budíčky upozorní sám. + Future _chooseDefaultMode() async { + try { + if ((await currentMode()) == 'up' || + (await distributors()).isNotEmpty) { + if (await setModeUnifiedPush()) return; + } + await setModeFcm(); + } catch (_) { + await init(); + await PushBootstrap.writeMode('off'); + } + } } diff --git a/app/lib/features/calls/call_log_text.dart b/app/lib/features/calls/call_log_text.dart new file mode 100644 index 0000000..b76632f --- /dev/null +++ b/app/lib/features/calls/call_log_text.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:sepot/features/calls/call_screen.dart'; +import 'package:sepot/l10n/generated/app_localizations.dart'; +import 'package:sepot/models/call_log.dart'; + +/// Ikona a text záznamu hovoru — sdílí chat i seznam chatů, ať se +/// formulace nerozejdou. +({IconData icon, String text, bool alert}) callLogLabel( + AppLocalizations l10n, CallLog log) { + final icon = log.video ? Icons.videocam_rounded : Icons.call_rounded; + return switch (log.outcome) { + CallOutcome.completed => ( + icon: icon, + text: log.video + ? l10n.callLogVideo(formatCallDuration(log.durationSeconds)) + : l10n.callLogVoice(formatCallDuration(log.durationSeconds)), + alert: false, + ), + CallOutcome.missed => ( + icon: Icons.phone_missed_rounded, + text: l10n.callLogMissed, + alert: true, + ), + CallOutcome.unanswered => ( + icon: icon, + text: l10n.callLogUnanswered, + alert: false, + ), + CallOutcome.rejected => ( + icon: Icons.call_end_rounded, + text: l10n.callLogRejected, + alert: false, + ), + CallOutcome.busy => (icon: icon, text: l10n.callLogBusy, alert: false), + CallOutcome.failed => ( + icon: Icons.error_outline_rounded, + text: l10n.callLogFailed, + alert: true, + ), + }; +} diff --git a/app/lib/features/calls/call_screen.dart b/app/lib/features/calls/call_screen.dart new file mode 100644 index 0000000..547db0f --- /dev/null +++ b/app/lib/features/calls/call_screen.dart @@ -0,0 +1,331 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:sepot/core/calls/call_controller.dart'; +import 'package:sepot/core/l10n/l10n.dart'; +import 'package:sepot/core/theme/app_theme.dart'; +import 'package:sepot/models/call_log.dart'; +import 'package:sepot/widgets/avatar.dart'; +import 'package:sepot/widgets/glass.dart'; + +/// Obrazovka hovoru: vytáčení, zvonění, spojování i hovor samotný. +/// Zavře se sama, když hovor skončí. +class CallScreen extends ConsumerStatefulWidget { + const CallScreen({super.key}); + + @override + ConsumerState createState() => _CallScreenState(); +} + +class _CallScreenState extends ConsumerState { + // Počítadlo délky hovoru — překreslení jednou za sekundu stačí. + Timer? _tick; + + @override + void initState() { + super.initState(); + _tick = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _tick?.cancel(); + super.dispose(); + } + + String _status(CallState call) { + final l10n = context.l10n; + switch (call.phase) { + case CallPhase.outgoing: + return l10n.callStatusRinging; + case CallPhase.incoming: + return call.video ? l10n.callIncomingVideo : l10n.callIncomingVoice; + case CallPhase.connecting: + return l10n.callStatusConnecting; + case CallPhase.active: + final d = DateTime.now().difference(call.connectedAt!); + return formatCallDuration(d.inSeconds); + case CallPhase.ended: + return switch (call.outcome) { + CallOutcome.busy => l10n.callEndedBusy, + CallOutcome.rejected => l10n.callEndedRejected, + CallOutcome.unanswered => l10n.callEndedUnanswered, + CallOutcome.failed => l10n.callEndedFailed, + _ => l10n.callEnded, + }; + case CallPhase.idle: + return ''; + } + } + + @override + Widget build(BuildContext context) { + final call = ref.watch(callProvider); + final controller = ref.read(callProvider.notifier); + ref.listen(callProvider.select((s) => s.phase), (_, phase) { + if (phase == CallPhase.idle && context.canPop()) context.pop(); + }); + + final showVideo = call.video && + (call.phase == CallPhase.active || call.phase == CallPhase.connecting); + + return PopScope( + // Zpět hovor neukončí ani neschová — jinak by běžel bez ovládání. + canPop: !call.busy, + child: Scaffold( + backgroundColor: SepotColors.hero, + body: Stack( + children: [ + if (showVideo) + Positioned.fill( + child: RTCVideoView( + controller.remoteRenderer, + objectFit: + RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, + ), + ), + SafeArea( + child: Column( + children: [ + const SizedBox(height: 48), + if (!showVideo) ...[ + SepotAvatar( + name: call.contactName, + seed: call.contactId, + size: 112), + const SizedBox(height: 24), + ], + Text( + call.contactName, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w700, + color: SepotColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + _status(call), + style: const TextStyle( + fontSize: 16, color: SepotColors.textSecondary), + ), + const SizedBox(height: 6), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.lock_rounded, + size: 13, color: SepotColors.mist), + const SizedBox(width: 4), + Text(context.l10n.callEncrypted, + style: const TextStyle( + fontSize: 12, color: SepotColors.mist)), + ], + ), + const Spacer(), + if (call.phase == CallPhase.incoming) + _IncomingActions( + onAccept: controller.accept, + onReject: controller.reject, + ) + else if (call.busy) + _InCallControls(call: call, controller: controller), + const SizedBox(height: 32), + ], + ), + ), + if (showVideo && !call.cameraOff) + Positioned( + right: 16, + top: MediaQuery.paddingOf(context).top + 16, + width: 108, + height: 152, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: RTCVideoView( + controller.localRenderer, + mirror: true, + objectFit: + RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _IncomingActions extends StatelessWidget { + const _IncomingActions({required this.onAccept, required this.onReject}); + + final VoidCallback onAccept; + final VoidCallback onReject; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _RoundAction( + icon: Icons.call_end_rounded, + label: l10n.callReject, + color: SepotColors.error, + onTap: onReject, + ), + _RoundAction( + icon: Icons.call_rounded, + label: l10n.callAccept, + color: SepotColors.sage, + onTap: onAccept, + ), + ], + ); + } +} + +class _InCallControls extends StatelessWidget { + const _InCallControls({required this.call, required this.controller}); + + final CallState call; + final CallController controller; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: GlassSurface( + borderRadius: BorderRadius.circular(32), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _ToggleAction( + icon: call.muted ? Icons.mic_off_rounded : Icons.mic_rounded, + label: l10n.callMute, + active: call.muted, + onTap: controller.toggleMute, + ), + _ToggleAction( + icon: Icons.volume_up_rounded, + label: l10n.callSpeaker, + active: call.speaker, + onTap: controller.toggleSpeaker, + ), + if (call.video) ...[ + _ToggleAction( + icon: call.cameraOff + ? Icons.videocam_off_rounded + : Icons.videocam_rounded, + label: l10n.callCamera, + active: call.cameraOff, + onTap: controller.toggleCamera, + ), + _ToggleAction( + icon: Icons.cameraswitch_rounded, + label: l10n.callSwitchCamera, + active: false, + onTap: controller.switchCamera, + ), + ], + _RoundAction( + icon: Icons.call_end_rounded, + label: l10n.callHangUp, + color: SepotColors.error, + onTap: controller.hangUp, + size: 56, + ), + ], + ), + ), + ); + } +} + +class _ToggleAction extends StatelessWidget { + const _ToggleAction({ + required this.icon, + required this.label, + required this.active, + required this.onTap, + }); + + final IconData icon; + final String label; + final bool active; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + toggled: active, + label: label, + child: InkResponse( + onTap: onTap, + radius: 32, + child: Container( + width: 52, + height: 52, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: active + ? SepotColors.textPrimary + : SepotColors.textPrimary.withValues(alpha: 0.1), + ), + child: Icon(icon, + color: active ? SepotColors.background : SepotColors.textPrimary), + ), + ), + ); + } +} + +class _RoundAction extends StatelessWidget { + const _RoundAction({ + required this.icon, + required this.label, + required this.color, + required this.onTap, + this.size = 72, + }); + + final IconData icon; + final String label; + final Color color; + final VoidCallback onTap; + final double size; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: label, + child: InkResponse( + onTap: onTap, + radius: size / 2 + 8, + child: Container( + width: size, + height: size, + decoration: BoxDecoration(shape: BoxShape.circle, color: color), + child: Icon(icon, color: SepotColors.background, size: size * 0.42), + ), + ), + ); + } +} + +/// „3:12“, u dlouhého hovoru „1:03:12“. +String formatCallDuration(int seconds) { + final h = seconds ~/ 3600; + final m = (seconds % 3600) ~/ 60; + final s = (seconds % 60).toString().padLeft(2, '0'); + return h > 0 ? '$h:${m.toString().padLeft(2, '0')}:$s' : '$m:$s'; +} diff --git a/app/lib/models/call_log.dart b/app/lib/models/call_log.dart new file mode 100644 index 0000000..b43d49c --- /dev/null +++ b/app/lib/models/call_log.dart @@ -0,0 +1,57 @@ +/// Záznam hovoru v chatu („Hovor · 3:12“, „Zmeškaný hovor“). Jen lokální: +/// každá strana si ho zapíše sama podle toho, jak hovor u ní skončil. +enum CallOutcome { + /// Hovor proběhl (spojil se), [CallLog.durationSeconds] je jeho délka. + completed, + + /// Příchozí, nikdo ho nevzal (nebo nabídka dorazila pozdě). + missed, + + /// Odchozí, protistrana ho nevzala do vypršení zvonění. + unanswered, + + /// Odmítnutý (mnou u příchozího, protistranou u odchozího). + rejected, + + /// Protistrana měla jiný hovor. + busy, + + /// Spojení se nepodařilo navázat (síť, firewall bez TURN). + failed, +} + +class CallLog { + const CallLog({ + required this.outcome, + required this.outgoing, + required this.video, + this.durationSeconds = 0, + }); + + final CallOutcome outcome; + final bool outgoing; + final bool video; + final int durationSeconds; + + Map toJson() => { + 'o': outcome.name, + 'out': outgoing, + 'vid': video, + if (durationSeconds > 0) 'dur': durationSeconds, + }; + + /// Neznámý výsledek z novější verze appky = null (řádek se nezobrazí, + /// ale zpráva se načte). + static CallLog? fromJson(Object? json) { + if (json is! Map) return null; + final outcome = + CallOutcome.values.where((o) => o.name == json['o']).firstOrNull; + if (outcome == null) return null; + return CallLog( + outcome: outcome, + outgoing: json['out'] == true, + video: json['vid'] == true, + durationSeconds: (json['dur'] as num?)?.toInt() ?? 0, + ); + } +} diff --git a/app/pubspec.lock b/app/pubspec.lock index 88aeb1c..fc87581 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -297,6 +297,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.5" + dart_webrtc: + dependency: transitive + description: + name: dart_webrtc + sha256: "078e3c431500147e5cc52b3c6ea41ed538f30c7720cc2467d2186c9251e62716" + url: "https://pub.dev" + source: hosted + version: "1.8.2" dbus: dependency: transitive description: @@ -430,6 +438,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_callkit_incoming: + dependency: "direct main" + description: + name: flutter_callkit_incoming + sha256: "2d47db2e8d32d333e0352d37b0a4e643f606a54f8a541db17af7c42f18c58318" + url: "https://pub.dev" + source: hosted + version: "3.1.6" flutter_driver: dependency: transitive description: flutter @@ -570,6 +586,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_webrtc: + dependency: "direct main" + description: + name: flutter_webrtc + sha256: c1d3674f61b14cbe98b60f0cce396835b1483163e644b8cd26fd3fdd55360fb9 + url: "https://pub.dev" + source: hosted + version: "1.6.2+hotfix.3" fuchsia_remote_debug_protocol: dependency: transitive description: flutter @@ -852,6 +876,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.11" + logger: + dependency: transitive + description: + name: logger + sha256: "2a0dc097e7b01d942475bdd552356db2d0f768b05540bd4b2b53f1840f2239a7" + url: "https://pub.dev" + source: hosted + version: "2.8.0" logging: dependency: transitive description: @@ -1537,6 +1569,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + webrtc_interface: + dependency: transitive + description: + name: webrtc_interface + sha256: c6f100eac5057d9a817a60473126f9828c796d42884d498af4f339c97b21014f + url: "https://pub.dev" + source: hosted + version: "1.5.1" win32: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 49bdda4..57d4613 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -53,6 +53,11 @@ dependencies: record: ^6.2.1 audioplayers: ^6.7.1 screen_protector: ^1.5.2 + # Hovory: WebRTC média, DTLS-SRTP je v protokolu povinné. + flutter_webrtc: ^1.6.2 + # Zvonění při zavřené appce: CallKit (iOS, z VoIP pushe) a celoobrazovkové + # zvonění na Androidu (z FCM budíčku k=call). + flutter_callkit_incoming: ^3.1.6 dev_dependencies: integration_test: