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"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<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" />
|
<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
|
<!-- 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. -->
|
nepoužívá (žádné reklamy/tracking) → odebíráme, ať sedí Play deklarace. -->
|
||||||
<uses-permission android:name="com.google.android.gms.permission.AD_ID"
|
<uses-permission android:name="com.google.android.gms.permission.AD_ID"
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
import CallKit
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import PushKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import WebRTC
|
||||||
|
import flutter_callkit_incoming
|
||||||
|
|
||||||
@main
|
@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?
|
private var apnsChannel: FlutterMethodChannel?
|
||||||
// Token dorazí asynchronně z APNs; když si o něj Dart řekne dřív, podržíme ho.
|
// Token dorazí asynchronně z APNs; když si o něj Dart řekne dřív, podržíme ho.
|
||||||
private var pendingToken: String?
|
private var pendingToken: String?
|
||||||
@@ -29,13 +35,100 @@ import UIKit
|
|||||||
apnsChannel = channel
|
apnsChannel = channel
|
||||||
|
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
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)
|
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).
|
// APNs vydal device token → předáme Dartu (ten ho pošle relayi).
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Foundation.Data
|
||||||
) {
|
) {
|
||||||
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
|
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
|
||||||
pendingToken = token
|
pendingToken = token
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
|
<string>audio</string>
|
||||||
|
<string>voip</string>
|
||||||
</array>
|
</array>
|
||||||
<key>NSCameraUsageDescription</key>
|
<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>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>The photo library is used to pick photos to send in a conversation.</string>
|
<string>The photo library is used to pick photos to send in a conversation.</string>
|
||||||
<key>NSFaceIDUsageDescription</key>
|
<key>NSFaceIDUsageDescription</key>
|
||||||
@@ -66,6 +68,6 @@
|
|||||||
<key>UIStatusBarHidden</key>
|
<key>UIStatusBarHidden</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* Šepot — texty oprávnění iOS (čeština) */
|
/* Š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.";
|
"NSPhotoLibraryUsageDescription" = "Galerie slouží k výběru fotek pro odeslání v konverzaci.";
|
||||||
"NSFaceIDUsageDescription" = "Face ID odemyká aplikaci místo PINu.";
|
"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) */
|
/* Š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.";
|
"NSPhotoLibraryUsageDescription" = "Die Fotomediathek dient zum Auswählen von Fotos, die du in einer Unterhaltung sendest.";
|
||||||
"NSFaceIDUsageDescription" = "Face ID entsperrt die App anstelle der PIN.";
|
"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) */
|
/* Š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.";
|
"NSPhotoLibraryUsageDescription" = "The photo library is used to pick photos to send in a conversation.";
|
||||||
"NSFaceIDUsageDescription" = "Face ID unlocks the app instead of your PIN.";
|
"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) */
|
/* Š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.";
|
"NSPhotoLibraryUsageDescription" = "La libreria foto serve a scegliere le foto da inviare in una conversazione.";
|
||||||
"NSFaceIDUsageDescription" = "Face ID sblocca l'app al posto del PIN.";
|
"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 (русский) */
|
/* Šepot — тексты разрешений iOS (русский) */
|
||||||
"NSCameraUsageDescription" = "Камера используется для сканирования QR-кодов контактов и съёмки фотографий для вложений.";
|
"NSCameraUsageDescription" = "Камера используется для сканирования QR-кодов контактов, съёмки фотографий для вложений и видеозвонков.";
|
||||||
"NSPhotoLibraryUsageDescription" = "Галерея используется для выбора фотографий, которые вы отправляете в чате.";
|
"NSPhotoLibraryUsageDescription" = "Галерея используется для выбора фотографий, которые вы отправляете в чате.";
|
||||||
"NSFaceIDUsageDescription" = "Face ID разблокирует приложение вместо PIN-кода.";
|
"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_core/firebase_core.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.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/apns_ios.dart';
|
||||||
import 'package:sepot/core/push/push_notifier.dart';
|
import 'package:sepot/core/push/push_notifier.dart';
|
||||||
import 'package:sepot/core/push/push_service.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
|
/// FCM budíček se zabitou appkou — plugin spouští handler ve vlastním
|
||||||
/// background isolate. Stejný model jako UnifiedPush killed-state:
|
/// background isolate. Stejný model jako UnifiedPush killed-state:
|
||||||
/// DB je zamčená, nic nejde dešifrovat → jen generická notifikace.
|
/// 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')
|
@pragma('vm:entry-point')
|
||||||
Future<void> sepotFcmBackgroundWake(RemoteMessage message) async {
|
Future<void> sepotFcmBackgroundWake(RemoteMessage message) async {
|
||||||
|
if (message.data['k'] == 'call') {
|
||||||
|
await CallRinger.ringFromPush();
|
||||||
|
return;
|
||||||
|
}
|
||||||
await Firebase.initializeApp();
|
await Firebase.initializeApp();
|
||||||
await PushNotifier.showWakeNotification();
|
await PushNotifier.showWakeNotification();
|
||||||
}
|
}
|
||||||
@@ -52,8 +59,11 @@ class PushBootstrap {
|
|||||||
|
|
||||||
/// Režim pushů žije v OS keystore (NE v SQLCipher DB) — musí být
|
/// 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.
|
/// čitelný před unlockem, aby main() věděl, zda registrovat FCM.
|
||||||
static Future<String> readMode() async =>
|
static Future<String> readMode() async => await readStoredMode() ?? 'off';
|
||||||
await _store.read(key: _modeKey) ?? '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) =>
|
static Future<void> writeMode(String mode) =>
|
||||||
_store.write(key: _modeKey, value: mode);
|
_store.write(key: _modeKey, value: mode);
|
||||||
|
|||||||
@@ -13,8 +13,11 @@
|
|||||||
/// killed-state startu `--unifiedpush-bg` bez UI); tahle třída je
|
/// killed-state startu `--unifiedpush-bg` bez UI); tahle třída je
|
||||||
/// most bootstrap ↔ messaging + API pro nastavení.
|
/// most bootstrap ↔ messaging + API pro nastavení.
|
||||||
///
|
///
|
||||||
/// FCM „fast mode" (opt-in) je druhá cesta (viz [setModeFcm]); default je
|
/// Výchozí režim na Androidu vybírá [restoreMode] při prvním startu: s
|
||||||
/// degooglovaný UnifiedPush.
|
/// 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;
|
library;
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
@@ -24,6 +27,7 @@ import 'dart:typed_data';
|
|||||||
|
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.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/apns_ios.dart';
|
||||||
import 'package:sepot/core/push/push_bootstrap.dart';
|
import 'package:sepot/core/push/push_bootstrap.dart';
|
||||||
import 'package:sepot/core/push/push_notifier.dart';
|
import 'package:sepot/core/push/push_notifier.dart';
|
||||||
@@ -80,11 +84,25 @@ class PushService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _registerApnsToken(String token) {
|
String? _apnsToken;
|
||||||
_messaging.registerPush(_registration(
|
|
||||||
|
/// 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,
|
pb.RegisterPush_Platform.PLATFORM_APNS,
|
||||||
Uint8List.fromList(utf8.encode(token)),
|
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
|
/// iOS nativní push (APNs) — zapne registraci u Apple; token přijde
|
||||||
@@ -166,8 +184,8 @@ class PushService {
|
|||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// FCM fast mode — OPT-IN: registrační token zařízení jde Googlu
|
/// FCM: registrační token zařízení jde Googlu (obsah budíčků nikdy,
|
||||||
/// (obsah budíčků nikdy, ťuk je konstantní `{"v":1}`).
|
/// ťuk je konstantní `{"v":1}`).
|
||||||
Future<void> setModeFcm() async {
|
Future<void> setModeFcm() async {
|
||||||
await init();
|
await init();
|
||||||
try {
|
try {
|
||||||
@@ -222,6 +240,10 @@ class PushService {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (await PushBootstrap.readStoredMode() == null) {
|
||||||
|
await _chooseDefaultMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
switch (await currentMode()) {
|
switch (await currentMode()) {
|
||||||
case 'up':
|
case 'up':
|
||||||
await enable();
|
await enable();
|
||||||
@@ -235,4 +257,23 @@ class PushService {
|
|||||||
await init(); // aspoň attach (killed-state notifikace)
|
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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.5"
|
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:
|
dbus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -430,6 +438,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_driver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -570,6 +586,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
fuchsia_remote_debug_protocol:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -852,6 +876,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.11"
|
version: "1.0.11"
|
||||||
|
logger:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logger
|
||||||
|
sha256: "2a0dc097e7b01d942475bdd552356db2d0f768b05540bd4b2b53f1840f2239a7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.8.0"
|
||||||
logging:
|
logging:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1537,6 +1569,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.0"
|
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:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ dependencies:
|
|||||||
record: ^6.2.1
|
record: ^6.2.1
|
||||||
audioplayers: ^6.7.1
|
audioplayers: ^6.7.1
|
||||||
screen_protector: ^1.5.2
|
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:
|
dev_dependencies:
|
||||||
integration_test:
|
integration_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user