-
-
Notifications
You must be signed in to change notification settings - Fork 960
Refactor: add PushProvider abstraction for FCM/WebSocket #6633
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
de68f11
c37ca01
379b8de
a60ff6f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } |
| 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 | ||
| } | ||
|
Comment on lines
+21
to
+38
|
||
| } | ||
|
|
||
| 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" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<Preference>("notification_permission")?.let { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -541,6 +545,46 @@ class SettingsFragment( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private fun updatePushProviderPrefs() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| findPreference<ListPreference>("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<String>() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val values = mutableListOf<String>() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+549
to
+585
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| findPreference<ListPreference>("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<String>() | |
| val values = mutableListOf<String>() | |
| 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 | |
| } | |
| } | |
| } | |
| } | |
| // The push provider selection is not fully wired to server registration yet. | |
| // Hide the preference to avoid exposing a non-functional setting. | |
| findPreference<ListPreference>("notification_push_provider")?.isVisible = false |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Pair<String, String>> { | ||
| val result = mutableListOf<Pair<String, String>>() | ||
| 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 | ||
|
Comment on lines
+165
to
+175
|
||
| } | ||
|
|
||
| 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 | ||
| } | ||
|
Comment on lines
+178
to
+190
|
||
|
|
||
| override suspend fun showChangeLog(context: Context) { | ||
| changeLog.showChangeLog(context, true) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -62,6 +62,17 @@ class WebsocketManager(appContext: Context, workerParams: WorkerParameters) : | |
| WebsocketSetting.ALWAYS | ||
| } | ||
|
|
||
| suspend fun restart(context: Context) { | ||
| val websocketNotifications = | ||
| PeriodicWorkRequestBuilder<WebsocketManager>(15, TimeUnit.MINUTES) | ||
| .build() | ||
| WorkManager.getInstance(context).enqueueUniquePeriodicWork( | ||
| UNIQUE_WORK_NAME, | ||
| ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, | ||
| websocketNotifications, | ||
| ) | ||
| } | ||
|
Comment on lines
+65
to
+74
|
||
|
|
||
| suspend fun start(context: Context) { | ||
| val websocketNotifications = | ||
| PeriodicWorkRequestBuilder<WebsocketManager>(15, TimeUnit.MINUTES) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These
suspendmethods catch a genericException. In coroutines this also catchesCancellationException, which should be allowed to propagate to avoid breaking cooperative cancellation. Please rethrowCancellationException(or avoid catchingExceptionbroadly) inisAvailable(),isActive(), andregister().