Skip to content

Add UnifiedPush support for push notifications#6621

Draft
sk7n4k3d wants to merge 7 commits intohome-assistant:mainfrom
sk7n4k3d:unifiedpush-implementation
Draft

Add UnifiedPush support for push notifications#6621
sk7n4k3d wants to merge 7 commits intohome-assistant:mainfrom
sk7n4k3d:unifiedpush-implementation

Conversation

@sk7n4k3d
Copy link
Copy Markdown

@sk7n4k3d sk7n4k3d commented Mar 23, 2026

Summary

  • Implements UnifiedPush as an alternative push notification provider, allowing notifications via a distributor app (e.g. ntfy) without FCM
  • Adds UnifiedPushProvider, UnifiedPushManager, UnifiedPushReceiver, and UnifiedPushWorker
  • Extends the Push provider settings to list installed UnifiedPush distributors with their app names
  • Adds IgnoreViolationRule for UnifiedPush connector library StrictMode violations (following the existing pattern for third-party libs)
  • Handles protobuf-java/javalite conflict via exclusion
  • Adds UnifiedPush guard in FcmPushProvider.isActive() and FCM onNewToken()

Depends on #6619 (PushProvider abstraction). This PR adds the UnifiedPush-specific implementation on top.

Testing

Tested on Pixel 9 Pro Fold (Android 16, GrapheneOS) with ntfy as UnifiedPush distributor:

  • Switch between WebSocket and UnifiedPush in both directions without app restart
  • Notifications received on both providers in foreground, background, and with app killed
  • UnifiedPush delivers via ntfy independently when app is killed
  • Auto-fallback to WebSocket when ntfy topic is deleted
  • No StrictMode/FailFast crashes
  • 11 unit tests for UnifiedPush message parsing

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Copilot AI review requested due to automatic review settings March 23, 2026 21:39
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 home-assistant bot marked this pull request as draft March 23, 2026 21:40
@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.

@sk7n4k3d sk7n4k3d marked this pull request as ready for review March 23, 2026 21:41
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 implements UnifiedPush support as an alternative push notification provider for the Home Assistant Android app. UnifiedPush allows users to receive push notifications via a distributor app (like ntfy) without relying on Google's FCM infrastructure.

Changes:

  • Implements a generic PushProvider abstraction layer with PushProviderManager for managing multiple push notification providers
  • Adds UnifiedPushProvider, UnifiedPushManager, UnifiedPushReceiver, and UnifiedPushWorker for UnifiedPush integration
  • Extends DeviceRegistration with pushUrl and pushEncrypt fields to support multiple push provider types
  • Implements FcmPushProvider and WebSocketPushProvider to work with the new abstraction
  • Adds settings UI (ListPreference) for users to select their preferred push provider
  • Includes comprehensive unit tests (186 lines in UnifiedPushMessageParsingTest, 204 lines in PushProviderManagerTest)

Reviewed changes

Copilot reviewed 33 out of 36 changed files in this pull request and generated no comments.

Show a summary per file
File Description
gradle/libs.versions.toml Adds unifiedpush-connector 3.0.9 dependency; includes several unexplained version downgrades
common/src/main/kotlin/push/ New PushProvider interface and PushProviderManager for managing providers
common/src/main/kotlin/data/ Extends DeviceRegistration and related classes with push URL and encryption fields
common/src/test/kotlin/push/ Comprehensive unit tests for push provider functionality
app/src/main/kotlin/push/ Implements PushProvider variants (FCM, WebSocket, UnifiedPush)
app/src/main/kotlin/unifiedpush/ UnifiedPush-specific implementation (Manager, Worker, Receiver)
app/src/main/kotlin/settings/ Settings integration for push provider selection
app/src/main/AndroidManifest.xml Registers UnifiedPushReceiver with UnifiedPush intent filters
app/lint-baseline.xml Acknowledges ExportedReceiver lint warning for UnifiedPushReceiver
build-logic/convention/ Adds unifiedpush-connector with protobuf-java exclusion to handle conflicts

@sk7n4k3d sk7n4k3d force-pushed the unifiedpush-implementation branch from eefa371 to 4caaaea Compare March 23, 2026 22:47
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 home-assistant bot marked this pull request as draft March 23, 2026 22:47
@sk7n4k3d sk7n4k3d force-pushed the unifiedpush-implementation branch from 4caaaea to 55aa73c Compare March 24, 2026 12:34
@sk7n4k3d sk7n4k3d force-pushed the unifiedpush-implementation branch from 55aa73c to b18ace5 Compare March 24, 2026 13:11
@sk7n4k3d sk7n4k3d marked this pull request as ready for review March 24, 2026 13:13
presenter.handlePushProviderChange(value)
}
if (value == "WebSocket") {
Toast.makeText(requireContext(), commonR.string.push_provider_websocket_enabled, Toast.LENGTH_SHORT).show()

Check failure

Code scanning / ktlint

Argument should be on a separate line (unless all arguments can fit a single line) Error

Argument should be on a separate line (unless all arguments can fit a single line)
presenter.handlePushProviderChange(value)
}
if (value == "WebSocket") {
Toast.makeText(requireContext(), commonR.string.push_provider_websocket_enabled, Toast.LENGTH_SHORT).show()

Check failure

Code scanning / ktlint

Argument should be on a separate line (unless all arguments can fit a single line) Error

Argument should be on a separate line (unless all arguments can fit a single line)
presenter.handlePushProviderChange(value)
}
if (value == "WebSocket") {
Toast.makeText(requireContext(), commonR.string.push_provider_websocket_enabled, Toast.LENGTH_SHORT).show()

Check failure

Code scanning / ktlint

Argument should be on a separate line (unless all arguments can fit a single line) Error

Argument should be on a separate line (unless all arguments can fit a single line)
presenter.handlePushProviderChange(value)
}
if (value == "WebSocket") {
Toast.makeText(requireContext(), commonR.string.push_provider_websocket_enabled, Toast.LENGTH_SHORT).show()

Check failure

Code scanning / ktlint

Argument should be on a separate line (unless all arguments can fit a single line) Error

Argument should be on a separate line (unless all arguments can fit a single line)
presenter.handlePushProviderChange(value)
}
if (value == "WebSocket") {
Toast.makeText(requireContext(), commonR.string.push_provider_websocket_enabled, Toast.LENGTH_SHORT).show()

Check failure

Code scanning / ktlint

Argument should be on a separate line (unless all arguments can fit a single line) Error

Argument should be on a separate line (unless all arguments can fit a single line)
presenter.handlePushProviderChange(value)
}
if (value == "WebSocket") {
Toast.makeText(requireContext(), commonR.string.push_provider_websocket_enabled, Toast.LENGTH_SHORT).show()

Check failure

Code scanning / ktlint

Argument should be on a separate line (unless all arguments can fit a single line) Error

Argument should be on a separate line (unless all arguments can fit a single line)
presenter.handlePushProviderChange(value)
}
if (value == "WebSocket") {
Toast.makeText(requireContext(), commonR.string.push_provider_websocket_enabled, Toast.LENGTH_SHORT).show()

Check failure

Code scanning / ktlint

Missing newline before ")" Error

Missing newline before ")"
presenter.handlePushProviderChange(value)
}
if (value == "WebSocket") {
Toast.makeText(requireContext(), commonR.string.push_provider_websocket_enabled, Toast.LENGTH_SHORT).show()

Check failure

Code scanning / ktlint

Missing newline before ")" Error

Missing newline before ")"
presenter.handlePushProviderChange(value)
}
if (value == "WebSocket") {
Toast.makeText(requireContext(), commonR.string.push_provider_websocket_enabled, Toast.LENGTH_SHORT).show()

Check failure

Code scanning / ktlint

Exceeded max line length (120) Error

Exceeded max line length (120)
presenter.handlePushProviderChange(value)
}
if (value == "WebSocket") {
Toast.makeText(requireContext(), commonR.string.push_provider_websocket_enabled, Toast.LENGTH_SHORT).show()

Check failure

Code scanning / ktlint

Exceeded max line length (120) Error

Exceeded max line length (120)
@TimoPtr TimoPtr marked this pull request as draft March 25, 2026 15:37
@TimoPtr
Copy link
Copy Markdown
Member

TimoPtr commented Mar 25, 2026

Let's first review the first PR.

@sk7n4k3d sk7n4k3d force-pushed the unifiedpush-implementation branch from b18ace5 to 01ffbe6 Compare March 25, 2026 15:59
@sk7n4k3d
Copy link
Copy Markdown
Author

Makes sense! I've rebased this on top of the updated #6619 and ktlint passes now. Ready whenever #6619 is merged.

Copy link
Copy Markdown

@p1gp1g p1gp1g left a comment

Choose a reason for hiding this comment

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

Hey, just saw your PR: I've seen many people waiting for this feature, that's going to make some people happy 👍

I just wanted to ask a question, cf. comment on UnifiedPushReceiver file

import timber.log.Timber

@AndroidEntryPoint
class UnifiedPushReceiver : MessagingReceiver() {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

(Maintainer of UnififedPush lib)

Any reason to use MessagingReceiver instead of PushService?

PushService is recommended over MessagingReceiver. If there is a reason, we may be able to improve the PushService

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Hey @p1gp1g, thanks for the drive-by from the lib maintainer itself! No good reason — I just built on an older pattern without checking for the newer API. You're right, PushService is the way.

Migrated in 2b648b7a:

  • UnifiedPushReceiverUnifiedPushService extending PushService
  • Removed the 4 deprecated <receiver> actions from the manifest, replaced with a non-exported <service> filtering on org.unifiedpush.android.connector.PUSH_EVENT
  • Hilt injection via @AndroidEntryPoint (the service is a proper Context, so cleaner overall than the old receiver approach)
  • Dropped the ExportedReceiver lint baseline entries since the service is internal-only

Also dropped the Context parameter from the overrides (the Service is the context). Still on connector 3.0.9 — the PushService API is available from 3.0.0 so no version bump needed.

Thanks again for the heads up, this is a much cleaner integration.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ok, great ! Happy to see that feature for home assistant 👍

Did you have to do anything server side?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Nothing server side! The Home Assistant mobile_app integration already supports arbitrary push_url / push_token values via POST /api/mobile_app/registrations, so UnifiedPush plugs in cleanly:

  • On distributor registration, we send the endpoint returned by onNewEndpoint as push_url, leave push_token empty, and set push_encrypt=true when we have the public keys from the UnifiedPush connector.
  • HA then posts notifications directly to that URL. For encrypted payloads we reuse the same WebPush-like format the FCM path uses (AES-GCM + ECDH with the keys we gave HA at registration time), so the same server logic handles both.
  • When the user switches provider (e.g. UnifiedPush → WebSocket), we re-register to overwrite the push_url / push_token.

The one small gotcha I hit and just fixed in de7243211: HA's backend validates push_url as a real URL, so sending an empty string to clear the config yields HTTP 400. The fix falls back to the BuildConfig.PUSH_URL placeholder in that branch, which the WebSocket path was already using.

So from a HA core perspective: zero changes needed. This is purely an Android-side integration on top of the existing mobile_app push protocol.

sk7n4k3d and others added 4 commits April 9, 2026 21:19
Introduce a PushProvider interface that abstracts push notification
delivery, allowing the user to choose between available providers
(FCM, WebSocket) via a new "Push provider" setting under Notifications.

This lays the groundwork for additional providers (e.g. UnifiedPush)
without coupling the abstraction to any specific implementation.

- Add PushProvider interface with name, isAvailable, isActive, register,
  unregister, and requiresPersistentConnection
- Add PushProviderManager for provider selection and server registration
- Add FcmPushProvider (full flavor) and WebSocketPushProvider
- Add Dagger multibinding modules for both flavors
- Add "Push provider" ListPreference in Notifications settings
- Add WebsocketManager.restart() for instant provider switching
- Extend DeviceRegistration with pushUrl and pushEncrypt fields
- Add 28 unit tests for PushProvider and PushProviderManager

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove suspend from WebsocketManager.restart() (no suspending calls)
- Remove unused context parameter from WebSocketPushProvider
- Clear server-side push config when switching to WebSocket
- Rethrow CancellationException in PushProviderManager and FcmPushProvider
- Derive initial push provider from available providers instead of hardcoded default
- Stabilize getAllProviders() ordering with sortedBy name
- Fix PushProvider KDoc to reflect user-selection model
- Wire handlePushProviderChange to actually call selectAndRegister
- Remove dead getString/putString cases for notification_push_provider
- Move push provider label resolution to Fragment using string resources

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Persist selectedPushProvider via PrefsRepository instead of in-memory
  variable to survive process death
- Trigger server-side registration update when push provider changes
  by calling updateServerRegistration after selectAndRegister
- Add explanatory comment for preferenceDataStore = null in
  SettingsFragment push provider preference
- Replace hardcoded "WebSocket" fallback with WebSocketPushProvider.NAME
  constant reference

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

- Fix import ordering in SettingsFragment.kt and SettingsPresenterImpl.kt
- Fix argument wrapping for Toast.makeText call in SettingsFragment.kt
- Fix constructor parameter formatting in FcmPushProvider.kt
- Clarify PushProvider KDoc: explicitly note user-configurable selection
  with no automatic "best provider" logic
sk7n4k3d added 2 commits April 9, 2026 21:19
The positional args broke when pushUrl/pushEncrypt were added to
DeviceRegistration. Boolean was being passed where String? was
expected. Named parameters make this resilient to parameter order.
Implement UnifiedPush as an alternative push notification provider,
allowing users to receive notifications via a distributor app (e.g. ntfy)
without relying on Google's FCM infrastructure.

- Add UnifiedPushProvider with distributor-aware registration
- Add UnifiedPushManager for registration lifecycle and retry logic
- Add UnifiedPushService extending PushService (recommended over deprecated MessagingReceiver)
- Add UnifiedPushWorker for background retry with backoff
- Add UnifiedPush distributor selection in Push provider settings
- Add IgnoreViolationRule for UnifiedPush connector StrictMode violations
- Add protobuf-java exclusion to avoid conflict with protobuf-javalite
- Add UnifiedPush guard in FcmPushProvider.isActive() and FCM onNewToken
- Extend MessagingManager with Map<String, Any> message handler
- Add 11 unit tests for UnifiedPush message parsing
- Add unifiedpush-connector 3.0.9 dependency
@sk7n4k3d sk7n4k3d force-pushed the unifiedpush-implementation branch from 01ffbe6 to 2b648b7 Compare April 9, 2026 19:36
- Mobile app registration rejected with HTTP 400 on servers that
  validate push_url as a URL: the "clear push config" branch of
  createUpdateRegistrationRequest was sending an empty string for
  push_url. Fall back to the BuildConfig PUSH_URL placeholder (the
  same value used for the initial WebSocket registration) so the
  payload is always valid.

- Settings screen crashed at inflation time with
  IllegalArgumentException "No string found by this key:
  notification_push_provider". The ListPreference was trying to read
  its initial value from the presenter data store before
  updatePushProviderPrefs() had a chance to detach it. Handle the
  notification_push_provider key directly in
  SettingsPresenterImpl.getString/putString (backed by PrefsRepository)
  and drop the now-unnecessary preferenceDataStore = null hack.
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.

5 participants