Skip to content

Add UnifiedPush support as alternative push provider#6634

Closed
sk7n4k3d wants to merge 8 commits intohome-assistant:mainfrom
sk7n4k3d:feature/unifiedpush
Closed

Add UnifiedPush support as alternative push provider#6634
sk7n4k3d wants to merge 8 commits intohome-assistant:mainfrom
sk7n4k3d:feature/unifiedpush

Conversation

@sk7n4k3d
Copy link
Copy Markdown

Summary

Adds UnifiedPush as a third push notification provider, allowing users to receive HA notifications without Google Play Services (FCM).

Depends on #6633 (PushProvider abstraction).

  • UnifiedPushProvider — implements PushProvider interface for UnifiedPush
  • UnifiedPushManager — handles distributor registration, key exchange, and message decryption
  • UnifiedPushReceiver — receives push messages from the UnifiedPush distributor
  • UnifiedPushWorker — background registration worker
  • Settings UI — distributor picker when UnifiedPush is selected as provider
  • Message parsing — decrypts and parses HA notification payloads from UnifiedPush

Users can choose their push provider in Settings: FCM (default), WebSocket, or UnifiedPush. UnifiedPush requires a compatible distributor app (ntfy, NextPush, etc.).

Split from #6599 as requested by @jpelgrom and @TimoPtr.

Test plan

  • UnifiedPush notifications work with ntfy as distributor
  • Distributor picker shows available distributors
  • Switching between FCM/WebSocket/UnifiedPush works correctly
  • UnifiedPush message parsing handles encrypted payloads
  • Unit tests pass (UnifiedPushMessageParsingTest)
  • No regressions on FCM or WebSocket push
  • Wear and automotive builds compile

Introduce PushProvider abstraction for push notification providers
(FCM, WebSocket) with PushProviderManager for runtime selection.

Add pushUrl and pushEncrypt fields to DeviceRegistration to support
alternative push endpoints. Update IntegrationRepositoryImpl to
persist and send these new fields during device registration.
Add concrete PushProvider implementations:
- FcmPushProvider: wraps Firebase Cloud Messaging (full flavor only)
- WebSocketPushProvider: uses persistent WebSocket connection

Wire providers via Dagger multibinding in PushProviderModule for
both full and minimal flavors.
Add push provider selection preference to settings UI, allowing
users to choose between available push providers (FCM, WebSocket).

Update SettingsPresenter/Impl to expose provider list and handle
provider changes. Add WebsocketManager.restart() for re-enabling
WebSocket when switching back to it.
Add tests for PushProvider interface contract and
PushProviderManager selection, registration, and server
update logic.
Add UnifiedPush support as an alternative push notification provider,
allowing users to receive notifications via a user-chosen distributor
app (e.g. ntfy, NextPush) without relying on Google FCM.

- UnifiedPushProvider: PushProvider implementation using UP connector
- UnifiedPushManager: handles distributor selection, registration,
  endpoint updates, and failure retry logic
- UnifiedPushReceiver: receives messages, endpoints, and registration
  events from the distributor
- UnifiedPushWorker: WorkManager-based registration retry
- Add UnifiedPush connector dependency to both full and minimal flavors
- Update FCM to skip token updates when UnifiedPush is active
- Add StrictMode ignore rule for UP connector disk I/O
- Add UnifiedPush preference storage in PrefsRepository
Update push provider settings to list available UnifiedPush
distributors installed on the device. Each distributor appears
as a separate entry (e.g. "UnifiedPush (ntfy)") in the push
provider selection list.

Wire distributor selection through UnifiedPushManager to handle
registration and endpoint switching when the user changes providers.
Verify JSON message parsing logic used by UnifiedPushReceiver to
deserialize push notifications into the format expected by
MessagingManager.handleMessage().
Regenerate dependency lockfiles after adding unifiedpush-connector.
Add lint baseline entries for the exported UnifiedPushReceiver and
ObsoleteSdkInt in SettingsFragment.
Copilot AI review requested due to automatic review settings March 26, 2026 17:09
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

Adds UnifiedPush as an additional push notification backend (alongside FCM and WebSocket) to support push notifications without Google Play Services, wiring it into settings, registration, and message handling.

Changes:

  • Introduces PushProvider/PushProviderManager abstractions plus UnifiedPush/FCM/WebSocket provider implementations
  • Adds UnifiedPush receiver/manager/worker and settings UI to select a provider/distributor
  • Extends device registration to include pushUrl and pushEncrypt, plus new prefs/strings and updated dependency wiring

Reviewed changes

Copilot reviewed 35 out of 38 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
wear/src/main/kotlin/io/homeassistant/companion/android/phone/PhoneSettingsListener.kt Updates Wear registration call site for new DeviceRegistration field(s)
wear/src/main/kotlin/io/homeassistant/companion/android/onboarding/integration/MobileAppIntegrationPresenterImpl.kt Updates Wear onboarding registration call site for new DeviceRegistration field(s)
wear/gradle.lockfile Dependency lock updates for wear module
gradle/libs.versions.toml Adds UnifiedPush connector version + bundle
common/src/test/kotlin/io/homeassistant/companion/android/common/push/UnifiedPushMessageParsingTest.kt Adds unit tests for UnifiedPush JSON parsing scenarios
common/src/test/kotlin/io/homeassistant/companion/android/common/push/PushProviderTest.kt Adds unit tests for PushProvider defaults and PushRegistrationResult
common/src/test/kotlin/io/homeassistant/companion/android/common/push/PushProviderManagerTest.kt Adds unit tests for provider manager behaviors
common/src/main/res/values/strings.xml Adds strings for push provider UI/state
common/src/main/kotlin/io/homeassistant/companion/android/common/push/PushProviderManager.kt New manager to select/register providers and update server registration
common/src/main/kotlin/io/homeassistant/companion/android/common/push/PushProvider.kt New push provider interface + registration result model
common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt Stores UnifiedPush distributor + enabled flag
common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepository.kt Exposes UnifiedPush distributor + enabled flag APIs
common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt Persists push_url and sends push_encrypt during registration updates
common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/DeviceRegistration.kt Extends registration model with pushUrl + pushEncrypt
common/gradle.lockfile Dependency lock updates for common module
build-logic/convention/src/main/kotlin/AndroidApplicationDependenciesConventionPlugin.kt Adds UnifiedPush connector dependency for full/minimal flavors
automotive/lint-baseline.xml Baseline updates for new lint findings
app/src/minimal/kotlin/io/homeassistant/companion/android/push/PushProviderModule.kt Hilt bindings for providers in minimal flavor
app/src/main/res/xml/preferences.xml Adds push provider ListPreference
app/src/main/kotlin/io/homeassistant/companion/android/websocket/WebsocketManager.kt Adds a restart() helper for websocket periodic work
app/src/main/kotlin/io/homeassistant/companion/android/util/UnifiedPushExtensions.kt Adds UnifiedPush convenience extension methods
app/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt Adds StrictMode ignore rule for UnifiedPush connector stack traces
app/src/main/kotlin/io/homeassistant/companion/android/unifiedpush/UnifiedPushWorker.kt Adds background registration worker
app/src/main/kotlin/io/homeassistant/companion/android/unifiedpush/UnifiedPushReceiver.kt Adds receiver for UnifiedPush messages/endpoints
app/src/main/kotlin/io/homeassistant/companion/android/unifiedpush/UnifiedPushManager.kt Adds distributor management + server registration update logic
app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt Adds provider/distributor listing + selection handling
app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenter.kt Extends presenter API for provider/distributor selection
app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsFragment.kt Populates push provider preference and handles user selection
app/src/main/kotlin/io/homeassistant/companion/android/push/WebSocketPushProvider.kt Implements WebSocket push provider
app/src/main/kotlin/io/homeassistant/companion/android/push/UnifiedPushProvider.kt Implements UnifiedPush push provider
app/src/main/kotlin/io/homeassistant/companion/android/notifications/MessagingManager.kt Adds UnifiedPush payload flattening overload
app/src/main/AndroidManifest.xml Registers UnifiedPush receiver
app/src/full/kotlin/io/homeassistant/companion/android/push/PushProviderModule.kt Hilt bindings for providers in full flavor
app/src/full/kotlin/io/homeassistant/companion/android/push/FcmPushProvider.kt Implements FCM push provider
app/src/full/kotlin/io/homeassistant/companion/android/notifications/FirebaseCloudMessagingService.kt Avoids updating registration when UnifiedPush is enabled
app/lint-baseline.xml Baseline updates for new lint findings

Comment on lines +1024 to +1035
<receiver
android:name=".unifiedpush.UnifiedPushReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED"/>
</intent-filter>
</receiver>

Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

The app declares an exported MessagingReceiver, which triggers ExportedReceiver lint and (per UnifiedPush connector docs) MessagingReceiver is deprecated in favor of using the connector’s embedded receiver + an app PushService (with android:exported="false") handling org.unifiedpush.android.connector.PUSH_EVENT. Consider migrating to PushService to avoid maintaining an exported receiver and to follow current connector guidance.

Suggested change
<receiver
android:name=".unifiedpush.UnifiedPushReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED"/>
</intent-filter>
</receiver>
<service
android:name=".unifiedpush.UnifiedPushService"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
</intent-filter>
</service>

Copilot uses AI. Check for mistakes.
Comment on lines +297 to +315
if (notificationData.containsKey("data")) {
for ((key, value) in notificationData["data"] as Map<*, *>) {
if (key == "actions" && value is List<*>) {
value.forEachIndexed { i, action ->
if (action is Map<*, *>) {
flattened["action_${i + 1}_key"] = action["action"].toString()
flattened["action_${i + 1}_title"] = action["title"].toString()
action["uri"]?.let { uri -> flattened["action_${i + 1}_uri"] = uri.toString() }
action["behavior"]?.let { behavior ->
flattened["action_${i + 1}_behavior"] =
behavior.toString()
}
}
}
} else {
flattened[key.toString()] = value.toString()
}
}
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

handleMessage(Map<String, Any>) assumes notificationData["data"] is always a Map and force-casts it. UnifiedPushReceiver currently passes non-primitive JSON values as strings, which will make this cast fail and drop the notification. Please use a safe cast (as? Map<*, *>) with graceful fallback (or validate/convert the payload to the expected structure before flattening).

Copilot uses AI. Check for mistakes.
Comment on lines +33 to +41
val jsonObject = Json.decodeFromString<JsonObject>(message.content.decodeToString())
val data: Map<String, Any> = jsonObject.mapValues { (_, value) ->
if (value is JsonPrimitive) {
value.contentOrNull ?: value.toString()
} else {
value.toString()
}
}
messagingManager.handleMessage(data, SOURCE)
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

Non-primitive JSON values are converted to value.toString(), so nested objects/arrays (like the Home Assistant data object and actions array) become strings instead of structured maps/lists. That breaks downstream parsing/flattening and will cause notifications to be dropped. Please implement a recursive JSON->Kotlin conversion that preserves JsonObject as Map and JsonArray as List (and primitives as String/Boolean/Number as needed).

Copilot uses AI. Check for mistakes.
Comment on lines +169 to +179
override fun getAvailablePushProviders(): List<Pair<String, String>> {
val result = mutableListOf<Pair<String, String>>()
for (provider in pushProviderManager.getAllProviders()) {
val label = when (provider.name) {
"FCM" -> "Firebase Cloud Messaging"
"WebSocket" -> "WebSocket"
"UnifiedPush" -> "UnifiedPush"
else -> provider.name
}
result.add(provider.name to label)
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

The push provider labels returned here are user-visible (used as ListPreference entries) but are hardcoded English strings. Please use string resources (you already added push_provider_fcm, push_provider_websocket, unifiedpush, etc.) so the UI is localizable and consistent with the rest of settings.

Copilot uses AI. Check for mistakes.
Comment on lines +551 to +561
pref.setOnPreferenceChangeListener { _, newValue ->
val value = newValue as? String
lifecycleScope.launch(Dispatchers.IO) {
presenter.handlePushProviderChange(value)
}
if (value == "WebSocket") {
Toast.makeText(requireContext(), commonR.string.push_provider_websocket_enabled, Toast.LENGTH_SHORT).show()
lifecycleScope.launch {
WebsocketManager.restart(requireContext())
}
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

When selecting WebSocket, this code restarts the WebSocket worker but does not ensure the server registration is updated to stop using the previous push endpoint/token. Given handlePushProviderChange() currently just disables UnifiedPush, it's possible to end up still registered for FCM/UnifiedPush while also running the WebSocket worker. Please ensure provider switching updates both local state and server registration coherently (and consider using the PushProviderManager workflow).

Copilot uses AI. Check for mistakes.
Comment on lines +328 to +335
if (!flattened.containsKey("webhook_id")) {
runBlocking {
serverManager.getServer(serverId)
}?.let { server ->
flattened["webhook_id"] = server.connection.webhookId.toString()
}
}
handleMessage(flattened, source)
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

This new overload uses runBlocking to call the suspend serverManager.getServer(...). This can block the calling thread (including the main thread if invoked from a receiver) and violates the project guidance to never use runBlocking. Consider making this overload suspend, or launching the lookup in an existing coroutine scope and continuing message handling asynchronously.

Suggested change
if (!flattened.containsKey("webhook_id")) {
runBlocking {
serverManager.getServer(serverId)
}?.let { server ->
flattened["webhook_id"] = server.connection.webhookId.toString()
}
}
handleMessage(flattened, source)
mainScope.launch {
if (!flattened.containsKey("webhook_id")) {
serverManager.getServer(serverId)?.let { server ->
flattened["webhook_id"] = server.connection.webhookId.toString()
}
}
handleMessage(flattened, source)
}

Copilot uses AI. Check for mistakes.
Comment on lines +185 to +206
override fun getActivePushProviderValue(): String {
selectedPushProvider?.let { return it }
val distributor = unifiedPushManager.getDistributor()
val value = if (distributor != null && distributor != UnifiedPushManager.DISTRIBUTOR_DISABLED) {
"${SettingsPresenter.PUSH_PROVIDER_UP_PREFIX}$distributor"
} else {
"WebSocket"
}
selectedPushProvider = value
return value
}

override fun handlePushProviderChange(value: String?) {
if (value == null) return
selectedPushProvider = value
if (value.startsWith(SettingsPresenter.PUSH_PROVIDER_UP_PREFIX)) {
val distributor = value.removePrefix(SettingsPresenter.PUSH_PROVIDER_UP_PREFIX)
unifiedPushManager.saveDistributor(distributor)
} else {
// Switching away from UnifiedPush — clear endpoint and re-register with server
unifiedPushManager.saveDistributor(null)
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

getActivePushProviderValue() defaults to WebSocket whenever UnifiedPush isn't enabled, even in the full flavor where FCM may be the actual active provider. Also, handlePushProviderChange() only toggles UnifiedPush and doesn't register/unregister or update server registration for FCM/WebSocket via PushProviderManager. This makes provider selection inconsistent and can leave the server registered with the wrong push token/url. Consider deriving the active value from pushProviderManager.getActiveProvider() and routing preference changes through pushProviderManager.selectAndRegister(...) + updateServerRegistration(...) for all providers.

Copilot uses AI. Check for mistakes.
} catch (_: PackageManager.NameNotFoundException) {
distributor
}
entries.add("UnifiedPush ($label)")
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

entries.add("UnifiedPush ($label)") introduces a user-visible, non-localized string composition. Please move this to a string resource with formatting (e.g., "UnifiedPush (%1$s)") to keep settings strings centralized and translatable.

Suggested change
entries.add("UnifiedPush ($label)")
entries.add(getString(commonR.string.notification_push_unifiedpush_with_label, label))

Copilot uses AI. Check for mistakes.
Comment on lines +91 to +99
servers.forEach { server ->
try {
serverManager.integrationRepository(server.id).updateRegistration(
deviceRegistration = deviceRegistration,
allowReregistration = false,
)
} catch (e: Exception) {
Timber.e(e, "Failed to update push registration for server ${server.id}")
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

updateServerRegistration catches all Exception, which will also swallow CancellationException and can prevent proper coroutine cancellation. Please rethrow CancellationException explicitly (or catch more specific exception types).

Copilot uses AI. Check for mistakes.
Comment on lines +27 to +30
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
UnifiedPushManager.register(this@UnifiedPushWorker.applicationContext)
Result.success()
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

doWork() always returns Result.success() even if UnifiedPush registration throws or fails. Please catch exceptions and return Result.retry()/Result.failure() as appropriate so WorkManager can back off and you get better observability for registration failures.

Copilot uses AI. Check for mistakes.
@sk7n4k3d
Copy link
Copy Markdown
Author

Duplicate of #6621 — closing. Will focus on #6619 and #6621.

@sk7n4k3d sk7n4k3d closed this Mar 26, 2026
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.

2 participants