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

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

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

Push on Android: a fresh install now picks UnifiedPush when a
distributor is installed and FCM otherwise, instead of leaving wake-ups
off. Without a wake-up Android puts the app to sleep and messages wait
until it is opened. Users can switch at any time in Settings.
This commit is contained in:
Julius Joska
2026-10-03 22:44:39 +02:00
parent 9a638545dd
commit 933a74c900
17 changed files with 1310 additions and 24 deletions
+12 -2
View File
@@ -20,6 +20,7 @@ import 'dart:io' show Platform;
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:sepot/core/calls/call_ringer.dart';
import 'package:sepot/core/push/apns_ios.dart';
import 'package:sepot/core/push/push_notifier.dart';
import 'package:sepot/core/push/push_service.dart';
@@ -28,8 +29,14 @@ import 'package:unifiedpush/unifiedpush.dart';
/// FCM budíček se zabitou appkou — plugin spouští handler ve vlastním
/// background isolate. Stejný model jako UnifiedPush killed-state:
/// DB je zamčená, nic nejde dešifrovat → jen generická notifikace.
/// Budíček hovoru (`k=call`) místo ní rovnou zazvoní; kdo
/// volá, appka zjistí až po odemčení z nabídky ve frontě.
@pragma('vm:entry-point')
Future<void> sepotFcmBackgroundWake(RemoteMessage message) async {
if (message.data['k'] == 'call') {
await CallRinger.ringFromPush();
return;
}
await Firebase.initializeApp();
await PushNotifier.showWakeNotification();
}
@@ -52,8 +59,11 @@ class PushBootstrap {
/// Režim pushů žije v OS keystore (NE v SQLCipher DB) — musí být
/// čitelný před unlockem, aby main() věděl, zda registrovat FCM.
static Future<String> readMode() async =>
await _store.read(key: _modeKey) ?? 'off';
static Future<String> readMode() async => await readStoredMode() ?? 'off';
/// Uložený režim, nebo null, když ho uživatel ani appka ještě nevybrali
/// — to odlišuje čerstvou instalaci od vědomého „vypnuto".
static Future<String?> readStoredMode() => _store.read(key: _modeKey);
static Future<void> writeMode(String mode) =>
_store.write(key: _modeKey, value: mode);
+48 -7
View File
@@ -13,8 +13,11 @@
/// killed-state startu `--unifiedpush-bg` bez UI); tahle třída je
/// most bootstrap ↔ messaging + API pro nastavení.
///
/// FCM „fast mode" (opt-in) je druhá cesta (viz [setModeFcm]); default je
/// degooglovaný UnifiedPush.
/// Výchozí režim na Androidu vybírá [restoreMode] při prvním startu: s
/// UnifiedPush distributorem UnifiedPush, jinak FCM. Bez budíčku Android
/// appku na pozadí uspí a zprávy čekají, než ji člověk otevře — to je pro
/// messenger horší než token u Googlu (obsah ani odesílatel budíčkem nejde).
/// Uživatel může kdykoli přepnout v Nastavení.
library;
import 'dart:async';
@@ -24,6 +27,7 @@ import 'dart:typed_data';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:sepot/core/calls/call_ringer.dart';
import 'package:sepot/core/push/apns_ios.dart';
import 'package:sepot/core/push/push_bootstrap.dart';
import 'package:sepot/core/push/push_notifier.dart';
@@ -80,11 +84,25 @@ class PushService {
}
}
void _registerApnsToken(String token) {
_messaging.registerPush(_registration(
String? _apnsToken;
/// APNs token pro zprávy + VoIP token pro hovory v jedné registraci
/// (relay drží obojí u zařízení).
Future<void> _registerApnsToken(String token) async {
_apnsToken = token;
final voip = await CallRinger.voipToken();
final reg = _registration(
pb.RegisterPush_Platform.PLATFORM_APNS,
Uint8List.fromList(utf8.encode(token)),
));
);
if (voip != null) reg.voipToken = utf8.encode(voip);
_messaging.registerPush(reg);
}
/// PushKit vydal (nový) VoIP token — přeregistrovat, pokud už APNs běží.
Future<void> refreshVoipToken() async {
final token = _apnsToken;
if (token != null) await _registerApnsToken(token);
}
/// iOS nativní push (APNs) — zapne registraci u Apple; token přijde
@@ -166,8 +184,8 @@ class PushService {
return ok;
}
/// FCM fast mode — OPT-IN: registrační token zařízení jde Googlu
/// (obsah budíčků nikdy, ťuk je konstantní `{"v":1}`).
/// FCM: registrační token zařízení jde Googlu (obsah budíčků nikdy,
/// ťuk je konstantní `{"v":1}`).
Future<void> setModeFcm() async {
await init();
try {
@@ -222,6 +240,10 @@ class PushService {
}
return;
}
if (await PushBootstrap.readStoredMode() == null) {
await _chooseDefaultMode();
return;
}
switch (await currentMode()) {
case 'up':
await enable();
@@ -235,4 +257,23 @@ class PushService {
await init(); // aspoň attach (killed-state notifikace)
}
}
/// První start na Androidu (nebo instalace z doby, kdy byl push
/// opt-in a nikdo ho nezapnul). Kdo má nainstalovaný UnifiedPush
/// distributor, vybral si degooglovanou cestu sám — dostane ji.
/// Ostatním zapneme FCM. Telefon bez Google služeb token nedostane;
/// pak zapíšeme „off", ať se to nezkouší při každém startu — seznam
/// chatů na chybějící budíčky upozorní sám.
Future<void> _chooseDefaultMode() async {
try {
if ((await currentMode()) == 'up' ||
(await distributors()).isNotEmpty) {
if (await setModeUnifiedPush()) return;
}
await setModeFcm();
} catch (_) {
await init();
await PushBootstrap.writeMode('off');
}
}
}