Skip to content
Draft
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
209 changes: 136 additions & 73 deletions app/gradle.lockfile

Large diffs are not rendered by default.

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,77 @@
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 kotlin.coroutines.cancellation.CancellationException
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) {
if (e is CancellationException) throw e
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) {
if (e is CancellationException) throw e
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) {
if (e is CancellationException) throw e
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, WebSocket, and UnifiedPush providers.
*/
@Module
@InstallIn(SingletonComponent::class)
abstract class PushProviderModule {

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

@Binds
@IntoSet
abstract fun bindWebSocketPushProvider(provider: WebSocketPushProvider): PushProvider

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

<service
android:name=".unifiedpush.UnifiedPushService"
android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
</intent-filter>
</service>

<receiver
android:name=".notifications.NotificationActionReceiver"
android:enabled="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,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 @@ -286,6 +287,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 UnifiedPushService.
// The actual PushRegistrationResult will be created when onNewEndpoint is called.
UnifiedPushManager.register(context)
return null // Async - result delivered via UnifiedPushService.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,54 @@
package io.homeassistant.companion.android.push

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