feat(app): 1:1 voice and video calls

End-to-end encrypted calls between two contacts over WebRTC. Call
signalling (offer, answer, hang-up) travels as ordinary Signal-encrypted
messages through the relay; media is DTLS-SRTP, peer to peer when
possible and through the operator's TURN server otherwise.

- Non-trickle ICE (candidates gathered for up to 3 s), 45 s ring
  timeout, busy handling, and a call log entry in the conversation.
- The system call UI rings even when the app is closed: CallKit from a
  PushKit VoIP push on iOS, a full-screen incoming call from an FCM
  wake-up on Android. The wake-up carries no caller; the app learns who
  is calling from the queued offer once it is unlocked.
- New permissions: camera for video, audio routing and Bluetooth
  headsets, network state; iOS background modes audio and voip.

Push on Android: a fresh install now picks UnifiedPush when a
distributor is installed and FCM otherwise, instead of leaving wake-ups
off. Without a wake-up Android puts the app to sleep and messages wait
until it is opened. Users can switch at any time in Settings.
This commit is contained in:
Julius Joska
2026-10-03 22:44:39 +02:00
parent 9a638545dd
commit 933a74c900
17 changed files with 1310 additions and 24 deletions
+8 -1
View File
@@ -1,8 +1,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Nahrávání hlasových zpráv -->
<!-- Nahrávání hlasových zpráv a hovory -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Hovory (WebRTC): kamera u videohovoru, přepínání reproduktoru
a Bluetooth sluchátka, sledování změny sítě během hovoru. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Firebase Messaging tiše přidává AD_ID oprávnění; Šepot inzertní ID
nepoužívá (žádné reklamy/tracking) → odebíráme, ať sedí Play deklarace. -->
<uses-permission android:name="com.google.android.gms.permission.AD_ID"
+95 -2
View File
@@ -1,8 +1,14 @@
import CallKit
import Flutter
import PushKit
import UIKit
import WebRTC
import flutter_callkit_incoming
@main
@objc class AppDelegate: FlutterAppDelegate {
@objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate {
// PushKit registry musí žít po celou dobu běhu appky, jinak VoIP pushe nechodí.
private var voipRegistry: PKPushRegistry?
private var apnsChannel: FlutterMethodChannel?
// Token dorazí asynchronně z APNs; když si o něj Dart řekne dřív, podržíme ho.
private var pendingToken: String?
@@ -29,13 +35,100 @@ import UIKit
apnsChannel = channel
GeneratedPluginRegistrant.register(with: self)
// Hovory: VoIP push z relaye → CallKit.
let registry = PKPushRegistry(queue: DispatchQueue.main)
registry.delegate = self
registry.desiredPushTypes = [.voIP]
voipRegistry = registry
// Zvukovou relaci u hovoru řídí CallKit; WebRTC ji smí zapnout až po
// jeho aktivaci (didActivateAudioSession), jinak hovor z uzamčené
// obrazovky nemá zvuk.
RTCAudioSession.sharedInstance().useManualAudio = true
RTCAudioSession.sharedInstance().isAudioEnabled = false
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
// MARK: - PushKit (VoIP)
func pushRegistry(_ registry: PKPushRegistry, didUpdate credentials: PKPushCredentials, for type: PKPushType) {
let token = credentials.token.map { String(format: "%02x", $0) }.joined()
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(token)
}
func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("")
}
// iOS vyžaduje, aby každý VoIP push HNED skončil hovorem v CallKitu — jinak
// appku ukončí a po opakování VoIP pushe přestane doručovat. Relay neví,
// kdo volá (sealed sender), proto zvoní „Šepot“; Dart zvonění spáruje
// s nabídkou podle `extra.push`, až ji po připojení dešifruje.
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> 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
+4 -2
View File
@@ -5,9 +5,11 @@
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
<string>audio</string>
<string>voip</string>
</array>
<key>NSCameraUsageDescription</key>
<string>The camera is used to scan contacts' QR codes and to take photos for attachments.</string>
<string>The camera is used to scan contacts' QR codes, take photos for attachments and for video calls.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>The photo library is used to pick photos to send in a conversation.</string>
<key>NSFaceIDUsageDescription</key>
@@ -66,6 +68,6 @@
<key>UIStatusBarHidden</key>
<false/>
<key>NSMicrophoneUsageDescription</key>
<string>Šepot needs the microphone to record voice messages.</string>
<string>Šepot needs the microphone for voice messages and calls.</string>
</dict>
</plist>
+2 -2
View File
@@ -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.";
+2 -2
View File
@@ -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.";
+2 -2
View File
@@ -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.";
+2 -2
View File
@@ -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.";
+2 -2
View File
@@ -1,5 +1,5 @@
/* Šepot — тексты разрешений iOS (русский) */
"NSCameraUsageDescription" = "Камера используется для сканирования QR-кодов контактов и съёмки фотографий для вложений.";
"NSCameraUsageDescription" = "Камера используется для сканирования QR-кодов контактов, съёмки фотографий для вложений и видеозвонков.";
"NSPhotoLibraryUsageDescription" = "Галерея используется для выбора фотографий, которые вы отправляете в чате.";
"NSFaceIDUsageDescription" = "Face ID разблокирует приложение вместо PIN-кода.";
"NSMicrophoneUsageDescription" = "Для записи голосовых сообщений приложению Šepot нужен доступ к микрофону.";
"NSMicrophoneUsageDescription" = "Приложению Šepot нужен доступ к микрофону для голосовых сообщений и звонков.";
+512
View File
@@ -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<CallState> {
/// 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<void> 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<void> onSignal(Contact contact, Map<String, dynamic> 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<void> _onOffer(
Contact contact, String cid, Map<String, dynamic> 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<void> _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<void> 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<void> 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<void> 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<void> 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<void> switchCamera() async {
final track = _localStream?.getVideoTracks().firstOrNull;
if (track != null) await Helper.switchCamera(track);
}
// ── WebRTC ─────────────────────────────────────────────────────────
Future<void> _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<RTCPeerConnection> _createPeerConnection() async {
final pc = await createPeerConnection({
'iceServers': await _iceServers(),
'sdpSemantics': 'unified-plan',
});
final local = _localStream;
for (final track in local?.getTracks() ?? const <MediaStreamTrack>[]) {
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<List<Map<String, dynamic>>> _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<String> _gatheredSdp(RTCPeerConnection pc) async {
final done = Completer<void>();
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<void> _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<void> _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<void> _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 <MediaStreamTrack>[]) {
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<void> _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, CallState>(CallController.new);
+147
View File
@@ -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<CallEvent?>? _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 = <String>{};
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<String?> 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<void> 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<void> 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<void> connected(String id) =>
FlutterCallkitIncoming.setCallConnected(id);
static Future<void> 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<String, dynamic>? 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',
),
);
}
+12 -2
View File
@@ -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<void> 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<String> readMode() async =>
await _store.read(key: _modeKey) ?? 'off';
static Future<String> 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<String?> readStoredMode() => _store.read(key: _modeKey);
static Future<void> writeMode(String mode) =>
_store.write(key: _modeKey, value: mode);
+48 -7
View File
@@ -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<void> _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<void> 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<void> 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<void> _chooseDefaultMode() async {
try {
if ((await currentMode()) == 'up' ||
(await distributors()).isNotEmpty) {
if (await setModeUnifiedPush()) return;
}
await setModeFcm();
} catch (_) {
await init();
await PushBootstrap.writeMode('off');
}
}
}
+41
View File
@@ -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,
),
};
}
+331
View File
@@ -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<CallScreen> createState() => _CallScreenState();
}
class _CallScreenState extends ConsumerState<CallScreen> {
// 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';
}
+57
View File
@@ -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<String, dynamic> 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,
);
}
}
+40
View File
@@ -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:
+5
View File
@@ -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: