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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Šepot — тексты разрешений iOS (русский) */
|
||||
"NSCameraUsageDescription" = "Камера используется для сканирования QR-кодов контактов и съёмки фотографий для вложений.";
|
||||
"NSCameraUsageDescription" = "Камера используется для сканирования QR-кодов контактов, съёмки фотографий для вложений и видеозвонков.";
|
||||
"NSPhotoLibraryUsageDescription" = "Галерея используется для выбора фотографий, которые вы отправляете в чате.";
|
||||
"NSFaceIDUsageDescription" = "Face ID разблокирует приложение вместо PIN-кода.";
|
||||
"NSMicrophoneUsageDescription" = "Для записи голосовых сообщений приложению Šepot нужен доступ к микрофону.";
|
||||
"NSMicrophoneUsageDescription" = "Приложению Šepot нужен доступ к микрофону для голосовых сообщений и звонков.";
|
||||
|
||||
@@ -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);
|
||||
@@ -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',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user