implemented an E2E-encrypted Nextcloud push-v2 notification system with support for RSA decryption and signature verification; introduced an iOS Notification Service Extension and native AppDelegate handlers for Talk actions (inline reply and mark-as-read); replaced the legacy notification registration with a new lifecycle managing app passwords and secure keypair storage; added background message handling with tray synchronization and a test notification utility in the settings.
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
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';
|
||||
|
||||
/// Orchestrates the full push-v2 registration lifecycle:
|
||||
/// Nextcloud device registration → MarianumConnect proxy registration, plus
|
||||
/// unregister and token-refresh handling.
|
||||
class PushRegistration {
|
||||
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';
|
||||
|
||||
/// Derives the push-proxy base URL from the active MarianumConnect endpoint,
|
||||
/// so a beta/dev build registers against the matching proxy automatically.
|
||||
String get _proxyServer => '${MarianumConnectEndpoint.current()}/push-proxy/';
|
||||
|
||||
/// Nextcloud origin the registration targets (full origin, no trailing
|
||||
/// slash) — persisted alongside the registration to detect endpoint changes.
|
||||
String get _ncBaseUrl => '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');
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers this device end-to-end. No-op-safe: transport failures are
|
||||
/// logged and swallowed so callers can fire-and-forget.
|
||||
Future<void> register() async {
|
||||
try {
|
||||
final fcmToken = await FirebaseMessaging.instance.getToken();
|
||||
if (fcmToken == null || fcmToken.isEmpty) {
|
||||
log('Push: no FCM token, skipping registration');
|
||||
return;
|
||||
}
|
||||
await ensureAppPassword();
|
||||
await _persistNativeAuthContext();
|
||||
|
||||
final proxyServer = _proxyServer;
|
||||
final ncBaseUrl = _ncBaseUrl;
|
||||
final pems = await _keypair.ensure();
|
||||
final registration = await _nextcloud.register(
|
||||
pushTokenHash: generatePushTokenHash(fcmToken),
|
||||
devicePublicKeyPem: pems.publicKeyPem,
|
||||
proxyServer: proxyServer,
|
||||
);
|
||||
|
||||
await _store.save(
|
||||
deviceIdentifier: registration.deviceIdentifier,
|
||||
serverPublicKeyPem: registration.publicKey,
|
||||
fcmToken: fcmToken,
|
||||
proxyServer: proxyServer,
|
||||
ncBaseUrl: ncBaseUrl,
|
||||
);
|
||||
|
||||
String? appVersion;
|
||||
try {
|
||||
appVersion = (await PackageInfo.fromPlatform()).version;
|
||||
} on Object {
|
||||
appVersion = null;
|
||||
}
|
||||
|
||||
await PushDeviceRegister().run(
|
||||
deviceIdentifier: registration.deviceIdentifier,
|
||||
deviceIdentifierSignature: registration.signature,
|
||||
userPublicKey: registration.publicKey,
|
||||
pushToken: fcmToken,
|
||||
platform: _platform,
|
||||
appVersion: appVersion,
|
||||
);
|
||||
log('Push: registered (created=${registration.created})');
|
||||
} on Object catch (e) {
|
||||
log('Push: registration failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 the subscription from Nextcloud and the proxy. Best-effort.
|
||||
Future<void> unregister() async {
|
||||
final deviceIdentifier = await _store.deviceIdentifier();
|
||||
try {
|
||||
await _nextcloud.unregister();
|
||||
} on Object catch (e) {
|
||||
log('Push: NC unregister failed: $e');
|
||||
}
|
||||
if (deviceIdentifier != null && deviceIdentifier.isNotEmpty) {
|
||||
try {
|
||||
await PushDeviceUnregister().run(deviceIdentifier: deviceIdentifier);
|
||||
} on Object catch (e) {
|
||||
log('Push: proxy unregister 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 an 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 {
|
||||
if (!await _store.isRegistered()) return false;
|
||||
return endpointChanged(
|
||||
registered: await _store.registeredProxyServer(),
|
||||
current: _proxyServer,
|
||||
) ||
|
||||
endpointChanged(
|
||||
registered: await _store.registeredNcBaseUrl(),
|
||||
current: _ncBaseUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers this device when push is both user-enabled and backend-capable.
|
||||
/// Requests the OS notification permission first (covers iOS + Android 13);
|
||||
/// an explicit denial skips registration entirely so NC/proxy never push to a
|
||||
/// device that cannot display notifications. 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 requestOsPermission()) {
|
||||
log('Push: OS notification permission denied, 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 the app password, then
|
||||
/// clear it 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');
|
||||
}
|
||||
await AccountData().clearAppPassword();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user