-
-
Notifications
You must be signed in to change notification settings - Fork 742
Introduce PushProvider interface for push notification providers #5301
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
base: main
Are you sure you want to change the base?
Introduce PushProvider interface for push notification providers #5301
Conversation
override fun isAvailable(context: Context): Boolean = true | ||
|
||
private var token: String? = null | ||
private val tokenMutex = Mutex() |
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.
Why do you need a mutex? Add documentation explaining why is this needed.
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.
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.
override suspend fun getToken(): String { | ||
return tokenMutex.withLock { token } ?: try { | ||
FirebaseMessaging.getInstance().token.await() | ||
} catch (e: Exception) { | ||
Timber.e(e, "Issue getting token") | ||
"" | ||
} | ||
} |
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.
You are not storing the new token in this class?
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.
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.
import kotlinx.coroutines.tasks.await | ||
import timber.log.Timber | ||
|
||
class FirebaseCloudMessagingProvider @Inject constructor( |
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.
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.
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.
I named it that based on FirebaseCloudMessaging and PushProvider, but yeah it is a bit misleading. Something like FirebasePushProviderImpl would probably be clearer.
app/src/main/kotlin/io/homeassistant/companion/android/notifications/PushModule.kt
Outdated
Show resolved
Hide resolved
class PushManager @Inject constructor( | ||
val providers: Map<String, @JvmSuppressWildcards 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.
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
import io.homeassistant.companion.android.common.notifications.PushProvider | ||
import javax.inject.Inject | ||
|
||
class PushManager @Inject constructor( |
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.
Add documentation about the purpose of this class.
companion object { | ||
const val SOURCE = "FCM" | ||
} | ||
|
||
override val setting = PushProviderSetting.FCM |
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.
It's confusing what is setting here?
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.
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.
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() | ||
} |
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.
Could you already prepare in this PR the fact that FCM is only available in full by keeping this class agnostic from FCM?
@Singleton | ||
@IntoMap | ||
@StringKey(FirebaseCloudMessagingProvider.SOURCE) | ||
abstract fun bindFirebasePushProvider(firebaseCloudMessagingProvider: FirebaseCloudMessagingProvider): 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.
Why this is not in full?
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.
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.
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.
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.
class LaunchPresenterImpl @Inject constructor( | ||
view: LaunchView, | ||
serverManager: ServerManager | ||
) : LaunchPresenterBase(view, serverManager) { | ||
serverManager: ServerManager, | ||
pushManager: PushManager | ||
) : LaunchPresenterBase(view, serverManager, pushManager) { |
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.
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.
import javax.inject.Inject | ||
import kotlinx.coroutines.CoroutineScope | ||
|
||
class FirebaseCloudMessagingProvider @Inject constructor() : 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.
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.
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.
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.
import kotlinx.coroutines.launch | ||
import timber.log.Timber | ||
|
||
interface 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.
Let's make sure this public interface is properly documented. Also it would be nice to create a section in https://developers.home-assistant.io/docs/android/ about how to add a new provider if this is meant to evolve.
fun isEnabled(context: Context): Boolean { | ||
val settingsDao = AppDatabase.getInstance(context).settingsDao() | ||
return serverManager(context).defaultServers.any { | ||
settingsDao.get(it.id)?.pushProvider == setting |
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.
Maybe we could consider using a sealed interface instead of an enum, so we don't need to store within the class what the class is already.
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.
I'm not sure that'd work, since the implementations are in a different module than the interface. Though that could be solved by moving PushProvider from :common to :app
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.
Do we need to have PushProvider in common?
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.
If we wanted to use this same architecture for Wear, then PushProvider would either have to be in :common or duplicated in :wear
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.
Another option could be implementing an id() function like with SensorManager:
fun PushProvider.id(): String {
val simpleName = this::class.simpleName ?: this::class.java.name
return simpleName.lowercase(Locale.US).replace(" ", "_")
}
This way the class doesn't need to store what it is, it's just derived from the class itself.
/** @return Set of server IDs for which this provider is enabled */ | ||
fun getEnabledServers(context: Context): Set<Int> { |
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.
Why not returning the Servers directly?
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.
This function signature was adapted from the one in SensorManager. The only place it's currently used is in updateRegistration()
which only needs the server ID.
} catch (e: Exception) { | ||
Timber.e(e, "Issue updating token") | ||
} |
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.
Could you narrow done the catch to only the potential exception that can occur?
suspend fun updateRegistration(context: Context, coroutineScope: CoroutineScope) { | ||
coroutineScope.launch { |
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.
Why do you need to get the scope here? Either you enforce the scope within the interface so you control it or you delegate to the caller function to decide which scope to use.
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.
This could be me not fully understanding how Kotlin handles coroutines, but does taking the coroutine scope as an argument not delegate it to the caller function?
Or do you just mean the initial coroutineScope.launch
? Yeah, that probably isn't needed. I just took onNewToken()
from FirebaseCloudMessagingService and adapted it for the interface.
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.
Let's remove the scope given as parameter.
common/src/main/kotlin/io/homeassistant/companion/android/common/notifications/PushProvider.kt
Show resolved
Hide resolved
common/src/main/kotlin/io/homeassistant/companion/android/common/notifications/PushProvider.kt
Outdated
Show resolved
Hide resolved
common/src/main/kotlin/io/homeassistant/companion/android/database/settings/Setting.kt
Outdated
Show resolved
Hide resolved
@@ -12,5 +12,7 @@ data class Setting( | |||
@ColumnInfo(name = "websocket_setting") | |||
var websocketSetting: WebsocketSetting, | |||
@ColumnInfo(name = "sensor_update_frequency") | |||
var sensorUpdateFrequency: SensorUpdateFrequencySetting | |||
var sensorUpdateFrequency: SensorUpdateFrequencySetting, | |||
@ColumnInfo(name = "push_provider", defaultValue = "FCM") |
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.
Could we avoid using a string here for the defaultValue, it's a source of typo and will make refactor harder if one day we want to refactor.
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.
Unfortunately, defaultValue must be a compile-time constant string.
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.
Ok the main issue is that the constant is in :app
so you don't have access it here. I don't like having a reference to FCM in common because it will also end up into minimal
and it doesn't make sense to have FCM in minimal as default.
I would encourage you to handle the case where the push provider is not set in the :app
so each flavor can have a proper default value. Also it creates a proper separation from app and common.
@@ -42,4 +48,11 @@ class SettingViewModel @Inject constructor( | |||
settingsDao.update(it) | |||
} | |||
} | |||
|
|||
fun updatePushProviderSetting(id: Int, setting: PushProviderSetting) { |
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.
fun updatePushProviderSetting(id: Int, setting: PushProviderSetting) { | |
fun updatePushProviderSetting(serverId: Int, setting: PushProviderSetting) { |
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.
Should the rest of the methods in SettingViewModel also be changed to match this?
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.
You can yes
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.
Thanks for pushing forward onto the suggestion made in your previous PR.
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.
I'm not sure if this is the best way to test the structure of the providers map since it relies on PushManager being in LaunchActivity. I think a better way would be to follow Android's Hilt testing guide but it requires adding the hilt-android-testing
dependency.
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.
You can add the new dependency if you need it. The test that I would like to have is a test that gets the map injected by hilt where you can assert the content of the map.
Multiple comments aren't addressed. Please take a look to them before asking for re-review. Also I would like @jpelgrom opinion on the architecture since he was the one initially thinking about introducing this concept. So take a look to my comments and ask us to review again your changes :) |
How do you imagine UnifiedPush would work with this architecture for choosing between multiple distributors? The current architecture forces each push provider to have a separate class, while my first expectation would be that each distributor could be considered a push provider (for example, you get a list: none / FCM / ntfy / Sunup / ...). |
The reason for each push provider having its own class is to support multiple protocols, as well as helping to separate things between minimal and full. Like @TimoPtr said, it's a good idea to keep any references to FCM out of minimal. And all of the UnifiedPush distributors (ntfy, Sunup, etc.) would be covered under one class since they all use the same protocol. The As for the UI aspect of choosing a distributor, I imagined a setting similar to what I had in my previous PR though more generic to include other push providers apart from just UnifiedPush. This architecture would also open up the possibility of using a different provider for each server. |
I understand the advantages and was the one suggesting a generic interface on your previous PR. But how would this setup work for for choosing a distributor, which is a concept specific to UnifiedPush, if the other parts of the app don't know about them as the provider interface doesn't have a way to expose it? (I tried do something similar for my UP proof of concept, though that has flaws in other ways, so I might simply be looking for something that you didn't add for a good reason and it hasn't clicked yet.) |
} | ||
|
||
/** | ||
* Get the push url for the default push provider. |
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.
The function returns the token
|
||
val appData = mutableMapOf<String, Any>("push_websocket_channel" to deviceRegistration.pushWebsocket) | ||
if (!pushToken.isNullOrBlank()) { | ||
appData["push_url"] = PUSH_URL | ||
appData["push_url"] = pushUrl?.ifBlank { PUSH_URL } ?: PUSH_URL |
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.
If there is going to be a generic interface, the app shouldn't fall back to a FCM URL. You could end up with a different push provider token + FCM URL.
}.map { it.id }.toSet() | ||
} | ||
|
||
suspend fun getUrl(): String = BuildConfig.PUSH_URL |
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.
This should not default to the FCM URL, but be abstract.
Ok, I get what you're saying. I was planning on initially just using the default UnifiedPush distributor with |
That could be an option. What would you return for that function in the FCM push provider?
That would require specific UI work outside the provider so kind of defeats the point, I think? |
For the FCM provider, I'd either return
For UnifiedPush, the library handles the UI work, though for other providers, yeah it would require work outside of it. The library's UI probably doesn't align with Home Assistant's, so it's probably not the right choice, just wanted to mention it. |
Summary
This PR introduces a generic interface for push notification providers. Currently, the only provider is FCM, but an interface allows supporting additional push providers in the future, such as UnifiedPush.
The implementation is based on the
SensorManager
as only one instance of each provider is needed but they should still be able to be enabled for only a subset of servers. APushManager
class has also been added to coordinate the various push providers, which is currently just hard coded to use FCM but would provide the primary API for managing push providers.Checklist
Any other notes
This came as a suggestion in this PR for adding UnifiedPush as an alternative push provider.