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/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"/> + ("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/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 033e785cf63..49237bad467 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -945,6 +945,11 @@ 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 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) + } +} 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, ), )