Files
sepot/app/lib/core/push/push_service.dart
T
Julius Joska 6f9b08e844 fix(push): do not call UnifiedPush outside Android
UnifiedPush only exists on Android. On iOS getDistributor() and
getDistributors() throw UnimplementedError, which surfaced as an uncaught
error from the Push tile in Settings. Report an empty state instead.
2026-10-11 16:12:41 +02:00

293 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í).
/// UnifiedPush existuje jen na Androidu; jinde by volání pluginu vyhodilo
/// UnimplementedError (iOS: dlaždice Push v Nastavení).
Future<List<String>> distributors() async =>
Platform.isAndroid ? UnifiedPush.getDistributors() : const [];
/// Aktuálně uložený distributor (null = push vypnutý / nevybraný).
Future<String?> currentDistributor() async =>
Platform.isAndroid ? UnifiedPush.getDistributor() : null;
/// 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 =>
Platform.isAndroid && (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;
if (!Platform.isAndroid) 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');
}
}
}