Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 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
Expand Up @@ -6,26 +6,27 @@ import dagger.hilt.android.scopes.ActivityScoped
import io.homeassistant.companion.android.BuildConfig
import io.homeassistant.companion.android.common.data.integration.DeviceRegistration
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.onboarding.getMessagingToken
import io.homeassistant.companion.android.notifications.PushManager
import javax.inject.Inject
import kotlinx.coroutines.launch
import timber.log.Timber

@ActivityScoped
class LaunchPresenterImpl @Inject constructor(
@ActivityContext context: Context,
serverManager: ServerManager
) : LaunchPresenterBase(context as LaunchView, serverManager) {
serverManager: ServerManager,
pushManager: PushManager
) : LaunchPresenterBase(context as LaunchView, serverManager, pushManager) {
override fun resyncRegistration() {
if (!serverManager.isRegistered()) return
serverManager.defaultServers.forEach {
ioScope.launch {
try {
serverManager.integrationRepository(it.id).updateRegistration(
DeviceRegistration(
"${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
null,
getMessagingToken()
appVersion = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
deviceName = null,
pushToken = pushManager.getToken()
)
)
serverManager.integrationRepository(it.id).getConfig() // Update cached data
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package io.homeassistant.companion.android.notifications

import android.content.Context
import com.google.firebase.messaging.FirebaseMessaging
import io.homeassistant.companion.android.common.notifications.PushProvider
import io.homeassistant.companion.android.database.settings.PushProviderSetting
import javax.inject.Inject
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.tasks.await
import timber.log.Timber

class FirebaseCloudMessagingProvider @Inject constructor(
Copy link
Member

@TimoPtr TimoPtr May 12, 2025

Choose a reason for hiding this comment

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

The class is named MessagingProvider but at first glance it simply store a token in memory and forward a message to the messagingManager with the right SOURCE. We probably change the name of the class.

Also could you add top level documentation about the behavior of the class especially about the token is stored and what the lifecycle of the token. You can also add links to Firebase documentation.

Copy link
Author

Choose a reason for hiding this comment

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

I named it that based on FirebaseCloudMessaging and PushProvider, but yeah it is a bit misleading. Something like FirebasePushProviderImpl would probably be clearer.

private val messagingManager: MessagingManager
) : PushProvider {

companion object {
const val SOURCE = "FCM"
}

override val setting = PushProviderSetting.FCM
Copy link
Member

Choose a reason for hiding this comment

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

It's confusing what is setting here?

Copy link
Author

Choose a reason for hiding this comment

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

It is used in isEnabled() for checking against the database. That way each implementation just has to define setting instead of implementing its own isEnabled(). I'll add documentation to explain that, or there could be a better way of handling it.


override fun isAvailable(context: Context): Boolean = true

private var token: String? = null
private val tokenMutex = Mutex()
Copy link
Member

Choose a reason for hiding this comment

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

Why do you need a mutex? Add documentation explaining why is this needed.

Copy link
Author

Choose a reason for hiding this comment

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

A mutex might not be necessary, as I'm not 100% positive on the flow of Android services and coroutine scopes. To my understanding, there is potential for a race condition if onNewToken() in FirebaseCloudMessagingService gets called at the same time as getToken(), such as at launch or during onboarding.


suspend fun setToken(token: String) = tokenMutex.withLock {
this.token = token
}

override suspend fun getToken(): String {
return tokenMutex.withLock { token } ?: try {
FirebaseMessaging.getInstance().token.await()
} catch (e: Exception) {
Timber.e(e, "Issue getting token")
""
}
}
Copy link
Member

Choose a reason for hiding this comment

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

You are not storing the new token in this class?

Copy link
Author

Choose a reason for hiding this comment

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

This function was primarily to move getMessagingToken() from onboarding into the push provider. The token gets stored with the device registration, and I believe the Firebase library also stores the token.


override fun onMessage(context: Context, notificationData: Map<String, String>) {
messagingManager.handleMessage(notificationData, SOURCE)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ package io.homeassistant.companion.android.notifications
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.common.data.integration.DeviceRegistration
import io.homeassistant.companion.android.common.data.servers.ServerManager
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand All @@ -14,22 +12,16 @@ import timber.log.Timber

@AndroidEntryPoint
class FirebaseCloudMessagingService : FirebaseMessagingService() {
companion object {
private const val SOURCE = "FCM"
}

@Inject
lateinit var serverManager: ServerManager

@Inject
lateinit var messagingManager: MessagingManager
lateinit var pushProvider: FirebaseCloudMessagingProvider

private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())

override fun onMessageReceived(remoteMessage: RemoteMessage) {
Timber.d("From: ${remoteMessage.from}")

messagingManager.handleMessage(remoteMessage.data, SOURCE)
pushProvider.onMessage(this, remoteMessage.data)
}

/**
Expand All @@ -39,23 +31,8 @@ class FirebaseCloudMessagingService : FirebaseMessagingService() {
*/
override fun onNewToken(token: String) {
mainScope.launch {
Timber.d("Refreshed token: $token")
if (!serverManager.isRegistered()) {
Timber.d("Not trying to update registration since we aren't authenticated.")
return@launch
}
serverManager.defaultServers.forEach {
launch {
try {
serverManager.integrationRepository(it.id).updateRegistration(
deviceRegistration = DeviceRegistration(pushToken = token),
allowReregistration = false
)
} catch (e: Exception) {
Timber.e(e, "Issue updating token")
}
}
}
pushProvider.setToken(token)
pushProvider.updateRegistration(this@FirebaseCloudMessagingService, mainScope)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import io.homeassistant.companion.android.database.server.ServerSessionInfo
import io.homeassistant.companion.android.database.server.ServerType
import io.homeassistant.companion.android.database.server.ServerUserInfo
import io.homeassistant.companion.android.database.settings.WebsocketSetting
import io.homeassistant.companion.android.notifications.PushManager
import io.homeassistant.companion.android.onboarding.OnboardApp
import io.homeassistant.companion.android.onboarding.getMessagingToken
import io.homeassistant.companion.android.sensors.LocationSensorManager
import io.homeassistant.companion.android.settings.SettingViewModel
import io.homeassistant.companion.android.settings.server.ServerChooserFragment
Expand Down Expand Up @@ -54,6 +54,9 @@ class LaunchActivity : AppCompatActivity(), LaunchView {
@Inject
lateinit var sensorDao: SensorDao

@Inject
lateinit var pushManager: PushManager

private val mainScope = CoroutineScope(Dispatchers.Main + Job())

private val settingViewModel: SettingViewModel by viewModels()
Expand Down Expand Up @@ -138,7 +141,7 @@ class LaunchActivity : AppCompatActivity(), LaunchView {
mainScope.launch {
if (result != null) {
val (url, authCode, deviceName, deviceTrackingEnabled, notificationsEnabled) = result
val messagingToken = getMessagingToken()
val messagingToken = pushManager.getToken()
if (messagingToken.isBlank() && BuildConfig.FLAVOR == "full") {
AlertDialog.Builder(this@LaunchActivity)
.setTitle(commonR.string.firebase_error_title)
Expand Down Expand Up @@ -196,9 +199,9 @@ class LaunchActivity : AppCompatActivity(), LaunchView {
serverManager.authenticationRepository(serverId).registerAuthorizationCode(authCode)
serverManager.integrationRepository(serverId).registerDevice(
DeviceRegistration(
"${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
deviceName,
messagingToken
appVersion = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
deviceName = deviceName,
pushToken = messagingToken
)
)
serverId = serverManager.convertTemporaryServer(serverId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.homeassistant.companion.android.launch

import io.homeassistant.companion.android.common.data.authentication.SessionState
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.notifications.PushManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
Expand All @@ -10,7 +11,8 @@ import kotlinx.coroutines.launch

abstract class LaunchPresenterBase(
private val view: LaunchView,
internal val serverManager: ServerManager
internal val serverManager: ServerManager,
internal val pushManager: PushManager
) : LaunchPresenter {

internal val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package io.homeassistant.companion.android.notifications

import io.homeassistant.companion.android.common.notifications.PushProvider
import javax.inject.Inject

class PushManager @Inject constructor(
Copy link
Member

Choose a reason for hiding this comment

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

Add documentation about the purpose of this class.

val providers: Map<String, @JvmSuppressWildcards PushProvider>
) {
Copy link
Member

@TimoPtr TimoPtr May 12, 2025

Choose a reason for hiding this comment

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

Could you create an instrumentation test that checks how the map looks like. It will be useful in the future to make sure the map contains the proper values. One test for each flavor


companion object {
private const val FCM_SOURCE = FirebaseCloudMessagingProvider.SOURCE
}

val defaultProvider: PushProvider get() {
return providers[FCM_SOURCE]!!
}

suspend fun getToken(): String {
return providers[FCM_SOURCE]!!.getToken()
}
Copy link
Member

Choose a reason for hiding this comment

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

Could you already prepare in this PR the fact that FCM is only available in full by keeping this class agnostic from FCM?

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package io.homeassistant.companion.android.notifications

import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoMap
import dagger.multibindings.StringKey
import io.homeassistant.companion.android.common.notifications.PushProvider
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
abstract class PushModule {
@Binds
@Singleton
@IntoMap
@StringKey(FirebaseCloudMessagingProvider.SOURCE)
abstract fun bindFirebasePushProvider(firebaseCloudMessagingProvider: FirebaseCloudMessagingProvider): PushProvider
Copy link
Member

Choose a reason for hiding this comment

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

Why this is not in full?

Copy link
Author

Choose a reason for hiding this comment

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

Any more push providers that are added would also need to be bound in the module, which may not be limited to full. I'm not fully familiar with hilt/dagger, but I imagine it would work to put bindFirebasePushProvider in its own module in full and any providers that work in both full and minimal could be put in one in main.

Copy link
Member

Choose a reason for hiding this comment

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

You can Inject into the map from multiple places. Here we should have a PushModule in full for FCM and another one in minimal without FCM. We don't want FCM reference in minimal.

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import io.homeassistant.companion.android.BuildConfig
import io.homeassistant.companion.android.database.settings.PushProviderSetting
import io.homeassistant.companion.android.database.settings.SensorUpdateFrequencySetting
import io.homeassistant.companion.android.database.settings.Setting
import io.homeassistant.companion.android.database.settings.SettingsDao
Expand All @@ -20,7 +21,12 @@ class SettingViewModel @Inject constructor(
fun getSetting(id: Int): Setting {
var setting = settingsDao.get(id)
if (setting == null) {
setting = Setting(id, if (BuildConfig.FLAVOR == "full") WebsocketSetting.NEVER else WebsocketSetting.ALWAYS, SensorUpdateFrequencySetting.NORMAL)
setting = Setting(
id,
if (BuildConfig.FLAVOR == "full") WebsocketSetting.NEVER else WebsocketSetting.ALWAYS,
SensorUpdateFrequencySetting.NORMAL,
if (BuildConfig.FLAVOR == "full") PushProviderSetting.FCM else PushProviderSetting.NONE
)
settingsDao.insert(setting)
}
return setting
Expand All @@ -42,4 +48,11 @@ class SettingViewModel @Inject constructor(
settingsDao.update(it)
}
}

fun updatePushProviderSetting(id: Int, setting: PushProviderSetting) {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
fun updatePushProviderSetting(id: Int, setting: PushProviderSetting) {
fun updatePushProviderSetting(serverId: Int, setting: PushProviderSetting) {

Copy link
Author

Choose a reason for hiding this comment

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

Should the rest of the methods in SettingViewModel also be changed to match this?

Copy link
Member

Choose a reason for hiding this comment

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

You can yes

settingsDao.get(id)?.let {
it.pushProvider = setting
settingsDao.update(it)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ import io.homeassistant.companion.android.database.server.ServerConnectionInfo
import io.homeassistant.companion.android.database.server.ServerSessionInfo
import io.homeassistant.companion.android.database.server.ServerType
import io.homeassistant.companion.android.database.server.ServerUserInfo
import io.homeassistant.companion.android.database.settings.PushProviderSetting
import io.homeassistant.companion.android.database.settings.SensorUpdateFrequencySetting
import io.homeassistant.companion.android.database.settings.Setting
import io.homeassistant.companion.android.database.settings.SettingsDao
import io.homeassistant.companion.android.database.settings.WebsocketSetting
import io.homeassistant.companion.android.notifications.PushManager
import io.homeassistant.companion.android.onboarding.OnboardApp
import io.homeassistant.companion.android.onboarding.getMessagingToken
import io.homeassistant.companion.android.sensors.LocationSensorManager
import io.homeassistant.companion.android.settings.language.LanguagesManager
import io.homeassistant.companion.android.themes.ThemesManager
Expand All @@ -50,6 +51,7 @@ class SettingsPresenterImpl @Inject constructor(
private val prefsRepository: PrefsRepository,
private val themesManager: ThemesManager,
private val langsManager: LanguagesManager,
private val pushManager: PushManager,
private val changeLog: ChangeLog,
private val settingsDao: SettingsDao,
private val sensorDao: SensorDao
Expand Down Expand Up @@ -162,7 +164,7 @@ class SettingsPresenterImpl @Inject constructor(
override suspend fun addServer(result: OnboardApp.Output?) {
if (result != null) {
val (url, authCode, deviceName, deviceTrackingEnabled, notificationsEnabled) = result
val messagingToken = getMessagingToken()
val messagingToken = pushManager.getToken()
var serverId: Int? = null
try {
val formattedUrl = UrlUtil.formattedUrlString(url)
Expand All @@ -179,9 +181,9 @@ class SettingsPresenterImpl @Inject constructor(
serverManager.authenticationRepository(serverId).registerAuthorizationCode(authCode)
serverManager.integrationRepository(serverId).registerDevice(
DeviceRegistration(
"${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
deviceName,
messagingToken
appVersion = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
deviceName = deviceName,
pushToken = messagingToken
)
)
serverManager.getServer()?.id?.let {
Expand Down Expand Up @@ -228,7 +230,8 @@ class SettingsPresenterImpl @Inject constructor(
Setting(
serverId,
if (enabled) WebsocketSetting.ALWAYS else WebsocketSetting.NEVER,
SensorUpdateFrequencySetting.NORMAL
SensorUpdateFrequencySetting.NORMAL,
PushProviderSetting.NONE
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,24 @@ package io.homeassistant.companion.android.launch
import io.homeassistant.companion.android.BuildConfig
import io.homeassistant.companion.android.common.data.integration.DeviceRegistration
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.notifications.PushManager
import javax.inject.Inject
import kotlinx.coroutines.launch
import timber.log.Timber

class LaunchPresenterImpl @Inject constructor(
view: LaunchView,
serverManager: ServerManager
) : LaunchPresenterBase(view, serverManager) {
serverManager: ServerManager,
pushManager: PushManager
) : LaunchPresenterBase(view, serverManager, pushManager) {
Copy link
Member

Choose a reason for hiding this comment

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

Now that you have an abstraction you could potentially already remove the two version of the LaunchPresenterImpl and keep only the one from full. Let's avoid as much as possible duplicates. It makes maintenance of minimal easier.

override fun resyncRegistration() {
if (!serverManager.isRegistered()) return
serverManager.defaultServers.forEach {
ioScope.launch {
try {
serverManager.integrationRepository(it.id).updateRegistration(
DeviceRegistration(
"${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
appVersion = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
)
)
serverManager.integrationRepository(it.id).getConfig() // Update cached data
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package io.homeassistant.companion.android.notifications

import android.content.Context
import io.homeassistant.companion.android.common.notifications.PushProvider
import io.homeassistant.companion.android.database.settings.PushProviderSetting
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope

class FirebaseCloudMessagingProvider @Inject constructor() : PushProvider {
Copy link
Member

Choose a reason for hiding this comment

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

Let's not create a fake FCM in minimal create a No-Op MessagingProvider if you need one that does nothing so it's explicit what it does.

Copy link
Author

Choose a reason for hiding this comment

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

Actually, I don't think a No-op provider is even necessary. The only thing it would be needed for is returning an empty token, but that can be avoided by just adding null checks elsewhere.


// Firebase Cloud Messaging depends on Google Play Services,
// and as a result FCM is not supported with the minimal flavor

companion object {
const val SOURCE = "FCM"
}

override val setting = PushProviderSetting.FCM

override fun isAvailable(context: Context): Boolean = false

override fun isEnabled(context: Context): Boolean = false

override fun isEnabled(context: Context, serverId: Int): Boolean = false

override fun getEnabledServers(context: Context): Set<Int> = emptySet()

override suspend fun getToken(): String = ""

override fun onMessage(context: Context, notificationData: Map<String, String>) {}

override suspend fun updateRegistration(context: Context, coroutineScope: CoroutineScope) {}
}
Loading