Files
sepot/app/lib/core/push/push_service.dart
T
Julius Joska e3a2378c34 fix(app): accept incoming calls, restore from recovery phrase, duress session isolation, message loss
Calls: `call` was missing from the known content types, so every offer was
acknowledged and dropped; the call screen column now fills the width (the
avatar jumped left after hang-up); a ring started from a push ends after
12 s when no offer arrives; STUN fallback when no TURN credentials.
Registration: X-Sepot-Identity-Proof lets a new device reclaim an existing
identity; retry backs off from 30 s to 10 min and exposes a conflict flag.
Prekeys: PrekeysLow triggers a batch of 50 one-time prekeys via POST
/v1/keys; the signed prekey rotates weekly, the Kyber prekey monthly.
Messaging: crypto operations run serially (concurrent encrypts derived the
same message key); stop() waits for in-flight deliveries (auto-lock between
decrypt and persist lost messages); disappearing-message cleanup no longer
overwrites fresh messages; a push wake-up reconnects stale connections; the
outbox carries polls and mentions; length limits on wire input.
Storage: a duress session never exposes the real identity or phrase, does
not start networking and cannot touch the keystore; deleting data in a
duress session leaves the real account alone; PIN guessing is throttled
(5 misses, then 30 s doubling up to 15 min); the duress blob shares the salt
so unlock time does not reveal which PIN was entered; iOS keychain items are
this-device only; biometrics are biometric-only; screen security defaults
to on and is applied after unlock; tapjacking filter in MainActivity.
Tests: content types, shared-salt unwrap, duress isolation.
2026-10-09 14:02:33 +02:00

288 lines
10 KiB
Dart

/// Napojení UnifiedPush distributora na relay budíčky.
///
/// Tok:
/// distributor (ntfy) → onNewEndpoint → relay.registerPush(UNIFIEDPUSH, url)
/// příchozí budíček → onMessage → messaging.onPushWake()
/// (připoj se na WS → vylej offline frontu)
///
/// Obsah budíčku se ZÁMĚRNĚ ignoruje — je to konstantní „ťuk" `{"v":1}`,
/// žádný obsah ani odesílatel jím neprochází. Stačí i nešifrovaný
/// (`decrypted == false`) — potřebujeme jen fakt „něco přišlo, připoj se".
///
/// Callbacky u UnifiedPush registruje [PushBootstrap] z main() (kvůli
/// killed-state startu `--unifiedpush-bg` bez UI); tahle třída je
/// most bootstrap ↔ messaging + API pro nastavení.
///
/// 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';
import 'dart:convert';
import 'dart:io' show Platform;
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';
import 'package:sepot/core/services/messaging_service.dart';
import 'package:sepot/proto/sepot.pb.dart' as pb;
import 'package:unifiedpush/unifiedpush.dart';
class PushService {
PushService(this._messaging);
final MessagingService _messaging;
StreamSubscription<String>? _fcmTokenSub;
StreamSubscription<RemoteMessage>? _fcmMessageSub;
pb.RegisterPush _registration(pb.RegisterPush_Platform platform,
[Uint8List? endpoint]) =>
pb.RegisterPush(
platform: platform,
endpoint: endpoint ?? Uint8List(0),
);
/// Může budíček rovnou probudit WS? (Jinak jde generická notifikace.)
bool get canWake => _messaging.isStarted;
/// Budíček: běžící messaging připojí WS; po auto-locku (zamčená DB) aspoň
/// generická notifikace bez obsahu, ať se uživatel o zprávě dozví.
void wake() {
if (canWake) {
_messaging.onPushWake();
} else {
unawaited(PushNotifier.showWakeNotification());
}
}
void handleNewEndpoint(String url) {
_messaging.registerPush(_registration(
pb.RegisterPush_Platform.PLATFORM_UNIFIEDPUSH,
Uint8List.fromList(utf8.encode(url)),
));
}
void handleUnregistered() {
// Distributor nás odregistroval → relay ať token smaže (anonymní režim).
_messaging
.registerPush(_registration(pb.RegisterPush_Platform.PLATFORM_NONE));
}
/// Napojí messaging na push callbacky a doručí relayi endpoint, který
/// případně dorazil během killed-state startu. Idempotentní — volat po
/// startu messaging vrstvy.
Future<void> init() async {
PushBootstrap.attach(this);
final pending = await PushBootstrap.takePendingEndpoint();
if (pending != null) {
handleNewEndpoint(pending);
}
// iOS: APNs token → relay jako PLATFORM_APNS; tichý budíček → wake.
if (ApnsBridge.isSupported) {
ApnsBridge.onToken = _registerApnsToken;
ApnsBridge.onWake = wake;
ApnsBridge.ensureHandler();
}
}
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
/// asynchronně a sám se pošle relayi. Bez Googlu.
Future<void> enableApns() async {
await init();
await PushNotifier.requestPermission();
await ApnsBridge.register();
final existing = await ApnsBridge.currentToken();
if (existing != null) _registerApnsToken(existing);
await PushBootstrap.writeMode('apns');
}
/// Zapne push: použije uloženého/výchozího distributora a zaregistruje se.
/// Vrací `false`, když žádný distributor není (uživatel musí nainstalovat
/// např. ntfy nebo vybrat přes [distributors]/[useDistributor]).
Future<bool> enable() async {
await init();
// Android 13+: bez POST_NOTIFICATIONS by killed-state budíček zapadl
await PushNotifier.requestPermission();
final ok = await UnifiedPush.tryUseCurrentOrDefaultDistributor();
if (!ok) return false;
await UnifiedPush.register();
return true;
}
/// Seznam nainstalovaných distributorů (pro výběr v nastavení).
Future<List<String>> distributors() => UnifiedPush.getDistributors();
/// Aktuálně uložený distributor (null = push vypnutý / nevybraný).
Future<String?> currentDistributor() => UnifiedPush.getDistributor();
/// Vybere konkrétního distributora a zaregistruje se u něj.
Future<void> useDistributor(String distributor) async {
await init();
await PushNotifier.requestPermission();
await UnifiedPush.saveDistributor(distributor);
await UnifiedPush.register();
}
/// Vypne push: odregistruje u distributora a řekne relayi smazat token.
Future<void> disable() async {
await UnifiedPush.unregister();
handleUnregistered();
}
Future<bool> get isEnabled async =>
(await UnifiedPush.getDistributor()) != null;
// ── Režimy (off / up / fcm) ─────────────────────────────────────────
/// Aktuální režim; 'up' zpětně dovodí z vybraného distributora
/// (instalace před zavedením režimů).
Future<String> currentMode() async {
final mode = await PushBootstrap.readMode();
if (mode != 'off') return mode;
return (await UnifiedPush.getDistributor()) != null ? 'up' : 'off';
}
/// Odmítl distributor poslední registraci? Nastavení to ukáže — jinak by
/// push vypadal zapnutý, ale budíčky by nechodily.
Future<bool> registrationFailed() => PushBootstrap.registrationFailed();
Future<void> setModeOff() async {
await _teardownFcm(deleteToken: true);
try {
await UnifiedPush.unregister();
} catch (_) {}
handleUnregistered();
await PushBootstrap.writeMode('off');
}
/// UnifiedPush režim (default, degooglovaný). Vrací false, když chybí
/// distributor — UI pak nabídne výběr/instalaci.
Future<bool> setModeUnifiedPush() async {
await _teardownFcm(deleteToken: true);
final ok = await enable();
if (ok) await PushBootstrap.writeMode('up');
return ok;
}
/// FCM: registrační token zařízení jde Googlu (obsah budíčků nikdy,
/// ťuk je konstantní `{"v":1}`).
Future<void> setModeFcm() async {
await init();
try {
await UnifiedPush.unregister();
} catch (_) {}
await PushNotifier.requestPermission();
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(sepotFcmBackgroundWake);
final fm = FirebaseMessaging.instance;
final token = await fm.getToken();
if (token == null) {
throw StateError('failed to obtain FCM token');
}
_registerFcmToken(token);
_fcmTokenSub ??= fm.onTokenRefresh.listen(_registerFcmToken);
// popředí: ťuk → připojit WS (paralela k UnifiedPush onMessage)
_fcmMessageSub ??= FirebaseMessaging.onMessage.listen((_) => wake());
await PushBootstrap.writeMode('fcm');
}
void _registerFcmToken(String token) {
_messaging.registerPush(_registration(
pb.RegisterPush_Platform.PLATFORM_FCM,
Uint8List.fromList(utf8.encode(token)),
));
}
Future<void> _teardownFcm({required bool deleteToken}) async {
await _fcmTokenSub?.cancel();
_fcmTokenSub = null;
await _fcmMessageSub?.cancel();
_fcmMessageSub = null;
if (deleteToken) {
try {
await FirebaseMessaging.instance.deleteToken();
} catch (_) {
// Firebase neinicializovaný / bez sítě — token vyprší sám
}
}
}
/// Obnova režimu po startu appky (volá pushInitProvider).
Future<void> restoreMode() async {
// iOS: jediná smysluplná cesta je APNs (UnifiedPush je Android-only).
// Zapínáme ho automaticky — pro iOS messenger jsou notifikace základ.
if (Platform.isIOS) {
try {
await enableApns();
} catch (_) {
await init();
}
return;
}
if (await PushBootstrap.readStoredMode() == null) {
await _chooseDefaultMode();
return;
}
switch (await currentMode()) {
case 'up':
await enable();
case 'fcm':
try {
await setModeFcm();
} catch (_) {
// bez sítě — token registrace proběhne při příštím startu
}
default:
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');
}
}
}