Skip to content

feat(notifications): add push proxy registration for remote delivery#536

Open
cogwheel0 wants to merge 1 commit into
mainfrom
feat/push-proxy-notifications
Open

feat(notifications): add push proxy registration for remote delivery#536
cogwheel0 wants to merge 1 commit into
mainfrom
feat/push-proxy-notifications

Conversation

@cogwheel0

@cogwheel0 cogwheel0 commented Jun 30, 2026

Copy link
Copy Markdown
Owner

Summary

  • add Firebase-backed device token collection plus Conduit push-proxy registration storage and webhook sync
  • add admin notification setting to enable Open WebUI user webhooks with a disclosure dialog; non-admin users auto-register when server support and local notification preferences allow it
  • wire remote push taps into the existing notification router and add iOS APNs/background notification entitlement setup
  • document required dart defines and the push-proxy registration/webhook contract

Verification

  • dart run build_runner build --delete-conflicting-outputs
  • flutter gen-l10n
  • flutter analyze
  • flutter test test/core/models/server_backed_settings_models_test.dart test/core/services/api_service_notification_webhook_test.dart test/features/notifications/remote_push_token_provider_test.dart test/features/notifications/notification_router_test.dart test/core/providers/app_startup_providers_test.dart
  • flutter test test/core/database/fts_perf_test.dart --plain-name "BUDGET 2: append to 500-msg chat < 10ms with FTS triggers live"

Notes

  • Full default flutter test was attempted once and hit the existing timing-sensitive FTS performance budget under suite load; the named FTS budget test passes in isolation.

Note

Add remote push notification registration via a push proxy and Firebase

  • Introduces RemotePushService that conditionally registers/unregisters device tokens with a Conduit-owned push proxy based on build-time Dart defines, server feature flags, OS permissions, and user preferences.
  • Adds FirebaseRemotePushTokenProvider backed by FCM/APNs to obtain device tokens, surface tap events (including cold-start), and handle token refreshes.
  • Adds RemotePushProxyClient to register/unregister installations with the proxy, and RemotePushSubscriptionStore to persist subscription state in secure storage across restarts.
  • Extends ApiService with methods to read/write the user notification webhook URL (with conflict protection via UserNotificationWebhookConflictException) and to toggle ENABLE_USER_WEBHOOKS via the admin API.
  • Updates NotificationSettingsPage so enabling notifications triggers push registration with result feedback, and adds an admin-only toggle to configure per-user webhooks on the server.
  • On sign-out, _cleanupUserScopedProvidersAfterSignOut now unregisters all locally tracked push subscriptions.
  • Risk: FCM/Firebase dependencies (firebase_core, firebase_messaging) are now required at runtime; builds without the expected Dart defines will skip remote push silently.
📊 Macroscope summarized 0ac45b8. 16 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

Summary by CodeRabbit

  • New Features

    • Added optional remote push notifications with server-side webhook setup and device registration.
    • Added an admin toggle to auto-configure server webhooks for notifications.
    • Added iOS support for background remote notifications.
  • Bug Fixes

    • Preserves existing user webhook settings when updating notification delivery.
    • Cleans up stored push subscriptions on sign-out and when notifications are disabled.
  • Documentation

    • Added setup and configuration docs for remote push notifications and required build settings.

Greptile Summary

This PR adds remote push notification support through a Conduit push proxy. The main changes are:

  • Firebase-backed token collection for Android and iOS.
  • Push proxy registration, storage, and unregister flows.
  • Open WebUI user webhook read/write helpers.
  • Admin notification settings for enabling user webhooks.
  • Remote notification tap routing through the existing notification router.
  • iOS APNs entitlement and background notification setup.
  • Documentation and tests for the new push flow.

Confidence Score: 4/5

The remote push feature needs fixes before merge because configuration and lifecycle paths can expose registration data or leave notifications registered incorrectly.

The review focused on the changed notification registration, startup, and socket-listener paths, with findings tied to concrete control-flow and configuration behavior. Local runtime reproduction was limited by the absence of Flutter tooling in the environment.

lib/features/notifications/services/remote_push_build_config.dart; lib/core/providers/app_startup_providers.dart; lib/features/notifications/providers/notification_socket_listener.dart

Security Review

The push proxy configuration accepts plaintext http endpoints for registration, which can expose push tokens and related identifiers in transit.

T-Rex T-Rex Logs

What T-Rex did

  • I created a focused Flutter test for plaintext push proxy acceptance and attempted to run it, but Flutter is not available in the execution environment.
  • I attempted to run a focused Flutter harness that seeds two remote push subscriptions and unregisters them, but the environment lacks a Flutter binary.
  • I prepared a focused Riverpod repro test to verify server switch behavior and attempted to run it, but Flutter executable is missing.
  • I collected artifacts for the remote-push-registration repro, including the ad-hoc test source and an output capture log.
  • I captured before/after logs for the admin-user-webhooks repro showing the missing Flutter tool on PATH.
  • I ran the verification, but local artifact references were not uploaded.
  • I gathered notification-settings remote traces, including the Flutter attempt log and a test harness placeholder for reproducibility if Flutter is available.

View all artifacts

T-Rex Ran code and verified through T-Rex

Reviews (1): Last reviewed commit: "feat(notifications): register push proxy..." | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

Context used:

  • Context used - AGENTS.md (source)

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds optional remote push wakeup support via a Firebase-backed token provider, a push proxy HTTP client, and a RemotePushService that orchestrates device registration, webhook persistence, and tap routing. Extends BackendConfig and ServerUserSettings with new fields, adds admin/user API endpoints, integrates controls into NotificationSettingsPage, configures iOS entitlements, and documents the protocol.

Changes

Remote Push Notification Feature

Layer / File(s) Summary
iOS push entitlements and background mode
ios/Runner.xcodeproj/project.pbxproj, ios/Runner/Info.plist, ios/Runner/Runner.entitlements
Adds APS_ENVIRONMENT build settings for Debug/Profile/Release, remote-notification UIBackgroundMode, and the aps-environment entitlement.
Firebase dependencies, BackendConfig and ServerUserSettings extensions
pubspec.yaml, lib/core/models/backend_config.dart, lib/core/models/server_user_settings.dart, test/core/models/server_backed_settings_models_test.dart
Adds firebase_core and firebase_messaging; extends BackendConfig with enableUserWebhooks (constructor, field, copyWith, dual-path fromJson/toJson); extends ServerUserSettings with notificationWebhookUrl; adds model tests.
RemotePushBuildConfig, data models, and enums
lib/features/notifications/services/remote_push_build_config.dart, lib/features/notifications/services/remote_push_models.dart
RemotePushBuildConfig reads dart-define values and validates proxy URI and per-platform Firebase options. Adds platform/token-type enums, RemotePushDeviceToken, RemotePushRegistration, RemotePushSubscription (with encode/decode), and RemotePushSyncResult.
RemotePushProxyClient and RemotePushSubscriptionStore
lib/features/notifications/services/remote_push_proxy_client.dart, lib/features/notifications/services/remote_push_subscription_store.dart
RemotePushProxyClient POSTs/DELETEs v1/installations with timeout and 404/410 no-op handling. RemotePushSubscriptionStore persists installation ID (UUIDv4) and per-server subscriptions in FlutterSecureStorage.
RemotePushTokenProvider and FirebaseRemotePushTokenProvider
lib/features/notifications/services/remote_push_token_provider.dart, test/features/notifications/remote_push_token_provider_test.dart
Abstract token provider interface; Firebase implementation with conditional init, background message handler, iOS APNS retry loop, and broadcast streams for token refreshes and taps; test-visible message-to-tap conversion with tests.
ApiService webhook merge logic and admin/user endpoints
lib/core/services/api_service.dart, test/core/services/api_service_notification_webhook_test.dart
Adds UserNotificationWebhookConflictException, conflict-aware mergeUserNotificationWebhookSettings helper, getAdminConfig(), setAdminUserWebhooksEnabled(), and updateUserNotificationWebhook(); unit tests verify write, removal, and conflict throw.
RemotePushService orchestration and provider
lib/features/notifications/services/remote_push_service.dart
Central service wiring token provider, subscription store, and proxy client; implements start, launchTap, syncForCurrentSettings, enableForActiveServer (full registration with conflict handling), disableForActiveServer, unregisterAllLocalSubscriptions; wired as remotePushServiceProvider.
Sign-out push unregistration
lib/core/providers/app_startup_providers.dart
Extends sign-out cleanup to fire-and-forget unregisterAllLocalSubscriptions() via remotePushServiceProvider with error logging.
NotificationSocketListener remote tap integration
lib/features/notifications/providers/notification_socket_listener.dart
Starts/syncs RemotePushService during build(), subscribes to its tap stream via _remoteTapSub, and switches handleLaunchTap() to the remote push source.
NotificationSettingsPage admin/user remote push UI
lib/features/notifications/views/notification_settings_page.dart, lib/l10n/app_en.arb
Admin-only server auto-configure tile with confirmation dialog; _setMaster/_setSystem trigger remote push sync or disable; _showRemotePushResult maps sync statuses to localized snackbars; adds 64 English localization strings.
Push notifications documentation
docs/push-notifications.md
Documents build-time dart-define requirements, admin and user enablement flows, push proxy HTTP protocol, and supported conduit_kind tap values.

Sequence Diagram(s)

sequenceDiagram
  participant App as NotificationSettingsPage
  participant Service as RemotePushService
  participant API as ApiService
  participant Proxy as RemotePushProxyClient
  participant Store as RemotePushSubscriptionStore
  participant Firebase as FirebaseRemotePushTokenProvider

  rect rgba(100, 149, 237, 0.5)
    note over App,Firebase: Admin enables server auto-configure
    App->>API: setAdminUserWebhooksEnabled(true)
    App->>API: refreshBackendConfig()
  end

  rect rgba(144, 238, 144, 0.5)
    note over App,Firebase: User enables system notifications
    App->>Service: syncForCurrentSettings(requestPermission: true)
    Service->>Firebase: initialize() + requestPermission()
    Service->>Firebase: getDeviceToken()
    Firebase-->>Service: RemotePushDeviceToken
    Service->>Store: getOrCreateInstallationId()
    Service->>Store: read(serverId)
    Service->>Proxy: register(serverId, serverUrl, userId, token)
    Proxy-->>Service: RemotePushRegistration (subscriptionId, webhookUrl)
    Service->>API: updateUserNotificationWebhook(webhookUrl)
    Service->>Store: write(newSubscription)
    Service->>Proxy: unregister(previousSubscription)
    Service-->>App: RemotePushSyncResult(registered)
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • cogwheel0/conduit#526: Modifies notification_socket_listener.dart tap and launch-tap handling, directly overlapping with this PR's remote tap subscription and handleLaunchTap() source switch.

Poem

🐇 Hop hop! A push arrives from afar,
Firebase whispers, "Here's your token, ta-da!"
The proxy registers, webhook in place,
Admin toggles it on with elegant grace.
Now notifications wake the app from sleep—
No more missed messages, not even a peep! 🔔

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: adding push proxy registration for remote notification delivery.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch feat/push-proxy-notifications

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Plaintext Push Proxy Accepted

A build can set CONDUIT_PUSH_PROXY_BASE_URL to an http URL and still pass isConfigured. That sends device push tokens, server URLs, user IDs, and installation IDs to the proxy without TLS during registration.

Context Used: AGENTS.md (source)

Comment on lines +122 to +123
ref
.read(remotePushServiceProvider)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Sign-Out Removes Every Subscription

Signing out of one session now calls unregisterAllLocalSubscriptions(), which iterates every stored remote_push_subscription_v1_* entry. A user with multiple saved servers can sign out of one account and silently unregister remote notifications for the others until each server is opened and synced again.

Context Used: AGENTS.md (source)

Comment on lines +145 to +146
unawaited(remotePush.start());
unawaited(remotePush.syncForCurrentSettings());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Server Switch Skips Push Sync

Remote push sync runs only when this keepAlive listener is first built. If the active server changes without a notification-setting toggle or Firebase token refresh, the socket listener rebinds to the new socket but the new server never gets its webhook registered, leaving remote notifications stale or missing for that server.

Context Used: AGENTS.md (source)

);
}),
);
unawaited(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium providers/app_startup_providers.dart:121

unregisterAllLocalSubscriptions() is fire-and-forget via unawaited, so a new subscription can be written for the same serverId before the snapshot from the previous sign-out is fully processed. The still-running cleanup then deletes that new local record while the server-side push registration remains active. This leaves the app with a live remote-push registration and no local tracking, so subsequent sign-out or disable flows cannot unregister it and the device keeps receiving pushes. Consider awaiting the cleanup (or guarding it against concurrent writes) before new subscriptions are registered.

Also found in 1 other location(s)

lib/features/notifications/views/notification_settings_page.dart:183

When the system-notifications toggle is turned on, _setSystem starts remotePushService.syncForCurrentSettings() with unawaited(...) and discards its RemotePushSyncResult. Any registration failure (serverUserWebhooksDisabled, permissionDenied, existingWebhookConflict, tokenUnavailable, failed, etc.) is therefore silent: the switch stays enabled even though remote/background delivery was not configured. This leaves users believing notifications are active when the new push-registration step actually failed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @lib/core/providers/app_startup_providers.dart around line 121:

`unregisterAllLocalSubscriptions()` is fire-and-forget via `unawaited`, so a new subscription can be written for the same `serverId` before the snapshot from the previous sign-out is fully processed. The still-running cleanup then deletes that new local record while the server-side push registration remains active. This leaves the app with a live remote-push registration and no local tracking, so subsequent sign-out or disable flows cannot unregister it and the device keeps receiving pushes. Consider awaiting the cleanup (or guarding it against concurrent writes) before new subscriptions are registered.

Also found in 1 other location(s):
- lib/features/notifications/views/notification_settings_page.dart:183 -- When the system-notifications toggle is turned on, `_setSystem` starts `remotePushService.syncForCurrentSettings()` with `unawaited(...)` and discards its `RemotePushSyncResult`. Any registration failure (`serverUserWebhooksDisabled`, `permissionDenied`, `existingWebhookConflict`, `tokenUnavailable`, `failed`, etc.) is therefore silent: the switch stays enabled even though remote/background delivery was not configured. This leaves users believing notifications are active when the new push-registration step actually failed.

stackTrace: st,
scope: 'notifications/push',
);
return const <RemotePushSubscription>[];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium services/remote_push_subscription_store.dart:56

readAll() catches every _storage.readAll() failure and returns an empty list. When unregisterAllLocalSubscriptions() calls this during sign-out, a transient keychain/keystore error causes the caller to see zero subscriptions, skip all unregister calls, and skip deleting local records — leaving the previous account's webhooks active with no retry path. Consider rethrowing after logging so the error propagates to the caller.

-      return const <RemotePushSubscription>[];
+      rethrow;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @lib/features/notifications/services/remote_push_subscription_store.dart around line 56:

`readAll()` catches every `_storage.readAll()` failure and returns an empty list. When `unregisterAllLocalSubscriptions()` calls this during sign-out, a transient keychain/keystore error causes the caller to see zero subscriptions, skip all unregister calls, and skip deleting local records — leaving the previous account's webhooks active with no retry path. Consider rethrowing after logging so the error propagates to the caller.

final remotePush = ref.read(remotePushServiceProvider);
unawaited(remotePush.start());
unawaited(remotePush.syncForCurrentSettings());
_remoteTapSub = remotePush.taps.listen((tap) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium providers/notification_socket_listener.dart:147

_remoteTapSub routes remote push taps straight into _handleTap using only NotificationTap.kind and sourceId, with no check that the tap belongs to the current user/session. Remote push payloads carry no server or account identity, and logout only clears local notifications — FCM notifications already in the OS tray remain. When a user signs out or switches servers and taps a stale remote notification, this handler deep-links the old sourceId into the current session, reopening the wrong chat/channel or routing the deep link against the wrong backend.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @lib/features/notifications/providers/notification_socket_listener.dart around line 147:

`_remoteTapSub` routes remote push taps straight into `_handleTap` using only `NotificationTap.kind` and `sourceId`, with no check that the tap belongs to the current user/session. Remote push payloads carry no server or account identity, and logout only clears local notifications — FCM notifications already in the OS tray remain. When a user signs out or switches servers and taps a stale remote notification, this handler deep-links the old `sourceId` into the current session, reopening the wrong chat/channel or routing the deep link against the wrong backend.

return _requireResponseMap(response.data, 'admin config');
}

Future<Map<String, dynamic>> setAdminUserWebhooksEnabled(bool enabled) async {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium services/api_service.dart:706

setAdminUserWebhooksEnabled performs a read-modify-write on the full admin config: it GETs the entire document, changes one key, then POSTs the whole stale snapshot back. If any other admin session modifies a different setting between the GET and the POST, this call silently reverts that change. Since only ENABLE_USER_WEBHOOKS needs updating, consider posting only that field to avoid overwriting concurrent changes.

Also found in 1 other location(s)

lib/features/notifications/services/remote_push_subscription_store.dart:15

getOrCreateInstallationId() performs a non-atomic read/then-write on _installationKey. RemotePushService.syncForCurrentSettings() can be started concurrently from app startup, settings toggles, and token refresh callbacks, so two first-run calls can both read null, generate different UUIDs, and both register separate remote subscriptions. Only the last-written installation/subscription remains in secure storage, leaving the other proxy subscription orphaned and still receiving pushes with no way to unregister it later.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @lib/core/services/api_service.dart around line 706:

`setAdminUserWebhooksEnabled` performs a read-modify-write on the full admin config: it GETs the entire document, changes one key, then POSTs the whole stale snapshot back. If any other admin session modifies a different setting between the GET and the POST, this call silently reverts that change. Since only `ENABLE_USER_WEBHOOKS` needs updating, consider posting only that field to avoid overwriting concurrent changes.

Also found in 1 other location(s):
- lib/features/notifications/services/remote_push_subscription_store.dart:15 -- `getOrCreateInstallationId()` performs a non-atomic read/then-write on `_installationKey`. `RemotePushService.syncForCurrentSettings()` can be started concurrently from app startup, settings toggles, and token refresh callbacks, so two first-run calls can both read `null`, generate different UUIDs, and both register separate remote subscriptions. Only the last-written installation/subscription remains in secure storage, leaving the other proxy subscription orphaned and still receiving pushes with no way to unregister it later.

);
final nextWebhookUrl = _normalizeStringForWebhookMerge(webhookUrl);

if (currentWebhookUrl != null && currentWebhookUrl != expectedWebhookUrl) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High services/api_service.dart:118

_mergeUserNotificationWebhookSettings throws UserNotificationWebhookConflictException whenever an existing webhook_url is present and expectedCurrentWebhookUrl is null, even when the existing URL already matches webhookUrl. After an app reinstall or cleared local store, the first sync passes expectedCurrentWebhookUrl == null; if the server still has the previous webhook, line 118 rejects the idempotent update and the caller unregisters the new proxy subscription, leaving remote push broken. Consider treating expectedCurrentWebhookUrl == null as a match when currentWebhookUrl == nextWebhookUrl.

  if (currentWebhookUrl != null &&
-      currentWebhookUrl != expectedWebhookUrl) {
+      currentWebhookUrl != nextWebhookUrl &&
+      currentWebhookUrl != expectedWebhookUrl) {
    throw UserNotificationWebhookConflictException(
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @lib/core/services/api_service.dart around line 118:

`_mergeUserNotificationWebhookSettings` throws `UserNotificationWebhookConflictException` whenever an existing `webhook_url` is present and `expectedCurrentWebhookUrl` is `null`, even when the existing URL already matches `webhookUrl`. After an app reinstall or cleared local store, the first sync passes `expectedCurrentWebhookUrl == null`; if the server still has the previous webhook, line 118 rejects the idempotent update and the caller unregisters the new proxy subscription, leaving remote push broken. Consider treating `expectedCurrentWebhookUrl == null` as a match when `currentWebhookUrl == nextWebhookUrl`.

if (nestedLdap is bool) enableLdap = nestedLdap;
final nestedLoginForm = features['enable_login_form'];
if (nestedLoginForm is bool) enableLoginForm = nestedLoginForm;
final nestedUserWebhooks = features['enable_user_webhooks'];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium models/backend_config.dart:392

In BackendConfig.fromJson, the nested fallback for features['enable_user_webhooks'] overwrites the already-parsed top-level json['enable_user_webhooks'] value unconditionally, unlike the other auth fields which only overwrite when the top-level value wasn't set. If a server sends both enable_user_webhooks and features.enable_user_webhooks with different values during a rollout, the app ignores the canonical top-level flag and uses the legacy nested value. Add a guard so the nested value is only used when the top-level one was absent.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @lib/core/models/backend_config.dart around line 392:

In `BackendConfig.fromJson`, the nested fallback for `features['enable_user_webhooks']` overwrites the already-parsed top-level `json['enable_user_webhooks']` value unconditionally, unlike the other auth fields which only overwrite when the top-level value wasn't set. If a server sends both `enable_user_webhooks` and `features.enable_user_webhooks` with different values during a rollout, the app ignores the canonical top-level flag and uses the legacy nested value. Add a guard so the nested value is only used when the top-level one was absent.


import 'remote_push_models.dart';

class RemotePushProxyClient {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High services/remote_push_proxy_client.dart:7

register() transmits the APNs/FCM push token, user_id, and server metadata to _baseUri without enforcing https. When the build config supplies an http URL (e.g. a misconfigured CONDUIT_PUSH_PROXY_BASE_URL), this code sends those secrets in cleartext over the network. Consider rejecting non-https base URIs in the constructor or register() so a misconfiguration fails fast instead of leaking tokens.

Also found in 1 other location(s)

lib/features/notifications/services/remote_push_build_config.dart:58

proxyBaseUri accepts both https and http URLs, so a build can be marked configured with an insecure CONDUIT_PUSH_PROXY_BASE_URL. RemotePushProxyClient will then send device push tokens plus server/user/installation identifiers over cleartext HTTP, allowing interception or tampering of remote-push registration traffic.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @lib/features/notifications/services/remote_push_proxy_client.dart around line 7:

`register()` transmits the APNs/FCM push token, `user_id`, and server metadata to `_baseUri` without enforcing `https`. When the build config supplies an `http` URL (e.g. a misconfigured `CONDUIT_PUSH_PROXY_BASE_URL`), this code sends those secrets in cleartext over the network. Consider rejecting non-`https` base URIs in the constructor or `register()` so a misconfiguration fails fast instead of leaking tokens.

Also found in 1 other location(s):
- lib/features/notifications/services/remote_push_build_config.dart:58 -- `proxyBaseUri` accepts both `https` and `http` URLs, so a build can be marked configured with an insecure `CONDUIT_PUSH_PROXY_BASE_URL`. `RemotePushProxyClient` will then send device push tokens plus server/user/installation identifiers over cleartext HTTP, allowing interception or tampering of remote-push registration traffic.

);
}
if (Platform.isIOS) {
for (var attempt = 0; attempt < 6; attempt++) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium services/remote_push_token_provider.dart:139

On iOS, getDeviceToken() polls FirebaseMessaging.instance.getAPNSToken() only six times with 350 ms delays (~2.1 s total) and returns null if no APNs token is available yet. Because onTokenRefresh only fires for FCM tokens, not APNs tokens, there is no later callback to retry once APNs registration completes, so a slow or offline APNs registration at launch leaves the device unregistered for push for the entire session. Consider increasing the retry window or retrying later (e.g., on app focus / connectivity restore) so registration eventually succeeds.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @lib/features/notifications/services/remote_push_token_provider.dart around line 139:

On iOS, `getDeviceToken()` polls `FirebaseMessaging.instance.getAPNSToken()` only six times with 350 ms delays (~2.1 s total) and returns `null` if no APNs token is available yet. Because `onTokenRefresh` only fires for FCM tokens, not APNs tokens, there is no later callback to retry once APNs registration completes, so a slow or offline APNs registration at launch leaves the device unregistered for push for the entire session. Consider increasing the retry window or retrying later (e.g., on app focus / connectivity restore) so registration eventually succeeds.

return _proxyClientFactory(uri);
}

Future<BackendConfig?> _freshBackendConfig() async {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High services/remote_push_service.dart:294

enableForActiveServer() calls disableForActiveServer(removeServerWebhook: true) whenever _freshBackendConfig() returns null or stale data after a refresh failure, because line 143 checks backendConfig?.enableUserWebhooks != true. A transient network error refreshing backendConfigProvider tears down an existing push subscription even though the server's webhook capability was never actually confirmed to be disabled. Consider distinguishing a refresh failure from a confirmed enableUserWebhooks: false so transient errors don't destroy an active subscription.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @lib/features/notifications/services/remote_push_service.dart around line 294:

`enableForActiveServer()` calls `disableForActiveServer(removeServerWebhook: true)` whenever `_freshBackendConfig()` returns `null` or stale data after a refresh failure, because line 143 checks `backendConfig?.enableUserWebhooks != true`. A transient network error refreshing `backendConfigProvider` tears down an existing push subscription even though the server's webhook capability was never actually confirmed to be disabled. Consider distinguishing a refresh failure from a confirmed `enableUserWebhooks: false` so transient errors don't destroy an active subscription.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/core/models/backend_config.dart`:
- Around line 392-395: The nested `features['enable_user_webhooks']` fallback in
`BackendConfig` is overwriting the canonical top-level `enable_user_webhooks`
value when both are present. Update the parsing logic to track whether the
top-level flag was already provided before applying the nested fallback, and
only read the nested value when the top-level key was absent. Keep the fix
localized to the `BackendConfig` mapping/parsing code that sets
`enableUserWebhooks`.

In `@lib/core/services/api_service.dart`:
- Around line 118-123: The conflict guard in api_service.dart’s webhook update
flow is too eager and throws even when no expected current URL was provided.
Update the check around the UserNotificationWebhookConflictException in
updateUserNotificationWebhook() / the webhook comparison logic so it only
compares currentWebhookUrl against expectedWebhookUrl when expectedWebhookUrl is
actually supplied, and otherwise allows the update to proceed idempotently.

In `@lib/features/notifications/services/remote_push_build_config.dart`:
- Around line 53-59: The proxyBaseUri validation in RemotePushBuildConfig
currently allows plain HTTP, which should be rejected for the push registration
flow. Update the proxyBaseUri getter to only accept HTTPS by default, or
explicitly allow HTTP only for localhost/debug-only cases, while keeping the
existing Uri parsing and host checks intact. Focus on the proxyBaseUrl-to-Uri
validation logic in proxyBaseUri so misconfigured non-secure endpoints are not
accepted.

In `@lib/features/notifications/services/remote_push_proxy_client.dart`:
- Around line 8-13: `RemotePushProxyClient` currently creates its own default
`http.Client` but never closes it, so the register/unregister flows in
`RemotePushService` can leak resources. Update `RemotePushProxyClient` to either
expose a `close()`/`dispose()` method that shuts down the internally created
client when no external client was injected, or refactor `RemotePushService` to
own and reuse a shared `http.Client` across `register` and `unregister`. Use the
`RemotePushProxyClient` constructor and the service methods that instantiate it
to wire up the lifecycle cleanup.

In `@lib/features/notifications/services/remote_push_service.dart`:
- Around line 73-75: Serialize all remote-push mutation paths in
RemotePushService so token refreshes, syncForCurrentSettings(), and
disableForActiveServer() cannot overlap for the same server. Add a single
in-flight guard or per-server mutex around the shared mutation flow before
touching the proxy, store, or webhook, and ensure the listener in
tokenRefreshes.listen waits on that same serialized path instead of firing
syncForCurrentSettings() unawaited. Update the mutation entry points in
RemotePushService to share the same lock so concurrent enables/disables cannot
race on previous subscription state or proxy registration.
- Around line 159-208: The sync flow in remote_push_service.dart updates the
server webhook before the new RemotePushSubscription is durably stored, so a
later _store.write(subscription) failure leaves remote state active with no
local record. Fix _sync or the surrounding registration sequence by making
persistence happen before calling api.updateUserNotificationWebhook(), or add
compensation on write failure that clears the webhook and unregisters the newly
created subscription via
_unregisterBestEffort/_unregisterSubscriptionBestEffort. Keep the existing error
handling around DebugLogger.error and RemotePushSyncResult, but ensure the
server-side webhook is only changed once local storage is safe.
- Around line 220-247: `disableForActiveServer()` and
`unregisterAllLocalSubscriptions()` are deleting the stored subscription even
when cleanup fails, which makes retries impossible. Update the cleanup flow
around `_unregisterSubscriptionBestEffort()` and
`api.updateUserNotificationWebhook()` so the local record is only removed after
remote cleanup succeeds, or mark the subscription as pending cleanup for later
retry. Keep the durable `subscriptionId`/`proxyBaseUrl` in `_store` on failure
so failed cleanup can be retried instead of being lost.

In `@lib/features/notifications/services/remote_push_token_provider.dart`:
- Around line 209-217: The source ID selection in remote_push_token_provider’s
token parsing currently uses one shared fallback list, which can pick the wrong
identifier for a notification kind like channel_message when both chat_id and
channel_id are present. Update the source lookup logic around the sourceId
resolution to keep the generic conduit_source_id and source_id fallbacks first,
then choose kind-specific fields based on the parsed notification kind (for
example, prefer channel_id for channel messages and chat_id for chat messages).
Use the existing parsing path in remote_push_token_provider to branch by
notification kind before calling _firstNonEmptyString.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8c3e9f81-fe0f-44ea-bec9-05808548c6c8

📥 Commits

Reviewing files that changed from the base of the PR and between 6acb512 and 0ac45b8.

⛔ Files ignored due to path filters (1)
  • pubspec.lock is excluded by !**/*.lock
📒 Files selected for processing (21)
  • docs/push-notifications.md
  • ios/Runner.xcodeproj/project.pbxproj
  • ios/Runner/Info.plist
  • ios/Runner/Runner.entitlements
  • lib/core/models/backend_config.dart
  • lib/core/models/server_user_settings.dart
  • lib/core/providers/app_startup_providers.dart
  • lib/core/services/api_service.dart
  • lib/features/notifications/providers/notification_socket_listener.dart
  • lib/features/notifications/services/remote_push_build_config.dart
  • lib/features/notifications/services/remote_push_models.dart
  • lib/features/notifications/services/remote_push_proxy_client.dart
  • lib/features/notifications/services/remote_push_service.dart
  • lib/features/notifications/services/remote_push_subscription_store.dart
  • lib/features/notifications/services/remote_push_token_provider.dart
  • lib/features/notifications/views/notification_settings_page.dart
  • lib/l10n/app_en.arb
  • pubspec.yaml
  • test/core/models/server_backed_settings_models_test.dart
  • test/core/services/api_service_notification_webhook_test.dart
  • test/features/notifications/remote_push_token_provider_test.dart

Comment on lines +392 to +395
final nestedUserWebhooks = features['enable_user_webhooks'];
if (nestedUserWebhooks is bool) {
enableUserWebhooks = nestedUserWebhooks;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve top-level enable_user_webhooks precedence.

This fallback block overwrites the canonical top-level value whenever both keys are present. A payload like {'enable_user_webhooks': true, 'features': {'enable_user_webhooks': false}} now parses as false, so the UI can hide webhook support even when the server explicitly enabled it. Track whether the top-level key was seen before applying the nested fallback.

Suggested fix
-    bool enableUserWebhooks = false;
+    bool enableUserWebhooks = false;
+    var hasTopLevelEnableUserWebhooks = false;
...
     final userWebhooksValue = json['enable_user_webhooks'];
-    if (userWebhooksValue is bool) enableUserWebhooks = userWebhooksValue;
+    if (userWebhooksValue is bool) {
+      enableUserWebhooks = userWebhooksValue;
+      hasTopLevelEnableUserWebhooks = true;
+    }
...
       final nestedUserWebhooks = features['enable_user_webhooks'];
-      if (nestedUserWebhooks is bool) {
+      if (nestedUserWebhooks is bool && !hasTopLevelEnableUserWebhooks) {
         enableUserWebhooks = nestedUserWebhooks;
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/core/models/backend_config.dart` around lines 392 - 395, The nested
`features['enable_user_webhooks']` fallback in `BackendConfig` is overwriting
the canonical top-level `enable_user_webhooks` value when both are present.
Update the parsing logic to track whether the top-level flag was already
provided before applying the nested fallback, and only read the nested value
when the top-level key was absent. Keep the fix localized to the `BackendConfig`
mapping/parsing code that sets `enableUserWebhooks`.

Comment on lines +118 to +123
if (currentWebhookUrl != null && currentWebhookUrl != expectedWebhookUrl) {
throw UserNotificationWebhookConflictException(
currentWebhookUrl: currentWebhookUrl,
expectedWebhookUrl: expectedWebhookUrl,
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make the conflict check truly optional.

This guard throws on any existing webhook when expectedCurrentWebhookUrl is omitted, so updateUserNotificationWebhook() is no longer idempotent. Retrying with the same target URL—or recovering after local state loss—still raises UserNotificationWebhookConflictException instead of converging. Gate the conflict on a supplied expected value.

Suggested fix
-  if (currentWebhookUrl != null && currentWebhookUrl != expectedWebhookUrl) {
+  if (expectedWebhookUrl != null &&
+      currentWebhookUrl != null &&
+      currentWebhookUrl != expectedWebhookUrl) {
     throw UserNotificationWebhookConflictException(
       currentWebhookUrl: currentWebhookUrl,
       expectedWebhookUrl: expectedWebhookUrl,
     );
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (currentWebhookUrl != null && currentWebhookUrl != expectedWebhookUrl) {
throw UserNotificationWebhookConflictException(
currentWebhookUrl: currentWebhookUrl,
expectedWebhookUrl: expectedWebhookUrl,
);
}
if (expectedWebhookUrl != null &&
currentWebhookUrl != null &&
currentWebhookUrl != expectedWebhookUrl) {
throw UserNotificationWebhookConflictException(
currentWebhookUrl: currentWebhookUrl,
expectedWebhookUrl: expectedWebhookUrl,
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/core/services/api_service.dart` around lines 118 - 123, The conflict
guard in api_service.dart’s webhook update flow is too eager and throws even
when no expected current URL was provided. Update the check around the
UserNotificationWebhookConflictException in updateUserNotificationWebhook() /
the webhook comparison logic so it only compares currentWebhookUrl against
expectedWebhookUrl when expectedWebhookUrl is actually supplied, and otherwise
allows the update to proceed idempotently.

Comment on lines +53 to +59
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject plain-HTTP push proxy URLs.

proxyBaseUri currently accepts http, but the registration flow sends the device push token, user ID, installation ID, and webhook URL through this base URI. A misconfigured build would leak notification credentials in plaintext. Restrict this to https, or gate http to explicit localhost/debug-only cases.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/features/notifications/services/remote_push_build_config.dart` around
lines 53 - 59, The proxyBaseUri validation in RemotePushBuildConfig currently
allows plain HTTP, which should be rejected for the push registration flow.
Update the proxyBaseUri getter to only accept HTTPS by default, or explicitly
allow HTTP only for localhost/debug-only cases, while keeping the existing Uri
parsing and host checks intact. Focus on the proxyBaseUrl-to-Uri validation
logic in proxyBaseUri so misconfigured non-secure endpoints are not accepted.

Comment on lines +8 to +13
RemotePushProxyClient({required Uri baseUri, http.Client? httpClient})
: _baseUri = baseUri,
_httpClient = httpClient ?? http.Client();

final Uri _baseUri;
final http.Client _httpClient;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file and its nearby context.
git ls-files 'lib/features/notifications/services/remote_push_proxy_client.dart' 'lib/features/notifications/services/remote_push_service.dart'
wc -l lib/features/notifications/services/remote_push_proxy_client.dart lib/features/notifications/services/remote_push_service.dart
cat -n lib/features/notifications/services/remote_push_proxy_client.dart | sed -n '1,220p'
printf '\n--- remote_push_service ---\n'
cat -n lib/features/notifications/services/remote_push_service.dart | sed -n '1,260p'

# Search for any close/dispose/ownership handling around http.Client and RemotePushProxyClient.
rg -n "RemotePushProxyClient|http\.Client|close\(\)|dispose\(|Client\(" lib/features/notifications -S

Repository: cogwheel0/conduit

Length of output: 17071


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the cleanup paths and the helper methods that create proxy clients.
sed -n '250,340p' lib/features/notifications/services/remote_push_service.dart

# Look for any custom disposal/closing behavior around RemotePushProxyClient or http.Client across the repo.
rg -n "RemotePushProxyClient.*close|RemotePushProxyClient.*dispose|_httpClient\.close\(|http\.Client\(\)" lib -S

Repository: cogwheel0/conduit

Length of output: 2611


Close internally owned http.Client instances. RemotePushService creates fresh RemotePushProxyClient instances for register/unregister flows, but the default http.Client in RemotePushProxyClient is never disposed. Add a close()/dispose() path or share an injected client from the service.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/features/notifications/services/remote_push_proxy_client.dart` around
lines 8 - 13, `RemotePushProxyClient` currently creates its own default
`http.Client` but never closes it, so the register/unregister flows in
`RemotePushService` can leak resources. Update `RemotePushProxyClient` to either
expose a `close()`/`dispose()` method that shuts down the internally created
client when no external client was injected, or refactor `RemotePushService` to
own and reuse a shared `http.Client` across `register` and `unregister`. Use the
`RemotePushProxyClient` constructor and the service methods that instantiate it
to wire up the lifecycle cleanup.

Comment on lines +73 to +75
_tokenRefreshSub ??= _tokenProvider.tokenRefreshes.listen((_) {
unawaited(syncForCurrentSettings());
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Serialize remote-push mutations per server.

tokenRefreshes.listen fires syncForCurrentSettings() unawaited, and the settings UI also drives syncForCurrentSettings() / disableForActiveServer() independently. Because these paths share no lock, two enables can read the same previous subscription, create separate proxy registrations, race on webhook updates, and leave orphaned proxy state or delete the just-written local record. Gate all mutation paths behind a single in-flight operation, or a per-server mutex, before touching the proxy, store, or server webhook.

Also applies to: 88-109, 112-200, 212-247

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/features/notifications/services/remote_push_service.dart` around lines 73
- 75, Serialize all remote-push mutation paths in RemotePushService so token
refreshes, syncForCurrentSettings(), and disableForActiveServer() cannot overlap
for the same server. Add a single in-flight guard or per-server mutex around the
shared mutation flow before touching the proxy, store, or webhook, and ensure
the listener in tokenRefreshes.listen waits on that same serialized path instead
of firing syncForCurrentSettings() unawaited. Update the mutation entry points
in RemotePushService to share the same lock so concurrent enables/disables
cannot race on previous subscription state or proxy registration.

Comment on lines +159 to +208
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't update the server webhook before local persistence is durable.

If updateUserNotificationWebhook() succeeds but _store.write(subscription) throws, the catch at Lines 201-208 reports failed after the proxy subscription and server webhook are already live. At that point there is no local record left to disable or unregister that remote state later. Persist the new subscription before mutating the server webhook, or compensate on write failure by clearing the webhook and unregistering the new proxy subscription.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/features/notifications/services/remote_push_service.dart` around lines
159 - 208, The sync flow in remote_push_service.dart updates the server webhook
before the new RemotePushSubscription is durably stored, so a later
_store.write(subscription) failure leaves remote state active with no local
record. Fix _sync or the surrounding registration sequence by making persistence
happen before calling api.updateUserNotificationWebhook(), or add compensation
on write failure that clears the webhook and unregisters the newly created
subscription via _unregisterBestEffort/_unregisterSubscriptionBestEffort. Keep
the existing error handling around DebugLogger.error and RemotePushSyncResult,
but ensure the server-side webhook is only changed once local storage is safe.

Comment on lines +220 to +247
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<void> unregisterAllLocalSubscriptions() async {
final subscriptions = await _store.readAll();
for (final subscription in subscriptions) {
await _unregisterSubscriptionBestEffort(subscription);
await _store.delete(subscription.serverId);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep failed cleanup operations retryable.

disableForActiveServer() logs webhook-removal failures, and _unregisterSubscriptionBestEffort() swallows proxy errors, but both callers still delete the stored subscription afterward. That drops the only durable subscriptionId/proxyBaseUrl needed to retry cleanup, so disable/sign-out can leave orphaned proxy registrations or a server webhook still pointing at them. Only delete the local record after cleanup succeeds, or persist a pending-cleanup state for retry.

Also applies to: 250-266

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/features/notifications/services/remote_push_service.dart` around lines
220 - 247, `disableForActiveServer()` and `unregisterAllLocalSubscriptions()`
are deleting the stored subscription even when cleanup fails, which makes
retries impossible. Update the cleanup flow around
`_unregisterSubscriptionBestEffort()` and `api.updateUserNotificationWebhook()`
so the local record is only removed after remote cleanup succeeds, or mark the
subscription as pending cleanup for later retry. Keep the durable
`subscriptionId`/`proxyBaseUrl` in `_store` on failure so failed cleanup can be
retried instead of being lost.

Comment on lines +209 to +217
final sourceId = _firstNonEmptyString([
data['conduit_source_id'],
data['source_id'],
data['sourceId'],
data['chat_id'],
data['chatId'],
data['channel_id'],
data['channelId'],
]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Select source-id fallbacks based on the parsed notification kind.

Line 209 uses one shared fallback list, so a channel_message payload that also includes chat_id will route to chat_id before channel_id. Keep generic conduit_source_id/source_id fallbacks, then prefer kind-specific IDs.

Suggested fix and regression coverage
-  final sourceId = _firstNonEmptyString([
-    data['conduit_source_id'],
-    data['source_id'],
-    data['sourceId'],
-    data['chat_id'],
-    data['chatId'],
-    data['channel_id'],
-    data['channelId'],
-  ]);
+  final sourceId = switch (kind) {
+    NotificationKind.chatCompletion => _firstNonEmptyString([
+      data['conduit_source_id'],
+      data['source_id'],
+      data['sourceId'],
+      data['chat_id'],
+      data['chatId'],
+    ]),
+    NotificationKind.channelMessage => _firstNonEmptyString([
+      data['conduit_source_id'],
+      data['source_id'],
+      data['sourceId'],
+      data['channel_id'],
+      data['channelId'],
+    ]),
+    _ => null,
+  };
+    test('prefers channel source fields for channel payloads', () {
+      final tap = notificationTapFromRemoteMessageDataForTest({
+        'kind': 'channel_message',
+        'chat_id': 'chat-1',
+        'channel_id': 'channel-1',
+      });
+
+      check(tap).isNotNull();
+      check(tap!.kind).equals(NotificationKind.channelMessage);
+      check(tap.sourceId).equals('channel-1');
+    });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
final sourceId = _firstNonEmptyString([
data['conduit_source_id'],
data['source_id'],
data['sourceId'],
data['chat_id'],
data['chatId'],
data['channel_id'],
data['channelId'],
]);
final sourceId = switch (kind) {
NotificationKind.chatCompletion => _firstNonEmptyString([
data['conduit_source_id'],
data['source_id'],
data['sourceId'],
data['chat_id'],
data['chatId'],
]),
NotificationKind.channelMessage => _firstNonEmptyString([
data['conduit_source_id'],
data['source_id'],
data['sourceId'],
data['channel_id'],
data['channelId'],
]),
_ => null,
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/features/notifications/services/remote_push_token_provider.dart` around
lines 209 - 217, The source ID selection in remote_push_token_provider’s token
parsing currently uses one shared fallback list, which can pick the wrong
identifier for a notification kind like channel_message when both chat_id and
channel_id are present. Update the source lookup logic around the sourceId
resolution to keep the generic conduit_source_id and source_id fallbacks first,
then choose kind-specific fields based on the parsed notification kind (for
example, prefer channel_id for channel messages and chat_id for chat messages).
Use the existing parsing path in remote_push_token_provider to branch by
notification kind before calling _firstNonEmptyString.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant