Skip to content

Commit eefa371

Browse files
aaronstealthclaude
andcommitted
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 UnifiedPushReceiver for message and endpoint handling - 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 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c14a7a2 commit eefa371

File tree

27 files changed

+1291
-271
lines changed

27 files changed

+1291
-271
lines changed

app/gradle.lockfile

Lines changed: 140 additions & 77 deletions
Large diffs are not rendered by default.

app/lint-baseline.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1796,4 +1796,15 @@
17961796
column="42"/>
17971797
</issue>
17981798

1799+
<issue
1800+
id="ExportedReceiver"
1801+
message="Exported receiver does not require permission"
1802+
errorLine1=" &lt;receiver"
1803+
errorLine2=" ~~~~~~~~">
1804+
<location
1805+
file="src/main/AndroidManifest.xml"
1806+
line="1024"
1807+
column="9"/>
1808+
</issue>
1809+
17991810
</issues>

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: 4 additions & 0 deletions
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,6 +15,7 @@ import timber.log.Timber
1415
*/
1516
@Singleton
1617
class FcmPushProvider @Inject constructor(
18+
private val prefsRepository: PrefsRepository,
1719
private val messagingTokenProvider: MessagingTokenProvider,
1820
) : PushProvider {
1921

@@ -30,6 +32,8 @@ class FcmPushProvider @Inject constructor(
3032
}
3133

3234
override suspend fun isActive(): Boolean {
35+
// FCM is active only when UnifiedPush is not enabled and a valid token exists.
36+
if (prefsRepository.isUnifiedPushEnabled()) return false
3337
return try {
3438
val token = messagingTokenProvider()
3539
!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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,6 +1021,18 @@
10211021
android:value="true" />
10221022
</service>
10231023

1024+
<receiver
1025+
android:name=".unifiedpush.UnifiedPushReceiver"
1026+
android:enabled="true"
1027+
android:exported="true">
1028+
<intent-filter>
1029+
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
1030+
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
1031+
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
1032+
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED"/>
1033+
</intent-filter>
1034+
</receiver>
1035+
10241036
<receiver
10251037
android:name=".notifications.NotificationActionReceiver"
10261038
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
@@ -102,6 +102,7 @@ import kotlinx.coroutines.Job
102102
import kotlinx.coroutines.async
103103
import kotlinx.coroutines.awaitAll
104104
import kotlinx.coroutines.flow.first
105+
import kotlinx.coroutines.flow.update
105106
import kotlinx.coroutines.launch
106107
import kotlinx.coroutines.runBlocking
107108
import kotlinx.coroutines.withContext
@@ -284,6 +285,57 @@ class MessagingManager @Inject constructor(
284285

285286
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
286287

288+
suspend fun isUnifiedPushEnabled(): Boolean = prefsRepository.isUnifiedPushEnabled()
289+
290+
suspend fun setUnifiedPushEnabled(enabled: Boolean) = prefsRepository.setUnifiedPushEnabled(enabled)
291+
292+
fun handleMessage(
293+
notificationData: Map<String, Any>,
294+
source: String,
295+
serverId: Int = ServerManager.SERVER_ID_ACTIVE,
296+
) {
297+
val flattened = mutableMapOf<String, String>()
298+
if (notificationData.containsKey("data")) {
299+
for ((key, value) in notificationData["data"] as Map<*, *>) {
300+
if (key == "actions" && value is List<*>) {
301+
value.forEachIndexed { i, action ->
302+
if (action is Map<*, *>) {
303+
flattened["action_${i + 1}_key"] = action["action"].toString()
304+
flattened["action_${i + 1}_title"] = action["title"].toString()
305+
action["uri"]?.let { uri -> flattened["action_${i + 1}_uri"] = uri.toString() }
306+
action["behavior"]?.let { behavior ->
307+
flattened["action_${i + 1}_behavior"] =
308+
behavior.toString()
309+
}
310+
}
311+
}
312+
} else {
313+
flattened[key.toString()] = value.toString()
314+
}
315+
}
316+
}
317+
// Message and title are in the root unlike all the others.
318+
listOf("message", "title").forEach { key ->
319+
if (notificationData.containsKey(key)) {
320+
flattened[key] = notificationData[key].toString()
321+
}
322+
}
323+
if (notificationData.containsKey("registration_info")) {
324+
val registrationInfo = notificationData["registration_info"]
325+
if (registrationInfo is Map<*, *> && registrationInfo.containsKey("webhook_id")) {
326+
flattened["webhook_id"] = registrationInfo["webhook_id"].toString()
327+
}
328+
}
329+
if (!flattened.containsKey("webhook_id")) {
330+
runBlocking {
331+
serverManager.getServer(serverId)
332+
}?.let { server ->
333+
flattened["webhook_id"] = server.connection.webhookId.toString()
334+
}
335+
}
336+
handleMessage(flattened, source)
337+
}
338+
287339
fun handleMessage(notificationData: Map<String, String>, source: String) {
288340
mainScope.launch {
289341
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 UnifiedPushReceiver.
44+
// The actual PushRegistrationResult will be created when onNewEndpoint is called.
45+
UnifiedPushManager.register(context)
46+
return null // Async - result delivered via UnifiedPushReceiver.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: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -565,11 +565,27 @@ class SettingsFragment(
565565
lifecycleScope.launch(Dispatchers.IO) {
566566
val entries = mutableListOf<String>()
567567
val values = mutableListOf<String>()
568+
val pm = requireContext().packageManager
568569

569570
val providers = presenter.getAvailablePushProviders()
570571
for (provider in providers) {
571-
entries.add(provider.second)
572-
values.add(provider.first)
572+
if (provider.first == "UnifiedPush") {
573+
val distributors = presenter.getUnifiedPushDistributors()
574+
for (distributor in distributors) {
575+
val label = try {
576+
pm.getApplicationLabel(
577+
pm.getApplicationInfo(distributor, PackageManager.GET_META_DATA),
578+
).toString()
579+
} catch (_: PackageManager.NameNotFoundException) {
580+
distributor
581+
}
582+
entries.add("UnifiedPush ($label)")
583+
values.add("${SettingsPresenter.PUSH_PROVIDER_UP_PREFIX}$distributor")
584+
}
585+
} else {
586+
entries.add(provider.second)
587+
values.add(provider.first)
588+
}
573589
}
574590

575591
val activeValue = presenter.getActivePushProviderValue()

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<Pair<String, String>>
2827
fun getActivePushProviderValue(): String
2928
fun handlePushProviderChange(value: String?)
29+
suspend fun showChangeLog(context: Context)
30+
suspend fun isChangeLogPopupEnabled(): Boolean
31+
suspend fun setChangeLogPopupEnabled(enabled: Boolean)
3032
}

0 commit comments

Comments
 (0)