Skip to content
Draft
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,71 @@
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 kotlin.coroutines.cancellation.CancellationException
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) {
if (e is CancellationException) throw e
Timber.e(e, "FCM is not available")
false
}
}

override suspend fun isActive(): Boolean {
return try {
val token = messagingTokenProvider()
!token.isBlank()
} catch (e: Exception) {
if (e is CancellationException) throw e
false
}
Comment thread
sk7n4k3d marked this conversation as resolved.
}

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) {
if (e is CancellationException) throw e
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,54 @@
package io.homeassistant.companion.android.push

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(
private val serverManager: ServerManager,
private val settingsDao: SettingsDao,
Comment thread
sk7n4k3d marked this conversation as resolved.
) : 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"
}
}
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,6 +55,7 @@ import io.homeassistant.companion.android.settings.wear.SettingsWearDetection
import io.homeassistant.companion.android.settings.widgets.ManageWidgetsSettingsFragment
import io.homeassistant.companion.android.util.QuestUtil
import io.homeassistant.companion.android.util.applyBottomSafeDrawingInsets
import io.homeassistant.companion.android.websocket.WebsocketManager
import io.homeassistant.companion.android.webview.WebViewActivity
import java.time.Instant
import java.time.ZoneId
Expand All @@ -63,6 +65,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber

class SettingsFragment(
Expand Down Expand Up @@ -253,6 +256,7 @@ class SettingsFragment(
}

updateNotificationChannelPrefs()
updatePushProviderPrefs()

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
findPreference<Preference>("notification_permission")?.let {
Expand Down Expand Up @@ -544,6 +548,52 @@ class SettingsFragment(
}
}

private fun updatePushProviderPrefs() {
findPreference<ListPreference>("notification_push_provider")?.let { pref ->
// Detach from the presenter data store because the push provider preference is
// persisted via PrefsRepository (not the generic PreferenceDataStore) and change
// handling requires a coroutine for server-side registration.
pref.preferenceDataStore = null
Comment thread
sk7n4k3d marked this conversation as resolved.
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()
WebsocketManager.restart(requireContext())
}
true
}
Comment thread
sk7n4k3d marked this conversation as resolved.

lifecycleScope.launch(Dispatchers.IO) {
val providerNames = presenter.getAvailablePushProviders()
val values = providerNames.toMutableList()
val entries = providerNames.map { name ->
when (name) {
"FCM" -> getString(commonR.string.push_provider_fcm)
"WebSocket" -> getString(commonR.string.push_provider_websocket)
else -> name
}
}.toMutableList()

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) {
activityViewModel.setAppActive(serverAuth, true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,7 @@ interface SettingsPresenter {
suspend fun showChangeLog(context: Context)
suspend fun isChangeLogPopupEnabled(): Boolean
suspend fun setChangeLogPopupEnabled(enabled: Boolean)
fun getAvailablePushProviders(): List<String>
suspend fun getActivePushProviderValue(): String
suspend fun handlePushProviderChange(value: String?)
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ 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.push.WebSocketPushProvider
import io.homeassistant.companion.android.settings.assist.DefaultAssistantManager
import io.homeassistant.companion.android.settings.language.LanguagesManager
import io.homeassistant.companion.android.themes.NightModeManager
Expand All @@ -40,6 +42,7 @@ class SettingsPresenterImpl @Inject constructor(
private val changeLog: ChangeLog,
private val settingsDao: SettingsDao,
private val defaultAssistantManager: DefaultAssistantManager,
private val pushProviderManager: PushProviderManager,
) : PreferenceDataStore(),
SettingsPresenter {

Expand Down Expand Up @@ -218,6 +221,26 @@ class SettingsPresenterImpl @Inject constructor(
}
}

override fun getAvailablePushProviders(): List<String> {
return pushProviderManager.getAllProviders().map { it.name }
}

override suspend fun getActivePushProviderValue(): String {
val persisted = prefsRepository.getSelectedPushProvider()
if (persisted != null) return persisted
return pushProviderManager.getAllProviders().firstOrNull()?.name
?: WebSocketPushProvider.NAME
}

override suspend fun handlePushProviderChange(value: String?) {
if (value == null) return
prefsRepository.setSelectedPushProvider(value)
val result = pushProviderManager.selectAndRegister(value)
if (result != null) {
pushProviderManager.updateServerRegistration(result)
}
}

private fun enableLauncherMode(enable: Boolean) {
view.getPackageManager()?.setComponentEnabledSetting(
launcherAliasComponent,
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
}

fun restart(context: Context) {
val websocketNotifications =
PeriodicWorkRequestBuilder<WebsocketManager>(15, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
UNIQUE_WORK_NAME,
ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
websocketNotifications,
)
}

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