Skip to content

Commit c14a7a2

Browse files
aaronstealthclaude
andcommitted
Add generic PushProvider abstraction for notification delivery
Introduce a PushProvider interface that abstracts push notification delivery, allowing the user to choose between available providers (FCM, WebSocket) via a new "Push provider" setting under Notifications. This lays the groundwork for additional providers (e.g. UnifiedPush) without coupling the abstraction to any specific implementation. - Add PushProvider interface with name, isAvailable, isActive, register, unregister, and requiresPersistentConnection - Add PushProviderManager for provider selection and server registration - Add FcmPushProvider (full flavor) and WebSocketPushProvider - Add Dagger multibinding modules for both flavors - Add "Push provider" ListPreference in Notifications settings - Add WebsocketManager.restart() for instant provider switching - Extend DeviceRegistration with pushUrl and pushEncrypt fields - Add 28 unit tests for PushProvider and PushProviderManager Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 246beff commit c14a7a2

File tree

16 files changed

+725
-2
lines changed

16 files changed

+725
-2
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package io.homeassistant.companion.android.push
2+
3+
import io.homeassistant.companion.android.common.push.PushProvider
4+
import io.homeassistant.companion.android.common.push.PushRegistrationResult
5+
import io.homeassistant.companion.android.common.util.MessagingTokenProvider
6+
import javax.inject.Inject
7+
import javax.inject.Singleton
8+
import timber.log.Timber
9+
10+
/**
11+
* Push provider implementation backed by Firebase Cloud Messaging.
12+
*
13+
* Only available in the "full" build flavor.
14+
*/
15+
@Singleton
16+
class FcmPushProvider @Inject constructor(
17+
private val messagingTokenProvider: MessagingTokenProvider,
18+
) : PushProvider {
19+
20+
override val name: String = NAME
21+
22+
override suspend fun isAvailable(): Boolean {
23+
return try {
24+
val token = messagingTokenProvider()
25+
!token.isBlank()
26+
} catch (e: Exception) {
27+
Timber.e(e, "FCM is not available")
28+
false
29+
}
30+
}
31+
32+
override suspend fun isActive(): Boolean {
33+
return try {
34+
val token = messagingTokenProvider()
35+
!token.isBlank()
36+
} catch (e: Exception) {
37+
false
38+
}
39+
}
40+
41+
override suspend fun register(): PushRegistrationResult? {
42+
return try {
43+
val token = messagingTokenProvider()
44+
if (token.isBlank()) {
45+
Timber.w("FCM token is blank")
46+
null
47+
} else {
48+
PushRegistrationResult(
49+
pushToken = token.value,
50+
pushUrl = "", // Empty URL means use built-in push URL
51+
encrypt = false,
52+
)
53+
}
54+
} catch (e: Exception) {
55+
Timber.e(e, "Failed to register FCM")
56+
null
57+
}
58+
}
59+
60+
override suspend fun unregister() {
61+
// FCM doesn't need explicit unregistration in this context.
62+
// Token invalidation is handled by Firebase automatically.
63+
Timber.d("FCM unregister called (no-op)")
64+
}
65+
66+
companion object {
67+
const val NAME = "FCM"
68+
}
69+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package io.homeassistant.companion.android.push
2+
3+
import dagger.Binds
4+
import dagger.Module
5+
import dagger.hilt.InstallIn
6+
import dagger.hilt.components.SingletonComponent
7+
import dagger.multibindings.IntoSet
8+
import io.homeassistant.companion.android.common.push.PushProvider
9+
10+
/**
11+
* Dagger module that provides push provider implementations for the full flavor.
12+
* Includes FCM and WebSocket providers.
13+
*/
14+
@Module
15+
@InstallIn(SingletonComponent::class)
16+
abstract class PushProviderModule {
17+
18+
@Binds
19+
@IntoSet
20+
abstract fun bindFcmPushProvider(provider: FcmPushProvider): PushProvider
21+
22+
@Binds
23+
@IntoSet
24+
abstract fun bindWebSocketPushProvider(provider: WebSocketPushProvider): PushProvider
25+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package io.homeassistant.companion.android.push
2+
3+
import android.content.Context
4+
import dagger.hilt.android.qualifiers.ApplicationContext
5+
import io.homeassistant.companion.android.common.data.servers.ServerManager
6+
import io.homeassistant.companion.android.common.push.PushProvider
7+
import io.homeassistant.companion.android.common.push.PushRegistrationResult
8+
import io.homeassistant.companion.android.database.settings.SettingsDao
9+
import io.homeassistant.companion.android.database.settings.WebsocketSetting
10+
import javax.inject.Inject
11+
import javax.inject.Singleton
12+
import timber.log.Timber
13+
14+
/**
15+
* Push provider implementation backed by a persistent WebSocket connection.
16+
*
17+
* This is always available and uses a persistent connection.
18+
* Used by the minimal flavor when no other provider is selected.
19+
*/
20+
@Singleton
21+
class WebSocketPushProvider @Inject constructor(
22+
@ApplicationContext private val context: Context,
23+
private val serverManager: ServerManager,
24+
private val settingsDao: SettingsDao,
25+
) : PushProvider {
26+
27+
override val name: String = NAME
28+
29+
override val requiresPersistentConnection: Boolean = true
30+
31+
override suspend fun isAvailable(): Boolean = true
32+
33+
override suspend fun isActive(): Boolean {
34+
if (!serverManager.isRegistered()) return false
35+
return serverManager.servers().any { server ->
36+
val setting = settingsDao.get(server.id)?.websocketSetting
37+
setting != null && setting != WebsocketSetting.NEVER
38+
}
39+
}
40+
41+
override suspend fun register(): PushRegistrationResult {
42+
Timber.d("WebSocket push provider registered (persistent connection mode)")
43+
return PushRegistrationResult(
44+
pushToken = "",
45+
pushUrl = null,
46+
encrypt = false,
47+
)
48+
}
49+
50+
override suspend fun unregister() {
51+
Timber.d("WebSocket push provider unregistered")
52+
}
53+
54+
companion object {
55+
const val NAME = "WebSocket"
56+
}
57+
}

app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsFragment.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package io.homeassistant.companion.android.settings
22

33
import android.app.UiModeManager
44
import android.content.Intent
5+
import android.widget.Toast
56
import android.content.pm.PackageManager
67
import android.content.res.Configuration
78
import android.os.Build
@@ -52,6 +53,7 @@ import io.homeassistant.companion.android.settings.wear.SettingsWearActivity
5253
import io.homeassistant.companion.android.settings.wear.SettingsWearDetection
5354
import io.homeassistant.companion.android.settings.widgets.ManageWidgetsSettingsFragment
5455
import io.homeassistant.companion.android.util.QuestUtil
56+
import io.homeassistant.companion.android.websocket.WebsocketManager
5557
import io.homeassistant.companion.android.util.applyBottomSafeDrawingInsets
5658
import io.homeassistant.companion.android.webview.WebViewActivity
5759
import java.time.Instant
@@ -61,6 +63,7 @@ import java.time.format.FormatStyle
6163
import kotlinx.coroutines.Dispatchers
6264
import kotlinx.coroutines.launch
6365
import kotlinx.coroutines.sync.Mutex
66+
import kotlinx.coroutines.withContext
6467
import kotlinx.coroutines.sync.withLock
6568
import timber.log.Timber
6669

@@ -250,6 +253,7 @@ class SettingsFragment(
250253
}
251254

252255
updateNotificationChannelPrefs()
256+
updatePushProviderPrefs()
253257

254258
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
255259
findPreference<Preference>("notification_permission")?.let {
@@ -541,6 +545,46 @@ class SettingsFragment(
541545
}
542546
}
543547

548+
private fun updatePushProviderPrefs() {
549+
findPreference<ListPreference>("notification_push_provider")?.let { pref ->
550+
pref.preferenceDataStore = null
551+
pref.setOnPreferenceChangeListener { _, newValue ->
552+
val value = newValue as? String
553+
lifecycleScope.launch(Dispatchers.IO) {
554+
presenter.handlePushProviderChange(value)
555+
}
556+
if (value == "WebSocket") {
557+
Toast.makeText(requireContext(), commonR.string.push_provider_websocket_enabled, Toast.LENGTH_SHORT).show()
558+
lifecycleScope.launch {
559+
WebsocketManager.restart(requireContext())
560+
}
561+
}
562+
true
563+
}
564+
565+
lifecycleScope.launch(Dispatchers.IO) {
566+
val entries = mutableListOf<String>()
567+
val values = mutableListOf<String>()
568+
569+
val providers = presenter.getAvailablePushProviders()
570+
for (provider in providers) {
571+
entries.add(provider.second)
572+
values.add(provider.first)
573+
}
574+
575+
val activeValue = presenter.getActivePushProviderValue()
576+
577+
withContext(Dispatchers.Main) {
578+
pref.entries = entries.toTypedArray()
579+
pref.entryValues = values.toTypedArray()
580+
if (pref.value == null || pref.value !in values) {
581+
pref.value = activeValue
582+
}
583+
}
584+
}
585+
}
586+
}
587+
544588
private fun onServerLockResult(result: Int): Boolean {
545589
if (result == Authenticator.SUCCESS && serverAuth != null) {
546590
(activity as? SettingsActivity)?.setAppActive(serverAuth, true)

app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenter.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,7 @@ interface SettingsPresenter {
2424
suspend fun showChangeLog(context: Context)
2525
suspend fun isChangeLogPopupEnabled(): Boolean
2626
suspend fun setChangeLogPopupEnabled(enabled: Boolean)
27+
fun getAvailablePushProviders(): List<Pair<String, String>>
28+
fun getActivePushProviderValue(): String
29+
fun handlePushProviderChange(value: String?)
2730
}

app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import io.homeassistant.companion.android.common.data.integration.impl.entities.
1313
import io.homeassistant.companion.android.common.data.prefs.NightModeTheme
1414
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
1515
import io.homeassistant.companion.android.common.data.servers.ServerManager
16+
import io.homeassistant.companion.android.common.push.PushProviderManager
1617
import io.homeassistant.companion.android.database.server.Server
1718
import io.homeassistant.companion.android.database.settings.SettingsDao
1819
import io.homeassistant.companion.android.settings.assist.DefaultAssistantManager
@@ -40,6 +41,7 @@ class SettingsPresenterImpl @Inject constructor(
4041
private val changeLog: ChangeLog,
4142
private val settingsDao: SettingsDao,
4243
private val defaultAssistantManager: DefaultAssistantManager,
44+
private val pushProviderManager: PushProviderManager,
4345
) : PreferenceDataStore(),
4446
SettingsPresenter {
4547

@@ -58,6 +60,7 @@ class SettingsPresenterImpl @Inject constructor(
5860
)
5961

6062
private var suggestionFlow = MutableStateFlow<SettingsHomeSuggestion?>(null)
63+
private var selectedPushProvider: String? = null
6164

6265
override fun getBoolean(key: String, defValue: Boolean): Boolean = runBlocking {
6366
return@runBlocking when (key) {
@@ -113,6 +116,7 @@ class SettingsPresenterImpl @Inject constructor(
113116
"languages" -> langsManager.getCurrentLang()
114117
"page_zoom" -> prefsRepository.getPageZoomLevel().toString()
115118
"screen_orientation" -> prefsRepository.getScreenOrientation()
119+
"notification_push_provider" -> selectedPushProvider
116120
else -> throw IllegalArgumentException("No string found by this key: $key")
117121
}
118122
}
@@ -124,6 +128,7 @@ class SettingsPresenterImpl @Inject constructor(
124128
"languages" -> langsManager.saveLang(value)
125129
"page_zoom" -> prefsRepository.setPageZoomLevel(value?.toIntOrNull())
126130
"screen_orientation" -> prefsRepository.saveScreenOrientation(value)
131+
"notification_push_provider" -> handlePushProviderChange(value)
127132
else -> throw IllegalArgumentException("No string found by this key: $key")
128133
}
129134
}
@@ -218,6 +223,27 @@ class SettingsPresenterImpl @Inject constructor(
218223
}
219224
}
220225

226+
override fun getAvailablePushProviders(): List<Pair<String, String>> {
227+
return pushProviderManager.getAllProviders().map { provider ->
228+
val label = when (provider.name) {
229+
"FCM" -> "Firebase Cloud Messaging"
230+
"WebSocket" -> "WebSocket"
231+
else -> provider.name
232+
}
233+
provider.name to label
234+
}
235+
}
236+
237+
override fun getActivePushProviderValue(): String {
238+
selectedPushProvider?.let { return it }
239+
return "WebSocket"
240+
}
241+
242+
override fun handlePushProviderChange(value: String?) {
243+
if (value == null) return
244+
selectedPushProvider = value
245+
}
246+
221247
private fun enableLauncherMode(enable: Boolean) {
222248
view.getPackageManager()?.setComponentEnabledSetting(
223249
launcherAliasComponent,

app/src/main/kotlin/io/homeassistant/companion/android/websocket/WebsocketManager.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,17 @@ class WebsocketManager(appContext: Context, workerParams: WorkerParameters) :
6262
WebsocketSetting.ALWAYS
6363
}
6464

65+
suspend fun restart(context: Context) {
66+
val websocketNotifications =
67+
PeriodicWorkRequestBuilder<WebsocketManager>(15, TimeUnit.MINUTES)
68+
.build()
69+
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
70+
UNIQUE_WORK_NAME,
71+
ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
72+
websocketNotifications,
73+
)
74+
}
75+
6576
suspend fun start(context: Context) {
6677
val websocketNotifications =
6778
PeriodicWorkRequestBuilder<WebsocketManager>(15, TimeUnit.MINUTES)

app/src/main/res/xml/preferences.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,11 @@
131131
app:enableCopying="true"
132132
android:icon="@drawable/ic_notifications"
133133
android:summary="@string/rate_limit_summary"/>
134+
<ListPreference
135+
android:key="notification_push_provider"
136+
android:title="@string/push_provider"
137+
android:icon="@drawable/ic_notifications"
138+
app:useSimpleSummaryProvider="true"/>
134139
</PreferenceCategory>
135140
<PreferenceCategory
136141
android:title="@string/assist"
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package io.homeassistant.companion.android.push
2+
3+
import dagger.Binds
4+
import dagger.Module
5+
import dagger.hilt.InstallIn
6+
import dagger.hilt.components.SingletonComponent
7+
import dagger.multibindings.IntoSet
8+
import io.homeassistant.companion.android.common.push.PushProvider
9+
10+
/**
11+
* Dagger module that provides push provider implementations for the minimal flavor.
12+
* Includes WebSocket provider only (no FCM).
13+
*/
14+
@Module
15+
@InstallIn(SingletonComponent::class)
16+
abstract class PushProviderModule {
17+
18+
@Binds
19+
@IntoSet
20+
abstract fun bindWebSocketPushProvider(provider: WebSocketPushProvider): PushProvider
21+
}

common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/DeviceRegistration.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ data class DeviceRegistration(
88
val appVersion: AppVersion? = null,
99
val deviceName: String? = null,
1010
var pushToken: MessagingToken? = null,
11+
var pushUrl: String? = null,
1112
var pushWebsocket: Boolean = true,
13+
var pushEncrypt: Boolean = false,
1214
)
1315

1416
@Qualifier

0 commit comments

Comments
 (0)