Skip to content

Commit 2b648b7

Browse files
committed
Add UnifiedPush support for push notifications
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
1 parent 54b225f commit 2b648b7

File tree

26 files changed

+1275
-268
lines changed

26 files changed

+1275
-268
lines changed

app/gradle.lockfile

Lines changed: 136 additions & 73 deletions
Large diffs are not rendered by default.

app/src/full/kotlin/io/homeassistant/companion/android/notifications/FirebaseCloudMessagingService.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ class FirebaseCloudMessagingService : FirebaseMessagingService() {
4141
override fun onNewToken(token: String) {
4242
mainScope.launch {
4343
Timber.d("Refreshed token: $token")
44+
if (messagingManager.isUnifiedPushEnabled()) {
45+
// Updating registration while using UnifiedPush will overwrite its token, so ignore new FCM tokens.
46+
Timber.d("Not trying to update registration since UnifiedPush is being used.")
47+
return@launch
48+
}
4449
if (!serverManager.isRegistered()) {
4550
Timber.d("Not trying to update registration since we aren't authenticated.")
4651
return@launch

app/src/full/kotlin/io/homeassistant/companion/android/push/FcmPushProvider.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.homeassistant.companion.android.push
22

3+
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
34
import io.homeassistant.companion.android.common.push.PushProvider
45
import io.homeassistant.companion.android.common.push.PushRegistrationResult
56
import io.homeassistant.companion.android.common.util.MessagingTokenProvider
@@ -14,7 +15,10 @@ import timber.log.Timber
1415
* Only available in the "full" build flavor.
1516
*/
1617
@Singleton
17-
class FcmPushProvider @Inject constructor(private val messagingTokenProvider: MessagingTokenProvider) : PushProvider {
18+
class FcmPushProvider @Inject constructor(
19+
private val prefsRepository: PrefsRepository,
20+
private val messagingTokenProvider: MessagingTokenProvider,
21+
) : PushProvider {
1822

1923
override val name: String = NAME
2024

@@ -30,6 +34,8 @@ class FcmPushProvider @Inject constructor(private val messagingTokenProvider: Me
3034
}
3135

3236
override suspend fun isActive(): Boolean {
37+
// FCM is active only when UnifiedPush is not enabled and a valid token exists.
38+
if (prefsRepository.isUnifiedPushEnabled()) return false
3339
return try {
3440
val token = messagingTokenProvider()
3541
!token.isBlank()

app/src/full/kotlin/io/homeassistant/companion/android/push/PushProviderModule.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import io.homeassistant.companion.android.common.push.PushProvider
99

1010
/**
1111
* Dagger module that provides push provider implementations for the full flavor.
12-
* Includes FCM and WebSocket providers.
12+
* Includes FCM, WebSocket, and UnifiedPush providers.
1313
*/
1414
@Module
1515
@InstallIn(SingletonComponent::class)
@@ -22,4 +22,8 @@ abstract class PushProviderModule {
2222
@Binds
2323
@IntoSet
2424
abstract fun bindWebSocketPushProvider(provider: WebSocketPushProvider): PushProvider
25+
26+
@Binds
27+
@IntoSet
28+
abstract fun bindUnifiedPushProvider(provider: UnifiedPushProvider): PushProvider
2529
}

app/src/main/AndroidManifest.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,6 +1034,14 @@
10341034
android:value="true" />
10351035
</service>
10361036

1037+
<service
1038+
android:name=".unifiedpush.UnifiedPushService"
1039+
android:exported="false">
1040+
<intent-filter>
1041+
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
1042+
</intent-filter>
1043+
</service>
1044+
10371045
<receiver
10381046
android:name=".notifications.NotificationActionReceiver"
10391047
android:enabled="true"

app/src/main/kotlin/io/homeassistant/companion/android/notifications/MessagingManager.kt

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ import kotlinx.coroutines.Job
104104
import kotlinx.coroutines.async
105105
import kotlinx.coroutines.awaitAll
106106
import kotlinx.coroutines.flow.first
107+
import kotlinx.coroutines.flow.update
107108
import kotlinx.coroutines.launch
108109
import kotlinx.coroutines.runBlocking
109110
import kotlinx.coroutines.withContext
@@ -286,6 +287,57 @@ class MessagingManager @Inject constructor(
286287

287288
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
288289

290+
suspend fun isUnifiedPushEnabled(): Boolean = prefsRepository.isUnifiedPushEnabled()
291+
292+
suspend fun setUnifiedPushEnabled(enabled: Boolean) = prefsRepository.setUnifiedPushEnabled(enabled)
293+
294+
fun handleMessage(
295+
notificationData: Map<String, Any>,
296+
source: String,
297+
serverId: Int = ServerManager.SERVER_ID_ACTIVE,
298+
) {
299+
val flattened = mutableMapOf<String, String>()
300+
if (notificationData.containsKey("data")) {
301+
for ((key, value) in notificationData["data"] as Map<*, *>) {
302+
if (key == "actions" && value is List<*>) {
303+
value.forEachIndexed { i, action ->
304+
if (action is Map<*, *>) {
305+
flattened["action_${i + 1}_key"] = action["action"].toString()
306+
flattened["action_${i + 1}_title"] = action["title"].toString()
307+
action["uri"]?.let { uri -> flattened["action_${i + 1}_uri"] = uri.toString() }
308+
action["behavior"]?.let { behavior ->
309+
flattened["action_${i + 1}_behavior"] =
310+
behavior.toString()
311+
}
312+
}
313+
}
314+
} else {
315+
flattened[key.toString()] = value.toString()
316+
}
317+
}
318+
}
319+
// Message and title are in the root unlike all the others.
320+
listOf("message", "title").forEach { key ->
321+
if (notificationData.containsKey(key)) {
322+
flattened[key] = notificationData[key].toString()
323+
}
324+
}
325+
if (notificationData.containsKey("registration_info")) {
326+
val registrationInfo = notificationData["registration_info"]
327+
if (registrationInfo is Map<*, *> && registrationInfo.containsKey("webhook_id")) {
328+
flattened["webhook_id"] = registrationInfo["webhook_id"].toString()
329+
}
330+
}
331+
if (!flattened.containsKey("webhook_id")) {
332+
runBlocking {
333+
serverManager.getServer(serverId)
334+
}?.let { server ->
335+
flattened["webhook_id"] = server.connection.webhookId.toString()
336+
}
337+
}
338+
handleMessage(flattened, source)
339+
}
340+
289341
fun handleMessage(notificationData: Map<String, String>, source: String) {
290342
mainScope.launch {
291343
var now = System.currentTimeMillis()
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package io.homeassistant.companion.android.push
2+
3+
import android.content.Context
4+
import dagger.hilt.android.qualifiers.ApplicationContext
5+
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
6+
import io.homeassistant.companion.android.common.push.PushProvider
7+
import io.homeassistant.companion.android.common.push.PushRegistrationResult
8+
import io.homeassistant.companion.android.unifiedpush.UnifiedPushManager
9+
import javax.inject.Inject
10+
import javax.inject.Singleton
11+
import org.unifiedpush.android.connector.UnifiedPush
12+
import timber.log.Timber
13+
14+
/**
15+
* Push provider implementation backed by UnifiedPush.
16+
*
17+
* UnifiedPush allows receiving push notifications via a user-chosen distributor app
18+
* (e.g. ntfy, NextPush) without relying on Google's FCM infrastructure.
19+
*
20+
*/
21+
@Singleton
22+
class UnifiedPushProvider @Inject constructor(
23+
@ApplicationContext private val context: Context,
24+
private val prefsRepository: PrefsRepository,
25+
private val unifiedPushManager: UnifiedPushManager,
26+
) : PushProvider {
27+
28+
override val name: String = NAME
29+
30+
override suspend fun isAvailable(): Boolean {
31+
val distributors = UnifiedPush.getDistributors(context)
32+
return distributors.isNotEmpty()
33+
}
34+
35+
override suspend fun isActive(): Boolean = prefsRepository.isUnifiedPushEnabled()
36+
37+
override suspend fun register(): PushRegistrationResult? {
38+
val distributor = UnifiedPush.getAckDistributor(context)
39+
if (distributor == null) {
40+
Timber.d("No UnifiedPush distributor acknowledged")
41+
return null
42+
}
43+
// Registration happens asynchronously via UnifiedPushService.
44+
// The actual PushRegistrationResult will be created when onNewEndpoint is called.
45+
UnifiedPushManager.register(context)
46+
return null // Async - result delivered via UnifiedPushService.onNewEndpoint
47+
}
48+
49+
override suspend fun unregister() {
50+
UnifiedPushManager.unregister(context)
51+
prefsRepository.setUnifiedPushEnabled(false)
52+
}
53+
54+
companion object {
55+
const val NAME = "UnifiedPush"
56+
}
57+
}

app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsFragment.kt

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -572,14 +572,35 @@ class SettingsFragment(
572572

573573
lifecycleScope.launch(Dispatchers.IO) {
574574
val providerNames = presenter.getAvailablePushProviders()
575-
val values = providerNames.toMutableList()
576-
val entries = providerNames.map { name ->
577-
when (name) {
578-
"FCM" -> getString(commonR.string.push_provider_fcm)
579-
"WebSocket" -> getString(commonR.string.push_provider_websocket)
580-
else -> name
575+
val entries = mutableListOf<String>()
576+
val values = mutableListOf<String>()
577+
val pm = requireContext().packageManager
578+
579+
for (name in providerNames) {
580+
if (name == "UnifiedPush") {
581+
val distributors = presenter.getUnifiedPushDistributors()
582+
for (distributor in distributors) {
583+
val label = try {
584+
pm.getApplicationLabel(
585+
pm.getApplicationInfo(distributor, PackageManager.GET_META_DATA),
586+
).toString()
587+
} catch (_: PackageManager.NameNotFoundException) {
588+
distributor
589+
}
590+
entries.add("UnifiedPush ($label)")
591+
values.add("${SettingsPresenter.PUSH_PROVIDER_UP_PREFIX}$distributor")
592+
}
593+
} else {
594+
entries.add(
595+
when (name) {
596+
"FCM" -> getString(commonR.string.push_provider_fcm)
597+
"WebSocket" -> getString(commonR.string.push_provider_websocket)
598+
else -> name
599+
},
600+
)
601+
values.add(name)
581602
}
582-
}.toMutableList()
603+
}
583604

584605
val activeValue = presenter.getActivePushProviderValue()
585606

app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenter.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface SettingsPresenter {
1111
companion object {
1212
const val SUGGESTION_ASSISTANT_APP = "assistant_app"
1313
const val SUGGESTION_NOTIFICATION_PERMISSION = "notification_permission"
14+
const val PUSH_PROVIDER_UP_PREFIX = "UnifiedPush:"
1415
}
1516

1617
fun init(view: SettingsView)
@@ -21,10 +22,11 @@ interface SettingsPresenter {
2122
fun getSuggestionFlow(): StateFlow<SettingsHomeSuggestion?>
2223
suspend fun getServersFlow(): Flow<List<Server>>
2324
suspend fun getNotificationRateLimits(): RateLimitResponse?
24-
suspend fun showChangeLog(context: Context)
25-
suspend fun isChangeLogPopupEnabled(): Boolean
26-
suspend fun setChangeLogPopupEnabled(enabled: Boolean)
25+
fun getUnifiedPushDistributors(): List<String>
2726
fun getAvailablePushProviders(): List<String>
2827
suspend fun getActivePushProviderValue(): String
2928
suspend fun handlePushProviderChange(value: String?)
29+
suspend fun showChangeLog(context: Context)
30+
suspend fun isChangeLogPopupEnabled(): Boolean
31+
suspend fun setChangeLogPopupEnabled(enabled: Boolean)
3032
}

app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import io.homeassistant.companion.android.push.WebSocketPushProvider
2020
import io.homeassistant.companion.android.settings.assist.DefaultAssistantManager
2121
import io.homeassistant.companion.android.settings.language.LanguagesManager
2222
import io.homeassistant.companion.android.themes.NightModeManager
23+
import io.homeassistant.companion.android.unifiedpush.UnifiedPushManager
2324
import io.homeassistant.companion.android.util.ChangeLog
2425
import javax.inject.Inject
2526
import kotlinx.coroutines.CoroutineScope
@@ -39,10 +40,11 @@ class SettingsPresenterImpl @Inject constructor(
3940
private val prefsRepository: PrefsRepository,
4041
private val nightModeManager: NightModeManager,
4142
private val langsManager: LanguagesManager,
43+
private val unifiedPushManager: UnifiedPushManager,
44+
private val pushProviderManager: PushProviderManager,
4245
private val changeLog: ChangeLog,
4346
private val settingsDao: SettingsDao,
4447
private val defaultAssistantManager: DefaultAssistantManager,
45-
private val pushProviderManager: PushProviderManager,
4648
) : PreferenceDataStore(),
4749
SettingsPresenter {
4850

@@ -221,23 +223,42 @@ class SettingsPresenterImpl @Inject constructor(
221223
}
222224
}
223225

226+
override fun getUnifiedPushDistributors(): List<String> = unifiedPushManager.getDistributors()
227+
224228
override fun getAvailablePushProviders(): List<String> {
225229
return pushProviderManager.getAllProviders().map { it.name }
226230
}
227231

228232
override suspend fun getActivePushProviderValue(): String {
229233
val persisted = prefsRepository.getSelectedPushProvider()
230234
if (persisted != null) return persisted
231-
return pushProviderManager.getAllProviders().firstOrNull()?.name
232-
?: WebSocketPushProvider.NAME
235+
val distributor = unifiedPushManager.getDistributor()
236+
val value = if (distributor != null && distributor != UnifiedPushManager.DISTRIBUTOR_DISABLED) {
237+
"${SettingsPresenter.PUSH_PROVIDER_UP_PREFIX}$distributor"
238+
} else {
239+
pushProviderManager.getAllProviders().firstOrNull()?.name
240+
?: WebSocketPushProvider.NAME
241+
}
242+
prefsRepository.setSelectedPushProvider(value)
243+
return value
233244
}
234245

235246
override suspend fun handlePushProviderChange(value: String?) {
236247
if (value == null) return
237248
prefsRepository.setSelectedPushProvider(value)
238-
val result = pushProviderManager.selectAndRegister(value)
239-
if (result != null) {
240-
pushProviderManager.updateServerRegistration(result)
249+
if (value.startsWith(SettingsPresenter.PUSH_PROVIDER_UP_PREFIX)) {
250+
val distributor = value.removePrefix(SettingsPresenter.PUSH_PROVIDER_UP_PREFIX)
251+
unifiedPushManager.saveDistributor(distributor)
252+
val result = pushProviderManager.selectAndRegister("UnifiedPush")
253+
if (result != null) {
254+
pushProviderManager.updateServerRegistration(result)
255+
}
256+
} else {
257+
unifiedPushManager.saveDistributor(null)
258+
val result = pushProviderManager.selectAndRegister(value)
259+
if (result != null) {
260+
pushProviderManager.updateServerRegistration(result)
261+
}
241262
}
242263
}
243264

0 commit comments

Comments
 (0)