Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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>

Comment on lines +1024 to +1035
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.
<receiver
android:name=".notifications.NotificationActionReceiver"
android:enabled="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,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()
}
}
}
Comment on lines +297 to +315
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.
// 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)
Comment on lines +328 to +335
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.
}

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