feat(odin): Allow collecting FCM device token in SDK & demonstrate it in sample app#376
feat(odin): Allow collecting FCM device token in SDK & demonstrate it in sample app#376odin-posthog wants to merge 23 commits intomainfrom
Conversation
95772c0 to
f8a4c90
Compare
| val storedToken = preferences.getValue(FCM_TOKEN) as? String | ||
| val lastUpdated = preferences.getValue(FCM_TOKEN_LAST_UPDATED) as? Long ?: 0L | ||
| val currentTime = config.dateProvider.currentDate().time | ||
| val oneHourInMillis = 60 * 60 * 1000L |
There was a problem hiding this comment.
this could be a static/global var so we dont have to compute on every method call
| // Register with backend on a background thread to avoid StrictMode NetworkViolation | ||
| // The Firebase callback runs on the main thread, so we need to move the network call off it | ||
| return try { | ||
| val api = PostHogApi(config) |
There was a problem hiding this comment.
setup already creates a PostHogApi, lets make it global or create a class that receives the api instance as we do with many other things eg remoteConfigProvider
| val distinctId = distinctId() | ||
|
|
||
| val executor = | ||
| Executors.newSingleThreadExecutor( |
There was a problem hiding this comment.
this should be a global instance and reused across calls eg queueExecutor
maybe within the class mentioned above https://github.com/PostHog/posthog-android/pull/376/files#r2728191619
| } catch (e: java.util.concurrent.TimeoutException) { | ||
| config.logger.log("Failed to register FCM token: Timeout after 5 seconds") | ||
| future.cancel(true) | ||
| false | ||
| } catch (e: java.util.concurrent.ExecutionException) { | ||
| val cause = e.cause ?: e | ||
| config.logger.log("Failed to register FCM token: ${cause.message ?: "Unknown error"}") | ||
| false | ||
| } catch (e: Throwable) { | ||
| config.logger.log("Failed to register FCM token: ${e.message ?: "Unknown error"}") | ||
| false |
There was a problem hiding this comment.
you probably want this to be done within the PostHogApi class or inside the class mentioned above, also take a look at PostHogApi and how it throws PostHogApiError, you dont need to await the future, maybe this should should receive a callback to be called in case something goes wrong, but the method should be a fire and forget instead of awaiting for up to 5s, if the user calls this from the main thread, you are causing an ANR
| return PostHogSessionManager.isSessionActive() | ||
| } | ||
|
|
||
| override fun registerPushToken(token: String): Boolean { |
There was a problem hiding this comment.
Does this method have to be atomic? what if multiple threads call this with multiple tokens? most likely, this will race and cause issues. do we have to protect against that?
| @@ -1,4 +1,5 @@ | |||
| ## Next | |||
There was a problem hiding this comment.
Is it correct to have the changelog in this PR already or should it be in the version bump PR? (or does it not matter?)
There was a problem hiding this comment.
under next is correct, just do not add a version yet
6f3c3c0 to
09a500b
Compare
| preferences.setValue(FCM_TOKEN, token) | ||
| preferences.setValue(FCM_TOKEN_LAST_UPDATED, currentTime) |
There was a problem hiding this comment.
What happens when the API call fails right below here (L1283)?
Should we clear these preference values we set here?
There was a problem hiding this comment.
Yeah, that's a good idea. Updated to clear preferences on error
|
|
||
| override fun registerPushToken( | ||
| token: String, | ||
| callback: PostHogPushTokenCallback?, |
There was a problem hiding this comment.
Will BE allow integrating only with a single Firebase project? If we support multiple firebase projects, I would expect to register a firebase config identifier along with this token (So that BE would know which Firebase project to use)? Correct me if my rationale is wrong here, but for people using multiple apps within a specific PostHog project, this could be problematic?
There was a problem hiding this comment.
That's a good point! Until now I had assumed that users would have one firebase project per posthog project, and that's probably true for most users, but it would be much better to support multiple. I added the firebase app id
| */ | ||
| @PostHogInternal | ||
| public data class PostHogPushSubscriptionRequest( | ||
| val api_key: String, |
There was a problem hiding this comment.
Is snake_cased idiomatic to Kotlin @marandaneto? Probably okay since this is a boundary/network object, just felt weird to my eye. iirc, there's a @SerializedName attr we could use?
There was a problem hiding this comment.
Turns out we do use that to transform to snake_case, updated the class to do that and keep the kotlin code camelCase
| preferences.setValue(FCM_TOKEN_LAST_UPDATED, currentTime) | ||
| } | ||
|
|
||
| val distinctId = distinctId() |
There was a problem hiding this comment.
I feel there may be a race condition here where distinctId() may change between the synchronization block above and the api call below? This would mean we may send the token with the wrong distinctId. @marandaneto wdyt?
There was a problem hiding this comment.
For our backend, we're okay with a "best effort to send correct distinctId", if we get a token for an outdated one and trigger a push notification of a more recent/correct distinctId, we query the persons db for past distinctIds associated with the user and find the token with the outdated distinctId
| } | ||
|
|
||
| val distinctId = distinctId() | ||
| pushTokenExecutor.executeSafely { |
There was a problem hiding this comment.
this assumes connectivity, what if the device is offline or if something fails? how would the user know that they should call this again at some point? should we document this behaviour?
| * Callback for push token registration results. | ||
| * @param success `true` if registration succeeded, `false` if it failed, or `null` if registration was skipped (e.g., token unchanged). | ||
| */ | ||
| public typealias PostHogPushTokenCallback = (success: Boolean?) -> Unit |
There was a problem hiding this comment.
i think you should use a SAM interface here which is more friendly to java and kotlin users
fun interface PostHogPushTokenCallback {
fun onComplete(success: Boolean?)
}
also its common to create new files for classes/interfaces
| // Wait for background thread to complete | ||
| Thread.sleep(100) |
There was a problem hiding this comment.
instead of using thread.sleep which slow down tests, you can use another approach
eg CountDownLatch, check the usage in tests and you'd see how to use it
| return PostHogSessionManager.isSessionActive() | ||
| } | ||
|
|
||
| override fun registerPushToken( |
There was a problem hiding this comment.
i still think that this method code impl should live within another class, and this method just forwards to that class eg remoteConfig/replayQueue etc
this class is huge already and it should be a clean interface where users just call something and dont get distracted by its implementation
7ea7efe to
92906d6
Compare
92906d6 to
3c67b45
Compare
…we're actually using
marandaneto
left a comment
There was a problem hiding this comment.
Left a few small comments but LGTM regardless, thanks.
| // val apiKey = "_6SG-F7I1vCuZ-HdJL3VZQqjBlaSb1_20hDPwqMNnGI" | ||
| // ManoelTesting: | ||
| val apiKey = "phc_6lqCaCDCBEWdIGieihq5R2dZpPVbAUFISA75vFZow06" | ||
| val apiKey = "phc_QFbR1y41s5sxnNTZoyKG2NJo2RlsCIWkUfdpawgb40D" |
There was a problem hiding this comment.
This is the rotated key, rollback
|
|
||
| private var remoteConfig: PostHogRemoteConfig? = null | ||
| private var replayQueue: PostHogQueueInterface? = null | ||
| private lateinit var api: PostHogApi |
There was a problem hiding this comment.
I'd avoid lateinit otherwise you crash if you use it and its not init yet, nullable var is fine
| callback: PostHogPushTokenCallback?, | ||
| ) { | ||
| if (!isEnabled()) { | ||
| pushTokenExecutor.executeSafely { callback?.onComplete(PostHogPushTokenError.SDK_DISABLED, null) } |
There was a problem hiding this comment.
You dont need to execute the callback always within the executor, i'd just run the callback on whatever thread it is
|
|
||
| private val apiKeys = mutableSetOf<String>() | ||
|
|
||
| private const val ONE_HOUR_IN_MILLIS = 60 * 60 * 1000L |
There was a problem hiding this comment.
Const usually goes within the companion object in kotlin
| * @param throwable When [error] is one of [PostHogPushTokenError.NETWORK_ERROR], [PostHogPushTokenError.INVALID_INPUT], [PostHogPushTokenError.UNAUTHORIZED], [PostHogPushTokenError.SERVER_ERROR], or [PostHogPushTokenError.OTHER], the exception that caused the failure; otherwise `null`. | ||
| */ | ||
| public fun interface PostHogPushTokenCallback { | ||
| public fun onComplete( |
There was a problem hiding this comment.
should this method have a boolean param like success: true|false?
right now the user needs to check both error and throwable to understand if it worked or not
the other option is an interface with 2 methods, onSuccess and onError, so its clear what happened
💡 Motivation and Context
We want to allow customers to send push notifications to their users in workflows. For that, we need to enable the SDK to collect and update the FCM device token that must be used for pushing notifications.
We're not collecting it automatically when initializing so users that don't need this functionality aren't forced to install firebase dependencies, and they have control over which users have their tokens collected (eg. signed-in only)
💚 How did you test it?
Manually in my emulator through the sample app, and with some automated tests
📝 Checklist