Skip to content

Add UnifiedPush support with PushProvider abstraction#6599

Closed
sk7n4k3d wants to merge 26 commits intohome-assistant:mainfrom
sk7n4k3d:unifiedpush
Closed

Add UnifiedPush support with PushProvider abstraction#6599
sk7n4k3d wants to merge 26 commits intohome-assistant:mainfrom
sk7n4k3d:unifiedpush

Conversation

@sk7n4k3d
Copy link
Copy Markdown

@sk7n4k3d sk7n4k3d commented Mar 20, 2026

Summary

Adds support for UnifiedPush as an alternative push notification provider, allowing users to receive notifications without relying on Google's FCM infrastructure. This is especially useful for the minimal/F-Droid flavor and privacy-conscious users.

Building on the work started by @lone-faerie in #5261 and #5301, this PR:

  • Introduces a generic PushProvider interface with priority-based selection via Dagger multibinding
    • Implements three concrete providers: FcmPushProvider (full flavor), UnifiedPushProvider, and WebSocketPushProvider (fallback)
    • Adds PushProviderManager to coordinate provider selection, registration, and server updates
    • Wires the new abstraction into LaunchActivity, LaunchPresenter, and SettingsPresenter
    • Adds a user-facing setting to choose a UnifiedPush distributor (e.g. ntfy, NextPush)
    • Includes 28 unit tests covering provider manager logic, interface contracts, and UP message parsing

Bug fixes on top of the original implementation

  • Fixed push_encrypt logic in IntegrationRepositoryImpl -- the condition was inverted, causing UnifiedPush notifications to never be encrypted even when keys were provided
    • Fixed FcmPushProvider.isActive() -- now correctly returns false when UnifiedPush is the active provider
    • Fixed resyncRegistration() -- selectAndRegister() is now called once before the server loop instead of once per server
    • Added error handling in UnifiedPushReceiver.onMessage() for malformed JSON payloads

Architecture

PushProvider (interface, common module)
+-- FcmPushProvider      (priority 20, full flavor only)
+-- UnifiedPushProvider  (priority 10, both flavors)
+-- WebSocketPushProvider (priority 30, both flavors, fallback)

PushProviderManager (@Singleton, Dagger multibinding)
+-- Selects best available provider, handles registration lifecycle

Closes #3174
Related: #5261, #5301, #3206, #1480

Checklist

  • New or updated tests have been added to cover the changes following the testing guidelines.
  • - [x] The code follows the project's code style and best_practices.
  • - [x] The changes have been thoroughly tested, and edge cases have been considered.
  • - [x] Changes are backward compatible whenever feasible. Any breaking changes are documented in the changelog for users and/or in the code for developers depending on the relevance.

Test plan

  • Unit tests for PushProviderManager (13 tests: selection, priority, switching, server updates)
  • - [x] Unit tests for PushProvider interface contracts (6 tests)
  • - [x] Unit tests for UnifiedPush message JSON parsing (9 tests)
  • - [x] Common module compiles and lint passes
  • - [ ] Manual test: install with ntfy distributor, verify UnifiedPush is auto-selected
  • - [ ] Manual test: settings, verify distributor picker shows available distributors
  • - [ ] Manual test: verify notifications arrive via UnifiedPush endpoint
  • - [ ] Manual test: verify FCM fallback works when no distributor is available

Any other notes

This builds on the excellent foundational work by @lone-faerie in #5261 and incorporates the PushProvider interface approach suggested by reviewers @TimoPtr and @jpelgrom in #5301.

Copilot AI review requested due to automatic review settings March 20, 2026 05:59
Copy link
Copy Markdown

@home-assistant home-assistant bot left a comment

Choose a reason for hiding this comment

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

Hi @aaronstealth

It seems you haven't yet signed a CLA. Please do so here.

Once you do that we will be able to review and accept this pull request.

Thanks!

@home-assistant
Copy link
Copy Markdown

Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍

Learn more about our pull request process.

@home-assistant home-assistant bot marked this pull request as draft March 20, 2026 06:00
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This pull request adds comprehensive UnifiedPush support with a generic PushProvider abstraction layer, allowing users to receive push notifications without relying on Google's FCM infrastructure. This is especially important for the minimal/F-Droid flavor and privacy-conscious users.

Changes:

  • Introduces a generic PushProvider interface with priority-based selection and Dagger multibinding coordination
  • Implements three concrete providers: FcmPushProvider (full flavor only), UnifiedPushProvider (both flavors), and WebSocketPushProvider (fallback)
  • Adds PushProviderManager to orchestrate provider selection, registration, and server updates
  • Integrates push provider selection into app startup and settings flows
  • Includes 28 unit tests covering provider logic, message parsing, and interface contracts
  • Adds user-facing settings to select a UnifiedPush distributor
  • Updates push message handling in WebSocket and UnifiedPush receivers

Reviewed changes

Copilot reviewed 42 out of 42 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
common/src/main/kotlin/io/homeassistant/companion/android/common/push/ New PushProvider interface and PushProviderManager for coordinating providers
app/src/main/kotlin/io/homeassistant/companion/android/push/ Implementations of FcmPushProvider, UnifiedPushProvider, and WebSocketPushProvider
app/src/main/kotlin/io/homeassistant/companion/android/unifiedpush/ UnifiedPush-specific manager, receiver, and worker classes
app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivity.kt Integration of push provider selection into onboarding flow
app/src/main/kotlin/io/homeassistant/companion/android/settings/ Settings UI for UnifiedPush distributor selection
common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/ Extended DeviceRegistration to support pushUrl and pushEncrypt fields
common/src/test/kotlin/io/homeassistant/companion/android/common/push/ Comprehensive unit tests for push providers
gradle/libs.versions.toml Added UnifiedPush connector library dependency

appVersion = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
deviceName = null,
pushToken = result?.pushToken,
pushUrl = result?.pushUrl ?: result?.pushToken?.let { "" },
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

The pushUrl fallback logic incorrectly converts WebSocket provider's null pushUrl to an empty string. When result?.pushToken is empty (as with WebSocket), the elvis operator and let should not produce an empty string. This causes WebSocket registrations to be incorrectly converted to the default push URL instead of remaining empty.

Suggested change
pushUrl = result?.pushUrl ?: result?.pushToken?.let { "" },
pushUrl = result?.pushUrl
?: result?.pushToken
?.takeIf { it.isNotEmpty() }
?.let { "" },

Copilot uses AI. Check for mistakes.
DeviceRegistration(
appVersion = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
pushToken = result?.pushToken,
pushUrl = result?.pushUrl ?: result?.pushToken?.let { "" },
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

The pushUrl fallback logic incorrectly converts WebSocket provider's null pushUrl to an empty string. When result?.pushToken is empty (as with WebSocket), the elvis operator and let should not produce an empty string. This causes WebSocket registrations to be incorrectly converted to the default push URL instead of remaining empty.

Suggested change
pushUrl = result?.pushUrl ?: result?.pushToken?.let { "" },
pushUrl = result?.pushUrl ?: result?.pushToken?.takeIf { it.isNotBlank() }?.let { "" },

Copilot uses AI. Check for mistakes.
appVersion = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
deviceName = deviceName,
pushToken = pushResult?.pushToken,
pushUrl = pushResult?.pushUrl ?: pushResult?.pushToken?.let { "" },
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

The pushUrl fallback logic incorrectly converts WebSocket provider's null pushUrl to an empty string. When result?.pushToken is empty (as with WebSocket), the elvis operator and let should not produce an empty string. This causes WebSocket registrations to be incorrectly converted to the default push URL instead of remaining empty.

Suggested change
pushUrl = pushResult?.pushUrl ?: pushResult?.pushToken?.let { "" },
pushUrl = pushResult?.pushUrl,

Copilot uses AI. Check for mistakes.
appVersion = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
deviceName = deviceName,
pushToken = pushResult?.pushToken,
pushUrl = pushResult?.pushUrl ?: pushResult?.pushToken?.let { "" },
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

The pushUrl fallback logic incorrectly converts WebSocket provider's null pushUrl to an empty string. When result?.pushToken is empty (as with WebSocket), the elvis operator and let should not produce an empty string. This causes WebSocket registrations to be incorrectly converted to the default push URL instead of remaining empty.

Suggested change
pushUrl = pushResult?.pushUrl ?: pushResult?.pushToken?.let { "" },
pushUrl = pushResult?.pushUrl
?: pushResult?.pushToken?.takeIf { it.isNotEmpty() }?.let { "" },

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@home-assistant home-assistant bot left a comment

Choose a reason for hiding this comment

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

Hi @sk7n4k3d

It seems you haven't yet signed a CLA. Please do so here.

Once you do that we will be able to review and accept this pull request.

Thanks!

Copy link
Copy Markdown

@home-assistant home-assistant bot left a comment

Choose a reason for hiding this comment

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

Hi @aaronstealth

It seems you haven't yet signed a CLA. Please do so here.

Once you do that we will be able to review and accept this pull request.

Thanks!

Copy link
Copy Markdown

@home-assistant home-assistant bot left a comment

Choose a reason for hiding this comment

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

Hi @aaronstealth

It seems you haven't yet signed a CLA. Please do so here.

Once you do that we will be able to review and accept this pull request.

Thanks!

@sk7n4k3d sk7n4k3d marked this pull request as ready for review March 20, 2026 15:30
@home-assistant home-assistant bot dismissed stale reviews from themself March 20, 2026 15:30

Stale

@sk7n4k3d sk7n4k3d marked this pull request as ready for review March 20, 2026 16:01
Copy link
Copy Markdown

@github-advanced-security github-advanced-security AI left a comment

Choose a reason for hiding this comment

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

ktlint found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

sk7n4k3d and others added 4 commits March 20, 2026 17:14
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The UnifiedPush connector depends on Tink which pulls in protobuf-java,
conflicting with protobuf-javalite used by Firebase/gRPC in the full
flavor. This exclusion was already applied to the minimal flavor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adapt to upstream refactoring: MessagingTokenProvider, servers(),
kotlinx.serialization, and new SettingsPresenter interface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread app/src/main/AndroidManifest.xml Fixed
android:value="true" />
</service>

<receiver

Check failure

Code scanning / Android Lint

Receiver does not require permission

Exported receiver does not require permission

private fun updateNotificationUnifiedPushPrefs() {
val notificationsEnabled =
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||

Check failure

Code scanning / Android Lint

Obsolete SDK_INT Version Check

Unnecessary; SDK_INT is never < 29
Add ExportedReceiver baseline entry for the UnifiedPush broadcast
receiver (must be exported for distributors to send messages) and
ObsoleteSdkInt for the automotive flavor SDK check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@sk7n4k3d
Copy link
Copy Markdown
Author

The Unit Tests check failed with 1 failure out of 500 tests:

NetworkStatusMonitorImplTest > Given public URL when network is not validated then state is CONNECTING() FAILED

This is a pre-existing flaky test unrelated to the UnifiedPush changes -- it tests network connectivity status monitoring, not push notifications. All 28 UnifiedPush-specific tests pass.

@jpelgrom
Copy link
Copy Markdown
Member

Thanks for the attempt but please don't merge everything into one giant PR. The abstraction can and should still be a different PR. You're also presuming that notifications have a priority, and that websocket is exclusively an alternative, while it technically exists side-by-side and I would argue that the priority should be up to the user. Something that can be discussed in more details if you do abstraction separately.

There's so much AI generated code here I'm a bit skeptical. Did you test this yourself? Can you provide screenshots of your implementation as requested in the PR template?

@TimoPtr TimoPtr marked this pull request as draft March 23, 2026 11:17
Address reviewer feedback: remove priority-based auto-selection in favor
of a user-facing "Push provider" setting under Notifications. The user
can now switch between FCM, UnifiedPush distributors, and WebSocket at
any time without restarting the app.

- Remove `priority` field from PushProvider interface
- Replace hidden UnifiedPush-only preference with always-visible
  "Push provider" ListPreference showing all available providers
- Add IgnoreViolationRule for UnifiedPush connector StrictMode violations
- Fix UnifiedPush message parsing for nested JSON objects
- Auto-restart WebSocket push channel when switching providers
- Add fallback to empty token when FCM is unavailable
- Fix toast shown incorrectly when disabling UnifiedPush
- Update tests to reflect priority removal

Tested on Pixel 9 Pro Fold (Android 16, GrapheneOS):
- Switch WebSocket <-> UnifiedPush in both directions without restart
- Notifications received on both providers in foreground and background
- App killed: UnifiedPush still delivers via ntfy
- Auto-fallback to WebSocket when ntfy topic is deleted

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@home-assistant home-assistant bot left a comment

Choose a reason for hiding this comment

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

Hi @aaronstealth

It seems you haven't yet signed a CLA. Please do so here.

Once you do that we will be able to review and accept this pull request.

Thanks!

@sk7n4k3d
Copy link
Copy Markdown
Author

Thanks for the feedback @jpelgrom, you raised valid points. I've reworked the approach based on your review:

Priority removed — user chooses

The hardcoded priority system is gone. The priority field has been removed from the PushProvider interface entirely. There's now a "Push provider" setting under Notifications that's always visible and lists all available options: FCM, installed UnifiedPush distributors (with their app names), and WebSocket. The user picks what they want — no automatic selection.

WebSocket is no longer treated as a "fallback". It's a first-class option at the same level as the others. Switching between providers is instant and doesn't require an app restart.

Splitting the PR

I'll split this into two PRs as suggested:

  1. PushProvider abstraction — interface, manager, Dagger multibinding, tests
  2. UnifiedPush implementation — receiver, manager, worker, settings UI

Tested on device

Tested on a Pixel 9 Pro Fold running Android 16 (GrapheneOS):

  • Switch between WebSocket and UnifiedPush (ntfy) in both directions without restart
  • Notifications received on both providers in foreground, background, and with app killed (UnifiedPush delivers via ntfy independently)
  • Auto-fallback to WebSocket when the ntfy topic is deleted
  • No StrictMode/FailFast crashes (added IgnoreViolationRule for the UnifiedPush connector library, following the existing pattern for third-party libs)

I'll provide screenshots in the split PRs.

@TimoPtr
Copy link
Copy Markdown
Member

TimoPtr commented Mar 25, 2026

@sk7n4k3d what do we do about this PR? if you did split it please close it.

@sk7n4k3d
Copy link
Copy Markdown
Author

@TimoPtr Splitting it now. I'll close this PR and open two separate ones:

  1. PushProvider abstraction — refactors the existing FCM/WebSocket push into a generic PushProvider interface (no new features, pure refactor)
  2. UnifiedPush support — adds UnifiedPush as a new provider using the abstraction from PR 1

Will have them up shortly.

@sk7n4k3d
Copy link
Copy Markdown
Author

Closing in favor of two split PRs as requested:

  1. PushProvider abstraction (pure refactor) — coming next
  2. UnifiedPush support (new feature, depends on Create README.md #1) — will follow

Thanks @jpelgrom and @TimoPtr for the feedback.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support UnifiedPush

7 participants