From de68f115d01c344321e8c7914b7e2010a69ce2e6 Mon Sep 17 00:00:00 2001 From: Sk7n4k3d Date: Thu, 26 Mar 2026 17:56:45 +0100 Subject: [PATCH 1/4] refactor: add PushProvider interface and manager Introduce PushProvider abstraction for push notification providers (FCM, WebSocket) with PushProviderManager for runtime selection. Add pushUrl and pushEncrypt fields to DeviceRegistration to support alternative push endpoints. Update IntegrationRepositoryImpl to persist and send these new fields during device registration. --- .../data/integration/DeviceRegistration.kt | 2 + .../impl/IntegrationRepositoryImpl.kt | 19 +++- .../android/common/push/PushProvider.kt | 58 ++++++++++ .../common/push/PushProviderManager.kt | 102 ++++++++++++++++++ .../MobileAppIntegrationPresenterImpl.kt | 1 + .../android/phone/PhoneSettingsListener.kt | 1 + 6 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 common/src/main/kotlin/io/homeassistant/companion/android/common/push/PushProvider.kt create mode 100644 common/src/main/kotlin/io/homeassistant/companion/android/common/push/PushProviderManager.kt diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/DeviceRegistration.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/DeviceRegistration.kt index f9bc429faa2..2b108bf1df0 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/DeviceRegistration.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/DeviceRegistration.kt @@ -8,7 +8,9 @@ data class DeviceRegistration( val appVersion: AppVersion? = null, val deviceName: String? = null, var pushToken: MessagingToken? = null, + var pushUrl: String? = null, var pushWebsocket: Boolean = true, + var pushEncrypt: Boolean = false, ) @Qualifier diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt index afadce47467..57878013fac 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt @@ -89,6 +89,9 @@ class IntegrationRepositoryImpl @AssistedInject constructor( // Note: _not_ server-specific private const val PREF_PUSH_TOKEN = "push_token" + // Note: _not_ server-specific + private const val PREF_PUSH_URL = "push_url" + // Note: _not_ server-specific private const val PREF_ORPHANED_THREAD_BORDER_AGENT_IDS = "orphaned_thread_border_agent_ids" @@ -188,6 +191,7 @@ class IntegrationRepositoryImpl @AssistedInject constructor( localStorage.getString(PREF_APP_VERSION)?.let { AppVersion.from(rawVersion = it) }, server().deviceName, localStorage.getString(PREF_PUSH_TOKEN)?.let { MessagingToken(it) }, + localStorage.getString(PREF_PUSH_URL), ) } @@ -201,6 +205,9 @@ class IntegrationRepositoryImpl @AssistedInject constructor( deviceRegistration.pushToken?.let { localStorage.putString(PREF_PUSH_TOKEN, it.value) } + if (deviceRegistration.pushUrl != null) { + localStorage.putString(PREF_PUSH_URL, deviceRegistration.pushUrl) + } } override suspend fun deletePreferences() { @@ -670,11 +677,19 @@ class IntegrationRepositoryImpl @AssistedInject constructor( private suspend fun createUpdateRegistrationRequest(deviceRegistration: DeviceRegistration): RegisterDeviceRequest { val oldDeviceRegistration = getRegistration() val pushToken = deviceRegistration.pushToken ?: oldDeviceRegistration.pushToken + val pushUrl = deviceRegistration.pushUrl ?: oldDeviceRegistration.pushUrl val appData = mutableMapOf("push_websocket_channel" to deviceRegistration.pushWebsocket) if (!pushToken.isNullOrBlank()) { - appData["push_url"] = PUSH_URL - appData["push_token"] = pushToken + appData["push_url"] = pushUrl?.ifBlank { PUSH_URL } ?: PUSH_URL + appData["push_token"] = pushToken.value + // Encryption is controlled by the push provider: UnifiedPush sets this to true + // when public keys are available, FCM/WebSocket leave it false. + appData["push_encrypt"] = deviceRegistration.pushEncrypt + } else if (!pushUrl.isNullOrBlank()) { + appData["push_url"] = pushUrl + appData["push_token"] = "" + appData["push_encrypt"] = false } return RegisterDeviceRequest( diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/push/PushProvider.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/push/PushProvider.kt new file mode 100644 index 00000000000..c7777a1a135 --- /dev/null +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/push/PushProvider.kt @@ -0,0 +1,58 @@ +package io.homeassistant.companion.android.common.push + +/** + * Abstraction for push notification providers (FCM, UnifiedPush, WebSocket). + * + * Each provider is responsible for: + * - Managing its own registration lifecycle + * - Providing the token/endpoint used to receive notifications + * - Indicating whether it requires a persistent connection (e.g. WebSocket) + * + * Implementations should be registered via Dagger multibinding so that + * [PushProviderManager] can select the best available provider at runtime. + */ +interface PushProvider { + + /** Human-readable name used for logging and notification source attribution. */ + val name: String + + /** Whether this provider is currently available on this device/build. */ + suspend fun isAvailable(): Boolean + + /** + * Whether this provider is currently the active push provider. + * An active provider is one that has been successfully registered and is delivering messages. + */ + suspend fun isActive(): Boolean + + /** + * Attempt to register this provider. + * + * @return a [PushRegistrationResult] describing the token/endpoint to send to the server, + * or `null` if registration failed. + */ + suspend fun register(): PushRegistrationResult? + + /** + * Unregister this provider. Called when switching to a different provider + * or when the user disables push notifications. + */ + suspend fun unregister() + + /** + * Whether this provider uses a persistent connection (like WebSocket) + * rather than a push endpoint. + */ + val requiresPersistentConnection: Boolean get() = false +} + +/** + * Result of a successful push provider registration. + * + * @property pushToken The token or key used to identify this device. + * For FCM this is the FCM token; for UnifiedPush this is the public key pair. + * @property pushUrl The URL where the server should send push notifications. + * Empty or null for FCM (uses built-in URL); non-empty for UnifiedPush. + * @property encrypt Whether the server should encrypt notifications before sending. + */ +data class PushRegistrationResult(val pushToken: String, val pushUrl: String? = null, val encrypt: Boolean = false) diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/push/PushProviderManager.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/push/PushProviderManager.kt new file mode 100644 index 00000000000..b12ba0f7076 --- /dev/null +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/push/PushProviderManager.kt @@ -0,0 +1,102 @@ +package io.homeassistant.companion.android.common.push + +import io.homeassistant.companion.android.common.data.integration.DeviceRegistration +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.common.util.MessagingToken +import javax.inject.Inject +import javax.inject.Singleton +import timber.log.Timber + +/** + * Manages the selection and lifecycle of push notification providers. + * + * Providers are injected via Dagger multibinding. The user chooses which provider + * to use via the app settings — there is no automatic priority-based selection. + */ +@Singleton +class PushProviderManager @Inject constructor( + private val providers: Set<@JvmSuppressWildcards PushProvider>, + private val serverManager: ServerManager, +) { + + /** + * Get the currently active push provider, if any. + */ + suspend fun getActiveProvider(): PushProvider? = providers.firstOrNull { it.isActive() } + + /** + * Get all registered providers. + */ + fun getAllProviders(): List = providers.toList() + + /** + * Get a provider by name. + */ + fun getProvider(name: String): PushProvider? = providers.firstOrNull { it.name == name } + + /** + * Register the user-selected push provider by name. + * If a different provider was previously active, it will be unregistered first. + * + * @param providerName The name of the provider the user selected. + * @return the [PushRegistrationResult] from the newly active provider, or null if registration failed. + */ + suspend fun selectAndRegister(providerName: String): PushRegistrationResult? { + val target = getProvider(providerName) + if (target == null) { + Timber.w("Provider '$providerName' not found") + return null + } + if (!target.isAvailable()) { + Timber.w("Provider '$providerName' is not available") + return null + } + + val currentActive = getActiveProvider() + + // Unregister current if switching providers + if (currentActive != null && currentActive.name != target.name) { + Timber.d("Switching push provider from ${currentActive.name} to ${target.name}") + currentActive.unregister() + } + + Timber.d("Registering push provider: ${target.name}") + return target.register() + } + + /** + * Update the server registration with the given push registration result. + * + * @param result The registration result from a push provider. + * @param serverId The specific server ID to update, or null to update all default servers. + */ + suspend fun updateServerRegistration(result: PushRegistrationResult, serverId: Int? = null) { + if (!serverManager.isRegistered()) { + Timber.d("Not updating registration: not authenticated") + return + } + + val deviceRegistration = DeviceRegistration( + pushToken = MessagingToken(result.pushToken), + pushUrl = result.pushUrl ?: "", + pushEncrypt = result.encrypt, + ) + + val servers = if (serverId != null) { + listOfNotNull(serverManager.getServer(serverId)) + } else { + serverManager.servers() + } + + servers.forEach { server -> + try { + serverManager.integrationRepository(server.id).updateRegistration( + deviceRegistration = deviceRegistration, + allowReregistration = false, + ) + } catch (e: Exception) { + Timber.e(e, "Failed to update push registration for server ${server.id}") + } + } + } +} diff --git a/wear/src/main/kotlin/io/homeassistant/companion/android/onboarding/integration/MobileAppIntegrationPresenterImpl.kt b/wear/src/main/kotlin/io/homeassistant/companion/android/onboarding/integration/MobileAppIntegrationPresenterImpl.kt index 18385021ebf..84b00a43cf9 100644 --- a/wear/src/main/kotlin/io/homeassistant/companion/android/onboarding/integration/MobileAppIntegrationPresenterImpl.kt +++ b/wear/src/main/kotlin/io/homeassistant/companion/android/onboarding/integration/MobileAppIntegrationPresenterImpl.kt @@ -34,6 +34,7 @@ class MobileAppIntegrationPresenterImpl @Inject constructor( appVersionProvider(), deviceName, messagingTokenProvider(), + null, false, ) } diff --git a/wear/src/main/kotlin/io/homeassistant/companion/android/phone/PhoneSettingsListener.kt b/wear/src/main/kotlin/io/homeassistant/companion/android/phone/PhoneSettingsListener.kt index 163eafd7737..4a84c38a230 100755 --- a/wear/src/main/kotlin/io/homeassistant/companion/android/phone/PhoneSettingsListener.kt +++ b/wear/src/main/kotlin/io/homeassistant/companion/android/phone/PhoneSettingsListener.kt @@ -208,6 +208,7 @@ class PhoneSettingsListener : appVersionProvider(), deviceName, messagingTokenProvider(), + null, false, ), ) From c37ca013e65cbc36cb292a8822f5163a4d2543fe Mon Sep 17 00:00:00 2001 From: Sk7n4k3d Date: Thu, 26 Mar 2026 17:56:51 +0100 Subject: [PATCH 2/4] refactor: implement FcmPushProvider and WebSocketPushProvider Add concrete PushProvider implementations: - FcmPushProvider: wraps Firebase Cloud Messaging (full flavor only) - WebSocketPushProvider: uses persistent WebSocket connection Wire providers via Dagger multibinding in PushProviderModule for both full and minimal flavors. --- .../companion/android/push/FcmPushProvider.kt | 69 +++++++++++++++++++ .../android/push/PushProviderModule.kt | 25 +++++++ .../android/push/WebSocketPushProvider.kt | 57 +++++++++++++++ .../android/push/PushProviderModule.kt | 21 ++++++ 4 files changed, 172 insertions(+) create mode 100644 app/src/full/kotlin/io/homeassistant/companion/android/push/FcmPushProvider.kt create mode 100644 app/src/full/kotlin/io/homeassistant/companion/android/push/PushProviderModule.kt create mode 100644 app/src/main/kotlin/io/homeassistant/companion/android/push/WebSocketPushProvider.kt create mode 100644 app/src/minimal/kotlin/io/homeassistant/companion/android/push/PushProviderModule.kt diff --git a/app/src/full/kotlin/io/homeassistant/companion/android/push/FcmPushProvider.kt b/app/src/full/kotlin/io/homeassistant/companion/android/push/FcmPushProvider.kt new file mode 100644 index 00000000000..dbd1b3bf68f --- /dev/null +++ b/app/src/full/kotlin/io/homeassistant/companion/android/push/FcmPushProvider.kt @@ -0,0 +1,69 @@ +package io.homeassistant.companion.android.push + +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 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 { + 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" + } +} diff --git a/app/src/full/kotlin/io/homeassistant/companion/android/push/PushProviderModule.kt b/app/src/full/kotlin/io/homeassistant/companion/android/push/PushProviderModule.kt new file mode 100644 index 00000000000..f0e5e13968b --- /dev/null +++ b/app/src/full/kotlin/io/homeassistant/companion/android/push/PushProviderModule.kt @@ -0,0 +1,25 @@ +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 and WebSocket providers. + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class PushProviderModule { + + @Binds + @IntoSet + abstract fun bindFcmPushProvider(provider: FcmPushProvider): PushProvider + + @Binds + @IntoSet + abstract fun bindWebSocketPushProvider(provider: WebSocketPushProvider): PushProvider +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/push/WebSocketPushProvider.kt b/app/src/main/kotlin/io/homeassistant/companion/android/push/WebSocketPushProvider.kt new file mode 100644 index 00000000000..d768fec6681 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/push/WebSocketPushProvider.kt @@ -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" + } +} diff --git a/app/src/minimal/kotlin/io/homeassistant/companion/android/push/PushProviderModule.kt b/app/src/minimal/kotlin/io/homeassistant/companion/android/push/PushProviderModule.kt new file mode 100644 index 00000000000..ba6f9bca909 --- /dev/null +++ b/app/src/minimal/kotlin/io/homeassistant/companion/android/push/PushProviderModule.kt @@ -0,0 +1,21 @@ +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 minimal flavor. + * Includes WebSocket provider only (no FCM). + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class PushProviderModule { + + @Binds + @IntoSet + abstract fun bindWebSocketPushProvider(provider: WebSocketPushProvider): PushProvider +} From 379b8de97dff30d4f4e6a61c3146fc49f44b3c97 Mon Sep 17 00:00:00 2001 From: Sk7n4k3d Date: Thu, 26 Mar 2026 17:58:30 +0100 Subject: [PATCH 3/4] refactor: wire PushProviderManager into settings and launch Add push provider selection preference to settings UI, allowing users to choose between available push providers (FCM, WebSocket). Update SettingsPresenter/Impl to expose provider list and handle provider changes. Add WebsocketManager.restart() for re-enabling WebSocket when switching back to it. --- .../android/settings/SettingsFragment.kt | 44 +++++++++++++++++++ .../android/settings/SettingsPresenter.kt | 3 ++ .../android/settings/SettingsPresenterImpl.kt | 31 +++++++++++++ .../android/websocket/WebsocketManager.kt | 11 +++++ app/src/main/res/xml/preferences.xml | 5 +++ common/src/main/res/values/strings.xml | 5 +++ 6 files changed, 99 insertions(+) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsFragment.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsFragment.kt index bc8e65db4e2..9d06f81723a 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsFragment.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsFragment.kt @@ -8,6 +8,7 @@ import android.os.Build import android.os.Bundle import android.provider.Settings import android.view.View +import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.content.res.AppCompatResources import androidx.core.app.NotificationManagerCompat @@ -54,12 +55,14 @@ import io.homeassistant.companion.android.settings.widgets.ManageWidgetsSettings import io.homeassistant.companion.android.util.QuestUtil import io.homeassistant.companion.android.util.applyBottomSafeDrawingInsets import io.homeassistant.companion.android.webview.WebViewActivity +import io.homeassistant.companion.android.websocket.WebsocketManager import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber @@ -250,6 +253,7 @@ class SettingsFragment( } updateNotificationChannelPrefs() + updatePushProviderPrefs() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { findPreference("notification_permission")?.let { @@ -541,6 +545,46 @@ class SettingsFragment( } } + private fun updatePushProviderPrefs() { + findPreference("notification_push_provider")?.let { pref -> + pref.preferenceDataStore = null + pref.setOnPreferenceChangeListener { _, newValue -> + val value = newValue as? String + lifecycleScope.launch(Dispatchers.IO) { + presenter.handlePushProviderChange(value) + } + if (value == "WebSocket") { + Toast.makeText(requireContext(), commonR.string.push_provider_websocket_enabled, Toast.LENGTH_SHORT).show() + lifecycleScope.launch { + WebsocketManager.restart(requireContext()) + } + } + true + } + + lifecycleScope.launch(Dispatchers.IO) { + val entries = mutableListOf() + val values = mutableListOf() + + val providers = presenter.getAvailablePushProviders() + for (provider in providers) { + entries.add(provider.second) + values.add(provider.first) + } + + val activeValue = presenter.getActivePushProviderValue() + + withContext(Dispatchers.Main) { + pref.entries = entries.toTypedArray() + pref.entryValues = values.toTypedArray() + if (pref.value == null || pref.value !in values) { + pref.value = activeValue + } + } + } + } + } + private fun onServerLockResult(result: Int): Boolean { if (result == Authenticator.SUCCESS && serverAuth != null) { (activity as? SettingsActivity)?.setAppActive(serverAuth, true) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenter.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenter.kt index 410a6d1b7c3..9c0e67294ae 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenter.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenter.kt @@ -21,6 +21,9 @@ interface SettingsPresenter { fun getSuggestionFlow(): StateFlow suspend fun getServersFlow(): Flow> suspend fun getNotificationRateLimits(): RateLimitResponse? + fun getAvailablePushProviders(): List> + fun getActivePushProviderValue(): String + fun handlePushProviderChange(value: String?) suspend fun showChangeLog(context: Context) suspend fun isChangeLogPopupEnabled(): Boolean suspend fun setChangeLogPopupEnabled(enabled: Boolean) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt index 0740d0a86e9..5112df8e37a 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt @@ -13,6 +13,7 @@ import io.homeassistant.companion.android.common.data.integration.impl.entities. import io.homeassistant.companion.android.common.data.prefs.NightModeTheme import io.homeassistant.companion.android.common.data.prefs.PrefsRepository import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.common.push.PushProviderManager import io.homeassistant.companion.android.database.server.Server import io.homeassistant.companion.android.database.settings.SettingsDao import io.homeassistant.companion.android.settings.assist.DefaultAssistantManager @@ -37,6 +38,7 @@ class SettingsPresenterImpl @Inject constructor( private val prefsRepository: PrefsRepository, private val nightModeManager: NightModeManager, private val langsManager: LanguagesManager, + private val pushProviderManager: PushProviderManager, private val changeLog: ChangeLog, private val settingsDao: SettingsDao, private val defaultAssistantManager: DefaultAssistantManager, @@ -113,6 +115,7 @@ class SettingsPresenterImpl @Inject constructor( "languages" -> langsManager.getCurrentLang() "page_zoom" -> prefsRepository.getPageZoomLevel().toString() "screen_orientation" -> prefsRepository.getScreenOrientation() + "notification_push_provider" -> selectedPushProvider else -> throw IllegalArgumentException("No string found by this key: $key") } } @@ -124,6 +127,7 @@ class SettingsPresenterImpl @Inject constructor( "languages" -> langsManager.saveLang(value) "page_zoom" -> prefsRepository.setPageZoomLevel(value?.toIntOrNull()) "screen_orientation" -> prefsRepository.saveScreenOrientation(value) + "notification_push_provider" -> handlePushProviderChange(value) else -> throw IllegalArgumentException("No string found by this key: $key") } } @@ -158,6 +162,33 @@ class SettingsPresenterImpl @Inject constructor( } } + override fun getAvailablePushProviders(): List> { + val result = mutableListOf>() + for (provider in pushProviderManager.getAllProviders()) { + val label = when (provider.name) { + "FCM" -> "Firebase Cloud Messaging" + "WebSocket" -> "WebSocket" + else -> provider.name + } + result.add(provider.name to label) + } + return result + } + + private var selectedPushProvider: String? = null + + override fun getActivePushProviderValue(): String { + selectedPushProvider?.let { return it } + val value = "WebSocket" + selectedPushProvider = value + return value + } + + override fun handlePushProviderChange(value: String?) { + if (value == null) return + selectedPushProvider = value + } + override suspend fun showChangeLog(context: Context) { changeLog.showChangeLog(context, true) } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/websocket/WebsocketManager.kt b/app/src/main/kotlin/io/homeassistant/companion/android/websocket/WebsocketManager.kt index 1f9faddb533..9cf12dbd343 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/websocket/WebsocketManager.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/websocket/WebsocketManager.kt @@ -62,6 +62,17 @@ class WebsocketManager(appContext: Context, workerParams: WorkerParameters) : WebsocketSetting.ALWAYS } + suspend fun restart(context: Context) { + val websocketNotifications = + PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + UNIQUE_WORK_NAME, + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + websocketNotifications, + ) + } + suspend fun start(context: Context) { val websocketNotifications = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 90faa41c188..ec05e9090da 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -131,6 +131,11 @@ app:enableCopying="true" android:icon="@drawable/ic_notifications" android:summary="@string/rate_limit_summary"/> + Unable to process notification \"%1$s\" as text to speech. Failed to initialize a text to speech engine. Please set the text for text to speech to process + Unable to register application + Push provider + Firebase Cloud Messaging + WebSocket + WebSocket push enabled Unknown address Update shortcut data Update widget From a60ff6f59623ac1eb8051b15044a646d70ea288c Mon Sep 17 00:00:00 2001 From: Sk7n4k3d Date: Thu, 26 Mar 2026 17:58:43 +0100 Subject: [PATCH 4/4] test: add PushProvider unit tests Add tests for PushProvider interface contract and PushProviderManager selection, registration, and server update logic. --- .../common/push/PushProviderManagerTest.kt | 204 ++++++++++++++++++ .../android/common/push/PushProviderTest.kt | 77 +++++++ 2 files changed, 281 insertions(+) create mode 100644 common/src/test/kotlin/io/homeassistant/companion/android/common/push/PushProviderManagerTest.kt create mode 100644 common/src/test/kotlin/io/homeassistant/companion/android/common/push/PushProviderTest.kt diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/common/push/PushProviderManagerTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/common/push/PushProviderManagerTest.kt new file mode 100644 index 00000000000..f53ced80e07 --- /dev/null +++ b/common/src/test/kotlin/io/homeassistant/companion/android/common/push/PushProviderManagerTest.kt @@ -0,0 +1,204 @@ +package io.homeassistant.companion.android.common.push + +import io.homeassistant.companion.android.common.data.integration.IntegrationRepository +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.database.server.Server +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class PushProviderManagerTest { + + private lateinit var serverManager: ServerManager + private lateinit var integrationRepository: IntegrationRepository + + @Before + fun setUp() { + serverManager = mockk(relaxed = true) + integrationRepository = mockk(relaxed = true) + coEvery { serverManager.isRegistered() } returns true + coEvery { serverManager.integrationRepository(any()) } returns integrationRepository + coEvery { integrationRepository.updateRegistration(any(), any()) } just Runs + } + + private fun createProvider( + name: String, + available: Boolean = true, + active: Boolean = false, + registrationResult: PushRegistrationResult? = null, + ): PushProvider = object : PushProvider { + override val name = name + override suspend fun isAvailable() = available + override suspend fun isActive() = active + override suspend fun register() = registrationResult + override suspend fun unregister() {} + } + + @Test + fun `getActiveProvider returns the active provider`() = runTest { + val provider1 = createProvider("FCM", active = false) + val provider2 = createProvider("UnifiedPush", active = true) + val manager = PushProviderManager(setOf(provider1, provider2), serverManager) + + val active = manager.getActiveProvider() + assertNotNull(active) + assertEquals("UnifiedPush", active!!.name) + } + + @Test + fun `getActiveProvider returns null when no provider is active`() = runTest { + val provider1 = createProvider("FCM", active = false) + val provider2 = createProvider("UnifiedPush", active = false) + val manager = PushProviderManager(setOf(provider1, provider2), serverManager) + + assertNull(manager.getActiveProvider()) + } + + @Test + fun `selectAndRegister registers the requested provider`() = runTest { + val result = PushRegistrationResult("token123", "https://push.example.com", true) + val provider1 = createProvider("FCM", available = true, registrationResult = PushRegistrationResult("fcm-token", "")) + val provider2 = createProvider("UnifiedPush", available = true, registrationResult = result) + val manager = PushProviderManager(setOf(provider1, provider2), serverManager) + + val reg = manager.selectAndRegister("UnifiedPush") + assertNotNull(reg) + assertEquals("token123", reg!!.pushToken) + assertEquals(true, reg.encrypt) + } + + @Test + fun `selectAndRegister returns null when requested provider unavailable`() = runTest { + val provider1 = createProvider("FCM", available = true, registrationResult = PushRegistrationResult("fcm-token", "")) + val provider2 = createProvider("UnifiedPush", available = false) + val manager = PushProviderManager(setOf(provider1, provider2), serverManager) + + val reg = manager.selectAndRegister("UnifiedPush") + assertNull(reg) + } + + @Test + fun `selectAndRegister returns null when provider not found`() = runTest { + val provider1 = createProvider("FCM", available = true) + val manager = PushProviderManager(setOf(provider1), serverManager) + + assertNull(manager.selectAndRegister("UnifiedPush")) + } + + @Test + fun `getAllProviders returns all providers`() { + val provider1 = createProvider("WebSocket") + val provider2 = createProvider("FCM") + val provider3 = createProvider("UnifiedPush") + val manager = PushProviderManager(setOf(provider1, provider2, provider3), serverManager) + + val all = manager.getAllProviders() + assertEquals(3, all.size) + } + + @Test + fun `getProvider returns provider by name`() { + val provider1 = createProvider("FCM") + val provider2 = createProvider("UnifiedPush") + val manager = PushProviderManager(setOf(provider1, provider2), serverManager) + + val found = manager.getProvider("FCM") + assertNotNull(found) + assertEquals("FCM", found!!.name) + } + + @Test + fun `getProvider returns null for unknown name`() { + val provider1 = createProvider("FCM") + val manager = PushProviderManager(setOf(provider1), serverManager) + + assertNull(manager.getProvider("UnifiedPush")) + } + + @Test + fun `updateServerRegistration updates all default servers`() = runTest { + val server1 = mockk(relaxed = true) { every { id } returns 1 } + val server2 = mockk(relaxed = true) { every { id } returns 2 } + coEvery { serverManager.servers() } returns listOf(server1, server2) + + val manager = PushProviderManager(emptySet(), serverManager) + val result = PushRegistrationResult("token", "https://push.example.com", true) + + manager.updateServerRegistration(result) + + coVerify(exactly = 1) { serverManager.integrationRepository(1) } + coVerify(exactly = 1) { serverManager.integrationRepository(2) } + coVerify(exactly = 2) { integrationRepository.updateRegistration(any(), any()) } + } + + @Test + fun `updateServerRegistration updates single server when serverId specified`() = runTest { + val server = mockk(relaxed = true) { every { id } returns 42 } + coEvery { serverManager.getServer(42) } returns server + + val manager = PushProviderManager(emptySet(), serverManager) + val result = PushRegistrationResult("token", "", false) + + manager.updateServerRegistration(result, serverId = 42) + + coVerify(exactly = 1) { serverManager.integrationRepository(42) } + coVerify(exactly = 1) { integrationRepository.updateRegistration(any(), any()) } + } + + @Test + fun `updateServerRegistration skips when not authenticated`() = runTest { + coEvery { serverManager.isRegistered() } returns false + + val manager = PushProviderManager(emptySet(), serverManager) + val result = PushRegistrationResult("token", "", false) + + manager.updateServerRegistration(result) + + coVerify(exactly = 0) { integrationRepository.updateRegistration(any(), any()) } + } + + @Test + fun `selectAndRegister unregisters current provider when switching`() = runTest { + var unregistered = false + val currentProvider = object : PushProvider { + override val name = "FCM" + override suspend fun isAvailable() = true + override suspend fun isActive() = true + override suspend fun register() = PushRegistrationResult("fcm", "") + override suspend fun unregister() { + unregistered = true + } + } + val newResult = PushRegistrationResult("up-token", "https://up.example.com", true) + val newProvider = object : PushProvider { + override val name = "UnifiedPush" + override suspend fun isAvailable() = true + override suspend fun isActive() = false + override suspend fun register() = newResult + override suspend fun unregister() {} + } + + val manager = PushProviderManager(setOf(currentProvider, newProvider), serverManager) + val reg = manager.selectAndRegister("UnifiedPush") + + assertEquals(true, unregistered) + assertNotNull(reg) + assertEquals("up-token", reg!!.pushToken) + } + + @Test + fun `PushRegistrationResult default values`() { + val result = PushRegistrationResult("token") + assertNull(result.pushUrl) + assertEquals(false, result.encrypt) + } +} diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/common/push/PushProviderTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/common/push/PushProviderTest.kt new file mode 100644 index 00000000000..2a89ebda977 --- /dev/null +++ b/common/src/test/kotlin/io/homeassistant/companion/android/common/push/PushProviderTest.kt @@ -0,0 +1,77 @@ +package io.homeassistant.companion.android.common.push + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PushProviderTest { + + @Test + fun `PushProvider default requiresPersistentConnection is false`() { + val provider = object : PushProvider { + override val name = "Test" + + override suspend fun isAvailable() = true + override suspend fun isActive() = false + override suspend fun register() = null + override suspend fun unregister() {} + } + assertFalse(provider.requiresPersistentConnection) + } + + @Test + fun `PushProvider can override requiresPersistentConnection`() { + val provider = object : PushProvider { + override val name = "WebSocket" + override val requiresPersistentConnection = true + override suspend fun isAvailable() = true + override suspend fun isActive() = false + override suspend fun register() = null + override suspend fun unregister() {} + } + assertTrue(provider.requiresPersistentConnection) + } + + @Test + fun `PushRegistrationResult preserves all fields`() { + val result = PushRegistrationResult( + pushToken = "auth:pubkey", + pushUrl = "https://ntfy.example.com/up123", + encrypt = true, + ) + assertEquals("auth:pubkey", result.pushToken) + assertEquals("https://ntfy.example.com/up123", result.pushUrl) + assertTrue(result.encrypt) + } + + @Test + fun `PushRegistrationResult with empty token and no url`() { + val result = PushRegistrationResult(pushToken = "") + assertEquals("", result.pushToken) + assertNull(result.pushUrl) + assertFalse(result.encrypt) + } + + @Test + fun `PushRegistrationResult equals and hashCode work correctly`() { + val result1 = PushRegistrationResult("token", "url", true) + val result2 = PushRegistrationResult("token", "url", true) + val result3 = PushRegistrationResult("other", "url", true) + + assertEquals(result1, result2) + assertEquals(result1.hashCode(), result2.hashCode()) + assertFalse(result1 == result3) + } + + @Test + fun `PushRegistrationResult copy works correctly`() { + val original = PushRegistrationResult("token", "url", true) + val copied = original.copy(pushToken = "new-token") + + assertEquals("new-token", copied.pushToken) + assertEquals("url", copied.pushUrl) + assertTrue(copied.encrypt) + } +}