Skip to content
Open
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
1 change: 1 addition & 0 deletions posthog-android/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
## Next
Copy link
Author

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?)

Copy link
Member

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

- Allow collecting FCM device token in SDK ([#376](https://github.com/PostHog/posthog-android/pull/376))

## 3.29.1 - 2026-01-21

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.posthog.android
import com.posthog.PostHogConfig
import com.posthog.PostHogInterface
import com.posthog.PostHogOnFeatureFlags
import com.posthog.PostHogPushTokenCallback
import java.util.Date
import java.util.UUID

Expand Down Expand Up @@ -89,6 +90,14 @@ public class PostHogFake : PostHogInterface {
override fun resetPersonPropertiesForFlags(reloadFeatureFlags: Boolean) {
}

override fun registerPushToken(
token: String,
firebaseAppId: String,
callback: PostHogPushTokenCallback?,
) {
callback?.invoke(true)
}

override fun setGroupPropertiesForFlags(
type: String,
groupProperties: Map<String, Any>,
Expand Down
3 changes: 3 additions & 0 deletions posthog/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
## Next

### Added
- Allow collecting FCM device token in SDK ([#376](https://github.com/PostHog/posthog-android/pull/376))

## 6.3.0 - 2025-01-21

- chore: do not capture $set events if user props have not changed ([#375](https://github.com/PostHog/posthog-android/pull/375))
Expand Down
26 changes: 25 additions & 1 deletion posthog/api/posthog.api
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public final class com/posthog/PersonProfiles : java/lang/Enum {

public final class com/posthog/PostHog : com/posthog/PostHogStateless, com/posthog/PostHogInterface {
public static final field Companion Lcom/posthog/PostHog$Companion;
public synthetic fun <init> (Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V
public synthetic fun <init> (Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun alias (Ljava/lang/String;)V
public fun capture (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;)V
public fun captureException (Ljava/lang/Throwable;Ljava/util/Map;)V
Expand All @@ -29,6 +29,7 @@ public final class com/posthog/PostHog : com/posthog/PostHogStateless, com/posth
public fun optIn ()V
public fun optOut ()V
public fun register (Ljava/lang/String;Ljava/lang/Object;)V
public fun registerPushToken (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
public fun reloadFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V
public fun reset ()V
public fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V
Expand Down Expand Up @@ -67,6 +68,7 @@ public final class com/posthog/PostHog$Companion : com/posthog/PostHogInterface
public fun optOut ()V
public final fun overrideSharedInstance (Lcom/posthog/PostHogInterface;)V
public fun register (Ljava/lang/String;Ljava/lang/Object;)V
public fun registerPushToken (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
public fun reloadFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V
public fun reset ()V
public fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V
Expand Down Expand Up @@ -284,6 +286,7 @@ public abstract interface class com/posthog/PostHogInterface : com/posthog/PostH
public abstract fun isSessionActive ()Z
public abstract fun isSessionReplayActive ()Z
public abstract fun register (Ljava/lang/String;Ljava/lang/Object;)V
public abstract fun registerPushToken (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
public abstract fun reloadFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V
public abstract fun reset ()V
public abstract fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V
Expand All @@ -305,6 +308,7 @@ public final class com/posthog/PostHogInterface$DefaultImpls {
public static synthetic fun getFeatureFlagPayload$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/Object;ILjava/lang/Object;)Ljava/lang/Object;
public static synthetic fun group$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V
public static synthetic fun isFeatureEnabled$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;ZLjava/lang/Boolean;ILjava/lang/Object;)Z
public static synthetic fun registerPushToken$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
public static synthetic fun reloadFeatureFlags$default (Lcom/posthog/PostHogInterface;Lcom/posthog/PostHogOnFeatureFlags;ILjava/lang/Object;)V
public static synthetic fun resetGroupPropertiesForFlags$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;ZILjava/lang/Object;)V
public static synthetic fun resetPersonPropertiesForFlags$default (Lcom/posthog/PostHogInterface;ZILjava/lang/Object;)V
Expand Down Expand Up @@ -585,6 +589,7 @@ public final class com/posthog/internal/PostHogApi {
public static synthetic fun flags$default (Lcom/posthog/internal/PostHogApi;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lcom/posthog/internal/PostHogFlagsResponse;
public final fun localEvaluation (Ljava/lang/String;Ljava/lang/String;)Lcom/posthog/internal/LocalEvaluationApiResponse;
public static synthetic fun localEvaluation$default (Lcom/posthog/internal/PostHogApi;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/posthog/internal/LocalEvaluationApiResponse;
public final fun registerPushSubscription (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
public final fun remoteConfig ()Lcom/posthog/internal/PostHogRemoteConfigResponse;
public final fun snapshot (Ljava/util/List;)V
}
Expand Down Expand Up @@ -737,6 +742,25 @@ public final class com/posthog/internal/PostHogPrintLogger : com/posthog/interna
public fun log (Ljava/lang/String;)V
}

public final class com/posthog/internal/PostHogPushSubscriptionRequest {
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Ljava/lang/String;
public final fun component3 ()Ljava/lang/String;
public final fun component4 ()Ljava/lang/String;
public final fun component5 ()Ljava/lang/String;
public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/posthog/internal/PostHogPushSubscriptionRequest;
public static synthetic fun copy$default (Lcom/posthog/internal/PostHogPushSubscriptionRequest;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/posthog/internal/PostHogPushSubscriptionRequest;
public fun equals (Ljava/lang/Object;)Z
public final fun getApi_key ()Ljava/lang/String;
public final fun getDistinct_id ()Ljava/lang/String;
public final fun getFirebase_app_id ()Ljava/lang/String;
public final fun getPlatform ()Ljava/lang/String;
public final fun getToken ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public abstract interface class com/posthog/internal/PostHogQueueInterface {
public abstract fun add (Lcom/posthog/PostHogEvent;)V
public abstract fun clear ()V
Expand Down
111 changes: 106 additions & 5 deletions posthog/src/main/java/com/posthog/PostHog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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()
}

Expand All @@ -133,7 +143,7 @@ public class PostHog private constructor(
val sendCachedEventsIntegration =
PostHogSendCachedEventsIntegration(
config,
api,
this.api,
startDate,
cachedEventsExecutor,
)
Expand Down Expand Up @@ -1222,6 +1232,82 @@ public class PostHog private constructor(
return PostHogSessionManager.isSessionActive()
}

override fun registerPushToken(
Copy link
Member

Choose a reason for hiding this comment

The 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
this class is huge already and it should be a clean interface where users just call something and dont get distracted by its implementation

token: String,
firebaseAppId: String,
callback: PostHogPushTokenCallback?,
Copy link
Collaborator

@ioannisj ioannisj Jan 27, 2026

Choose a reason for hiding this comment

The 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?

Copy link
Author

Choose a reason for hiding this comment

The 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
Copy link
Collaborator

Choose a reason for hiding this comment

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

What happens when the API call fails right below here (L1283)?
Should we clear these preference values we set here?

Copy link
Author

Choose a reason for hiding this comment

The 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()
Copy link
Collaborator

Choose a reason for hiding this comment

The 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?

Copy link
Author

Choose a reason for hiding this comment

The 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 {
Copy link
Member

Choose a reason for hiding this comment

The 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
}
30 changes: 30 additions & 0 deletions posthog/src/main/java/com/posthog/PostHogInterface.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The 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

fun interface PostHogPushTokenCallback {
    fun onComplete(success: Boolean?)
}

also its common to create new files for classes/interfaces


/**
* The PostHog SDK entry point
*/
Expand Down Expand Up @@ -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.
*
Expand Down
Loading
Loading