import 'push_registration_type.dart'; import 'push_secure_storage.dart'; /// Persists the bookkeeping produced by successful push registrations — /// per [PushRegistrationType]: the Nextcloud device identifier, the FCM token /// the registration was made with (so a token refresh can be detected), the /// endpoints it was bound to (so an endpoint switch in the dev tools can be /// detected) and the last attempt outcome. The server public key and the /// device keypair are shared between both registrations. /// /// Key layout: the `general` type uses the pre-dual key names unchanged, so /// existing installs are implicitly migrated — their stored registration IS /// the general one; the missing talk registration is added by the next /// register-on-start self-heal. class PushRegistrationStore { static const _deviceIdentifierKey = 'push_device_identifier'; static const _serverPublicKeyKey = 'push_server_public_key_pem'; static const _registeredTokenKey = 'push_registered_fcm_token'; static const _proxyServerKey = 'push_registered_proxy_server'; static const _ncBaseUrlKey = 'push_registered_nc_base_url'; static const _lastAttemptAtKey = 'push_last_registration_at'; static const _lastAttemptErrorKey = 'push_last_registration_error'; // Native-only context: the iOS AppDelegate answers Talk notification actions // (reply / mark-as-read) directly via URLSession while the Flutter engine is // not guaranteed to run. It needs the Nextcloud username and base URL from the // shared (group-scoped) keychain; the app password already lives there // (AccountData writes `nextcloud_app_password` group-scoped). static const _usernameKey = 'nextcloud_username'; static const _baseUrlKey = 'nextcloud_base_url'; // Mirror of the in-app notification toggle (`notificationSettings.enabled`), // written group-scoped so the FCM background isolate (no bloc access) and the // iOS NSE can gate rendering. The device stays *registered* when off so silent // sync pushes keep flowing — only the visible alert is suppressed. static const _notificationsEnabledKey = 'push_notifications_enabled'; static const _perTypeKeys = [ _deviceIdentifierKey, _registeredTokenKey, _proxyServerKey, _ncBaseUrlKey, _lastAttemptAtKey, _lastAttemptErrorKey, ]; final FlutterSecureStorageLike _storage; const PushRegistrationStore([this._storage = const PushSecureStorage()]); /// Type-specific key: general keeps the legacy names (implicit migration of /// pre-dual installs), talk appends a suffix. static String keyFor(String baseKey, PushRegistrationType type) => type == PushRegistrationType.general ? baseKey : '${baseKey}_talk'; Future save({ required PushRegistrationType type, required String deviceIdentifier, required String serverPublicKeyPem, required String fcmToken, required String proxyServer, required String ncBaseUrl, }) async { await _storage.write( key: keyFor(_deviceIdentifierKey, type), value: deviceIdentifier, ); await _storage.write(key: _serverPublicKeyKey, value: serverPublicKeyPem); await _storage.write( key: keyFor(_registeredTokenKey, type), value: fcmToken, ); await _storage.write( key: keyFor(_proxyServerKey, type), value: proxyServer, ); await _storage.write(key: keyFor(_ncBaseUrlKey, type), value: ncBaseUrl); } /// Persists the username and Nextcloud base URL group-scoped so the native /// iOS Talk action handler (AppDelegate) can authenticate OCS calls. The base /// URL is a full origin like `https://cloud.marianum-fulda.de` (domain + /// optional path, no trailing slash). Future saveNativeAuthContext({ required String username, required String baseUrl, }) async { await _storage.write(key: _usernameKey, value: username); await _storage.write(key: _baseUrlKey, value: baseUrl); } /// Mirrors the in-app notification toggle so the background isolate / iOS NSE /// can read it without bloc access. Future setNotificationsEnabled(bool enabled) => _storage.write( key: _notificationsEnabledKey, value: enabled ? '1' : '0', ); /// The mirrored notification toggle. Defaults to `true` when unset (fresh /// install / pre-mirror build) so a missing mirror never silences pushes. Future notificationsEnabled() async => await _storage.read(key: _notificationsEnabledKey) != '0'; Future deviceIdentifier(PushRegistrationType type) => _storage.read(key: keyFor(_deviceIdentifierKey, type)); /// Per-user server public key — identical for both registrations. Future serverPublicKeyPem() => _storage.read(key: _serverPublicKeyKey); Future registeredFcmToken(PushRegistrationType type) => _storage.read(key: keyFor(_registeredTokenKey, type)); /// Proxy-server URL the registration of [type] was made with. Future registeredProxyServer(PushRegistrationType type) => _storage.read(key: keyFor(_proxyServerKey, type)); /// Nextcloud base URL the registration of [type] was made against. Future registeredNcBaseUrl(PushRegistrationType type) => _storage.read(key: keyFor(_ncBaseUrlKey, type)); /// True when a registration of [type] has been persisted. Future isRegistered(PushRegistrationType type) async => (await registeredFcmToken(type))?.isNotEmpty ?? false; /// Records the outcome of the most recent registration attempt of [type] so /// the push status view can show when it ran and why it failed. [error] /// null = success (stored as empty string). Future saveLastRegistrationAttempt({ required PushRegistrationType type, required DateTime at, String? error, }) async { await _storage.write( key: keyFor(_lastAttemptAtKey, type), value: at.toIso8601String(), ); await _storage.write( key: keyFor(_lastAttemptErrorKey, type), value: error ?? '', ); } /// Timestamp of the last registration attempt of [type], or null when none /// ran yet. Future lastRegistrationAt(PushRegistrationType type) async { final raw = await _storage.read(key: keyFor(_lastAttemptAtKey, type)); if (raw == null || raw.isEmpty) return null; return DateTime.tryParse(raw); } /// Error text of the last registration attempt of [type], or null when it /// succeeded (or never ran). Future lastRegistrationError(PushRegistrationType type) async { final raw = await _storage.read(key: keyFor(_lastAttemptErrorKey, type)); return (raw == null || raw.isEmpty) ? null : raw; } Future clear() async { for (final type in PushRegistrationType.values) { for (final key in _perTypeKeys) { await _storage.delete(key: keyFor(key, type)); } } await _storage.delete(key: _serverPublicKeyKey); await _storage.delete(key: _usernameKey); await _storage.delete(key: _baseUrlKey); } }