405 lines
16 KiB
Dart
405 lines
16 KiB
Dart
import 'dart:developer';
|
|
import 'dart:io';
|
|
|
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
|
import 'package:nextcloud/notifications.dart' show generatePushTokenHash;
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
|
|
import '../api/marianumcloud/app_password/delete_app_password.dart';
|
|
import '../api/marianumcloud/app_password/get_app_password.dart';
|
|
import '../api/marianumconnect/marianumconnect_endpoint.dart';
|
|
import '../api/marianumconnect/queries/push_device_register/push_device_register.dart';
|
|
import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart';
|
|
import '../model/account_data.dart';
|
|
import '../model/endpoint_data.dart';
|
|
import 'nextcloud_push_api.dart';
|
|
import 'push_keypair.dart';
|
|
import 'push_registration_store.dart';
|
|
import 'push_registration_type.dart';
|
|
|
|
/// Orchestrates the full push-v2 registration lifecycle:
|
|
/// Nextcloud device registration → MarianumConnect proxy registration, plus
|
|
/// unregister and token-refresh handling.
|
|
///
|
|
/// Every device maintains TWO Nextcloud registrations (see
|
|
/// [PushRegistrationType]) sharing one keypair: a `general` one and a
|
|
/// Talk-classified one, because NC routes Talk pushes only to apptype=talk
|
|
/// subscriptions once the user has any (e.g. the official Talk app).
|
|
class PushRegistration {
|
|
/// User agents matching nextcloud/server `IRequest` Talk patterns
|
|
/// (`USER_AGENT_TALK_ANDROID = '/^Mozilla\/5\.0 \(Android\) Nextcloud\-Talk
|
|
/// v([^ ]*).*$/'`, `USER_AGENT_TALK_IOS = '/^Mozilla\/5\.0 \(iOS\)
|
|
/// Nextcloud\-Talk v([^ ]*).*$/'`). Sent only on the talk registration so
|
|
/// NC stores it with apptype `talk`.
|
|
static const String talkUserAgentAndroid =
|
|
'Mozilla/5.0 (Android) Nextcloud-Talk v1.0.0 MarianumMobile';
|
|
static const String talkUserAgentIos =
|
|
'Mozilla/5.0 (iOS) Nextcloud-Talk v1.0.0 MarianumMobile';
|
|
final PushKeypair _keypair;
|
|
final PushRegistrationStore _store;
|
|
final NextcloudPushApi _nextcloud;
|
|
|
|
PushRegistration({
|
|
PushKeypair? keypair,
|
|
PushRegistrationStore? store,
|
|
NextcloudPushApi? nextcloud,
|
|
}) : _keypair = keypair ?? const PushKeypair(),
|
|
_store = store ?? const PushRegistrationStore(),
|
|
_nextcloud = nextcloud ?? NextcloudPushApi();
|
|
|
|
String get _platform => Platform.isIOS ? 'ios' : 'android';
|
|
|
|
String get _talkUserAgent =>
|
|
Platform.isIOS ? talkUserAgentIos : talkUserAgentAndroid;
|
|
|
|
/// Derives the push-proxy base URL from the active MarianumConnect endpoint,
|
|
/// so a beta/dev build registers against the matching proxy automatically.
|
|
/// Public so the push status view can compare it against the stored binding.
|
|
String get currentProxyServer =>
|
|
'${MarianumConnectEndpoint.current()}/push-proxy/';
|
|
|
|
/// Nextcloud origin the registration targets (full origin, no trailing
|
|
/// slash) — persisted alongside the registration to detect endpoint changes.
|
|
String get currentNcBaseUrl => 'https://${EndpointData().nextcloud().full()}';
|
|
|
|
/// Ensures the Nextcloud app password exists (idempotent, best-effort). Push
|
|
/// registration binds to it, so it must be obtained before registering.
|
|
Future<void> ensureAppPassword() async {
|
|
if (AccountData().hasAppPassword()) return;
|
|
try {
|
|
final appPassword = await GetAppPassword().run();
|
|
await AccountData().setAppPassword(appPassword);
|
|
} on Object catch (e) {
|
|
log('Push: could not obtain app password (non-blocking): $e');
|
|
}
|
|
}
|
|
|
|
/// Ensures the second app password backing the Talk registration exists
|
|
/// (each `getapppassword` call with the real password mints a fresh one).
|
|
Future<void> ensureTalkAppPassword() async {
|
|
if (AccountData().hasAppPasswordTalk()) return;
|
|
try {
|
|
final appPassword = await GetAppPassword().run();
|
|
await AccountData().setAppPasswordTalk(appPassword);
|
|
} on Object catch (e) {
|
|
log('Push: could not obtain talk app password (non-blocking): $e');
|
|
}
|
|
}
|
|
|
|
/// Registers this device end-to-end: both Nextcloud registrations (general,
|
|
/// then talk) each followed by their MarianumConnect proxy registration.
|
|
/// Partial results are persisted per type — one failing registration never
|
|
/// blocks the other. Returns true only when BOTH succeeded. No-op-safe:
|
|
/// transport failures are logged and swallowed so callers can
|
|
/// fire-and-forget (and simply ignore the result).
|
|
Future<bool> register() async {
|
|
final String? fcmToken;
|
|
try {
|
|
fcmToken = await FirebaseMessaging.instance.getToken();
|
|
} on Object catch (e) {
|
|
log('Push: could not obtain FCM token: $e');
|
|
await _recordAttempts('Kein FCM-Token verfügbar');
|
|
return false;
|
|
}
|
|
if (fcmToken == null || fcmToken.isEmpty) {
|
|
log('Push: no FCM token, skipping registration');
|
|
await _recordAttempts('Kein FCM-Token verfügbar');
|
|
return false;
|
|
}
|
|
|
|
await ensureAppPassword();
|
|
await ensureTalkAppPassword();
|
|
await _persistNativeAuthContext();
|
|
|
|
final PushKeypairPems pems;
|
|
try {
|
|
pems = await _keypair.ensure();
|
|
} on Object catch (e) {
|
|
log('Push: keypair unavailable: $e');
|
|
await _recordAttempts(_shortError(e));
|
|
return false;
|
|
}
|
|
|
|
String? appVersion;
|
|
try {
|
|
appVersion = (await PackageInfo.fromPlatform()).version;
|
|
} on Object {
|
|
appVersion = null;
|
|
}
|
|
|
|
var allOk = true;
|
|
for (final type in PushRegistrationType.values) {
|
|
final ok = await _registerType(
|
|
type: type,
|
|
fcmToken: fcmToken,
|
|
pems: pems,
|
|
appVersion: appVersion,
|
|
);
|
|
allOk = allOk && ok;
|
|
}
|
|
return allOk;
|
|
}
|
|
|
|
Future<bool> _registerType({
|
|
required PushRegistrationType type,
|
|
required String fcmToken,
|
|
required PushKeypairPems pems,
|
|
required String? appVersion,
|
|
}) async {
|
|
try {
|
|
final proxyServer = currentProxyServer;
|
|
final ncBaseUrl = currentNcBaseUrl;
|
|
final isTalk = type == PushRegistrationType.talk;
|
|
final registration = await _nextcloud.register(
|
|
pushTokenHash: generatePushTokenHash(pushTokenVariant(fcmToken, type)),
|
|
devicePublicKeyPem: pems.publicKeyPem,
|
|
proxyServer: proxyServer,
|
|
authorizationHeader: isTalk
|
|
? AccountData().getTalkBasicAuthHeader()
|
|
: null,
|
|
userAgent: isTalk ? _talkUserAgent : null,
|
|
);
|
|
|
|
await _store.save(
|
|
type: type,
|
|
deviceIdentifier: registration.deviceIdentifier,
|
|
serverPublicKeyPem: registration.publicKey,
|
|
fcmToken: fcmToken,
|
|
proxyServer: proxyServer,
|
|
ncBaseUrl: ncBaseUrl,
|
|
);
|
|
|
|
await PushDeviceRegister().run(
|
|
deviceIdentifier: registration.deviceIdentifier,
|
|
deviceIdentifierSignature: registration.signature,
|
|
userPublicKey: registration.publicKey,
|
|
pushToken: fcmToken,
|
|
platform: _platform,
|
|
registrationType: type.wireName,
|
|
appVersion: appVersion,
|
|
);
|
|
log(
|
|
'Push: registered ${type.wireName} '
|
|
'(created=${registration.created})',
|
|
);
|
|
await _recordAttempt(type, null);
|
|
return true;
|
|
} on Object catch (e) {
|
|
log('Push: ${type.wireName} registration failed: $e');
|
|
await _recordAttempt(type, _shortError(e));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Persists the attempt outcome for the push status view. Storage failures
|
|
/// must never mask the actual registration result.
|
|
Future<void> _recordAttempt(PushRegistrationType type, String? error) async {
|
|
try {
|
|
await _store.saveLastRegistrationAttempt(
|
|
type: type,
|
|
at: DateTime.now(),
|
|
error: error,
|
|
);
|
|
} on Object {
|
|
// ignore — the status view simply shows the previous attempt
|
|
}
|
|
}
|
|
|
|
Future<void> _recordAttempts(String? error) async {
|
|
for (final type in PushRegistrationType.values) {
|
|
await _recordAttempt(type, error);
|
|
}
|
|
}
|
|
|
|
static String _shortError(Object e) {
|
|
final text = e.toString();
|
|
return text.length > 300 ? '${text.substring(0, 300)}…' : text;
|
|
}
|
|
|
|
/// Writes the username and Nextcloud base URL into the shared keychain so the
|
|
/// native iOS Talk action handler can authenticate OCS calls without the
|
|
/// Flutter engine. Best-effort — a failure here must not abort registration.
|
|
Future<void> _persistNativeAuthContext() async {
|
|
try {
|
|
final endpoint = EndpointData().nextcloud();
|
|
await _store.saveNativeAuthContext(
|
|
username: AccountData().getUsername(),
|
|
baseUrl: 'https://${endpoint.full()}',
|
|
);
|
|
} on Object catch (e) {
|
|
log('Push: could not persist native auth context: $e');
|
|
}
|
|
}
|
|
|
|
/// Removes both subscriptions from Nextcloud and the proxy. Best-effort —
|
|
/// each step is independent so one failure never blocks the rest.
|
|
Future<void> unregister() async {
|
|
for (final type in PushRegistrationType.values) {
|
|
final deviceIdentifier = await _store.deviceIdentifier(type);
|
|
try {
|
|
// The DELETE removes the subscription bound to the authenticating
|
|
// session token — each registration with its own app password.
|
|
await _nextcloud.unregister(
|
|
authorizationHeader: type == PushRegistrationType.talk
|
|
? AccountData().getTalkBasicAuthHeader()
|
|
: null,
|
|
);
|
|
} on Object catch (e) {
|
|
log('Push: NC unregister (${type.wireName}) failed: $e');
|
|
}
|
|
if (deviceIdentifier != null && deviceIdentifier.isNotEmpty) {
|
|
try {
|
|
await PushDeviceUnregister().run(deviceIdentifier: deviceIdentifier);
|
|
} on Object catch (e) {
|
|
log('Push: proxy unregister (${type.wireName}) failed: $e');
|
|
}
|
|
}
|
|
}
|
|
await _store.clear();
|
|
}
|
|
|
|
/// Pure decision for whether a persisted registration endpoint no longer
|
|
/// matches the currently active one. A missing/empty stored value never
|
|
/// forces a re-registration — old installs (pre endpoint-tracking) heal via
|
|
/// the regular register-on-start path instead of a forced extra roundtrip.
|
|
static bool endpointChanged({
|
|
required String? registered,
|
|
required String current,
|
|
}) => registered != null && registered.isNotEmpty && registered != current;
|
|
|
|
/// True when any existing registration was made against a different
|
|
/// MarianumConnect proxy or Nextcloud base URL than the ones currently
|
|
/// configured (dev-tools endpoint switch, live/beta/custom).
|
|
Future<bool> needsEndpointReRegistration() async {
|
|
for (final type in PushRegistrationType.values) {
|
|
if (!await _store.isRegistered(type)) continue;
|
|
final changed =
|
|
endpointChanged(
|
|
registered: await _store.registeredProxyServer(type),
|
|
current: currentProxyServer,
|
|
) ||
|
|
endpointChanged(
|
|
registered: await _store.registeredNcBaseUrl(type),
|
|
current: currentNcBaseUrl,
|
|
);
|
|
if (changed) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Re-registers when the active endpoints diverge from the registered ones.
|
|
/// The NC POST updates the existing subscription server-side to the new
|
|
/// proxyServer URL; afterwards the device row lands at the NEW backend via
|
|
/// `PUT me/push-device`. No DELETE at the old proxy: its bearer token belongs
|
|
/// to a different token universe (cleared on endpoint switch) and the stale
|
|
/// row ages out through the backend's cleanup cron once pushes stop.
|
|
Future<void> reRegisterIfEndpointChanged() async {
|
|
if (!await needsEndpointReRegistration()) return;
|
|
log('Push: endpoint changed, re-registering');
|
|
await register();
|
|
}
|
|
|
|
/// Pure decision for whether push may be delivered given an OS permission
|
|
/// [status]: only an explicit denial blocks registration. `authorized` and
|
|
/// `provisional` obviously allow it; `notDetermined` is kept permissive so a
|
|
/// transient plugin/platform hiccup never silently disables push (the OS
|
|
/// simply won't show notifications until the user decides).
|
|
static bool isPermissionUsable(AuthorizationStatus status) =>
|
|
status != AuthorizationStatus.denied;
|
|
|
|
/// Requests the OS notification permission (covers iOS + Android 13) and
|
|
/// returns whether registration should proceed. Errors from the plugin are
|
|
/// treated as usable — better a possibly-idle registration than silently
|
|
/// losing push over a transient failure.
|
|
static Future<bool> requestOsPermission() async {
|
|
try {
|
|
final settings = await FirebaseMessaging.instance.requestPermission();
|
|
return isPermissionUsable(settings.authorizationStatus);
|
|
} on Object catch (e) {
|
|
log('Push: requestPermission failed: $e');
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// True when the user has explicitly denied the OS notification permission.
|
|
/// Read-only (no prompt) — used by the settings UI to surface the state.
|
|
static Future<bool> isOsPermissionDenied() async {
|
|
try {
|
|
final settings = await FirebaseMessaging.instance
|
|
.getNotificationSettings();
|
|
return settings.authorizationStatus == AuthorizationStatus.denied;
|
|
} on Object {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// True when the OS notification permission is already granted
|
|
/// (`authorized`/`provisional`). Read-only — never triggers the OS prompt.
|
|
/// Used by the cold-start/self-heal path so it registers only for devices
|
|
/// that already opted in, leaving the actual prompt to the first Talk visit.
|
|
static Future<bool> isOsPermissionGranted() async {
|
|
try {
|
|
final settings = await FirebaseMessaging.instance
|
|
.getNotificationSettings();
|
|
return settings.authorizationStatus == AuthorizationStatus.authorized ||
|
|
settings.authorizationStatus == AuthorizationStatus.provisional;
|
|
} on Object {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Registers this device when push is both user-enabled and backend-capable.
|
|
/// Only registers when the OS notification permission is *already* granted —
|
|
/// it never triggers the OS prompt itself. Requesting the permission is the
|
|
/// job of the first Talk visit (see `maybePromptTalkNotifications`), which
|
|
/// keeps the prompt out of the cold-start path. Safe to call on every start —
|
|
/// Nextcloud dedups an unchanged registration — which also self-heals a
|
|
/// device whose registration was lost.
|
|
static Future<void> syncSubscription({
|
|
required bool enabled,
|
|
required bool capable,
|
|
}) async {
|
|
if (!(enabled && capable)) return;
|
|
if (!await isOsPermissionGranted()) {
|
|
log('Push: OS notification permission not granted, skipping registration');
|
|
return;
|
|
}
|
|
final registration = PushRegistration();
|
|
// register() below refreshes an unchanged subscription anyway; the check
|
|
// only surfaces the endpoint switch in the log for diagnosability.
|
|
if (await registration.needsEndpointReRegistration()) {
|
|
log('Push: registered endpoints outdated, re-registering');
|
|
}
|
|
await registration.register();
|
|
}
|
|
|
|
/// Re-registers after an FCM token refresh. The Nextcloud device identifier
|
|
/// stays stable across refreshes, so this simply re-runs registration (NC
|
|
/// first, then the proxy) with the new token.
|
|
Future<void> onTokenRefresh() => register();
|
|
|
|
/// Full teardown for logout: unregister push, revoke BOTH app passwords
|
|
/// (each authenticated with itself — the endpoint revokes the credential it
|
|
/// is called with), then clear them locally. Ordered so the proxy stops
|
|
/// pushing before credentials are gone.
|
|
Future<void> logoutCleanup() async {
|
|
await unregister();
|
|
try {
|
|
await DeleteAppPassword().run();
|
|
} on Object catch (e) {
|
|
log('Push: delete app password failed: $e');
|
|
}
|
|
try {
|
|
if (AccountData().hasAppPasswordTalk()) {
|
|
await DeleteAppPassword().run(
|
|
authorizationHeader: AccountData().getTalkBasicAuthHeader(),
|
|
);
|
|
}
|
|
} on Object catch (e) {
|
|
log('Push: delete talk app password failed: $e');
|
|
}
|
|
await AccountData().clearAppPassword();
|
|
await AccountData().clearAppPasswordTalk();
|
|
}
|
|
}
|