-
Notifications
You must be signed in to change notification settings - Fork 33
feat(odin): Allow collecting FCM device token in SDK & demonstrate it in sample app #376
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?
Changes from all commits
76717dc
b3a5aee
2359402
82ec4a9
b70dfe7
3fe9042
837f6c4
bd63f11
328d51b
d71e58c
f8a4c90
09a500b
bd713e6
7100007
7bbd1f3
096be67
eff9c1a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,11 +3,14 @@ package com.posthog | |
| import com.posthog.errortracking.PostHogErrorTrackingAutoCaptureIntegration | ||
| import com.posthog.internal.PostHogApi | ||
| import com.posthog.internal.PostHogApiEndpoint | ||
| import com.posthog.internal.PostHogApiError | ||
| import com.posthog.internal.PostHogNoOpLogger | ||
| import com.posthog.internal.PostHogPreferences.Companion.ALL_INTERNAL_KEYS | ||
| import com.posthog.internal.PostHogPreferences.Companion.ANONYMOUS_ID | ||
| import com.posthog.internal.PostHogPreferences.Companion.BUILD | ||
| import com.posthog.internal.PostHogPreferences.Companion.DISTINCT_ID | ||
| import com.posthog.internal.PostHogPreferences.Companion.FCM_TOKEN | ||
| import com.posthog.internal.PostHogPreferences.Companion.FCM_TOKEN_LAST_UPDATED | ||
| import com.posthog.internal.PostHogPreferences.Companion.GROUPS | ||
| import com.posthog.internal.PostHogPreferences.Companion.IS_IDENTIFIED | ||
| import com.posthog.internal.PostHogPreferences.Companion.OPT_OUT | ||
|
|
@@ -20,6 +23,7 @@ import com.posthog.internal.PostHogSendCachedEventsIntegration | |
| import com.posthog.internal.PostHogSerializer | ||
| import com.posthog.internal.PostHogSessionManager | ||
| import com.posthog.internal.PostHogThreadFactory | ||
| import com.posthog.internal.executeSafely | ||
| import com.posthog.internal.personPropertiesContext | ||
| import com.posthog.internal.replay.PostHogSessionReplayHandler | ||
| import com.posthog.internal.sortMapRecursively | ||
|
|
@@ -47,6 +51,10 @@ public class PostHog private constructor( | |
| Executors.newSingleThreadScheduledExecutor( | ||
| PostHogThreadFactory("PostHogSendCachedEventsThread"), | ||
| ), | ||
| private val pushTokenExecutor: ExecutorService = | ||
| Executors.newSingleThreadExecutor( | ||
| PostHogThreadFactory("PostHogFCMTokenRegistration"), | ||
| ), | ||
| private val reloadFeatureFlags: Boolean = true, | ||
| ) : PostHogInterface, PostHogStateless() { | ||
| private val anonymousLock = Any() | ||
|
|
@@ -56,9 +64,11 @@ public class PostHog private constructor( | |
|
|
||
| private val featureFlagsCalledLock = Any() | ||
| private val cachedPersonPropertiesLock = Any() | ||
| private val pushTokenLock = Any() | ||
|
|
||
| private var remoteConfig: PostHogRemoteConfig? = null | ||
| private var replayQueue: PostHogQueueInterface? = null | ||
| private lateinit var api: PostHogApi | ||
| private val featureFlagsCalled = mutableMapOf<String, MutableList<Any?>>() | ||
|
|
||
| // Used to deduplicate setPersonProperties calls | ||
|
|
@@ -97,25 +107,25 @@ public class PostHog private constructor( | |
|
|
||
| val cachePreferences = config.cachePreferences ?: memoryPreferences | ||
| config.cachePreferences = cachePreferences | ||
| val api = PostHogApi(config) | ||
| this.api = PostHogApi(config) | ||
| val queue = | ||
| config.queueProvider( | ||
| config, | ||
| api, | ||
| this.api, | ||
| PostHogApiEndpoint.BATCH, | ||
| config.storagePrefix, | ||
| queueExecutor, | ||
| ) | ||
| val replayQueue = | ||
| config.queueProvider( | ||
| config, | ||
| api, | ||
| this.api, | ||
| PostHogApiEndpoint.SNAPSHOT, | ||
| config.replayStoragePrefix, | ||
| replayExecutor, | ||
| ) | ||
| val featureFlags = | ||
| config.remoteConfigProvider(config, api, remoteConfigExecutor) { | ||
| config.remoteConfigProvider(config, this.api, remoteConfigExecutor) { | ||
| getDefaultPersonProperties() | ||
| } | ||
|
|
||
|
|
@@ -133,7 +143,7 @@ public class PostHog private constructor( | |
| val sendCachedEventsIntegration = | ||
| PostHogSendCachedEventsIntegration( | ||
| config, | ||
| api, | ||
| this.api, | ||
| startDate, | ||
| cachedEventsExecutor, | ||
| ) | ||
|
|
@@ -1222,6 +1232,82 @@ public class PostHog private constructor( | |
| return PostHogSessionManager.isSessionActive() | ||
| } | ||
|
|
||
| override fun registerPushToken( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 |
||
| token: String, | ||
| firebaseAppId: String, | ||
| callback: PostHogPushTokenCallback?, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 |
||
| ) { | ||
| if (!isEnabled()) { | ||
| callback?.invoke(false) | ||
| return | ||
| } | ||
|
|
||
| if (token.isBlank()) { | ||
| config?.logger?.log("registerPushToken called with blank token") | ||
| callback?.invoke(false) | ||
| return | ||
| } | ||
|
|
||
| if (firebaseAppId.isBlank()) { | ||
| config?.logger?.log("registerPushToken called with blank firebaseAppId") | ||
| callback?.invoke(false) | ||
| return | ||
| } | ||
|
|
||
| val config = | ||
| this.config ?: run { | ||
| callback?.invoke(false) | ||
| return | ||
| } | ||
| val preferences = getPreferences() | ||
|
|
||
| synchronized(pushTokenLock) { | ||
| 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 tokenChanged = storedToken != token | ||
| val shouldUpdate = tokenChanged || (currentTime - lastUpdated >= ONE_HOUR_IN_MILLIS) | ||
|
|
||
| if (!shouldUpdate) { | ||
| config.logger.log("FCM token registration skipped: token unchanged and less than hour since last update") | ||
| callback?.invoke(null) | ||
| return | ||
| } | ||
|
|
||
| preferences.setValue(FCM_TOKEN, token) | ||
| preferences.setValue(FCM_TOKEN_LAST_UPDATED, currentTime) | ||
|
Comment on lines
+1278
to
+1279
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What happens when the API call fails right below here (L1283)?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, that's a good idea. Updated to clear preferences on error |
||
| } | ||
|
|
||
| val distinctId = distinctId() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 |
||
| pushTokenExecutor.executeSafely { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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? |
||
| if (!isEnabled()) { | ||
| config.logger.log("FCM token registration skipped: SDK is disabled") | ||
| callback?.invoke(false) | ||
| return@executeSafely | ||
| } | ||
| try { | ||
| this.api.registerPushSubscription(distinctId, token, firebaseAppId) | ||
| config.logger.log("FCM token registered successfully") | ||
| callback?.invoke(true) | ||
| } catch (e: PostHogApiError) { | ||
| config.logger.log("Failed to register FCM token: ${e.message} (code: ${e.statusCode})") | ||
| synchronized(pushTokenLock) { | ||
| preferences.remove(FCM_TOKEN) | ||
| preferences.remove(FCM_TOKEN_LAST_UPDATED) | ||
| } | ||
| callback?.invoke(false) | ||
| } catch (e: Throwable) { | ||
| config.logger.log("Failed to register FCM token: ${e.message ?: "Unknown error"}") | ||
| synchronized(pushTokenLock) { | ||
| preferences.remove(FCM_TOKEN) | ||
| preferences.remove(FCM_TOKEN_LAST_UPDATED) | ||
| } | ||
| callback?.invoke(false) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| override fun <T : PostHogConfig> getConfig(): T? { | ||
| @Suppress("UNCHECKED_CAST") | ||
| return super<PostHogStateless>.config as? T | ||
|
|
@@ -1306,6 +1392,8 @@ public class PostHog private constructor( | |
|
|
||
| private val apiKeys = mutableSetOf<String>() | ||
|
|
||
| private const val ONE_HOUR_IN_MILLIS = 60 * 60 * 1000L | ||
|
|
||
| @PostHogVisibleForTesting | ||
| public fun overrideSharedInstance(postHog: PostHogInterface) { | ||
| shared = postHog | ||
|
|
@@ -1335,13 +1423,18 @@ public class PostHog private constructor( | |
| featureFlagsExecutor: ExecutorService, | ||
| cachedEventsExecutor: ExecutorService, | ||
| reloadFeatureFlags: Boolean, | ||
| pushTokenExecutor: ExecutorService = | ||
| Executors.newSingleThreadExecutor( | ||
| PostHogThreadFactory("PostHogFCMTokenRegistration"), | ||
| ), | ||
| ): PostHogInterface { | ||
| val instance = | ||
| PostHog( | ||
| queueExecutor, | ||
| replayExecutor, | ||
| featureFlagsExecutor, | ||
| cachedEventsExecutor, | ||
| pushTokenExecutor, | ||
| reloadFeatureFlags = reloadFeatureFlags, | ||
| ) | ||
| instance.setup(config) | ||
|
|
@@ -1539,5 +1632,13 @@ public class PostHog private constructor( | |
| override fun getSessionId(): UUID? { | ||
| return shared.getSessionId() | ||
| } | ||
|
|
||
| override fun registerPushToken( | ||
| token: String, | ||
| firebaseAppId: String, | ||
| callback: PostHogPushTokenCallback?, | ||
| ) { | ||
| shared.registerPushToken(token, firebaseAppId, callback) | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,12 @@ package com.posthog | |
| import java.util.Date | ||
| import java.util.UUID | ||
|
|
||
| /** | ||
| * 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i think you should use a SAM interface here which is more friendly to java and kotlin users also its common to create new files for classes/interfaces |
||
|
|
||
| /** | ||
| * The PostHog SDK entry point | ||
| */ | ||
|
|
@@ -223,6 +229,30 @@ public interface PostHogInterface : PostHogCoreInterface { | |
| */ | ||
| public fun resetPersonPropertiesForFlags(reloadFeatureFlags: Boolean = true) | ||
|
|
||
| /** | ||
| * Registers a push notification token (FCM token) with PostHog. | ||
| * The SDK will automatically rate-limit registrations to once per hour unless the token has changed. | ||
| * | ||
| * Registration is performed asynchronously. Use the optional callback to be notified of success or failure. | ||
| * | ||
| * Users should retrieve the FCM token using: | ||
| * - Java: `FirebaseMessaging.getInstance().getToken()` | ||
| * - Kotlin: `Firebase.messaging.token` | ||
| * | ||
| * The Firebase app ID can be obtained using: | ||
| * - Java: `FirebaseApp.getInstance().getOptions().getProjectId()` | ||
| * - Kotlin: `Firebase.app.options.projectId` | ||
| * | ||
| * @param token The FCM registration token | ||
| * @param firebaseAppId Firebase app ID (project ID) to associate with the token | ||
| * @param callback Optional callback to be notified when registration completes. Called with `true` on success, `false` on failure, or `null` if registration was skipped (e.g., token unchanged). | ||
| */ | ||
| public fun registerPushToken( | ||
| token: String, | ||
| firebaseAppId: String, | ||
| callback: PostHogPushTokenCallback? = null, | ||
| ) | ||
|
|
||
| /** | ||
| * Sets properties for a specific group type to include when evaluating feature flags. | ||
| * | ||
|
|
||
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.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
under next is correct, just do not add a version yet