diff --git a/posthog-android/CHANGELOG.md b/posthog-android/CHANGELOG.md index 2cda2ca8..791e1c70 100644 --- a/posthog-android/CHANGELOG.md +++ b/posthog-android/CHANGELOG.md @@ -1,4 +1,5 @@ ## Next +- Allow collecting FCM device token in SDK ([#376](https://github.com/PostHog/posthog-android/pull/376)) ## 3.29.1 - 2026-01-21 diff --git a/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt b/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt index 813b1bf3..b740d202 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt @@ -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 @@ -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, diff --git a/posthog/CHANGELOG.md b/posthog/CHANGELOG.md index d8ead894..16d4a898 100644 --- a/posthog/CHANGELOG.md +++ b/posthog/CHANGELOG.md @@ -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)) diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 2aa9aaeb..3ea88789 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -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 (Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 } @@ -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 (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 diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 94b9ed54..64177c63 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -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>() // Used to deduplicate setPersonProperties calls @@ -97,11 +107,11 @@ 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, @@ -109,13 +119,13 @@ public class PostHog private constructor( 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( + token: String, + firebaseAppId: String, + callback: PostHogPushTokenCallback?, + ) { + 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) + } + + val distinctId = distinctId() + pushTokenExecutor.executeSafely { + 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 getConfig(): T? { @Suppress("UNCHECKED_CAST") return super.config as? T @@ -1306,6 +1392,8 @@ public class PostHog private constructor( private val apiKeys = mutableSetOf() + private const val ONE_HOUR_IN_MILLIS = 60 * 60 * 1000L + @PostHogVisibleForTesting public fun overrideSharedInstance(postHog: PostHogInterface) { shared = postHog @@ -1335,6 +1423,10 @@ public class PostHog private constructor( featureFlagsExecutor: ExecutorService, cachedEventsExecutor: ExecutorService, reloadFeatureFlags: Boolean, + pushTokenExecutor: ExecutorService = + Executors.newSingleThreadExecutor( + PostHogThreadFactory("PostHogFCMTokenRegistration"), + ), ): PostHogInterface { val instance = PostHog( @@ -1342,6 +1434,7 @@ public class PostHog private constructor( 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) + } } } diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index cbf37211..22ce3760 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -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 + /** * 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. * diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index 7e207a48..edfa1799 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -253,6 +253,37 @@ public class PostHogApi( } } + @Throws(PostHogApiError::class, IOException::class) + public fun registerPushSubscription( + distinctId: String, + token: String, + firebaseAppId: String, + ) { + val pushSubscriptionRequest = + PostHogPushSubscriptionRequest( + apiKey = config.apiKey, + distinctId = distinctId, + token = token, + platform = "android", + firebaseAppId = firebaseAppId, + ) + + val url = "$theHost/api/sdk/push_subscriptions/register" + + logRequest(pushSubscriptionRequest, url) + + val request = + makeRequest(url) { + config.serializer.serialize(pushSubscriptionRequest, it.bufferedWriter()) + } + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw PostHogApiError(response.code, response.message, response.body) + } + } + } + private fun logResponse(response: Response): Response { if (config.debug) { try { diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt b/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt index ed06f51d..9186f581 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt @@ -44,6 +44,8 @@ public interface PostHogPreferences { public const val VERSION: String = "version" public const val BUILD: String = "build" public const val STRINGIFIED_KEYS: String = "stringifiedKeys" + internal const val FCM_TOKEN = "fcmToken" + internal const val FCM_TOKEN_LAST_UPDATED = "fcmTokenLastUpdated" public val ALL_INTERNAL_KEYS: Set = setOf( @@ -66,6 +68,8 @@ public interface PostHogPreferences { FLAGS, PERSON_PROPERTIES_FOR_FLAGS, GROUP_PROPERTIES_FOR_FLAGS, + FCM_TOKEN, + FCM_TOKEN_LAST_UPDATED, ) } } diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionRequest.kt b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionRequest.kt new file mode 100644 index 00000000..20218737 --- /dev/null +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionRequest.kt @@ -0,0 +1,19 @@ +package com.posthog.internal + +import com.google.gson.annotations.SerializedName +import com.posthog.PostHogInternal + +/** + * Request body for push subscription registration + */ +@PostHogInternal +public data class PostHogPushSubscriptionRequest( + @SerializedName("api_key") + val apiKey: String, + @SerializedName("distinct_id") + val distinctId: String, + val token: String, + val platform: String, + @SerializedName("firebase_app_id") + val firebaseAppId: String, +) diff --git a/posthog/src/test/java/com/posthog/PostHogPersonProfilesTest.kt b/posthog/src/test/java/com/posthog/PostHogPersonProfilesTest.kt index 385a65d3..5958aed6 100644 --- a/posthog/src/test/java/com/posthog/PostHogPersonProfilesTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogPersonProfilesTest.kt @@ -48,7 +48,7 @@ internal class PostHogPersonProfilesTest { replayQueueExecutor, remoteConfigExecutor, cachedEventsExecutor, - false, + reloadFeatureFlags = false, ) } diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 1fb808ef..bf29357c 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -3,6 +3,8 @@ package com.posthog import com.posthog.internal.PostHogBatchEvent import com.posthog.internal.PostHogContext import com.posthog.internal.PostHogMemoryPreferences +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.GROUP_PROPERTIES_FOR_FLAGS import com.posthog.internal.PostHogPreferences.Companion.PERSON_PROPERTIES_FOR_FLAGS @@ -36,6 +38,7 @@ internal class PostHogTest { private val replayQueueExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestReplayQueue")) private val remoteConfigExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestRemoteConfig")) private val cachedEventsExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestCachedEvents")) + private val pushTokenExecutor = Executors.newSingleThreadExecutor(PostHogThreadFactory("TestPushToken")) private val serializer = PostHogSerializer(PostHogConfig(API_KEY)) private lateinit var config: PostHogConfig @@ -92,6 +95,7 @@ internal class PostHogTest { remoteConfigExecutor, cachedEventsExecutor, reloadFeatureFlags, + pushTokenExecutor, ) } @@ -2050,6 +2054,7 @@ internal class PostHogTest { remoteConfigExecutor, cachedEventsExecutor, true, + pushTokenExecutor, ) // Manually trigger flags reload @@ -2608,4 +2613,249 @@ internal class PostHogTest { sut.close() } + + @Test + fun `registerPushToken successfully registers token`() { + val responseBody = """{"status": "ok", "subscription_id": "test-subscription-id"}""" + val http = + mockHttp( + response = + MockResponse() + .setResponseCode(200) + .setBody(responseBody), + ) + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false) + + var callbackResult: Boolean? = null + sut.registerPushToken("test-fcm-token", "test-firebase-app-id") { success -> + callbackResult = success + } + + // Wait for background thread to complete + Thread.sleep(100) + + assertEquals(true, callbackResult) + assertEquals(1, http.requestCount) + + val request = http.takeRequest() + assertEquals("/api/sdk/push_subscriptions/register", request.path) + assertEquals("POST", request.method) + + // Verify request body contains expected fields + val requestBody = request.body.unGzip() + assertTrue(requestBody.contains("\"api_key\"")) + assertTrue(requestBody.contains("\"distinct_id\"")) + assertTrue(requestBody.contains("\"token\":\"test-fcm-token\"")) + assertTrue(requestBody.contains("\"platform\":\"android\"")) + assertTrue(requestBody.contains("\"firebase_app_id\":\"test-firebase-app-id\"")) + + sut.close() + } + + @Test + fun `registerPushToken calls callback with false when SDK is disabled`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false) + sut.close() + + var callbackResult: Boolean? = null + sut.registerPushToken("test-fcm-token", "test-firebase-app-id") { success -> + callbackResult = success + } + + assertEquals(false, callbackResult) + assertEquals(0, http.requestCount) + } + + @Test + fun `registerPushToken calls callback with false for blank token`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false) + + var callbackResult: Boolean? = null + sut.registerPushToken("", "test-firebase-app-id") { success -> + callbackResult = success + } + + assertEquals(false, callbackResult) + assertEquals(0, http.requestCount) + + sut.close() + } + + @Test + fun `registerPushToken calls callback with false for blank firebaseAppId`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false) + + var callbackResult: Boolean? = null + sut.registerPushToken("test-fcm-token", "") { success -> + callbackResult = success + } + + assertEquals(false, callbackResult) + assertEquals(0, http.requestCount) + + sut.close() + } + + @Test + fun `registerPushToken skips registration when token unchanged and less than 1 hour`() { + val responseBody = """{"status": "ok", "subscription_id": "test-subscription-id"}""" + val http = + mockHttp( + total = 2, + response = + MockResponse() + .setResponseCode(200) + .setBody(responseBody), + ) + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false) + + // First registration + var callbackResult1: Boolean? = null + sut.registerPushToken("test-fcm-token", "test-firebase-app-id") { success -> + callbackResult1 = success + } + Thread.sleep(100) // Wait for background thread + assertEquals(true, callbackResult1) + assertEquals(1, http.requestCount) + + // Second registration with same token immediately - should skip API call + var callbackResult2: Boolean? = null + sut.registerPushToken("test-fcm-token", "test-firebase-app-id") { success -> + callbackResult2 = success + } + Thread.sleep(100) // Wait for background thread + assertEquals(null, callbackResult2) // null means skipped + // Should not make a second request when token is unchanged and less than 1 hour + assertEquals(1, http.requestCount) + + sut.close() + } + + @Test + fun `registerPushToken registers again when token changes`() { + val responseBody = """{"status": "ok", "subscription_id": "test-subscription-id"}""" + val http = + mockHttp( + total = 2, + response = + MockResponse() + .setResponseCode(200) + .setBody(responseBody), + ) + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false) + + // First registration + var callbackResult1: Boolean? = null + sut.registerPushToken("test-fcm-token-1", "test-firebase-app-id") { success -> + callbackResult1 = success + } + Thread.sleep(100) // Wait for background thread + assertEquals(true, callbackResult1) + assertEquals(1, http.requestCount) + + // Second registration with different token - should register again + var callbackResult2: Boolean? = null + sut.registerPushToken("test-fcm-token-2", "test-firebase-app-id") { success -> + callbackResult2 = success + } + Thread.sleep(100) // Wait for background thread + assertEquals(true, callbackResult2) + assertEquals(2, http.requestCount) + + sut.close() + } + + @Test + fun `registerPushToken calls callback with false on API error`() { + val http = + mockHttp( + response = + MockResponse() + .setResponseCode(400) + .setBody("Bad Request"), + ) + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false) + + var callbackResult: Boolean? = null + sut.registerPushToken("test-fcm-token", "test-firebase-app-id") { success -> + callbackResult = success + } + + Thread.sleep(100) // Wait for background thread + assertEquals(false, callbackResult) + assertEquals(1, http.requestCount) + + sut.close() + } + + @Test + fun `registerPushToken clears preferences on API error`() { + val http = + mockHttp( + response = + MockResponse() + .setResponseCode(400) + .setBody("Bad Request"), + ) + val url = http.url("/") + val preferences = PostHogMemoryPreferences() + + val sut = getSut(url.toString(), preloadFeatureFlags = false, cachePreferences = preferences) + + sut.registerPushToken("test-fcm-token", "test-firebase-app-id") + Thread.sleep(100) // Wait for background thread + + val storedToken = preferences.getValue(FCM_TOKEN) as? String + val lastUpdated = preferences.getValue(FCM_TOKEN_LAST_UPDATED) as? Long + + assertNull(storedToken) + assertNull(lastUpdated) + + sut.close() + } + + @Test + fun `registerPushToken stores token and timestamp in preferences`() { + val responseBody = """{"status": "ok", "subscription_id": "test-subscription-id"}""" + val http = + mockHttp( + response = + MockResponse() + .setResponseCode(200) + .setBody(responseBody), + ) + val url = http.url("/") + val preferences = PostHogMemoryPreferences() + + val sut = getSut(url.toString(), preloadFeatureFlags = false, cachePreferences = preferences) + + sut.registerPushToken("test-fcm-token", "test-firebase-app-id") + Thread.sleep(100) // Wait for background thread + + val storedToken = preferences.getValue(FCM_TOKEN) as? String + val lastUpdated = preferences.getValue(FCM_TOKEN_LAST_UPDATED) as? Long + + assertEquals("test-fcm-token", storedToken) + assertNotNull(lastUpdated) + assertTrue(lastUpdated > 0) + + sut.close() + } } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt index 0f85fa98..c43a06c8 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt @@ -5,6 +5,7 @@ import com.posthog.BuildConfig import com.posthog.PostHogConfig import com.posthog.generateEvent import com.posthog.mockHttp +import com.posthog.unGzip import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.Assert.assertThrows @@ -310,4 +311,69 @@ internal class PostHogApiTest { } assertEquals(401, exc.statusCode) } + + @Test + fun `registerPushSubscription returns successful response`() { + val responseBody = """{"status": "ok", "subscription_id": "test-subscription-id"}""" + val http = + mockHttp( + response = + MockResponse() + .setResponseCode(200) + .setBody(responseBody), + ) + val url = http.url("/") + + val sut = getSut(host = url.toString()) + + sut.registerPushSubscription("test-distinct-id", "test-fcm-token", "test-firebase-app-id") + + val request = http.takeRequest() + + assertEquals("posthog-java/${BuildConfig.VERSION_NAME}", request.headers["User-Agent"]) + assertEquals("POST", request.method) + assertEquals("/api/sdk/push_subscriptions/register", request.path) + assertEquals("gzip", request.headers["Content-Encoding"]) + assertEquals("gzip", request.headers["Accept-Encoding"]) + assertEquals("application/json; charset=utf-8", request.headers["Content-Type"]) + + // Verify request body contains expected fields + val requestBody = request.body.unGzip() + assertTrue(requestBody.contains("\"api_key\":\"$API_KEY\"")) + assertTrue(requestBody.contains("\"distinct_id\":\"test-distinct-id\"")) + assertTrue(requestBody.contains("\"token\":\"test-fcm-token\"")) + assertTrue(requestBody.contains("\"platform\":\"android\"")) + assertTrue(requestBody.contains("\"firebase_app_id\":\"test-firebase-app-id\"")) + } + + @Test + fun `registerPushSubscription throws if not successful`() { + val http = mockHttp(response = MockResponse().setResponseCode(400).setBody("Bad Request")) + val url = http.url("/") + + val sut = getSut(host = url.toString()) + + val exc = + assertThrows(PostHogApiError::class.java) { + sut.registerPushSubscription("test-distinct-id", "test-fcm-token", "test-firebase-app-id") + } + assertEquals(400, exc.statusCode) + assertEquals("Client Error", exc.message) + assertNotNull(exc.body) + } + + @Test + fun `registerPushSubscription throws on 401 unauthorized`() { + val http = mockHttp(response = MockResponse().setResponseCode(401).setBody("""{"error": "Invalid API key"}""")) + val url = http.url("/") + + val sut = getSut(host = url.toString()) + + val exc = + assertThrows(PostHogApiError::class.java) { + sut.registerPushSubscription("test-distinct-id", "test-fcm-token", "test-firebase-app-id") + } + assertEquals(401, exc.statusCode) + assertEquals("Client Error", exc.message) + } }