diff --git a/docs/push-notifications.md b/docs/push-notifications.md new file mode 100644 index 000000000..cfb7edbe4 --- /dev/null +++ b/docs/push-notifications.md @@ -0,0 +1,112 @@ +# Push Notifications + +Conduit supports optional remote push wakeups through a Conduit-owned push proxy +and Open WebUI's per-user notification webhooks. + +The app still uses the existing socket/local notification path for foreground +and connected background behavior. Remote push is only activated when the app is +built with push proxy and Firebase configuration. + +## Build Configuration + +Set these Dart defines for a build that should register remote push: + +```bash +--dart-define=CONDUIT_PUSH_PROXY_BASE_URL=https://push.example.com +--dart-define=CONDUIT_FIREBASE_API_KEY=... +--dart-define=CONDUIT_FIREBASE_PROJECT_ID=... +--dart-define=CONDUIT_FIREBASE_MESSAGING_SENDER_ID=... +--dart-define=CONDUIT_FIREBASE_APP_ID_ANDROID=... +--dart-define=CONDUIT_FIREBASE_APP_ID_IOS=... +``` + +Optional: + +```bash +--dart-define=CONDUIT_FIREBASE_IOS_BUNDLE_ID=app.cogwheel.conduit +--dart-define=CONDUIT_FIREBASE_ANDROID_CLIENT_ID=... +``` + +If these are omitted, the notification settings screen still works, but remote +push registration is disabled and the app will not write webhook URLs. + +## Admin Flow + +Admins see an **Auto-configure server** switch in notification settings. Turning +it on shows a disclosure dialog and then sets Open WebUI's +`ENABLE_USER_WEBHOOKS` admin config flag through: + +```text +GET /api/v1/auths/admin/config +POST /api/v1/auths/admin/config +``` + +Conduit posts the full admin config payload back with only +`ENABLE_USER_WEBHOOKS` changed, matching Open WebUI's admin-config update +contract. + +## User Flow + +When a user enables system notifications, Conduit: + +1. Requests OS notification permission. +2. Checks `/api/config` for `features.enable_user_webhooks`. +3. Reads the device push token: + - Android: FCM registration token. + - iOS: APNs token exposed by Firebase Messaging. +4. Registers the token with `CONDUIT_PUSH_PROXY_BASE_URL`. +5. Writes the returned webhook URL to + `ui.notifications.webhook_url` in `/api/v1/users/user/settings/update`. + +Conduit refuses to overwrite an existing webhook URL unless it matches the URL +that Conduit previously stored for that server. This prevents silently replacing +a user's ntfy, Nextcloud, or custom Open WebUI webhook integration. + +## Push Proxy Protocol + +Registration request: + +```http +POST /v1/installations +content-type: application/json +``` + +```json +{ + "protocol_version": 1, + "app": "conduit", + "server_id": "server-id", + "server_url": "https://openwebui.example.com", + "user_id": "user-id", + "installation_id": "stable-device-installation-id", + "platform": "ios", + "token_type": "apns", + "push_token": "native-token" +} +``` + +Registration response: + +```json +{ + "subscription_id": "subscription-id", + "webhook_url": "https://push.example.com/v1/openwebui/webhooks/..." +} +``` + +Unregister: + +```http +DELETE /v1/installations/{subscription_id} +``` + +Notification tap payloads should include: + +```json +{ + "conduit_kind": "chat_completion", + "conduit_source_id": "chat-id" +} +``` + +Supported `conduit_kind` values are `chat_completion` and `channel_message`. diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3bd0c1129..dca9cddb7 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -802,6 +802,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; APP_GROUP_ID = group.app.cogwheel.conduit.x2662v5dt2.debug; APP_URL_SCHEME = "conduit-debug"; + APS_ENVIRONMENT = development; ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-Debug"; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; @@ -1003,6 +1004,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; APP_GROUP_ID = group.app.cogwheel.conduit.x2662v5dt2.debug; APP_URL_SCHEME = "conduit-debug"; + APS_ENVIRONMENT = development; ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-Debug"; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; @@ -1043,6 +1045,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; APP_GROUP_ID = group.app.cogwheel.conduit; APP_URL_SCHEME = conduit; + APS_ENVIRONMENT = production; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 7abe06364..1ed88ceae 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -107,6 +107,7 @@ audio processing + remote-notification voip UILaunchStoryboardName diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index 52fc60667..3dbd4bfe4 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -8,5 +8,7 @@ com.apple.developer.carplay-voice-based-conversation + aps-environment + $(APS_ENVIRONMENT) diff --git a/lib/core/models/backend_config.dart b/lib/core/models/backend_config.dart index eb53ab2f4..08ac36f28 100644 --- a/lib/core/models/backend_config.dart +++ b/lib/core/models/backend_config.dart @@ -123,6 +123,7 @@ class BackendConfig { this.oauthProviders = const OAuthProviders(), this.enableLdap = false, this.enableLoginForm = true, + this.enableUserWebhooks = false, }); /// Mirrors `features.enable_websocket` from OpenWebUI. @@ -152,6 +153,9 @@ class BackendConfig { /// Whether the standard login form (email/password) is enabled. final bool enableLoginForm; + /// Whether Open WebUI allows per-user webhook URLs for notifications. + final bool enableUserWebhooks; + /// Whether SSO (OAuth) login is available. bool get hasSsoEnabled => oauthProviders.hasAnyProvider; @@ -173,6 +177,7 @@ class BackendConfig { OAuthProviders? oauthProviders, bool? enableLdap, bool? enableLoginForm, + bool? enableUserWebhooks, }) { return BackendConfig( enableWebsocket: enableWebsocket ?? this.enableWebsocket, @@ -191,6 +196,7 @@ class BackendConfig { oauthProviders: oauthProviders ?? this.oauthProviders, enableLdap: enableLdap ?? this.enableLdap, enableLoginForm: enableLoginForm ?? this.enableLoginForm, + enableUserWebhooks: enableUserWebhooks ?? this.enableUserWebhooks, ); } @@ -231,6 +237,7 @@ class BackendConfig { 'oauth': {'providers': oauthProviders.toJson()}, 'enable_ldap': enableLdap, 'enable_login_form': enableLoginForm, + 'enable_user_webhooks': enableUserWebhooks, }; } @@ -251,6 +258,7 @@ class BackendConfig { OAuthProviders oauthProviders = const OAuthProviders(); bool enableLdap = false; bool enableLoginForm = true; + bool enableUserWebhooks = false; // Try canonical format first final value = json['enable_websocket']; @@ -311,6 +319,8 @@ class BackendConfig { if (ldapValue is bool) enableLdap = ldapValue; final loginFormValue = json['enable_login_form']; if (loginFormValue is bool) enableLoginForm = loginFormValue; + final userWebhooksValue = json['enable_user_webhooks']; + if (userWebhooksValue is bool) enableUserWebhooks = userWebhooksValue; // Fallback to nested format for backwards compatibility final features = json['features']; @@ -379,6 +389,10 @@ class BackendConfig { if (nestedLdap is bool) enableLdap = nestedLdap; final nestedLoginForm = features['enable_login_form']; if (nestedLoginForm is bool) enableLoginForm = nestedLoginForm; + final nestedUserWebhooks = features['enable_user_webhooks']; + if (nestedUserWebhooks is bool) { + enableUserWebhooks = nestedUserWebhooks; + } } return BackendConfig( @@ -398,6 +412,7 @@ class BackendConfig { oauthProviders: oauthProviders, enableLdap: enableLdap, enableLoginForm: enableLoginForm, + enableUserWebhooks: enableUserWebhooks, ); } } diff --git a/lib/core/models/server_user_settings.dart b/lib/core/models/server_user_settings.dart index 9e53a62a6..e0f152a50 100644 --- a/lib/core/models/server_user_settings.dart +++ b/lib/core/models/server_user_settings.dart @@ -11,6 +11,7 @@ class ServerUserSettings { this.notificationEnabled, this.notificationSound, this.notificationSoundAlways, + this.notificationWebhookUrl, }); final String? systemPrompt; @@ -25,6 +26,10 @@ class ServerUserSettings { final bool? notificationSound; final bool? notificationSoundAlways; + /// Open WebUI stores the per-user notification webhook at + /// `ui.notifications.webhook_url`. + final String? notificationWebhookUrl; + /// The user's preferred default model, if one is configured. String? get defaultModelId => defaultModelIds.isEmpty ? null : defaultModelIds.first; @@ -42,6 +47,9 @@ class ServerUserSettings { notificationEnabled: _coerceBool(json['notificationEnabled']), notificationSound: _coerceBool(json['notificationSound']), notificationSoundAlways: _coerceBool(json['notificationSoundAlways']), + notificationWebhookUrl: _normalizeString( + _coerceJsonMap(ui?['notifications'])?['webhook_url'], + ), ); } @@ -53,6 +61,7 @@ class ServerUserSettings { bool? notificationEnabled, bool? notificationSound, bool? notificationSoundAlways, + Object? notificationWebhookUrl = _serverUserSettingsUnset, }) { return ServerUserSettings( systemPrompt: systemPrompt == _serverUserSettingsUnset @@ -65,6 +74,9 @@ class ServerUserSettings { notificationSound: notificationSound ?? this.notificationSound, notificationSoundAlways: notificationSoundAlways ?? this.notificationSoundAlways, + notificationWebhookUrl: notificationWebhookUrl == _serverUserSettingsUnset + ? this.notificationWebhookUrl + : notificationWebhookUrl as String?, ); } } diff --git a/lib/core/providers/app_startup_providers.dart b/lib/core/providers/app_startup_providers.dart index 39e219f2b..a97c74d80 100644 --- a/lib/core/providers/app_startup_providers.dart +++ b/lib/core/providers/app_startup_providers.dart @@ -28,6 +28,7 @@ import '../../features/chat/providers/chat_providers.dart'; import '../../features/chat/providers/remap_route_sync_provider.dart'; import '../../features/notifications/providers/notification_socket_listener.dart'; import '../../features/notifications/services/local_notification_service.dart'; +import '../../features/notifications/services/remote_push_service.dart'; part 'app_startup_providers.g.dart'; @@ -117,6 +118,19 @@ Future _cleanupUserScopedProvidersAfterSignOut(Ref ref) async { ); }), ); + unawaited( + ref + .read(remotePushServiceProvider) + .unregisterAllLocalSubscriptions() + .catchError((Object e, StackTrace st) { + DebugLogger.error( + 'failed to unregister push subscriptions on sign-out', + scope: 'notifications/push', + error: e, + stackTrace: st, + ); + }), + ); ref.invalidate(notificationRouterProvider); ref.invalidate(notificationSocketListenerProvider); } catch (error, stackTrace) { diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 96192507f..d6a158c61 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -71,6 +71,88 @@ bool isTlsHandshakeFailureForTest(DioException error) { message.contains('alert bad certificate'); } +/// Raised when Conduit would overwrite a user-managed Open WebUI webhook. +class UserNotificationWebhookConflictException implements Exception { + UserNotificationWebhookConflictException({ + required this.currentWebhookUrl, + this.expectedWebhookUrl, + }); + + final String currentWebhookUrl; + final String? expectedWebhookUrl; + + @override + String toString() => + 'UserNotificationWebhookConflictException(current webhook differs)'; +} + +@visibleForTesting +Map mergeUserNotificationWebhookSettingsForTest( + Map source, { + required String? webhookUrl, + String? expectedCurrentWebhookUrl, +}) { + return _mergeUserNotificationWebhookSettings( + source, + webhookUrl: webhookUrl, + expectedCurrentWebhookUrl: expectedCurrentWebhookUrl, + ); +} + +Map _mergeUserNotificationWebhookSettings( + Map source, { + required String? webhookUrl, + String? expectedCurrentWebhookUrl, +}) { + final settings = normalizeJsonLikeMap(source); + final ui = _coerceJsonMapForWebhookMerge(settings['ui']); + final notifications = _coerceJsonMapForWebhookMerge(ui['notifications']); + final currentWebhookUrl = _normalizeStringForWebhookMerge( + notifications['webhook_url'], + ); + final expectedWebhookUrl = _normalizeStringForWebhookMerge( + expectedCurrentWebhookUrl, + ); + final nextWebhookUrl = _normalizeStringForWebhookMerge(webhookUrl); + + if (currentWebhookUrl != null && currentWebhookUrl != expectedWebhookUrl) { + throw UserNotificationWebhookConflictException( + currentWebhookUrl: currentWebhookUrl, + expectedWebhookUrl: expectedWebhookUrl, + ); + } + + if (nextWebhookUrl == null) { + notifications.remove('webhook_url'); + } else { + notifications['webhook_url'] = nextWebhookUrl; + } + + ui['notifications'] = notifications; + settings['ui'] = ui; + return settings; +} + +Map _coerceJsonMapForWebhookMerge(Object? value) { + if (value is Map) { + return normalizeJsonLikeMap(value); + } + if (value is Map) { + return normalizeJsonLikeMap( + value.map((key, entry) => MapEntry(key?.toString() ?? '', entry)), + ); + } + return {}; +} + +String? _normalizeStringForWebhookMerge(Object? value) { + if (value is! String) { + return null; + } + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; +} + /// Get MIME type from file extension. String? _getMimeType(String fileName) { final ext = fileName.toLowerCase().split('.').last; @@ -615,6 +697,23 @@ class ApiService { } } + Future> getAdminConfig() async { + _traceApi('Fetching admin config'); + final response = await _dio.get('/api/v1/auths/admin/config'); + return _requireResponseMap(response.data, 'admin config'); + } + + Future> setAdminUserWebhooksEnabled(bool enabled) async { + final config = await getAdminConfig(); + config['ENABLE_USER_WEBHOOKS'] = enabled; + _traceApi('Updating admin user webhook config'); + final response = await _dio.post( + '/api/v1/auths/admin/config', + data: config, + ); + return _coerceResponseMap(response.data) ?? config; + } + Future _enrichBackendConfigWithAudioConfig( BackendConfig config, ) async { @@ -2417,6 +2516,29 @@ class ApiService { return ServerUserSettings.fromJson(data); } + /// Persists Conduit's per-user notification webhook URL under Open WebUI's + /// `ui.notifications.webhook_url` setting. If [expectedCurrentWebhookUrl] is + /// supplied, an existing different webhook is treated as user-managed and is + /// not overwritten. + Future updateUserNotificationWebhook({ + required String? webhookUrl, + String? expectedCurrentWebhookUrl, + }) async { + final settings = _mergeUserNotificationWebhookSettings( + await getUserSettings(), + webhookUrl: webhookUrl, + expectedCurrentWebhookUrl: expectedCurrentWebhookUrl, + ); + + _traceApi('Updating user notification webhook'); + final response = await _dio.post( + '/api/v1/users/user/settings/update', + data: settings, + ); + final data = _coerceResponseMap(response.data) ?? settings; + return ServerUserSettings.fromJson(data); + } + Future updateUserPinnedModels( List modelIds, ) async { diff --git a/lib/features/notifications/providers/notification_socket_listener.dart b/lib/features/notifications/providers/notification_socket_listener.dart index 319886479..3578e549f 100644 --- a/lib/features/notifications/providers/notification_socket_listener.dart +++ b/lib/features/notifications/providers/notification_socket_listener.dart @@ -18,6 +18,7 @@ import '../services/local_notification_service.dart'; import '../services/notification_event_classifier.dart'; import '../services/notification_router.dart'; import '../services/notification_sound_service.dart'; +import '../services/remote_push_service.dart'; part 'notification_socket_listener.g.dart'; @@ -117,6 +118,7 @@ class NotificationSocketListener extends _$NotificationSocketListener { SocketEventSubscription? _channelSub; StreamSubscription? _reconnectSub; StreamSubscription? _tapSub; + StreamSubscription? _remoteTapSub; SocketService? _boundSocket; @override @@ -126,6 +128,7 @@ class NotificationSocketListener extends _$NotificationSocketListener { _channelSub?.dispose(); _reconnectSub?.cancel(); _tapSub?.cancel(); + _remoteTapSub?.cancel(); }); final local = ref.read(localNotificationServiceProvider); @@ -138,6 +141,13 @@ class NotificationSocketListener extends _$NotificationSocketListener { unawaited(_handleTap(ref, tap)); }); + final remotePush = ref.read(remotePushServiceProvider); + unawaited(remotePush.start()); + unawaited(remotePush.syncForCurrentSettings()); + _remoteTapSub = remotePush.taps.listen((tap) { + unawaited(_handleTap(ref, tap)); + }); + _bindSocket(ref.read(socketServiceProvider)); ref.listen(socketServiceProvider, (_, next) { _bindSocket(next); @@ -157,6 +167,10 @@ class NotificationSocketListener extends _$NotificationSocketListener { if (tap != null) { await _handleTap(ref, tap); } + final remoteTap = await ref.read(remotePushServiceProvider).launchTap(); + if (remoteTap != null) { + await _handleTap(ref, remoteTap); + } } void _bindSocket(SocketService? socket) { @@ -186,8 +200,7 @@ class NotificationSocketListener extends _$NotificationSocketListener { }); } - String get _currentUserId => - ref.read(currentUserProvider).value?.id ?? ''; + String get _currentUserId => ref.read(currentUserProvider).value?.id ?? ''; void _onChatEvent(Map event) { final notification = _classifier.classifyChatEvent( diff --git a/lib/features/notifications/services/remote_push_build_config.dart b/lib/features/notifications/services/remote_push_build_config.dart new file mode 100644 index 000000000..69ab61a97 --- /dev/null +++ b/lib/features/notifications/services/remote_push_build_config.dart @@ -0,0 +1,103 @@ +import 'dart:io'; + +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; + +/// Build-time configuration for Conduit's optional push proxy integration. +/// +/// OSS/local builds can omit all defines; remote push then stays disabled while +/// local/socket notifications continue to work. +@immutable +class RemotePushBuildConfig { + const RemotePushBuildConfig({ + required this.proxyBaseUrl, + required this.firebaseApiKey, + required this.firebaseProjectId, + required this.firebaseMessagingSenderId, + required this.firebaseAndroidAppId, + required this.firebaseIosAppId, + required this.firebaseIosBundleId, + required this.firebaseAndroidClientId, + }); + + static const current = RemotePushBuildConfig( + proxyBaseUrl: String.fromEnvironment('CONDUIT_PUSH_PROXY_BASE_URL'), + firebaseApiKey: String.fromEnvironment('CONDUIT_FIREBASE_API_KEY'), + firebaseProjectId: String.fromEnvironment('CONDUIT_FIREBASE_PROJECT_ID'), + firebaseMessagingSenderId: String.fromEnvironment( + 'CONDUIT_FIREBASE_MESSAGING_SENDER_ID', + ), + firebaseAndroidAppId: String.fromEnvironment( + 'CONDUIT_FIREBASE_APP_ID_ANDROID', + ), + firebaseIosAppId: String.fromEnvironment('CONDUIT_FIREBASE_APP_ID_IOS'), + firebaseIosBundleId: String.fromEnvironment( + 'CONDUIT_FIREBASE_IOS_BUNDLE_ID', + ), + firebaseAndroidClientId: String.fromEnvironment( + 'CONDUIT_FIREBASE_ANDROID_CLIENT_ID', + ), + ); + + final String proxyBaseUrl; + final String firebaseApiKey; + final String firebaseProjectId; + final String firebaseMessagingSenderId; + final String firebaseAndroidAppId; + final String firebaseIosAppId; + final String firebaseIosBundleId; + final String firebaseAndroidClientId; + + bool get isSupportedPlatform => Platform.isAndroid || Platform.isIOS; + + Uri? get proxyBaseUri { + final trimmed = proxyBaseUrl.trim(); + if (trimmed.isEmpty) return null; + final uri = Uri.tryParse(trimmed); + if (uri == null || !uri.hasScheme || uri.host.isEmpty) return null; + if (uri.scheme != 'https' && uri.scheme != 'http') return null; + return uri; + } + + bool get hasSharedFirebaseOptions => + firebaseApiKey.trim().isNotEmpty && + firebaseProjectId.trim().isNotEmpty && + firebaseMessagingSenderId.trim().isNotEmpty; + + bool get isConfigured => + isSupportedPlatform && + proxyBaseUri != null && + firebaseOptionsForCurrentPlatform != null; + + FirebaseOptions? get firebaseOptionsForCurrentPlatform { + if (!hasSharedFirebaseOptions) return null; + if (Platform.isAndroid) { + final appId = firebaseAndroidAppId.trim(); + if (appId.isEmpty) return null; + return FirebaseOptions( + apiKey: firebaseApiKey.trim(), + appId: appId, + messagingSenderId: firebaseMessagingSenderId.trim(), + projectId: firebaseProjectId.trim(), + androidClientId: _blankToNull(firebaseAndroidClientId), + ); + } + if (Platform.isIOS) { + final appId = firebaseIosAppId.trim(); + if (appId.isEmpty) return null; + return FirebaseOptions( + apiKey: firebaseApiKey.trim(), + appId: appId, + messagingSenderId: firebaseMessagingSenderId.trim(), + projectId: firebaseProjectId.trim(), + iosBundleId: _blankToNull(firebaseIosBundleId), + ); + } + return null; + } + + static String? _blankToNull(String value) { + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } +} diff --git a/lib/features/notifications/services/remote_push_models.dart b/lib/features/notifications/services/remote_push_models.dart new file mode 100644 index 000000000..3404584a6 --- /dev/null +++ b/lib/features/notifications/services/remote_push_models.dart @@ -0,0 +1,183 @@ +import 'dart:convert'; + +enum RemotePushPlatform { + android('android'), + ios('ios'); + + const RemotePushPlatform(this.wireName); + + final String wireName; +} + +enum RemotePushTokenType { + fcm('fcm'), + apns('apns'); + + const RemotePushTokenType(this.wireName); + + final String wireName; +} + +class RemotePushDeviceToken { + const RemotePushDeviceToken({ + required this.platform, + required this.tokenType, + required this.value, + }); + + final RemotePushPlatform platform; + final RemotePushTokenType tokenType; + final String value; +} + +class RemotePushRegistration { + const RemotePushRegistration({ + required this.subscriptionId, + required this.webhookUrl, + }); + + final String subscriptionId; + final String webhookUrl; + + factory RemotePushRegistration.fromJson(Map json) { + final subscriptionId = (json['subscription_id'] ?? json['id']) + ?.toString() + .trim(); + final webhookUrl = json['webhook_url']?.toString().trim(); + if (subscriptionId == null || + subscriptionId.isEmpty || + webhookUrl == null || + webhookUrl.isEmpty) { + throw const FormatException('push proxy registration response invalid'); + } + return RemotePushRegistration( + subscriptionId: subscriptionId, + webhookUrl: webhookUrl, + ); + } +} + +class RemotePushSubscription { + const RemotePushSubscription({ + required this.serverId, + required this.userId, + required this.installationId, + required this.subscriptionId, + required this.webhookUrl, + required this.proxyBaseUrl, + required this.platform, + required this.tokenType, + required this.updatedAt, + }); + + final String serverId; + final String userId; + final String installationId; + final String subscriptionId; + final String webhookUrl; + final String proxyBaseUrl; + final RemotePushPlatform platform; + final RemotePushTokenType tokenType; + final DateTime updatedAt; + + Map toJson() => { + 'server_id': serverId, + 'user_id': userId, + 'installation_id': installationId, + 'subscription_id': subscriptionId, + 'webhook_url': webhookUrl, + 'proxy_base_url': proxyBaseUrl, + 'platform': platform.wireName, + 'token_type': tokenType.wireName, + 'updated_at': updatedAt.toIso8601String(), + }; + + String encode() => jsonEncode(toJson()); + + static RemotePushSubscription? tryDecode(String? value) { + if (value == null || value.isEmpty) return null; + try { + final decoded = jsonDecode(value); + if (decoded is! Map) return null; + final json = decoded.map((key, entry) => MapEntry(key.toString(), entry)); + final serverId = json['server_id']?.toString().trim(); + final userId = json['user_id']?.toString().trim(); + final installationId = json['installation_id']?.toString().trim(); + final subscriptionId = json['subscription_id']?.toString().trim(); + final webhookUrl = json['webhook_url']?.toString().trim(); + final proxyBaseUrl = json['proxy_base_url']?.toString().trim(); + final platform = _parsePlatform(json['platform']); + final tokenType = _parseTokenType(json['token_type']); + final updatedAt = + DateTime.tryParse(json['updated_at']?.toString() ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0); + if (serverId == null || + serverId.isEmpty || + userId == null || + userId.isEmpty || + installationId == null || + installationId.isEmpty || + subscriptionId == null || + subscriptionId.isEmpty || + webhookUrl == null || + webhookUrl.isEmpty || + proxyBaseUrl == null || + proxyBaseUrl.isEmpty || + platform == null || + tokenType == null) { + return null; + } + return RemotePushSubscription( + serverId: serverId, + userId: userId, + installationId: installationId, + subscriptionId: subscriptionId, + webhookUrl: webhookUrl, + proxyBaseUrl: proxyBaseUrl, + platform: platform, + tokenType: tokenType, + updatedAt: updatedAt, + ); + } catch (_) { + return null; + } + } + + static RemotePushPlatform? _parsePlatform(Object? value) { + final wireName = value?.toString(); + for (final platform in RemotePushPlatform.values) { + if (platform.wireName == wireName) return platform; + } + return null; + } + + static RemotePushTokenType? _parseTokenType(Object? value) { + final wireName = value?.toString(); + for (final tokenType in RemotePushTokenType.values) { + if (tokenType.wireName == wireName) return tokenType; + } + return null; + } +} + +enum RemotePushSyncStatus { + registered, + disabledByPreference, + buildNotConfigured, + unsupportedPlatform, + serverUserWebhooksDisabled, + missingSession, + permissionDenied, + tokenUnavailable, + existingWebhookConflict, + failed, +} + +class RemotePushSyncResult { + const RemotePushSyncResult(this.status, {this.error}); + + final RemotePushSyncStatus status; + final Object? error; + + bool get isRegistered => status == RemotePushSyncStatus.registered; +} diff --git a/lib/features/notifications/services/remote_push_proxy_client.dart b/lib/features/notifications/services/remote_push_proxy_client.dart new file mode 100644 index 000000000..d0ae7fbfc --- /dev/null +++ b/lib/features/notifications/services/remote_push_proxy_client.dart @@ -0,0 +1,97 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'remote_push_models.dart'; + +class RemotePushProxyClient { + RemotePushProxyClient({required Uri baseUri, http.Client? httpClient}) + : _baseUri = baseUri, + _httpClient = httpClient ?? http.Client(); + + final Uri _baseUri; + final http.Client _httpClient; + + Future register({ + required String serverId, + required String serverUrl, + required String userId, + required String installationId, + required RemotePushDeviceToken deviceToken, + }) async { + final response = await _httpClient + .post( + _resolve('v1/installations'), + headers: const { + 'content-type': 'application/json', + 'accept': 'application/json', + }, + body: jsonEncode({ + 'protocol_version': 1, + 'app': 'conduit', + 'server_id': serverId, + 'server_url': serverUrl, + 'user_id': userId, + 'installation_id': installationId, + 'platform': deviceToken.platform.wireName, + 'token_type': deviceToken.tokenType.wireName, + 'push_token': deviceToken.value, + }), + ) + .timeout(const Duration(seconds: 20)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw RemotePushProxyException( + 'push proxy registration failed', + statusCode: response.statusCode, + ); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw const FormatException('push proxy registration response invalid'); + } + return RemotePushRegistration.fromJson( + decoded.map((key, value) => MapEntry(key.toString(), value)), + ); + } + + Future unregister(RemotePushSubscription subscription) async { + final response = await _httpClient + .delete( + _resolve( + 'v1/installations/${Uri.encodeComponent(subscription.subscriptionId)}', + ), + headers: const {'accept': 'application/json'}, + ) + .timeout(const Duration(seconds: 20)); + + if (response.statusCode == 404 || response.statusCode == 410) { + return; + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw RemotePushProxyException( + 'push proxy unregister failed', + statusCode: response.statusCode, + ); + } + } + + Uri _resolve(String path) { + final base = _baseUri.toString().endsWith('/') + ? _baseUri + : Uri.parse('${_baseUri.toString()}/'); + return base.resolve(path); + } +} + +class RemotePushProxyException implements Exception { + const RemotePushProxyException(this.message, {this.statusCode}); + + final String message; + final int? statusCode; + + @override + String toString() => statusCode == null + ? 'RemotePushProxyException($message)' + : 'RemotePushProxyException($message, statusCode: $statusCode)'; +} diff --git a/lib/features/notifications/services/remote_push_service.dart b/lib/features/notifications/services/remote_push_service.dart new file mode 100644 index 000000000..8d92f1240 --- /dev/null +++ b/lib/features/notifications/services/remote_push_service.dart @@ -0,0 +1,339 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/models/backend_config.dart'; +import '../../../core/models/user.dart'; +import '../../../core/providers/app_providers.dart' + show apiServiceProvider, backendConfigProvider, currentUserProvider; +import '../../../core/providers/storage_providers.dart' + show secureStorageProvider; +import '../../../core/services/api_service.dart'; +import '../../../core/services/settings_service.dart'; +import '../../../core/utils/debug_logger.dart'; +import 'local_notification_service.dart'; +import 'remote_push_build_config.dart'; +import 'remote_push_models.dart'; +import 'remote_push_proxy_client.dart'; +import 'remote_push_subscription_store.dart'; +import 'remote_push_token_provider.dart'; + +typedef RemotePushProxyClientFactory = + RemotePushProxyClient Function(Uri baseUri); + +class RemotePushService { + RemotePushService({ + required Ref ref, + required RemotePushBuildConfig config, + required RemotePushSubscriptionStore store, + required RemotePushTokenProvider tokenProvider, + RemotePushProxyClientFactory? proxyClientFactory, + }) : _ref = ref, + _config = config, + _store = store, + _tokenProvider = tokenProvider, + _proxyClientFactory = + proxyClientFactory ?? + ((baseUri) => RemotePushProxyClient(baseUri: baseUri)); + + final Ref _ref; + final RemotePushBuildConfig _config; + final RemotePushSubscriptionStore _store; + final RemotePushTokenProvider _tokenProvider; + final RemotePushProxyClientFactory _proxyClientFactory; + + final StreamController _taps = + StreamController.broadcast(); + + StreamSubscription? _tapSub; + StreamSubscription? _tokenRefreshSub; + Future? _starting; + bool _started = false; + + Stream get taps => _taps.stream; + + bool get isBuildConfigured => _config.isConfigured; + + Future start() { + if (_started) return Future.value(true); + if (!_config.isSupportedPlatform) return Future.value(false); + if (!_config.isConfigured) return Future.value(false); + return _starting ??= _doStart(); + } + + Future _doStart() async { + try { + final initialized = await _tokenProvider.initialize(); + if (!initialized) return false; + _tapSub ??= _tokenProvider.taps.listen((tap) { + if (!_taps.isClosed) { + _taps.add(tap); + } + }); + _tokenRefreshSub ??= _tokenProvider.tokenRefreshes.listen((_) { + unawaited(syncForCurrentSettings()); + }); + _started = true; + return true; + } finally { + _starting = null; + } + } + + Future launchTap() async { + if (!await start()) return null; + return _tokenProvider.launchTap(); + } + + Future syncForCurrentSettings({ + bool requestPermission = false, + }) async { + if (!_config.isSupportedPlatform) { + return const RemotePushSyncResult( + RemotePushSyncStatus.unsupportedPlatform, + ); + } + if (!_config.isConfigured) { + return const RemotePushSyncResult( + RemotePushSyncStatus.buildNotConfigured, + ); + } + + final settings = _ref.read(appSettingsProvider); + if (!settings.notificationsEnabled || !settings.notificationSystem) { + await disableForActiveServer(removeServerWebhook: true); + return const RemotePushSyncResult( + RemotePushSyncStatus.disabledByPreference, + ); + } + return enableForActiveServer(requestPermission: requestPermission); + } + + Future enableForActiveServer({ + bool requestPermission = false, + }) async { + if (!_config.isSupportedPlatform) { + return const RemotePushSyncResult( + RemotePushSyncStatus.unsupportedPlatform, + ); + } + final proxyBaseUri = _config.proxyBaseUri; + if (!_config.isConfigured || proxyBaseUri == null) { + return const RemotePushSyncResult( + RemotePushSyncStatus.buildNotConfigured, + ); + } + if (!await start()) { + return const RemotePushSyncResult( + RemotePushSyncStatus.buildNotConfigured, + ); + } + + if (requestPermission && !await _tokenProvider.requestPermission()) { + return const RemotePushSyncResult(RemotePushSyncStatus.permissionDenied); + } + + final api = _ref.read(apiServiceProvider); + final user = await _currentUser(); + if (api == null || user == null || user.id.isEmpty) { + return const RemotePushSyncResult(RemotePushSyncStatus.missingSession); + } + + final backendConfig = await _freshBackendConfig(); + if (backendConfig?.enableUserWebhooks != true) { + await disableForActiveServer(removeServerWebhook: true); + return const RemotePushSyncResult( + RemotePushSyncStatus.serverUserWebhooksDisabled, + ); + } + + final deviceToken = await _tokenProvider.getDeviceToken(); + if (deviceToken == null) { + return const RemotePushSyncResult(RemotePushSyncStatus.tokenUnavailable); + } + + final previous = await _store.read(api.serverConfig.id); + final client = _proxyClientFactory(proxyBaseUri); + + try { + final installationId = await _store.getOrCreateInstallationId(); + final registration = await client.register( + serverId: api.serverConfig.id, + serverUrl: api.serverConfig.url, + userId: user.id, + installationId: installationId, + deviceToken: deviceToken, + ); + final subscription = RemotePushSubscription( + serverId: api.serverConfig.id, + userId: user.id, + installationId: installationId, + subscriptionId: registration.subscriptionId, + webhookUrl: registration.webhookUrl, + proxyBaseUrl: proxyBaseUri.toString(), + platform: deviceToken.platform, + tokenType: deviceToken.tokenType, + updatedAt: DateTime.now(), + ); + + try { + await api.updateUserNotificationWebhook( + webhookUrl: subscription.webhookUrl, + expectedCurrentWebhookUrl: previous?.webhookUrl, + ); + } on UserNotificationWebhookConflictException catch (e) { + await _unregisterBestEffort(client, subscription); + return RemotePushSyncResult( + RemotePushSyncStatus.existingWebhookConflict, + error: e, + ); + } catch (e) { + await _unregisterBestEffort(client, subscription); + rethrow; + } + + await _store.write(subscription); + if (previous != null && + previous.subscriptionId != subscription.subscriptionId) { + await _unregisterSubscriptionBestEffort(previous); + } + return const RemotePushSyncResult(RemotePushSyncStatus.registered); + } catch (e, st) { + DebugLogger.error( + 'remote push sync failed', + error: e, + stackTrace: st, + scope: 'notifications/push', + ); + return RemotePushSyncResult(RemotePushSyncStatus.failed, error: e); + } + } + + Future disableForActiveServer({ + required bool removeServerWebhook, + }) async { + final api = _ref.read(apiServiceProvider); + if (api == null) return; + final subscription = await _store.read(api.serverConfig.id); + if (subscription == null) return; + + if (removeServerWebhook) { + try { + await api.updateUserNotificationWebhook( + webhookUrl: null, + expectedCurrentWebhookUrl: subscription.webhookUrl, + ); + } on UserNotificationWebhookConflictException { + // The user or another integration replaced the webhook; leave it alone. + } catch (e, st) { + DebugLogger.error( + 'failed to remove remote push webhook', + error: e, + stackTrace: st, + scope: 'notifications/push', + ); + } + } + + await _unregisterSubscriptionBestEffort(subscription); + await _store.delete(subscription.serverId); + } + + Future unregisterAllLocalSubscriptions() async { + final subscriptions = await _store.readAll(); + for (final subscription in subscriptions) { + await _unregisterSubscriptionBestEffort(subscription); + await _store.delete(subscription.serverId); + } + } + + Future _unregisterSubscriptionBestEffort( + RemotePushSubscription subscription, + ) async { + try { + await _unregisterBestEffort( + _clientForSubscription(subscription), + subscription, + ); + } catch (e, st) { + DebugLogger.error( + 'remote push unregister setup failed', + error: e, + stackTrace: st, + scope: 'notifications/push', + ); + } + } + + Future _unregisterBestEffort( + RemotePushProxyClient client, + RemotePushSubscription subscription, + ) async { + try { + await client.unregister(subscription); + } catch (e, st) { + DebugLogger.error( + 'remote push unregister failed', + error: e, + stackTrace: st, + scope: 'notifications/push', + ); + } + } + + RemotePushProxyClient _clientForSubscription( + RemotePushSubscription subscription, + ) { + final uri = Uri.tryParse(subscription.proxyBaseUrl) ?? _config.proxyBaseUri; + if (uri == null) { + throw StateError('remote push subscription has no proxy base URL'); + } + return _proxyClientFactory(uri); + } + + Future _freshBackendConfig() async { + try { + await _ref.read(backendConfigProvider.notifier).refresh(); + } catch (e, st) { + DebugLogger.error( + 'failed to refresh backend config for remote push', + error: e, + stackTrace: st, + scope: 'notifications/push', + ); + } + return _ref.read(backendConfigProvider).value; + } + + Future _currentUser() async { + final current = _ref.read(currentUserProvider).value; + if (current != null) return current; + try { + return await _ref + .read(currentUserProvider.future) + .timeout(const Duration(seconds: 3)); + } catch (_) { + return null; + } + } + + void dispose() { + _tapSub?.cancel(); + _tokenRefreshSub?.cancel(); + _tokenProvider.dispose(); + _taps.close(); + } +} + +final remotePushServiceProvider = Provider((ref) { + final service = RemotePushService( + ref: ref, + config: RemotePushBuildConfig.current, + store: RemotePushSubscriptionStore(ref.watch(secureStorageProvider)), + tokenProvider: FirebaseRemotePushTokenProvider( + config: RemotePushBuildConfig.current, + ), + ); + ref.onDispose(service.dispose); + return service; +}); diff --git a/lib/features/notifications/services/remote_push_subscription_store.dart b/lib/features/notifications/services/remote_push_subscription_store.dart new file mode 100644 index 000000000..3ce986482 --- /dev/null +++ b/lib/features/notifications/services/remote_push_subscription_store.dart @@ -0,0 +1,61 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../core/utils/debug_logger.dart'; +import 'remote_push_models.dart'; + +class RemotePushSubscriptionStore { + RemotePushSubscriptionStore(this._storage); + + final FlutterSecureStorage _storage; + + static const _installationKey = 'remote_push_installation_id_v1'; + static const _subscriptionPrefix = 'remote_push_subscription_v1_'; + + Future getOrCreateInstallationId() async { + final current = (await _storage.read(key: _installationKey))?.trim(); + if (current != null && current.isNotEmpty) { + return current; + } + final next = const Uuid().v4(); + await _storage.write(key: _installationKey, value: next); + return next; + } + + Future read(String serverId) async { + final value = await _storage.read(key: _key(serverId)); + return RemotePushSubscription.tryDecode(value); + } + + Future write(RemotePushSubscription subscription) async { + await _storage.write( + key: _key(subscription.serverId), + value: subscription.encode(), + ); + } + + Future delete(String serverId) async { + await _storage.delete(key: _key(serverId)); + } + + Future> readAll() async { + try { + final all = await _storage.readAll(); + return all.entries + .where((entry) => entry.key.startsWith(_subscriptionPrefix)) + .map((entry) => RemotePushSubscription.tryDecode(entry.value)) + .whereType() + .toList(growable: false); + } catch (e, st) { + DebugLogger.error( + 'failed to read remote push subscriptions', + error: e, + stackTrace: st, + scope: 'notifications/push', + ); + return const []; + } + } + + static String _key(String serverId) => '$_subscriptionPrefix$serverId'; +} diff --git a/lib/features/notifications/services/remote_push_token_provider.dart b/lib/features/notifications/services/remote_push_token_provider.dart new file mode 100644 index 000000000..0907dffba --- /dev/null +++ b/lib/features/notifications/services/remote_push_token_provider.dart @@ -0,0 +1,244 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; + +import '../../../core/utils/debug_logger.dart'; +import '../models/app_notification.dart'; +import 'local_notification_service.dart'; +import 'remote_push_build_config.dart'; +import 'remote_push_models.dart'; + +@pragma('vm:entry-point') +Future conduitFirebaseMessagingBackgroundHandler( + RemoteMessage message, +) async { + // Visible background notifications are displayed by the OS. Data-only + // background handling is intentionally a later inbox/sync concern. +} + +abstract class RemotePushTokenProvider { + Future initialize(); + + Future requestPermission(); + + Future getDeviceToken(); + + Stream get tokenRefreshes; + + Stream get taps; + + Future launchTap(); + + void dispose(); +} + +class FirebaseRemotePushTokenProvider implements RemotePushTokenProvider { + FirebaseRemotePushTokenProvider({required RemotePushBuildConfig config}) + : _config = config; + + final RemotePushBuildConfig _config; + final StreamController _tokenRefreshes = + StreamController.broadcast(); + final StreamController _taps = + StreamController.broadcast(); + + StreamSubscription? _tokenRefreshSub; + StreamSubscription? _tapSub; + bool _initialized = false; + Future? _initializing; + + @override + Stream get tokenRefreshes => _tokenRefreshes.stream; + + @override + Stream get taps => _taps.stream; + + @override + Future initialize() { + if (_initialized) return Future.value(true); + if (!_config.isConfigured) return Future.value(false); + return _initializing ??= _doInitialize(); + } + + Future _doInitialize() async { + try { + final options = _config.firebaseOptionsForCurrentPlatform; + if (options == null) return false; + if (Firebase.apps.isEmpty) { + await Firebase.initializeApp(options: options); + } + FirebaseMessaging.onBackgroundMessage( + conduitFirebaseMessagingBackgroundHandler, + ); + + final messaging = FirebaseMessaging.instance; + _tokenRefreshSub ??= messaging.onTokenRefresh.listen((_) { + unawaited(_publishCurrentToken()); + }); + _tapSub ??= FirebaseMessaging.onMessageOpenedApp.listen((message) { + final tap = notificationTapFromRemoteMessageDataForTest(message.data); + if (tap != null && !_taps.isClosed) { + _taps.add(tap); + } + }); + + _initialized = true; + return true; + } catch (e, st) { + DebugLogger.error( + 'failed to initialize remote push', + error: e, + stackTrace: st, + scope: 'notifications/push', + ); + return false; + } finally { + _initializing = null; + } + } + + @override + Future requestPermission() async { + if (!await initialize()) return false; + try { + final settings = await FirebaseMessaging.instance.requestPermission( + alert: true, + badge: true, + sound: true, + ); + return settings.authorizationStatus == AuthorizationStatus.authorized || + settings.authorizationStatus == AuthorizationStatus.provisional; + } catch (e, st) { + DebugLogger.error( + 'remote push permission request failed', + error: e, + stackTrace: st, + scope: 'notifications/push', + ); + return false; + } + } + + @override + Future getDeviceToken() async { + if (!await initialize()) return null; + try { + if (Platform.isAndroid) { + final token = (await FirebaseMessaging.instance.getToken())?.trim(); + if (token == null || token.isEmpty) return null; + return RemotePushDeviceToken( + platform: RemotePushPlatform.android, + tokenType: RemotePushTokenType.fcm, + value: token, + ); + } + if (Platform.isIOS) { + for (var attempt = 0; attempt < 6; attempt++) { + final token = (await FirebaseMessaging.instance.getAPNSToken()) + ?.trim(); + if (token != null && token.isNotEmpty) { + return RemotePushDeviceToken( + platform: RemotePushPlatform.ios, + tokenType: RemotePushTokenType.apns, + value: token, + ); + } + await Future.delayed(const Duration(milliseconds: 350)); + } + } + return null; + } catch (e, st) { + DebugLogger.error( + 'failed to read remote push token', + error: e, + stackTrace: st, + scope: 'notifications/push', + ); + return null; + } + } + + @override + Future launchTap() async { + if (!await initialize()) return null; + try { + final message = await FirebaseMessaging.instance.getInitialMessage(); + if (message == null) return null; + return notificationTapFromRemoteMessageDataForTest(message.data); + } catch (e, st) { + DebugLogger.error( + 'failed to read remote push launch tap', + error: e, + stackTrace: st, + scope: 'notifications/push', + ); + return null; + } + } + + Future _publishCurrentToken() async { + final token = await getDeviceToken(); + if (token != null && !_tokenRefreshes.isClosed) { + _tokenRefreshes.add(token); + } + } + + @override + void dispose() { + _tokenRefreshSub?.cancel(); + _tapSub?.cancel(); + _tokenRefreshes.close(); + _taps.close(); + } +} + +@visibleForTesting +NotificationTap? notificationTapFromRemoteMessageDataForTest( + Map data, +) { + final kind = _parseNotificationKind( + data['conduit_kind'] ?? + data['kind'] ?? + data['notification_kind'] ?? + data['type'], + ); + if (kind == null) return null; + final sourceId = _firstNonEmptyString([ + data['conduit_source_id'], + data['source_id'], + data['sourceId'], + data['chat_id'], + data['chatId'], + data['channel_id'], + data['channelId'], + ]); + if (sourceId == null) return null; + return NotificationTap(kind: kind, sourceId: sourceId); +} + +NotificationKind? _parseNotificationKind(Object? value) { + final normalized = value?.toString().trim(); + return switch (normalized) { + 'chat_completion' || + 'chatCompletion' || + 'chat:completion' => NotificationKind.chatCompletion, + 'channel_message' || + 'channelMessage' || + 'channel:message' || + 'channel_message_created' => NotificationKind.channelMessage, + _ => null, + }; +} + +String? _firstNonEmptyString(List values) { + for (final value in values) { + if (value is String) { + final trimmed = value.trim(); + if (trimmed.isNotEmpty) return trimmed; + } + } + return null; +} diff --git a/lib/features/notifications/views/notification_settings_page.dart b/lib/features/notifications/views/notification_settings_page.dart index 46b9a92be..2e7bf9a3b 100644 --- a/lib/features/notifications/views/notification_settings_page.dart +++ b/lib/features/notifications/views/notification_settings_page.dart @@ -12,6 +12,9 @@ import '../../../shared/theme/theme_extensions.dart'; import '../../profile/widgets/customization_tile.dart'; import '../../profile/widgets/settings_page_scaffold.dart'; import '../services/local_notification_service.dart'; +import '../services/remote_push_build_config.dart'; +import '../services/remote_push_models.dart'; +import '../services/remote_push_service.dart'; /// Notification preferences. The master toggle requests OS permission on /// opt-in. The three Open WebUI-aligned prefs (master / sound / sound-always) @@ -26,6 +29,10 @@ class NotificationSettingsPage extends ConsumerWidget { final settings = ref.watch(appSettingsProvider); final notifier = ref.read(appSettingsProvider.notifier); final enabled = settings.notificationsEnabled; + final backendConfig = ref.watch(backendConfigProvider).value; + final currentUser = ref.watch(currentUserProvider).value; + final isAdmin = currentUser?.role == 'admin'; + final serverUserWebhooksEnabled = backendConfig?.enableUserWebhooks == true; Widget tile({ required IconData icon, @@ -79,8 +86,23 @@ class NotificationSettingsPage extends ConsumerWidget { title: l10n.notificationSystemTitle, subtitle: l10n.notificationSystemDescription, value: settings.notificationSystem, - onChanged: notifier.setNotificationSystem, + onChanged: (value) => _setSystem(ref, value), ), + if (isAdmin) ...[ + const SizedBox(height: Spacing.sm), + tile( + icon: CupertinoIcons.cloud_upload_fill, + title: l10n.notificationServerAutoConfigureTitle, + subtitle: serverUserWebhooksEnabled + ? l10n.notificationServerAutoConfigureEnabledDescription + : RemotePushBuildConfig.current.isConfigured + ? l10n.notificationServerAutoConfigureDescription + : l10n.notificationRemotePushBuildMissing, + value: serverUserWebhooksEnabled, + dependantOnMaster: false, + onChanged: (value) => _setServerAutoConfigure(context, ref, value), + ), + ], const SizedBox(height: Spacing.sm), tile( icon: CupertinoIcons.speaker_2_fill, @@ -138,6 +160,33 @@ class NotificationSettingsPage extends ConsumerWidget { type: AdaptiveSnackBarType.warning, ); } + if (granted) { + final result = await ref + .read(remotePushServiceProvider) + .syncForCurrentSettings(requestPermission: true); + if (context.mounted) { + _showRemotePushResult(context, result); + } + } + } else { + unawaited( + ref + .read(remotePushServiceProvider) + .disableForActiveServer(removeServerWebhook: true), + ); + } + } + + Future _setSystem(WidgetRef ref, bool value) async { + await ref.read(appSettingsProvider.notifier).setNotificationSystem(value); + if (value) { + unawaited(ref.read(remotePushServiceProvider).syncForCurrentSettings()); + } else { + unawaited( + ref + .read(remotePushServiceProvider) + .disableForActiveServer(removeServerWebhook: true), + ); } } @@ -184,4 +233,113 @@ class NotificationSettingsPage extends ConsumerWidget { ), ); } + + Future _setServerAutoConfigure( + BuildContext context, + WidgetRef ref, + bool value, + ) async { + final l10n = AppLocalizations.of(context)!; + final confirmed = await showCupertinoDialog( + context: context, + builder: (dialogContext) => CupertinoAlertDialog( + title: Text( + value + ? l10n.notificationServerAutoConfigureEnableDialogTitle + : l10n.notificationServerAutoConfigureDisableDialogTitle, + ), + content: Text( + value + ? l10n.notificationServerAutoConfigureEnableDialogMessage + : l10n.notificationServerAutoConfigureDisableDialogMessage, + ), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.cancel), + ), + CupertinoDialogAction( + isDestructiveAction: !value, + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text( + value + ? l10n.notificationServerAutoConfigureEnableAction + : l10n.notificationServerAutoConfigureDisableAction, + ), + ), + ], + ), + ); + if (confirmed != true) return; + + final api = ref.read(apiServiceProvider); + if (api == null) return; + + try { + await api.setAdminUserWebhooksEnabled(value); + await ref.read(backendConfigProvider.notifier).refresh(); + if (value) { + final result = await ref + .read(remotePushServiceProvider) + .syncForCurrentSettings(); + if (context.mounted) { + _showRemotePushResult(context, result); + } + } else { + await ref + .read(remotePushServiceProvider) + .disableForActiveServer(removeServerWebhook: true); + } + if (!context.mounted) return; + AdaptiveSnackBar.show( + context, + message: l10n.notificationServerAutoConfigureDone, + type: AdaptiveSnackBarType.success, + ); + } catch (e, st) { + DebugLogger.error( + 'failed to update server notification setup', + error: e, + stackTrace: st, + scope: 'notifications/settings', + ); + if (!context.mounted) return; + AdaptiveSnackBar.show( + context, + message: l10n.notificationServerAutoConfigureFailed, + type: AdaptiveSnackBarType.error, + ); + } + } + + void _showRemotePushResult( + BuildContext context, + RemotePushSyncResult result, + ) { + final l10n = AppLocalizations.of(context)!; + final message = switch (result.status) { + RemotePushSyncStatus.registered => l10n.notificationRemotePushConfigured, + RemotePushSyncStatus.serverUserWebhooksDisabled => + l10n.notificationRemotePushServerDisabled, + RemotePushSyncStatus.buildNotConfigured || + RemotePushSyncStatus.unsupportedPlatform => + l10n.notificationRemotePushBuildMissing, + RemotePushSyncStatus.existingWebhookConflict => + l10n.notificationRemotePushConflict, + RemotePushSyncStatus.permissionDenied => + l10n.notificationsPermissionDenied, + RemotePushSyncStatus.tokenUnavailable || + RemotePushSyncStatus.failed => l10n.notificationRemotePushFailed, + RemotePushSyncStatus.disabledByPreference || + RemotePushSyncStatus.missingSession => null, + }; + if (message == null) return; + AdaptiveSnackBar.show( + context, + message: message, + type: result.status == RemotePushSyncStatus.registered + ? AdaptiveSnackBarType.success + : AdaptiveSnackBarType.warning, + ); + } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f26d330ad..605a9a5c3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3582,6 +3582,70 @@ "@notificationViewAction": { "description": "Action label on an in-app notification banner that opens the target." }, + "notificationServerAutoConfigureTitle": "Auto-configure server", + "@notificationServerAutoConfigureTitle": { + "description": "Admin toggle title for enabling Open WebUI user webhooks." + }, + "notificationServerAutoConfigureDescription": "Enable Open WebUI user webhooks so Conduit can register push wakeups for users on this server.", + "@notificationServerAutoConfigureDescription": { + "description": "Admin toggle description before server-side user webhooks are enabled." + }, + "notificationServerAutoConfigureEnabledDescription": "Open WebUI user webhooks are enabled; users who enable notifications are registered automatically.", + "@notificationServerAutoConfigureEnabledDescription": { + "description": "Admin toggle description after server-side user webhooks are enabled." + }, + "notificationServerAutoConfigureEnableDialogTitle": "Enable server push wakeups?", + "@notificationServerAutoConfigureEnableDialogTitle": { + "description": "Title of the admin disclosure dialog for enabling server push setup." + }, + "notificationServerAutoConfigureEnableDialogMessage": "Conduit will enable Open WebUI user webhooks on this server. Each user's app can save a per-user Conduit webhook URL for the push proxy. Notification metadata is sent to the proxy so APNs or FCM can wake devices.", + "@notificationServerAutoConfigureEnableDialogMessage": { + "description": "Disclosure shown to admins before enabling Open WebUI user webhooks." + }, + "notificationServerAutoConfigureDisableDialogTitle": "Disable server push wakeups?", + "@notificationServerAutoConfigureDisableDialogTitle": { + "description": "Title of the admin disclosure dialog for disabling server push setup." + }, + "notificationServerAutoConfigureDisableDialogMessage": "Open WebUI will stop sending user notification webhooks. Existing user webhook settings remain stored, but Conduit push wakeups will not be delivered while this is off.", + "@notificationServerAutoConfigureDisableDialogMessage": { + "description": "Disclosure shown to admins before disabling Open WebUI user webhooks." + }, + "notificationServerAutoConfigureEnableAction": "Enable", + "@notificationServerAutoConfigureEnableAction": { + "description": "Confirmation action for enabling server push setup." + }, + "notificationServerAutoConfigureDisableAction": "Disable", + "@notificationServerAutoConfigureDisableAction": { + "description": "Confirmation action for disabling server push setup." + }, + "notificationServerAutoConfigureDone": "Server notification setup updated.", + "@notificationServerAutoConfigureDone": { + "description": "Snackbar shown after server notification setup changes successfully." + }, + "notificationServerAutoConfigureFailed": "Couldn't update server notification setup.", + "@notificationServerAutoConfigureFailed": { + "description": "Snackbar shown after server notification setup fails." + }, + "notificationRemotePushConfigured": "Remote notifications configured for this server.", + "@notificationRemotePushConfigured": { + "description": "Snackbar shown after Conduit successfully registers its push webhook." + }, + "notificationRemotePushServerDisabled": "An admin must enable Open WebUI user webhooks before remote notifications can work.", + "@notificationRemotePushServerDisabled": { + "description": "Snackbar shown when remote push setup cannot continue because server-side user webhooks are disabled." + }, + "notificationRemotePushBuildMissing": "This build is missing push proxy configuration.", + "@notificationRemotePushBuildMissing": { + "description": "Snackbar or subtitle shown when the app was built without push proxy/Firebase configuration." + }, + "notificationRemotePushConflict": "Existing webhook found. Remove it in Open WebUI notification settings before enabling Conduit remote notifications.", + "@notificationRemotePushConflict": { + "description": "Snackbar shown when Conduit refuses to overwrite an existing user webhook URL." + }, + "notificationRemotePushFailed": "Couldn't configure remote notifications.", + "@notificationRemotePushFailed": { + "description": "Snackbar shown when push proxy registration fails." + }, "notificationsPermissionDenied": "Notifications are turned off in system settings.", "@notificationsPermissionDenied": { "description": "Shown when the user enables notifications but the OS permission was denied." diff --git a/pubspec.lock b/pubspec.lock index d6246c0cb..3aabe576a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "99.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: "78f98c1f9c4dbbd22c2bb7b7f17c4a5c06150e8b2cb791a0947979ad0d3dabd5" + url: "https://pub.dev" + source: hosted + version: "1.3.73" adaptive_platform_ui: dependency: "direct main" description: @@ -457,6 +465,54 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.3+5" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: d2625088d8f8836a7a74a7eb94a5372d70ad88382602ba2dcc02805c294d0d16 + url: "https://pub.dev" + source: hosted + version: "4.11.0" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: "913e7c96ef83a80ad7e1c3f8a059167b3de23b5d5e07fa3ed8f11abe24de98b6" + url: "https://pub.dev" + source: hosted + version: "7.1.0" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "30ba3ae56f5beb2cea836033201570612c911661889f815eca73b6056c7b55bf" + url: "https://pub.dev" + source: hosted + version: "3.9.0" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: ce21a510e5a9aed67a0404476981e19ec0361a0301eeba547dc93dc2e7dec99a + url: "https://pub.dev" + source: hosted + version: "16.4.1" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: e10f6d521e7ed663d0ea2f4ec7de4c6729f8c2ce25d32faf6d6b4219da8515c2 + url: "https://pub.dev" + source: hosted + version: "4.9.0" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: "7ab45dfaf8efcd1a769baa9b8debbd0da281f5d3fc07274296b564396a980292" + url: "https://pub.dev" + source: hosted + version: "4.2.1" fixnum: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8449647c3..42a46ac32 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -100,6 +100,8 @@ dependencies: fleather: ^1.27.0 # Document model + markdown/HTML codecs backing the Fleather note editor. parchment: ^1.27.0 + firebase_core: ^4.11.0 + firebase_messaging: ^16.4.1 dev_dependencies: flutter_test: diff --git a/test/core/models/server_backed_settings_models_test.dart b/test/core/models/server_backed_settings_models_test.dart index f58822da6..ab3b3764f 100644 --- a/test/core/models/server_backed_settings_models_test.dart +++ b/test/core/models/server_backed_settings_models_test.dart @@ -2,6 +2,7 @@ import 'package:checks/checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:conduit/core/models/account_metadata.dart'; +import 'package:conduit/core/models/backend_config.dart'; import 'package:conduit/core/models/server_about_info.dart'; import 'package:conduit/core/models/server_user_settings.dart'; @@ -72,6 +73,35 @@ void main() { check(settings.notificationSound).isNull(); check(settings.notificationSoundAlways).isNull(); }); + + test('parses user notification webhook URL', () { + final settings = ServerUserSettings.fromJson({ + 'ui': { + 'notifications': {'webhook_url': ' https://push.example.test/hook '}, + }, + }); + + check( + settings.notificationWebhookUrl, + ).equals('https://push.example.test/hook'); + }); + }); + + group('BackendConfig.fromJson', () { + test('parses nested user webhooks feature flag', () { + final config = BackendConfig.fromJson({ + 'features': {'enable_user_webhooks': true}, + }); + + check(config.enableUserWebhooks).isTrue(); + check(config.toJson()['enable_user_webhooks']).equals(true); + }); + + test('parses top-level user webhooks feature flag', () { + final config = BackendConfig.fromJson({'enable_user_webhooks': true}); + + check(config.enableUserWebhooks).isTrue(); + }); }); group('AccountMetadata.fromJson', () { diff --git a/test/core/services/api_service_notification_webhook_test.dart b/test/core/services/api_service_notification_webhook_test.dart new file mode 100644 index 000000000..c4e5fc50d --- /dev/null +++ b/test/core/services/api_service_notification_webhook_test.dart @@ -0,0 +1,48 @@ +import 'package:checks/checks.dart'; +import 'package:conduit/core/services/api_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('mergeUserNotificationWebhookSettingsForTest', () { + test('writes webhook URL under ui.notifications', () { + final updated = mergeUserNotificationWebhookSettingsForTest({ + 'notificationEnabled': true, + 'ui': {'memory': true}, + }, webhookUrl: ' https://push.example.test/hook '); + + check(updated['notificationEnabled']).equals(true); + final ui = updated['ui'] as Map; + check(ui['memory']).equals(true); + final notifications = ui['notifications'] as Map; + check( + notifications['webhook_url'], + ).equals('https://push.example.test/hook'); + }); + + test('removes an expected existing webhook URL', () { + final updated = mergeUserNotificationWebhookSettingsForTest( + { + 'ui': { + 'notifications': {'webhook_url': 'https://push.example.test/old'}, + }, + }, + webhookUrl: null, + expectedCurrentWebhookUrl: 'https://push.example.test/old', + ); + + final ui = updated['ui'] as Map; + final notifications = ui['notifications'] as Map; + check(notifications.containsKey('webhook_url')).isFalse(); + }); + + test('refuses to overwrite a different existing webhook URL', () { + check( + () => mergeUserNotificationWebhookSettingsForTest({ + 'ui': { + 'notifications': {'webhook_url': 'https://other.example.test/hook'}, + }, + }, webhookUrl: 'https://push.example.test/hook'), + ).throws(); + }); + }); +} diff --git a/test/features/notifications/remote_push_token_provider_test.dart b/test/features/notifications/remote_push_token_provider_test.dart new file mode 100644 index 000000000..fb3718226 --- /dev/null +++ b/test/features/notifications/remote_push_token_provider_test.dart @@ -0,0 +1,44 @@ +import 'package:checks/checks.dart'; +import 'package:conduit/features/notifications/models/app_notification.dart'; +import 'package:conduit/features/notifications/services/remote_push_token_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('notificationTapFromRemoteMessageDataForTest', () { + test('parses chat completion payloads', () { + final tap = notificationTapFromRemoteMessageDataForTest({ + 'kind': 'chat_completion', + 'chat_id': 'chat-1', + }); + + check(tap).isNotNull(); + check(tap!.kind).equals(NotificationKind.chatCompletion); + check(tap.sourceId).equals('chat-1'); + }); + + test('parses channel message payloads', () { + final tap = notificationTapFromRemoteMessageDataForTest({ + 'conduit_kind': 'channel_message', + 'conduit_source_id': 'channel-1', + }); + + check(tap).isNotNull(); + check(tap!.kind).equals(NotificationKind.channelMessage); + check(tap.sourceId).equals('channel-1'); + }); + + test('ignores payloads without a supported kind or target', () { + check( + notificationTapFromRemoteMessageDataForTest({ + 'kind': 'unknown', + 'source_id': 'chat-1', + }), + ).isNull(); + check( + notificationTapFromRemoteMessageDataForTest({ + 'kind': 'chat_completion', + }), + ).isNull(); + }); + }); +}