Add UnifiedPush support as alternative push provider#6634
Add UnifiedPush support as alternative push provider#6634sk7n4k3d wants to merge 8 commits intohome-assistant:mainfrom
Conversation
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.
There was a problem hiding this comment.
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/PushProviderManagerabstractions plus UnifiedPush/FCM/WebSocket provider implementations - Adds UnifiedPush receiver/manager/worker and settings UI to select a provider/distributor
- Extends device registration to include
pushUrlandpushEncrypt, 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 |
| <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> | ||
|
|
There was a problem hiding this comment.
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.
| <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> |
| 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() | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
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).
| 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) |
There was a problem hiding this comment.
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).
| 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) | ||
| } |
There was a problem hiding this comment.
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.
| 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()) | ||
| } | ||
| } |
There was a problem hiding this comment.
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).
| if (!flattened.containsKey("webhook_id")) { | ||
| runBlocking { | ||
| serverManager.getServer(serverId) | ||
| }?.let { server -> | ||
| flattened["webhook_id"] = server.connection.webhookId.toString() | ||
| } | ||
| } | ||
| handleMessage(flattened, source) |
There was a problem hiding this comment.
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.
| 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) | |
| } |
| 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) | ||
| } |
There was a problem hiding this comment.
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.
| } catch (_: PackageManager.NameNotFoundException) { | ||
| distributor | ||
| } | ||
| entries.add("UnifiedPush ($label)") |
There was a problem hiding this comment.
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.
| entries.add("UnifiedPush ($label)") | |
| entries.add(getString(commonR.string.notification_push_unifiedpush_with_label, label)) |
| 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}") | ||
| } |
There was a problem hiding this comment.
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).
| override suspend fun doWork(): Result = withContext(Dispatchers.IO) { | ||
| UnifiedPushManager.register(this@UnifiedPushWorker.applicationContext) | ||
| Result.success() | ||
| } |
There was a problem hiding this comment.
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.
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— implementsPushProviderinterface for UnifiedPushUnifiedPushManager— handles distributor registration, key exchange, and message decryptionUnifiedPushReceiver— receives push messages from the UnifiedPush distributorUnifiedPushWorker— background registration workerUsers 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
UnifiedPushMessageParsingTest)