Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
46530c8
Add org.unifiedpush.android.connector to dependencies
lone-faerie Apr 26, 2025
eea01c1
Add UnifiedPush distributor setting
lone-faerie Apr 26, 2025
e8e97b0
Add UnifiedPush service
lone-faerie Apr 27, 2025
8c5a7a2
Fix minimal dependencies
lone-faerie Apr 27, 2025
cfd9fa2
Fix memory leak
lone-faerie Apr 29, 2025
6b761a0
Use UnifiedPush public key as push token
lone-faerie Apr 30, 2025
a540d10
Remove unused imports
lone-faerie Apr 30, 2025
50f2320
Register UnifiedPush on launch
lone-faerie May 1, 2025
e64c776
Show toast when UnifiedPush is first enabled
lone-faerie May 1, 2025
f7306e2
Code refactor
lone-faerie May 1, 2025
0fb7e06
Fix wear build
lone-faerie May 1, 2025
6e8d346
Lint code
lone-faerie May 1, 2025
6108ffb
Don't register default distributor on reregistration
lone-faerie May 2, 2025
8ca1c72
Fix linting
lone-faerie May 2, 2025
8edda09
Refactor: add generic PushProvider interface for FCM/UnifiedPush/WebS…
sk7n4k3d Mar 20, 2026
bfa64c2
Add unit tests for PushProvider interface and UnifiedPush message par…
sk7n4k3d Mar 20, 2026
7b3022a
Wire PushProviderManager into LaunchActivity, LaunchPresenter, and Se…
sk7n4k3d Mar 20, 2026
873d387
Fix push_encrypt logic, FCM isActive check, resync duplication, and e…
sk7n4k3d Mar 20, 2026
b832294
Fix compilation and tests for upstream compatibility
sk7n4k3d Mar 20, 2026
c95440d
Fix ktlint formatting errors
sk7n4k3d Mar 20, 2026
585c80e
Exclude protobuf-java from UnifiedPush connector in full flavor
sk7n4k3d Mar 20, 2026
9733eff
Update dependency lockfiles after protobuf exclusion
sk7n4k3d Mar 20, 2026
8ea839e
Fix compilation errors for upstream API compatibility
sk7n4k3d Mar 20, 2026
6f5208f
Fix import ordering in UnifiedPushReceiver
sk7n4k3d Mar 20, 2026
62a1a12
Update lint baselines for UnifiedPushReceiver and ObsoleteSdkInt
sk7n4k3d Mar 20, 2026
21e5e3e
Remove hardcoded priority, let user choose push provider
aaronstealth Mar 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 129 additions & 66 deletions app/gradle.lockfile

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions app/lint-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1796,4 +1796,15 @@
column="42"/>
</issue>

<issue
id="ExportedReceiver"
message="Exported receiver does not require permission"
errorLine1=" &lt;receiver"
errorLine2=" ~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="1024"
column="9"/>
</issue>

</issues>
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ class FirebaseCloudMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
mainScope.launch {
Timber.d("Refreshed token: $token")
if (messagingManager.isUnifiedPushEnabled()) {
// Updating registration while using UnifiedPush will overwrite its token, so ignore new FCM tokens.
Timber.d("Not trying to update registration since UnifiedPush is being used.")
return@launch
}
if (!serverManager.isRegistered()) {
Timber.d("Not trying to update registration since we aren't authenticated.")
return@launch
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package io.homeassistant.companion.android.push

import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
import io.homeassistant.companion.android.common.push.PushProvider
import io.homeassistant.companion.android.common.push.PushRegistrationResult
import io.homeassistant.companion.android.common.util.MessagingTokenProvider
import javax.inject.Inject
import javax.inject.Singleton
import timber.log.Timber

/**
* Push provider implementation backed by Firebase Cloud Messaging.
*
* Only available in the "full" build flavor.
*/
@Singleton
class FcmPushProvider @Inject constructor(
private val prefsRepository: PrefsRepository,
private val messagingTokenProvider: MessagingTokenProvider,
) : PushProvider {

override val name: String = NAME

override suspend fun isAvailable(): Boolean {
return try {
val token = messagingTokenProvider()
!token.isBlank()
} catch (e: Exception) {
Timber.e(e, "FCM is not available")
false
}
}

override suspend fun isActive(): Boolean {
// FCM is active only when UnifiedPush is not enabled and a valid token exists.
if (prefsRepository.isUnifiedPushEnabled()) return false
return try {
val token = messagingTokenProvider()
!token.isBlank()
} catch (e: Exception) {
false
}
}

override suspend fun register(): PushRegistrationResult? {
return try {
val token = messagingTokenProvider()
if (token.isBlank()) {
Timber.w("FCM token is blank")
null
} else {
PushRegistrationResult(
pushToken = token.value,
pushUrl = "", // Empty URL means use built-in push URL
encrypt = false,
)
}
} catch (e: Exception) {
Timber.e(e, "Failed to register FCM")
null
}
}

override suspend fun unregister() {
// FCM doesn't need explicit unregistration in this context.
// Token invalidation is handled by Firebase automatically.
Timber.d("FCM unregister called (no-op)")
}

companion object {
const val NAME = "FCM"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package io.homeassistant.companion.android.push

import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import io.homeassistant.companion.android.common.push.PushProvider

/**
* Dagger module that provides push provider implementations for the full flavor.
* Includes FCM, UnifiedPush, and WebSocket providers.
*/
@Module
@InstallIn(SingletonComponent::class)
abstract class PushProviderModule {

@Binds
@IntoSet
abstract fun bindFcmPushProvider(provider: FcmPushProvider): PushProvider

@Binds
@IntoSet
abstract fun bindUnifiedPushProvider(provider: UnifiedPushProvider): PushProvider

@Binds
@IntoSet
abstract fun bindWebSocketPushProvider(provider: WebSocketPushProvider): PushProvider
}
12 changes: 12 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,18 @@
android:value="true" />
</service>

<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>

<receiver
android:name=".notifications.NotificationActionReceiver"
android:enabled="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
Expand Down Expand Up @@ -284,6 +285,57 @@ class MessagingManager @Inject constructor(

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

suspend fun isUnifiedPushEnabled(): Boolean = prefsRepository.isUnifiedPushEnabled()

suspend fun setUnifiedPushEnabled(enabled: Boolean) = prefsRepository.setUnifiedPushEnabled(enabled)

fun handleMessage(
notificationData: Map<String, Any>,
source: String,
serverId: Int = ServerManager.SERVER_ID_ACTIVE,
) {
val flattened = mutableMapOf<String, String>()
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()
}
}
}
// Message and title are in the root unlike all the others.
listOf("message", "title").forEach { key ->
if (notificationData.containsKey(key)) {
flattened[key] = notificationData[key].toString()
}
}
if (notificationData.containsKey("registration_info")) {
val registrationInfo = notificationData["registration_info"]
if (registrationInfo is Map<*, *> && registrationInfo.containsKey("webhook_id")) {
flattened["webhook_id"] = registrationInfo["webhook_id"].toString()
}
}
if (!flattened.containsKey("webhook_id")) {
runBlocking {
serverManager.getServer(serverId)
}?.let { server ->
flattened["webhook_id"] = server.connection.webhookId.toString()
}
}
handleMessage(flattened, source)
}

fun handleMessage(notificationData: Map<String, String>, source: String) {
mainScope.launch {
var now = System.currentTimeMillis()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package io.homeassistant.companion.android.push

import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
import io.homeassistant.companion.android.common.push.PushProvider
import io.homeassistant.companion.android.common.push.PushRegistrationResult
import io.homeassistant.companion.android.unifiedpush.UnifiedPushManager
import javax.inject.Inject
import javax.inject.Singleton
import org.unifiedpush.android.connector.UnifiedPush
import timber.log.Timber

/**
* Push provider implementation backed by UnifiedPush.
*
* UnifiedPush allows receiving push notifications via a user-chosen distributor app
* (e.g. ntfy, NextPush) without relying on Google's FCM infrastructure.
*
*/
@Singleton
class UnifiedPushProvider @Inject constructor(
@ApplicationContext private val context: Context,
private val prefsRepository: PrefsRepository,
private val unifiedPushManager: UnifiedPushManager,
) : PushProvider {

override val name: String = NAME

override suspend fun isAvailable(): Boolean {
val distributors = UnifiedPush.getDistributors(context)
return distributors.isNotEmpty()
}

override suspend fun isActive(): Boolean = prefsRepository.isUnifiedPushEnabled()

override suspend fun register(): PushRegistrationResult? {
val distributor = UnifiedPush.getAckDistributor(context)
if (distributor == null) {
Timber.d("No UnifiedPush distributor acknowledged")
return null
}
// Registration happens asynchronously via UnifiedPushReceiver.
// The actual PushRegistrationResult will be created when onNewEndpoint is called.
UnifiedPushManager.register(context)
return null // Async - result delivered via UnifiedPushReceiver.onNewEndpoint
}

override suspend fun unregister() {
UnifiedPushManager.unregister(context)
prefsRepository.setUnifiedPushEnabled(false)
}

companion object {
const val NAME = "UnifiedPush"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package io.homeassistant.companion.android.push

import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.common.push.PushProvider
import io.homeassistant.companion.android.common.push.PushRegistrationResult
import io.homeassistant.companion.android.database.settings.SettingsDao
import io.homeassistant.companion.android.database.settings.WebsocketSetting
import javax.inject.Inject
import javax.inject.Singleton
import timber.log.Timber

/**
* Push provider implementation backed by a persistent WebSocket connection.
*
* This is always available and uses a persistent connection.
* Used by the minimal flavor when no other provider is selected.
*/
@Singleton
class WebSocketPushProvider @Inject constructor(
@ApplicationContext private val context: Context,
private val serverManager: ServerManager,
private val settingsDao: SettingsDao,
) : PushProvider {

override val name: String = NAME

override val requiresPersistentConnection: Boolean = true

override suspend fun isAvailable(): Boolean = true

override suspend fun isActive(): Boolean {
if (!serverManager.isRegistered()) return false
return serverManager.servers().any { server ->
val setting = settingsDao.get(server.id)?.websocketSetting
setting != null && setting != WebsocketSetting.NEVER
}
}

override suspend fun register(): PushRegistrationResult {
Timber.d("WebSocket push provider registered (persistent connection mode)")
return PushRegistrationResult(
pushToken = "",
pushUrl = null,
encrypt = false,
)
}

override suspend fun unregister() {
Timber.d("WebSocket push provider unregistered")
}

companion object {
const val NAME = "WebSocket"
}
}
Loading
Loading