Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
Comment on lines +22 to +57
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These suspend methods catch a generic Exception. In coroutines this also catches CancellationException, which should be allowed to propagate to avoid breaking cooperative cancellation. Please rethrow CancellationException (or avoid catching Exception broadly) in isAvailable(), isActive(), and register().

Copilot uses AI. Check for mistakes.
}

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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WebSocketPushProvider injects context but never uses it, and isActive() treats a missing DB row (settingsDao.get(...) == null) as inactive even though other parts of the app use a flavor-dependent default when the setting is absent (see WebsocketManager / SettingViewModel.DEFAULT_WEBSOCKET_SETTING). Please remove the unused dependency and align the defaulting logic so isActive() accurately reflects whether WebSocket push is enabled.

Copilot uses AI. Check for mistakes.
}

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
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -250,6 +253,7 @@ class SettingsFragment(
}

updateNotificationChannelPrefs()
updatePushProviderPrefs()

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
findPreference<Preference>("notification_permission")?.let {
Expand Down Expand Up @@ -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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The push provider preference currently only updates an in-memory value (presenter.handlePushProviderChange) and restarts the WebSocket worker when selecting "WebSocket". There is no call here to actually select/register a PushProvider or update server registration, and a repo-wide search shows no other usage of the notification_push_provider key. As a result, the setting is user-visible but effectively has no functional impact (and switching away from WebSocket also isn't handled). Wire this to the new PushProviderManager (select/register + update server registration) or remove/hide the preference until it is functional.

Suggested change
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

Copilot uses AI. Check for mistakes.
}

private fun onServerLockResult(result: Int): Boolean {
if (result == Authenticator.SUCCESS && serverAuth != null) {
(activity as? SettingsActivity)?.setAppActive(serverAuth, true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ interface SettingsPresenter {
fun getSuggestionFlow(): StateFlow<SettingsHomeSuggestion?>
suspend fun getServersFlow(): Flow<List<Server>>
suspend fun getNotificationRateLimits(): RateLimitResponse?
fun getAvailablePushProviders(): List<Pair<String, String>>
fun getActivePushProviderValue(): String
fun handlePushProviderChange(value: String?)
suspend fun showChangeLog(context: Context)
suspend fun isChangeLogPopupEnabled(): Boolean
suspend fun setChangeLogPopupEnabled(enabled: Boolean)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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")
}
}
Expand All @@ -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")
}
}
Expand Down Expand Up @@ -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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getAvailablePushProviders() hardcodes user-facing labels ("Firebase Cloud Messaging", "WebSocket") instead of using string resources. This also leaves the newly added push_provider_* strings unused (likely triggering unused resource lint). Consider returning resource IDs or building the entries in SettingsFragment using getString(commonR.string.push_provider_fcm) etc.

Copilot uses AI. Check for mistakes.
}

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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getActivePushProviderValue() always defaults to "WebSocket" and handlePushProviderChange() only updates an in-memory field. Because the ListPreference is explicitly set to use the default SharedPreferences store (preferenceDataStore = null in SettingsFragment), the presenter will not reflect the persisted preference value across process restarts, and getString("notification_push_provider") can return null/stale data if this preference ever uses the presenter data store again. Persist and read this value from a real storage (PrefsRepository/LocalStorage/SharedPreferences) instead of an in-memory var.

Copilot uses AI. Check for mistakes.

override suspend fun showChangeLog(context: Context) {
changeLog.showChangeLog(context, true)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

restart() duplicates start() logic but doesn't include the legacy cleanup of OLD_UNIQUE_WORK_NAME that start() performs. If restart() is used while the old worker still exists, this can reintroduce duplicate periodic workers. Consider reusing start() (or extracting shared scheduling code) and ensure the old unique work name is also cancelled during restart.

Copilot uses AI. Check for mistakes.

suspend fun start(context: Context) {
val websocketNotifications =
PeriodicWorkRequestBuilder<WebsocketManager>(15, TimeUnit.MINUTES)
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/res/xml/preferences.xml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@
app:enableCopying="true"
android:icon="@drawable/ic_notifications"
android:summary="@string/rate_limit_summary"/>
<ListPreference
android:key="notification_push_provider"
android:title="@string/push_provider"
android:icon="@drawable/ic_notifications"
app:useSimpleSummaryProvider="true"/>
</PreferenceCategory>
<PreferenceCategory
android:title="@string/assist"
Expand Down
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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading