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..94f032bd 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt @@ -89,6 +89,10 @@ public class PostHogFake : PostHogInterface { override fun resetPersonPropertiesForFlags(reloadFeatureFlags: Boolean) { } + override fun registerPushToken(token: String): Boolean { + return true + } + override fun setGroupPropertiesForFlags( type: String, groupProperties: Map, diff --git a/posthog/CHANGELOG.md b/posthog/CHANGELOG.md index d8ead894..0f05f2f2 100644 --- a/posthog/CHANGELOG.md +++ b/posthog/CHANGELOG.md @@ -1,5 +1,8 @@ ## Next +### Added +- Allow collecting FCM device token in SDK core ([#396](https://github.com/PostHog/posthog-android/pull/396)) + ## 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..d052a0e1 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -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;)Z 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;)Z 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;)Z public abstract fun reloadFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V public abstract fun reset ()V public abstract fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V @@ -585,6 +588,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;)V public final fun remoteConfig ()Lcom/posthog/internal/PostHogRemoteConfigResponse; public final fun snapshot (Ljava/util/List;)V } @@ -737,6 +741,23 @@ 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;)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 copy (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;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 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..8e22756d 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -8,6 +8,8 @@ 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 @@ -1222,6 +1224,78 @@ public class PostHog private constructor( return PostHogSessionManager.isSessionActive() } + override fun registerPushToken(token: String): Boolean { + if (!isEnabled()) { + return false + } + + if (token.isBlank()) { + config?.logger?.log("registerPushToken called with blank token") + return false + } + + val config = this.config ?: return false + val preferences = getPreferences() + + // Get stored token and last update timestamp + 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 + + // Check if token has changed or if an hour has passed + val tokenChanged = storedToken != token + val shouldUpdate = tokenChanged || (currentTime - lastUpdated >= oneHourInMillis) + + if (!shouldUpdate) { + config.logger.log("FCM token registration skipped: token unchanged and less than hour since last update") + return true + } + + // 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) + val distinctId = distinctId() + + val executor = + Executors.newSingleThreadExecutor( + PostHogThreadFactory("PostHogFCMTokenRegistration"), + ) + val future = + executor.submit { + api.registerPushSubscription(distinctId, token) + true + } + try { + val success = future.get(5, java.util.concurrent.TimeUnit.SECONDS) + + if (success) { + preferences.setValue(FCM_TOKEN, token) + preferences.setValue(FCM_TOKEN_LAST_UPDATED, currentTime) + config.logger.log("FCM token registered successfully") + } + success + } 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 + } finally { + executor.shutdown() + } + } catch (e: Throwable) { + config.logger.log("Failed to register FCM token: $e") + false + } + } + override fun getConfig(): T? { @Suppress("UNCHECKED_CAST") return super.config as? T @@ -1539,5 +1613,9 @@ public class PostHog private constructor( override fun getSessionId(): UUID? { return shared.getSessionId() } + + override fun registerPushToken(token: String): Boolean { + return shared.registerPushToken(token) + } } } diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index cbf37211..042fa9fc 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -223,6 +223,19 @@ 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. + * + * Users should retrieve the FCM token using: + * - Java: `FirebaseMessaging.getInstance().getToken()` + * - Kotlin: `Firebase.messaging.token` + * + * @param token The FCM registration token + * @return true if registration was successful, false otherwise + */ + public fun registerPushToken(token: String): Boolean + /** * 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..94f25be3 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -253,6 +253,35 @@ public class PostHogApi( } } + @Throws(PostHogApiError::class, IOException::class) + public fun registerPushSubscription( + distinctId: String, + token: String, + ) { + val pushSubscriptionRequest = + PostHogPushSubscriptionRequest( + api_key = config.apiKey, + distinct_id = distinctId, + token = token, + platform = "android", + ) + + 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..6d6dd8b4 --- /dev/null +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionRequest.kt @@ -0,0 +1,14 @@ +package com.posthog.internal + +import com.posthog.PostHogInternal + +/** + * Request body for push subscription registration + */ +@PostHogInternal +public data class PostHogPushSubscriptionRequest( + val api_key: String, + val distinct_id: String, + val token: String, + val platform: String, +) diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 1fb808ef..f8e234b8 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 @@ -2608,4 +2610,180 @@ 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) + + val result = sut.registerPushToken("test-fcm-token") + + // Wait for background thread to complete + Thread.sleep(100) + + assertTrue(result) + 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\"")) + + sut.close() + } + + @Test + fun `registerPushToken returns false when SDK is disabled`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false) + sut.close() + + val result = sut.registerPushToken("test-fcm-token") + + assertFalse(result) + assertEquals(0, http.requestCount) + } + + @Test + fun `registerPushToken returns false for blank token`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false) + + val result = sut.registerPushToken("") + + assertFalse(result) + 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 + val result1 = sut.registerPushToken("test-fcm-token") + Thread.sleep(100) // Wait for background thread + assertTrue(result1) + assertEquals(1, http.requestCount) + + // Second registration with same token immediately - should skip API call + val result2 = sut.registerPushToken("test-fcm-token") + Thread.sleep(100) // Wait for background thread + assertTrue(result2) + // 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 + val result1 = sut.registerPushToken("test-fcm-token-1") + Thread.sleep(100) // Wait for background thread + assertTrue(result1) + assertEquals(1, http.requestCount) + + // Second registration with different token - should register again + val result2 = sut.registerPushToken("test-fcm-token-2") + Thread.sleep(100) // Wait for background thread + assertTrue(result2) + assertEquals(2, http.requestCount) + + sut.close() + } + + @Test + fun `registerPushToken returns 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) + + val result = sut.registerPushToken("test-fcm-token") + + Thread.sleep(100) // Wait for background thread + assertFalse(result) + assertEquals(1, http.requestCount) + + 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") + 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..f433699d 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,68 @@ 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") + + 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\"")) + } + + @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") + } + 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") + } + assertEquals(401, exc.statusCode) + assertEquals("Client Error", exc.message) + } }