933a74c900
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.
161 lines
6.0 KiB
Dart
161 lines
6.0 KiB
Dart
/// Vstupní vrstva UnifiedPush — registruje se z main() PŘED runApp,
|
|
/// protože Android budí zabitou appku s argumentem `--unifiedpush-bg`
|
|
/// a bez UI: callbacky pak musí existovat dřív, než (ne)vznikne widget
|
|
/// tree.
|
|
///
|
|
/// Dvě situace při příchodu budíčku:
|
|
/// - appka běží (messaging nastartován) → wake: připoj WS, vylej frontu
|
|
/// - killed/zamčeno → DB je za PINem, nic nejde dešifrovat →
|
|
/// jen generická lokální notifikace (PushNotifier); zprávy se
|
|
/// stáhnou, až uživatel appku odemkne
|
|
///
|
|
/// Nový endpoint doručený na pozadí se NEDÁ poslat relayi (vyžaduje
|
|
/// autentizovaný WS + krypto z DB) → uloží se do OS keystore a odešle
|
|
/// při příštím plném startu (PushService.init ho vyzvedne).
|
|
library;
|
|
|
|
import 'dart:async';
|
|
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';
|
|
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();
|
|
}
|
|
|
|
class PushBootstrap {
|
|
PushBootstrap._();
|
|
|
|
/// True = proces nastartoval Android kvůli pushi (`--unifiedpush-bg`),
|
|
/// bez UI. main() pak NEvolá runApp.
|
|
static bool backgroundStart = false;
|
|
|
|
static PushService? _service;
|
|
|
|
static const _pendingEndpointKey = 'pending_push_endpoint';
|
|
static const _modeKey = 'push_mode'; // 'off' | 'up' | 'fcm'
|
|
static const _registrationFailedKey = 'push_registration_failed';
|
|
static const _store = FlutterSecureStorage(
|
|
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
|
);
|
|
|
|
/// 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 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);
|
|
|
|
/// Odmítl distributor registraci? Selhání se dřív jen spolklo, takže
|
|
/// uživatel měl v Nastavení „push zapnutý", ale budíčky nechodily a
|
|
/// zprávy čekaly, než appku otevře. Nastavení si to přečte a řekne to.
|
|
static Future<bool> registrationFailed() async =>
|
|
await _store.read(key: _registrationFailedKey) == '1';
|
|
|
|
static Future<void> _setRegistrationFailed(bool failed) => failed
|
|
? _store.write(key: _registrationFailedKey, value: '1')
|
|
: _store.delete(key: _registrationFailedKey);
|
|
|
|
static Future<void> init(List<String> args) async {
|
|
backgroundStart = args.contains('--unifiedpush-bg');
|
|
|
|
// iOS: nativní APNs most. onWake musí fungovat i v killed-state
|
|
// (iOS probudí engine tichým pushem), proto handler registrujeme tady,
|
|
// před runApp. Messaging se napojí přes attach()/PushService.init.
|
|
if (Platform.isIOS) {
|
|
ApnsBridge.onWake = _wake;
|
|
ApnsBridge.ensureHandler();
|
|
} else {
|
|
await UnifiedPush.initialize(
|
|
onNewEndpoint: _onNewEndpoint,
|
|
onRegistrationFailed: (_, _) =>
|
|
unawaited(_setRegistrationFailed(true)),
|
|
onUnregistered: _onUnregistered,
|
|
onMessage: _onMessage,
|
|
);
|
|
}
|
|
|
|
// Firebase se dotkne Googlu (installations FID) → inicializovat
|
|
// VÝHRADNĚ když si uživatel FCM režim sám zapnul.
|
|
if (!Platform.isLinux && await readMode() == 'fcm') {
|
|
try {
|
|
await Firebase.initializeApp();
|
|
FirebaseMessaging.onBackgroundMessage(sepotFcmBackgroundWake);
|
|
} catch (_) {
|
|
// bez google-services konfigurace / na desktopu — FCM nedostupné
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Napojí plnou appku (messaging vrstvu) na už zaregistrované callbacky.
|
|
static void attach(PushService service) {
|
|
_service = service;
|
|
}
|
|
|
|
/// Sdílené probuzení (UnifiedPush i APNs): appka běží → připoj WS a
|
|
/// vylej frontu; killed/zamčeno → generická notifikace bez obsahu.
|
|
static void _wake() {
|
|
final s = _service;
|
|
if (s != null && s.canWake) {
|
|
s.wake();
|
|
} else {
|
|
unawaited(PushNotifier.showWakeNotification());
|
|
}
|
|
}
|
|
|
|
static void _onMessage(PushMessage message, String instance) {
|
|
// Obsah budíčku se ZÁMĚRNĚ ignoruje (konstantní ťuk {"v":1}).
|
|
final s = _service;
|
|
if (s != null && s.canWake) {
|
|
s.wake();
|
|
} else {
|
|
unawaited(PushNotifier.showWakeNotification());
|
|
}
|
|
}
|
|
|
|
static void _onNewEndpoint(PushEndpoint endpoint, String instance) {
|
|
unawaited(_setRegistrationFailed(false)); // registrace se povedla
|
|
final s = _service;
|
|
if (s != null) {
|
|
s.handleNewEndpoint(endpoint.url);
|
|
} else {
|
|
unawaited(_store.write(key: _pendingEndpointKey, value: endpoint.url));
|
|
}
|
|
}
|
|
|
|
static void _onUnregistered(String instance) {
|
|
_service?.handleUnregistered();
|
|
}
|
|
|
|
/// Vyzvedne (a smaže) endpoint uložený během background startu.
|
|
static Future<String?> takePendingEndpoint() async {
|
|
final value = await _store.read(key: _pendingEndpointKey);
|
|
if (value != null) {
|
|
await _store.delete(key: _pendingEndpointKey);
|
|
}
|
|
return value;
|
|
}
|
|
}
|