From 442bbb89abe9830fc1ad10c679f5bbb1f8451fd8 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 24 Jul 2026 17:53:57 +0300 Subject: [PATCH 1/5] feat: attach optional identity token to push subscription requests --- .changeset/witty-donkeys-shake.md | 6 + posthog/api/posthog.api | 12 +- .../main/java/com/posthog/PostHogConfig.kt | 16 ++ .../java/com/posthog/internal/PostHogApi.kt | 8 +- .../PostHogPushSubscriptionManager.kt | 135 ++++++++- .../PostHogPushSubscriptionRequest.kt | 3 + .../com/posthog/internal/PostHogApiTest.kt | 45 +++ .../PostHogPushSubscriptionManagerTest.kt | 264 ++++++++++++++++++ 8 files changed, 472 insertions(+), 17 deletions(-) create mode 100644 .changeset/witty-donkeys-shake.md diff --git a/.changeset/witty-donkeys-shake.md b/.changeset/witty-donkeys-shake.md new file mode 100644 index 00000000..5f362a12 --- /dev/null +++ b/.changeset/witty-donkeys-shake.md @@ -0,0 +1,6 @@ +--- +"posthog-android": minor +"posthog": minor +--- + +Add optional identity verification for push subscriptions: set `pushIdentityProvider` on the config to attach a backend-minted `identity_token` (JWT) to push register/unregister requests. The token is cached per identity, reused across in-session retries, and refreshed once automatically when the backend rejects a request with 401. diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 331565ba..1dad4836 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -150,8 +150,8 @@ public class com/posthog/PostHogConfig { public static final field DEFAULT_MAX_QUEUE_SIZE I public static final field DEFAULT_US_ASSETS_HOST Ljava/lang/String; public static final field DEFAULT_US_HOST Ljava/lang/String; - public fun (Ljava/lang/String;Ljava/lang/String;ZZZIZLjava/util/List;ZZIIIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;ZLcom/posthog/PostHogPropertiesSanitizer;Lkotlin/jvm/functions/Function1;ZLcom/posthog/PersonProfiles;ZLjava/net/Proxy;Lcom/posthog/surveys/PostHogSurveysConfig;Lcom/posthog/logs/PostHogLogsConfig;Lkotlin/jvm/functions/Function6;Lkotlin/jvm/functions/Function5;Lcom/posthog/errortracking/PostHogErrorTrackingConfig;Ljava/lang/String;Lcom/posthog/PostHogBootstrapConfig;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;ZZZIZLjava/util/List;ZZIIIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;ZLcom/posthog/PostHogPropertiesSanitizer;Lkotlin/jvm/functions/Function1;ZLcom/posthog/PersonProfiles;ZLjava/net/Proxy;Lcom/posthog/surveys/PostHogSurveysConfig;Lcom/posthog/logs/PostHogLogsConfig;Lkotlin/jvm/functions/Function6;Lkotlin/jvm/functions/Function5;Lcom/posthog/errortracking/PostHogErrorTrackingConfig;Ljava/lang/String;Lcom/posthog/PostHogBootstrapConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;ZZZIZLjava/util/List;ZZIIIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;ZLcom/posthog/PostHogPropertiesSanitizer;Lkotlin/jvm/functions/Function1;ZLcom/posthog/PersonProfiles;ZLjava/net/Proxy;Lcom/posthog/surveys/PostHogSurveysConfig;Lcom/posthog/logs/PostHogLogsConfig;Lkotlin/jvm/functions/Function6;Lkotlin/jvm/functions/Function5;Lcom/posthog/errortracking/PostHogErrorTrackingConfig;Ljava/lang/String;Lcom/posthog/PostHogBootstrapConfig;Lkotlin/jvm/functions/Function3;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;ZZZIZLjava/util/List;ZZIIIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;ZLcom/posthog/PostHogPropertiesSanitizer;Lkotlin/jvm/functions/Function1;ZLcom/posthog/PersonProfiles;ZLjava/net/Proxy;Lcom/posthog/surveys/PostHogSurveysConfig;Lcom/posthog/logs/PostHogLogsConfig;Lkotlin/jvm/functions/Function6;Lkotlin/jvm/functions/Function5;Lcom/posthog/errortracking/PostHogErrorTrackingConfig;Ljava/lang/String;Lcom/posthog/PostHogBootstrapConfig;Lkotlin/jvm/functions/Function3;IILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun addBeforeSend (Lcom/posthog/PostHogBeforeSend;)V public final fun addIntegration (Lcom/posthog/PostHogIntegration;)V public final fun getApiKey ()Ljava/lang/String; @@ -187,6 +187,7 @@ public class com/posthog/PostHogConfig { public final fun getPreloadFeatureFlags ()Z public final fun getPropertiesSanitizer ()Lcom/posthog/PostHogPropertiesSanitizer; public final fun getProxy ()Ljava/net/Proxy; + public final fun getPushIdentityProvider ()Lkotlin/jvm/functions/Function3; public final fun getQueueProvider ()Lkotlin/jvm/functions/Function5; public final fun getReleaseIdentifier ()Ljava/lang/String; public final fun getRemoteConfig ()Z @@ -237,6 +238,7 @@ public class com/posthog/PostHogConfig { public final fun setPreloadFeatureFlags (Z)V public final fun setPropertiesSanitizer (Lcom/posthog/PostHogPropertiesSanitizer;)V public final fun setProxy (Ljava/net/Proxy;)V + public final fun setPushIdentityProvider (Lkotlin/jvm/functions/Function3;)V public final fun setReleaseIdentifier (Ljava/lang/String;)V public final fun setRemoteConfig (Z)V public final fun setRemoteConfigHolder (Lcom/posthog/internal/PostHogRemoteConfig;)V @@ -737,8 +739,10 @@ public final class com/posthog/internal/PostHogApi { public static synthetic fun flags$default (Lcom/posthog/internal/PostHogApi;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;ZILjava/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 pushSubscription (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V - public final fun pushUnsubscription (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public final fun pushSubscription (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public static synthetic fun pushSubscription$default (Lcom/posthog/internal/PostHogApi;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V + public final fun pushUnsubscription (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public static synthetic fun pushUnsubscription$default (Lcom/posthog/internal/PostHogApi;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V public final fun remoteConfig ()Lcom/posthog/internal/PostHogRemoteConfigResponse; public final fun snapshot (Ljava/util/List;)V } diff --git a/posthog/src/main/java/com/posthog/PostHogConfig.kt b/posthog/src/main/java/com/posthog/PostHogConfig.kt index e01f3d69..d13974c9 100644 --- a/posthog/src/main/java/com/posthog/PostHogConfig.kt +++ b/posthog/src/main/java/com/posthog/PostHogConfig.kt @@ -328,6 +328,22 @@ public open class PostHogConfig( * Defaults to null (no bootstrap). */ public var bootstrap: PostHogBootstrapConfig? = null, + /** + * Hook that supplies a signed identity token for push subscription requests, enabling the + * optional identity verification of the push subscriptions API. + * + * Invoked with the distinctId and appId the request is about to carry, whenever a token is + * needed and none is cached for that exact pair — and once more after a 401 rejection. Your + * backend mints the token — an HS256 JWT signed with the project's secret API key (never + * embedded in the app), with claims `sub` = distinct id, `app_id`, `aud` = + * "posthog:push_identity", and an `exp` — and you pass it to `completion`, from any thread. Pass `completion(null)` + * when no token can be minted (e.g. the user is anonymous); the request is then sent without + * one. `completion` must be called exactly once: further calls are ignored, and never calling + * it strands the current registration until the next app launch. + * + * Defaults to null (requests are sent without an identity token). + */ + public var pushIdentityProvider: ((distinctId: String, appId: String, completion: (String?) -> Unit) -> Unit)? = null, ) { @Volatile private var tracingHeadersList: List? = null diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index a6c8afac..13217851 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -139,7 +139,8 @@ public class PostHogApi( deviceToken: String, platform: String, appId: String, - ): Unit = sendPushSubscription("POST", distinctId, deviceToken, platform, appId) + identityToken: String? = null, + ): Unit = sendPushSubscription("POST", distinctId, deviceToken, platform, appId, identityToken) @Throws(PostHogApiError::class, IOException::class) public fun pushUnsubscription( @@ -147,7 +148,8 @@ public class PostHogApi( deviceToken: String, platform: String, appId: String, - ): Unit = sendPushSubscription("DELETE", distinctId, deviceToken, platform, appId) + identityToken: String? = null, + ): Unit = sendPushSubscription("DELETE", distinctId, deviceToken, platform, appId, identityToken) @Throws(PostHogApiError::class, IOException::class) private fun sendPushSubscription( @@ -156,6 +158,7 @@ public class PostHogApi( deviceToken: String, platform: String, appId: String, + identityToken: String?, ) { val pushSubscription = PostHogPushSubscriptionRequest( @@ -164,6 +167,7 @@ public class PostHogApi( deviceToken = deviceToken, platform = platform, appId = appId, + identityToken = identityToken, ) val url = "$theHost/api/push_subscriptions/" diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt index 48327c3f..783ab39f 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt @@ -61,6 +61,15 @@ internal class PostHogPushSubscriptionManager( @Volatile private var closed = false + // In-memory only: a short-lived credential must never land on disk; a fresh process re-mints + // via config.pushIdentityProvider. Reused across in-session backoff retries for the same + // (distinctId, appId); any other pair is a miss and re-mints. + @Volatile private var cachedIdentityToken: CachedIdentityToken? = null + + // One fresh-token retry per send-cycle after a 401 (single refresh, no loop). Cleared on + // success, a new register, and an identity-change resend; a fresh process starts clear. + @Volatile private var didAuthRetry = false + private val pendingFile: File? by lazy { val prefix = config.storagePrefix ?: return@lazy null // Must stay out of /: PostHogQueue scans that whole directory as @@ -107,6 +116,7 @@ internal class PostHogPushSubscriptionManager( retryCount = 0 nextAttemptAtMs = 0L halted = false + didAuthRetry = false cancelTimer() attempt() } @@ -141,6 +151,7 @@ internal class PostHogPushSubscriptionManager( } retryCount = 0 halted = false + didAuthRetry = false cancelTimer() attempt() } @@ -176,16 +187,24 @@ internal class PostHogPushSubscriptionManager( config.logger.log("Push unregister skipped: missing distinctId, token, or appId.") return } - try { - api.pushUnsubscription( - distinctId = distinctId, - deviceToken = deviceToken, - platform = platform, - appId = appId, - ) - config.logger.log("Push notification token unregistered successfully.") - } catch (e: Throwable) { - config.logger.log("Push unregister failed: $e. Ignoring (best-effort).") + // Best-effort stays single-shot: the token is resolved once (old id on the reset path) and + // a 401 is not refreshed — the durable path is the re-register POST. + resolveIdentityToken(distinctId, appId) { identityToken -> + if (closed || config.optOut) { + return@resolveIdentityToken + } + try { + api.pushUnsubscription( + distinctId = distinctId, + deviceToken = deviceToken, + platform = platform, + appId = appId, + identityToken = identityToken, + ) + config.logger.log("Push notification token unregistered successfully.") + } catch (e: Throwable) { + config.logger.log("Push unregister failed: $e. Ignoring (best-effort).") + } } } @@ -235,11 +254,13 @@ internal class PostHogPushSubscriptionManager( closed = true cancelTimer() retryCount = 0 + cachedIdentityToken = null } /** Opt-out: stop the retry/offline-poll timer now. The guard in [attempt] blocks any actual send. */ fun onOptOut() { cancelTimer() + cachedIdentityToken = null } private fun isWithinBackoffWindow(): Boolean = System.currentTimeMillis() < nextAttemptAtMs @@ -276,16 +297,36 @@ internal class PostHogPushSubscriptionManager( return } + // isSending stays claimed across an async token mint so resume paths can't double-send; + // [performSend] releases it on every path. + resolveIdentityToken(distinctId, record.appId) { identityToken -> + performSend(record, distinctId, identityToken) + } + } + + // Runs on the executor (inline from [attempt], or re-entered from the provider completion). + // [distinctId] is the id the identity token was resolved for, so body and token always match. + private fun performSend( + record: PendingRecord, + distinctId: String, + identityToken: String?, + ) { + if (closed || config.optOut) { + isSending.set(false) + return + } try { api.pushSubscription( distinctId = distinctId, deviceToken = record.deviceToken, platform = record.platform, appId = record.appId, + identityToken = identityToken, ) config.logger.log("Push notification token registered successfully.") retryCount = 0 nextAttemptAtMs = 0L + didAuthRetry = false // Keep the record with the delivered marker so a later identify() can re-register. val delivered = record.copy(deliveredForDistinctId = distinctId) pendingRecord = delivered @@ -298,8 +339,31 @@ internal class PostHogPushSubscriptionManager( } private fun handleFailure(e: Throwable) { + if ((e as? PostHogApiError)?.statusCode == 401) { + val provider = config.pushIdentityProvider + if (provider != null && !didAuthRetry) { + // One fresh-token retry, then terminal. Re-queued (not inline) so the failing + // send's isSending release in [performSend] happens before the retry claims it. + didAuthRetry = true + cachedIdentityToken = null + config.logger.log("Push subscription rejected (401): refreshing identity token and retrying once.") + executor.executeSafely { attempt() } + return + } + config.logger.log( + "Push subscription rejected (401). " + + if (provider == null) { + "Identity verification may be required — configure pushIdentityProvider. " + + "Keeping record for next launch." + } else { + "Identity token refresh did not help. Keeping record for next launch." + }, + ) + haltForSession() + return + } if (!isRetryable(e)) { - // 400/401 etc.: stop retrying this session but keep the record for one retry next launch. + // 400 etc.: stop retrying this session but keep the record for one retry next launch. config.logger.log("Push subscription failed with non-retryable error: $e.") haltForSession() return @@ -360,6 +424,49 @@ internal class PostHogPushSubscriptionManager( } } + // Resolves the identity token for [distinctId]/[appId], preferring a cached exact match. The + // provider's completion may arrive from any thread and only the first call counts; no provider, + // a null completion, or a throw all fall back to token-less — the pre-identity behavior. + private fun resolveIdentityToken( + distinctId: String, + appId: String, + onResolved: (String?) -> Unit, + ) { + val cached = cachedIdentityToken + if (cached != null && cached.distinctId == distinctId && cached.appId == appId) { + config.logger.log("Push subscription request sent with cached identity token.") + onResolved(cached.token) + return + } + val provider = config.pushIdentityProvider + if (provider == null) { + config.logger.log("Push subscription request sent without identity token (no pushIdentityProvider).") + onResolved(null) + return + } + val completed = AtomicBoolean(false) + try { + provider(distinctId, appId) { token -> + if (completed.compareAndSet(false, true)) { + executor.executeSafely { + if (token != null) { + cachedIdentityToken = CachedIdentityToken(token, distinctId, appId) + config.logger.log("Push subscription request sent with freshly minted identity token.") + } else { + config.logger.log("Push subscription request sent without identity token (provider completed null).") + } + onResolved(token) + } + } + } + } catch (e: Throwable) { + config.logger.log("pushIdentityProvider threw: $e. Sending without identity token.") + if (completed.compareAndSet(false, true)) { + onResolved(null) + } + } + } + private fun isRetryable(e: Throwable): Boolean { return when (e) { is PostHogApiError -> e.statusCode == 429 || e.statusCode in 500..599 @@ -418,4 +525,10 @@ internal class PostHogPushSubscriptionManager( @SerializedName("delivered_for_distinct_id") val deliveredForDistinctId: String? = null, ) + + private data class CachedIdentityToken( + val token: String, + val distinctId: String, + val appId: String, + ) } diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionRequest.kt b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionRequest.kt index 9c17db22..fb63094b 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionRequest.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionRequest.kt @@ -13,4 +13,7 @@ internal data class PostHogPushSubscriptionRequest( val platform: String, @SerializedName("app_id") val appId: String, + // Gson omits null fields, so an absent token keeps the body byte-identical to the 5-field contract. + @SerializedName("identity_token") + val identityToken: String? = null, ) diff --git a/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt index b6d74aed..ff7aa1f8 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt @@ -793,6 +793,28 @@ internal class PostHogApiTest { assertEquals("fcm-token-123", parsed["device_token"]) assertEquals("android", parsed["platform"]) assertEquals("firebase-project-id", parsed["app_id"]) + // Vector 10: no identity token -> the key is omitted entirely, not serialized as null. + assertFalse(body.contains("identity_token")) + } + + @Test + fun `pushSubscription includes identity_token when provided`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(host = url.toString()) + + sut.pushSubscription( + distinctId = "distinctId", + deviceToken = "fcm-token-123", + platform = "android", + appId = "firebase-project-id", + identityToken = "jwt-abc", + ) + + val request = http.takeRequest() + val parsed = PostHogSerializer(PostHogConfig(API_KEY)).deserialize>(request.body.unGzip().reader()) + assertEquals("jwt-abc", parsed["identity_token"]) } @Test @@ -841,6 +863,29 @@ internal class PostHogApiTest { assertEquals("fcm-token-123", parsed["device_token"]) assertEquals("android", parsed["platform"]) assertEquals("firebase-project-id", parsed["app_id"]) + // Vector 10: no identity token -> the key is omitted entirely, not serialized as null. + assertFalse(body.contains("identity_token")) + } + + @Test + fun `pushUnsubscription includes identity_token when provided`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(host = url.toString()) + + sut.pushUnsubscription( + distinctId = "distinctId", + deviceToken = "fcm-token-123", + platform = "android", + appId = "firebase-project-id", + identityToken = "jwt-abc", + ) + + val request = http.takeRequest() + assertEquals("DELETE", request.method) + val parsed = PostHogSerializer(PostHogConfig(API_KEY)).deserialize>(request.body.unGzip().reader()) + assertEquals("jwt-abc", parsed["identity_token"]) } @Test diff --git a/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt index 573e637c..70ddfc7f 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt @@ -332,6 +332,23 @@ internal class PostHogPushSubscriptionManagerTest { assertEquals(1, http.requestCount) } + @Test + fun `unregister DELETE gets no 401 fresh-token refresh`() { + // Best-effort leg stays single-shot even with a provider: one mint, one DELETE, no retry. + val http = mockHttp(total = 2, response = MockResponse().setResponseCode(401)) + val (sut, config, _) = getSut(http) + val minted = java.util.concurrent.atomic.AtomicInteger(0) + config.pushIdentityProvider = { _, _, completion -> completion("jwt-${minted.incrementAndGet()}") } + + sut.unregister("distinct-1", "fcm-token", "firebase-project", "android") + flush() + + assertEquals("DELETE", http.takeRequest().method) + assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) + assertEquals(1, http.requestCount) + assertEquals(1, minted.get()) + } + @Test fun `handleReset unregisters the old identity then re-registers under the new anonymous id`() { val http = mockHttp(total = 3, response = MockResponse().setBody("")) @@ -606,6 +623,253 @@ internal class PostHogPushSubscriptionManagerTest { assertEquals(1, http.requestCount) } + @Test + fun `register and unregisterCurrent attach the provider token to POST and DELETE bodies`() { + // Vector 9: provider set completing "jwt-abc" -> both legs carry identity_token alongside the 5 fields. + val http = mockHttp(total = 2, response = MockResponse().setBody("")) + val (sut, config, _) = getSut(http) + config.pushIdentityProvider = { _, _, completion -> completion("jwt-abc") } + + sut.register("fcm-token", "firebase-project", "android") + flush() + + val post = http.takeRequest() + assertEquals("POST", post.method) + val postBody = post.body.unGzip() + assertTrue(postBody.contains("\"identity_token\":\"jwt-abc\"")) + assertTrue(postBody.contains("\"api_key\"")) + assertTrue(postBody.contains("\"distinct_id\"")) + assertTrue(postBody.contains("\"device_token\"")) + assertTrue(postBody.contains("\"platform\"")) + assertTrue(postBody.contains("\"app_id\"")) + + sut.unregisterCurrent() + flush() + + val del = http.takeRequest() + assertEquals("DELETE", del.method) + assertTrue(del.body.unGzip().contains("\"identity_token\":\"jwt-abc\"")) + } + + @Test + fun `register without a provider omits identity_token from the raw body`() { + // Vector 10: no provider -> the serialized body has no identity_token key at all. + val http = mockHttp() + val (sut, _, _) = getSut(http) + + sut.register("fcm-token", "firebase-project", "android") + flush() + + assertFalse(http.takeRequest().body.unGzip().contains("identity_token")) + } + + @Test + fun `register with a provider completing null sends token-less`() { + // Vector 10: completion(null) -> key omitted, request still goes out and delivers. + val http = mockHttp() + val (sut, config, storagePrefix) = getSut(http) + config.pushIdentityProvider = { _, _, completion -> completion(null) } + + sut.register("fcm-token", "firebase-project", "android") + flush() + flush() // the null completion re-enters via a queued executor task + + assertFalse(http.takeRequest(2, TimeUnit.SECONDS)!!.body.unGzip().contains("identity_token")) + flush() + assertEquals("distinct-1", readRecord(config, pendingFile(storagePrefix!!))?.deliveredForDistinctId) + } + + @Test + fun `handleReset resolves the old id token for the DELETE and the anon id token for the re-POST`() { + // Vector 11: each leg carries a token for the distinct id it sends; cached tokens are reused + // per (distinctId, appId), so only ids never minted before invoke the provider. + val http = mockHttp(total = 4, response = MockResponse().setBody("")) + val (sut, config, _) = getSut(http) + val invocations = mutableListOf() + config.pushIdentityProvider = { id, _, completion -> + synchronized(invocations) { invocations.add(id) } + completion("tok-$id") + } + + distinctId = "user-A" + sut.register("fcm-token", "firebase-project", "android") + flush() + assertTrue(http.takeRequest(2, TimeUnit.SECONDS)!!.body.unGzip().contains("\"identity_token\":\"tok-user-A\"")) + + distinctId = "user-B" + sut.resendIfDistinctIdChanged() + flush() + assertTrue(http.takeRequest(2, TimeUnit.SECONDS)!!.body.unGzip().contains("\"identity_token\":\"tok-user-B\"")) + + distinctId = "anon-2" + sut.handleReset("user-B") + + val del = http.takeRequest(2, TimeUnit.SECONDS)!! + assertEquals("DELETE", del.method) + val delBody = del.body.unGzip() + assertTrue(delBody.contains("\"distinct_id\":\"user-B\"")) + assertTrue(delBody.contains("\"identity_token\":\"tok-user-B\"")) + + val post = http.takeRequest(2, TimeUnit.SECONDS)!! + assertEquals("POST", post.method) + val postBody = post.body.unGzip() + assertTrue(postBody.contains("\"distinct_id\":\"anon-2\"")) + assertTrue(postBody.contains("\"identity_token\":\"tok-anon-2\"")) + + flush() + // The DELETE leg reused user-B's cached token; only the three distinct ids minted, once each. + assertEquals(listOf("user-A", "user-B", "anon-2"), synchronized(invocations) { invocations.toList() }) + } + + @Test + fun `a 500 retry reuses the cached token without re-minting`() { + // Vector 12: 500 then 200 -> provider invoked once, both attempts carry the same token. + val http = MockWebServer() + http.start() + http.enqueue(MockResponse().setResponseCode(500)) + http.enqueue(MockResponse().setBody("")) + + val (sut, config, _) = getSut(http) + sut.retryDelayMillisPerSecond = 1L + val minted = java.util.concurrent.atomic.AtomicInteger(0) + config.pushIdentityProvider = { _, _, completion -> completion("jwt-${minted.incrementAndGet()}") } + + sut.register("fcm-token", "firebase-project", "android") + + assertTrue(http.takeRequest(2, TimeUnit.SECONDS)!!.body.unGzip().contains("\"identity_token\":\"jwt-1\"")) + assertTrue(http.takeRequest(2, TimeUnit.SECONDS)!!.body.unGzip().contains("\"identity_token\":\"jwt-1\"")) + assertEquals(2, http.requestCount) + assertEquals(1, minted.get()) + http.shutdown() + } + + @Test + fun `a 401 re-mints once and retries with the fresh token`() { + // Vector 13: 401 then 200 -> provider invoked a second time, retry carries the fresh token. + val http = MockWebServer() + http.start() + http.enqueue(MockResponse().setResponseCode(401)) + http.enqueue(MockResponse().setBody("")) + + val (sut, config, storagePrefix) = getSut(http) + val minted = java.util.concurrent.atomic.AtomicInteger(0) + config.pushIdentityProvider = { _, _, completion -> completion("jwt-${minted.incrementAndGet()}") } + + sut.register("fcm-token", "firebase-project", "android") + + assertTrue(http.takeRequest(2, TimeUnit.SECONDS)!!.body.unGzip().contains("\"identity_token\":\"jwt-1\"")) + assertTrue(http.takeRequest(2, TimeUnit.SECONDS)!!.body.unGzip().contains("\"identity_token\":\"jwt-2\"")) + assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) + assertEquals(2, minted.get()) + flush() + assertEquals("distinct-1", readRecord(config, pendingFile(storagePrefix!!))?.deliveredForDistinctId) + http.shutdown() + } + + @Test + fun `a second 401 is terminal after the single refresh`() { + // Vector 13: 401 then 401 -> exactly two provider invocations and two requests, then halt. + val http = MockWebServer() + http.start() + http.enqueue(MockResponse().setResponseCode(401)) + http.enqueue(MockResponse().setResponseCode(401)) + http.enqueue(MockResponse().setBody("")) // only consumed if the halt fails + + val (sut, config, storagePrefix) = getSut(http) + val minted = java.util.concurrent.atomic.AtomicInteger(0) + config.pushIdentityProvider = { _, _, completion -> completion("jwt-${minted.incrementAndGet()}") } + + sut.register("fcm-token", "firebase-project", "android") + + assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) + assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) + assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) + assertEquals(2, http.requestCount) + assertEquals(2, minted.get()) + + // Halted for the session: record kept without a delivered marker, resume paths are no-ops. + val file = pendingFile(storagePrefix!!) + assertTrue(file.exists()) + assertNull(readRecord(config, file)?.deliveredForDistinctId) + sut.retryPending() + flush() + assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) + assertEquals(2, http.requestCount) + http.shutdown() + } + + @Test + fun `a 401 without a provider halts immediately and logs the provider hint`() { + // Vector 14: one request, no retry, record kept, log names pushIdentityProvider. + val http = mockHttp(total = 5, response = MockResponse().setResponseCode(401)) + val (sut, config, storagePrefix) = getSut(http) + val messages = mutableListOf() + config.logger = + object : PostHogLogger { + override fun log(message: String) { + synchronized(messages) { messages.add(message) } + } + + override fun isEnabled(): Boolean = true + } + + sut.register("fcm-token", "firebase-project", "android") + flush() + + assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) + assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) + assertEquals(1, http.requestCount) + val file = pendingFile(storagePrefix!!) + assertTrue(file.exists()) + assertNull(readRecord(config, file)?.deliveredForDistinctId) + assertTrue(synchronized(messages) { messages.any { it.contains("pushIdentityProvider") } }) + } + + @Test + fun `a throwing provider sends token-less`() { + val http = mockHttp() + val (sut, config, _) = getSut(http) + config.pushIdentityProvider = { _, _, _ -> throw RuntimeException("mint failed") } + + sut.register("fcm-token", "firebase-project", "android") + flush() + + val request = http.takeRequest(2, TimeUnit.SECONDS)!! + assertFalse(request.body.unGzip().contains("identity_token")) + assertEquals(1, http.requestCount) + } + + @Test + fun `a provider completing from another thread still attaches the token`() { + val http = mockHttp() + val (sut, config, _) = getSut(http) + config.pushIdentityProvider = { _, _, completion -> + Thread { completion("jwt-thread") }.start() + } + + sut.register("fcm-token", "firebase-project", "android") + + assertTrue(http.takeRequest(2, TimeUnit.SECONDS)!!.body.unGzip().contains("\"identity_token\":\"jwt-thread\"")) + } + + @Test + fun `only the first provider completion is honored`() { + val http = mockHttp(total = 2) + val (sut, config, _) = getSut(http) + config.pushIdentityProvider = { _, _, completion -> + completion("first") + completion("second") + } + + sut.register("fcm-token", "firebase-project", "android") + flush() + flush() + + assertTrue(http.takeRequest(2, TimeUnit.SECONDS)!!.body.unGzip().contains("\"identity_token\":\"first\"")) + assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) + assertEquals(1, http.requestCount) + } + private fun parsedDistinctId(request: okhttp3.mockwebserver.RecordedRequest): String? { val serializer = PostHogSerializer(PostHogConfig(API_KEY)) val parsed = serializer.deserialize>(request.body.unGzip().reader()) From 4cb2681a49cd4cb4676be501e9f85bfa0da0b967 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 27 Jul 2026 13:02:09 +0300 Subject: [PATCH 2/5] chore: drop changeset until release --- .changeset/witty-donkeys-shake.md | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 .changeset/witty-donkeys-shake.md diff --git a/.changeset/witty-donkeys-shake.md b/.changeset/witty-donkeys-shake.md deleted file mode 100644 index 5f362a12..00000000 --- a/.changeset/witty-donkeys-shake.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"posthog-android": minor -"posthog": minor ---- - -Add optional identity verification for push subscriptions: set `pushIdentityProvider` on the config to attach a backend-minted `identity_token` (JWT) to push register/unregister requests. The token is cached per identity, reused across in-session retries, and refreshed once automatically when the backend rejects a request with 401. From 1afa08af2737ad302a0e431dfb23300b8ffdf872 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 27 Jul 2026 13:10:07 +0300 Subject: [PATCH 3/5] fix: do not cache identity token minted after opt-out --- .../PostHogPushSubscriptionManager.kt | 7 ++++- .../PostHogPushSubscriptionManagerTest.kt | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt index 783ab39f..5ec14065 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt @@ -450,7 +450,12 @@ internal class PostHogPushSubscriptionManager( if (completed.compareAndSet(false, true)) { executor.executeSafely { if (token != null) { - cachedIdentityToken = CachedIdentityToken(token, distinctId, appId) + // A mint can complete after opt-out cleared the cache; caching it would + // resurrect a stale credential on a later opt-in. The 401 refresh covers + // the residual race window. + if (!closed && !config.optOut) { + cachedIdentityToken = CachedIdentityToken(token, distinctId, appId) + } config.logger.log("Push subscription request sent with freshly minted identity token.") } else { config.logger.log("Push subscription request sent without identity token (provider completed null).") diff --git a/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt index 70ddfc7f..52907da6 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt @@ -332,6 +332,34 @@ internal class PostHogPushSubscriptionManagerTest { assertEquals(1, http.requestCount) } + @Test + fun `a mint completing after optOut is not cached and opt-in re-mints`() { + val http = mockHttp(total = 2, response = MockResponse().setBody("")) + val (sut, config, _) = getSut(http) + var pending: ((String?) -> Unit)? = null + val minted = java.util.concurrent.atomic.AtomicInteger(0) + config.pushIdentityProvider = { _, _, completion -> + minted.incrementAndGet() + pending = completion + } + + sut.register("fcm-token", "firebase-project", "android") + flush() + config.optOut = true + sut.onOptOut() + pending!!.invoke("jwt-stale") + flush() + + config.optOut = false + sut.register("fcm-token", "firebase-project", "android") + flush() + pending!!.invoke("jwt-fresh") + flush() + + assertEquals(2, minted.get()) + assertTrue(http.takeRequest().body.unGzip().contains("\"identity_token\":\"jwt-fresh\"")) + } + @Test fun `unregister DELETE gets no 401 fresh-token refresh`() { // Best-effort leg stays single-shot even with a provider: one mint, one DELETE, no retry. From abe5ebec7502fb4b1ffbaebc816b6044f1118cd0 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Tue, 28 Jul 2026 13:44:31 +0300 Subject: [PATCH 4/5] fix: harden async push identity token mint against races, hangs, and leaks --- .../main/java/com/posthog/PostHogConfig.kt | 4 + .../java/com/posthog/internal/PostHogApi.kt | 7 +- .../PostHogPushSubscriptionManager.kt | 103 +++++++++++++++--- .../PostHogPushSubscriptionManagerTest.kt | 76 +++++++++++++ 4 files changed, 172 insertions(+), 18 deletions(-) diff --git a/posthog/src/main/java/com/posthog/PostHogConfig.kt b/posthog/src/main/java/com/posthog/PostHogConfig.kt index d13974c9..fb9cd492 100644 --- a/posthog/src/main/java/com/posthog/PostHogConfig.kt +++ b/posthog/src/main/java/com/posthog/PostHogConfig.kt @@ -341,6 +341,10 @@ public open class PostHogConfig( * one. `completion` must be called exactly once: further calls are ignored, and never calling * it strands the current registration until the next app launch. * + * The hook itself runs on the SDK's push executor thread (only `completion` may be called from + * any thread), so return quickly: do the token minting asynchronously and call `completion` when + * it finishes. Blocking here stalls the push retry/offline-resume loop. + * * Defaults to null (requests are sent without an identity token). */ public var pushIdentityProvider: ((distinctId: String, appId: String, completion: (String?) -> Unit) -> Unit)? = null, diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index 13217851..7bd1ec4f 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -173,7 +173,12 @@ public class PostHogApi( val url = "$theHost/api/push_subscriptions/" val request = makeRequest(url, method = method) { - logRequest(pushSubscription, url) + // Redact the identity token: it is a short-lived bearer credential and logRequest + // writes the whole body to the debug logger (Logcat on Android). CWE-532. + logRequest( + pushSubscription.copy(identityToken = identityToken?.let { "" }), + url, + ) config.serializer.serialize(pushSubscription, it.bufferedWriter()) } diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt index 5ec14065..7f74c821 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt @@ -70,6 +70,11 @@ internal class PostHogPushSubscriptionManager( // success, a new register, and an identity-change resend; a fresh process starts clear. @Volatile private var didAuthRetry = false + // A register/resend/retry that arrived while a send was in flight (isSending claimed across the + // async mint) sets this instead of no-oping; [performSend] replays one fresh attempt on release + // so the latest token isn't stranded behind the finished send. Executor-thread only. + @Volatile private var pendingResend = false + private val pendingFile: File? by lazy { val prefix = config.storagePrefix ?: return@lazy null // Must stay out of /: PostHogQueue scans that whole directory as @@ -81,6 +86,12 @@ internal class PostHogPushSubscriptionManager( // millis. Production keeps the real 1000; tests shrink it so retries fire near-instantly. internal var retryDelayMillisPerSecond: Long = 1_000L + // Watchdog window for pushIdentityProvider: if the host never calls completion within this, fall + // back to a token-less send so a misbehaving provider can't wedge sending for the whole process. + // ponytail: fixed 10s heuristic; a slow legitimate mint on a bad network is cut off and retried + // token-less (401 re-mints). Tune here (and keep parity with iOS) if that proves too tight. + internal var identityTokenMintTimeoutMillis: Long = 10_000L + fun register( deviceToken: String, appId: String, @@ -173,24 +184,29 @@ internal class PostHogPushSubscriptionManager( } } - // Executor-thread body of [unregister]. + // Executor-thread body of [unregister]. [onComplete] runs after the DELETE has been sent (or + // skipped) so callers can chain work that must not race the DELETE on the wire — see [handleReset]. private fun performUnregister( distinctId: String, deviceToken: String, appId: String, platform: String, + onComplete: (() -> Unit)? = null, ) { if (closed || config.optOut) { + onComplete?.invoke() return } if (distinctId.isBlank() || deviceToken.isBlank() || appId.isBlank()) { config.logger.log("Push unregister skipped: missing distinctId, token, or appId.") + onComplete?.invoke() return } // Best-effort stays single-shot: the token is resolved once (old id on the reset path) and // a 401 is not refreshed — the durable path is the re-register POST. resolveIdentityToken(distinctId, appId) { identityToken -> if (closed || config.optOut) { + onComplete?.invoke() return@resolveIdentityToken } try { @@ -204,6 +220,8 @@ internal class PostHogPushSubscriptionManager( config.logger.log("Push notification token unregistered successfully.") } catch (e: Throwable) { config.logger.log("Push unregister failed: $e. Ignoring (best-effort).") + } finally { + onComplete?.invoke() } } } @@ -211,7 +229,8 @@ internal class PostHogPushSubscriptionManager( /** * reset()/logout: unregister the stored token for the old identity, then re-register it under * the new anonymous id ([performRegister] reads the current id at send time). No-op when nothing - * is stored. Both run in one executor task, keeping the DELETE ordered before the re-register POST. + * is stored. The re-register is chained on the DELETE's completion so the DELETE reaches the wire + * before the re-register POST, even though identity-token minting makes both legs asynchronous. */ fun handleReset(oldDistinctId: String) { executor.executeSafely { @@ -221,9 +240,12 @@ internal class PostHogPushSubscriptionManager( // DELETE would unset the very id we re-register under — and performRegister's dedup guard // would then skip the re-POST, leaving the device unregistered. if (oldDistinctId != distinctIdProvider()) { - performUnregister(oldDistinctId, record.deviceToken, record.appId, record.platform) + performUnregister(oldDistinctId, record.deviceToken, record.appId, record.platform) { + performRegister(record.deviceToken, record.appId, record.platform) + } + } else { + performRegister(record.deviceToken, record.appId, record.platform) } - performRegister(record.deviceToken, record.appId, record.platform) } } @@ -294,6 +316,9 @@ internal class PostHogPushSubscriptionManager( } if (!isSending.compareAndSet(false, true)) { + // A send is already in flight (isSending is held across the async mint). Fold this request + // in: [performSend] replays one fresh attempt on release so this token isn't dropped. + pendingResend = true return } @@ -311,11 +336,18 @@ internal class PostHogPushSubscriptionManager( distinctId: String, identityToken: String?, ) { - if (closed || config.optOut) { - isSending.set(false) - return - } try { + if (closed || config.optOut) { + return + } + // The record can be cleared or replaced during the async mint (unregisterCurrent, or a + // newer register). Re-read it and bail if it no longer matches, so a late mint can't + // resurrect a just-DELETEd subscription or POST a token the newer record superseded. + val current = currentRecord() + if (current == null || current.deviceToken != record.deviceToken || current.appId != record.appId) { + config.logger.log("Push subscription send skipped: record changed during identity token mint.") + return + } api.pushSubscription( distinctId = distinctId, deviceToken = record.deviceToken, @@ -335,9 +367,29 @@ internal class PostHogPushSubscriptionManager( handleFailure(e) } finally { isSending.set(false) + servicePendingResend() } } + // A register/resend/retry that arrived mid-send set [pendingResend] instead of sending. Replay one + // fresh attempt now that isSending is released, so the latest token isn't stranded behind this + // send's backoff or halt state. Executor-thread only (called from [performSend]). + private fun servicePendingResend() { + if (!pendingResend) { + return + } + pendingResend = false + if (closed || config.optOut || currentRecord() == null) { + return + } + retryCount = 0 + nextAttemptAtMs = 0L + halted = false + didAuthRetry = false + cancelTimer() + attempt() + } + private fun handleFailure(e: Throwable) { if ((e as? PostHogApiError)?.statusCode == 401) { val provider = config.pushIdentityProvider @@ -432,22 +484,38 @@ internal class PostHogPushSubscriptionManager( appId: String, onResolved: (String?) -> Unit, ) { - val cached = cachedIdentityToken - if (cached != null && cached.distinctId == distinctId && cached.appId == appId) { - config.logger.log("Push subscription request sent with cached identity token.") - onResolved(cached.token) - return - } + // Provider is checked before the cache: clearing pushIdentityProvider mid-session means "stop + // attaching tokens now", so a stale cached credential must not outlive it (matches iOS). val provider = config.pushIdentityProvider if (provider == null) { - config.logger.log("Push subscription request sent without identity token (no pushIdentityProvider).") + config.logger.log("No identity token attached to push request (no pushIdentityProvider).") onResolved(null) return } + val cached = cachedIdentityToken + if (cached != null && cached.distinctId == distinctId && cached.appId == appId) { + config.logger.log("Attaching cached identity token to push request.") + onResolved(cached.token) + return + } val completed = AtomicBoolean(false) + // A provider that never calls its completion would hold isSending for the whole process and + // wedge every later send. Bound the wait: if the mint doesn't land in time, fall back to a + // token-less send. A late real completion is a no-op via `completed`. + val watchdog = Timer(true) + watchdog.schedule(identityTokenMintTimeoutMillis) { + if (completed.compareAndSet(false, true)) { + config.logger.log( + "pushIdentityProvider did not complete within ${identityTokenMintTimeoutMillis}ms; sending without identity token.", + ) + executor.executeSafely { onResolved(null) } + } + watchdog.cancel() + } try { provider(distinctId, appId) { token -> if (completed.compareAndSet(false, true)) { + watchdog.cancel() executor.executeSafely { if (token != null) { // A mint can complete after opt-out cleared the cache; caching it would @@ -456,15 +524,16 @@ internal class PostHogPushSubscriptionManager( if (!closed && !config.optOut) { cachedIdentityToken = CachedIdentityToken(token, distinctId, appId) } - config.logger.log("Push subscription request sent with freshly minted identity token.") + config.logger.log("Attaching freshly minted identity token to push request.") } else { - config.logger.log("Push subscription request sent without identity token (provider completed null).") + config.logger.log("No identity token attached to push request (provider completed null).") } onResolved(token) } } } } catch (e: Throwable) { + watchdog.cancel() config.logger.log("pushIdentityProvider threw: $e. Sending without identity token.") if (completed.compareAndSet(false, true)) { onResolved(null) diff --git a/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt index 52907da6..ce065489 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt @@ -360,6 +360,82 @@ internal class PostHogPushSubscriptionManagerTest { assertTrue(http.takeRequest().body.unGzip().contains("\"identity_token\":\"jwt-fresh\"")) } + @Test + fun `a registration arriving mid-mint is replayed and the stale send bails`() { + // A + B: a new token registers while the first send's identity token is still minting. The + // stale first send must bail (its record was superseded) and the newer registration must be + // replayed, so exactly one POST goes out — carrying the second token, not the first. The replay + // reuses the just-cached token (same distinctId/appId), so the provider is only invoked once. + val http = mockHttp(total = 2, response = MockResponse().setBody("")) + val (sut, config, _) = getSut(http) + val mints = java.util.concurrent.LinkedBlockingQueue<(String?) -> Unit>() + config.pushIdentityProvider = { _, _, completion -> mints.add(completion) } + + sut.register("fcm-token-1", "firebase-project", "android") + flush() + // First mint is still outstanding (isSending held); a newer token registers mid-mint. + sut.register("fcm-token-2", "firebase-project", "android") + flush() + + // First mint completes: its record is now stale (fcm-token-2 superseded it), so the send bails + // and replays the pending registration, which sends the one real POST with the cached token. + mints.take().invoke("jwt-abc") + flush() + + val post = http.takeRequest() + assertEquals("POST", post.method) + val body = post.body.unGzip() + assertTrue(body.contains("\"device_token\":\"fcm-token-2\"")) + assertTrue(body.contains("\"identity_token\":\"jwt-abc\"")) + assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) // no stale POST for fcm-token-1 + assertEquals(1, http.requestCount) + assertEquals(0, mints.size) // provider invoked once; the replay reused the cache + } + + @Test + fun `a provider that never completes falls back to a token-less send instead of wedging`() { + // D: an outstanding mint holds isSending across the whole process. The watchdog must fire a + // token-less send so sending recovers instead of being wedged forever. + val http = mockHttp(total = 2, response = MockResponse().setBody("")) + val (sut, config, _) = getSut(http) + sut.identityTokenMintTimeoutMillis = 50 + config.pushIdentityProvider = { _, _, _ -> } // never calls completion + + sut.register("fcm-token-1", "firebase-project", "android") + val post = http.takeRequest(2, TimeUnit.SECONDS) + assertNotNull(post) + assertEquals("POST", post!!.method) + assertFalse(post.body.unGzip().contains("identity_token")) + + // isSending was released by the fallback, so a later registration is not wedged. + sut.register("fcm-token-2", "firebase-project", "android") + val post2 = http.takeRequest(2, TimeUnit.SECONDS) + assertNotNull(post2) + assertTrue(post2!!.body.unGzip().contains("\"device_token\":\"fcm-token-2\"")) + } + + @Test + fun `clearing the provider mid-session stops attaching the cached token`() { + // F: the provider is checked before the cache, so removing pushIdentityProvider mid-session + // sends token-less immediately instead of riding the previously cached credential. + val http = mockHttp(total = 2, response = MockResponse().setBody("")) + val (sut, config, _) = getSut(http) + config.pushIdentityProvider = { _, _, completion -> completion("jwt-cached") } + + sut.register("fcm-token-1", "firebase-project", "android") + flush() + assertTrue(http.takeRequest().body.unGzip().contains("\"identity_token\":\"jwt-cached\"")) + + // App removes the provider; a subsequent send for the same distinctId/appId must not reuse the cache. + config.pushIdentityProvider = null + sut.register("fcm-token-2", "firebase-project", "android") + flush() + + val body = http.takeRequest().body.unGzip() + assertTrue(body.contains("\"device_token\":\"fcm-token-2\"")) + assertFalse(body.contains("identity_token")) + } + @Test fun `unregister DELETE gets no 401 fresh-token refresh`() { // Best-effort leg stays single-shot even with a provider: one mint, one DELETE, no retry. From 4408f611406612b02e10cdb4453ce3d3a2a28638 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Tue, 28 Jul 2026 20:09:33 +0300 Subject: [PATCH 5/5] fix: clear didAuthRetry on opt-out and order cache clear on executor --- .../internal/PostHogPushSubscriptionManager.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt index 7f74c821..62ebf858 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt @@ -88,7 +88,7 @@ internal class PostHogPushSubscriptionManager( // Watchdog window for pushIdentityProvider: if the host never calls completion within this, fall // back to a token-less send so a misbehaving provider can't wedge sending for the whole process. - // ponytail: fixed 10s heuristic; a slow legitimate mint on a bad network is cut off and retried + // Fixed 10s heuristic; a slow legitimate mint on a bad network is cut off and retried // token-less (401 re-mints). Tune here (and keep parity with iOS) if that proves too tight. internal var identityTokenMintTimeoutMillis: Long = 10_000L @@ -282,7 +282,15 @@ internal class PostHogPushSubscriptionManager( /** Opt-out: stop the retry/offline-poll timer now. The guard in [attempt] blocks any actual send. */ fun onOptOut() { cancelTimer() - cachedIdentityToken = null + // Order both clears on the executor with the mint-completion cache write so an opt-out + // mid-mint can't leave a stale token cached (the residual race resolveIdentityToken notes). + // didAuthRetry is cleared too: a 401 before opt-out would otherwise strand the flag, and the + // retryPending() resume path after opt-in doesn't clear it, so the next 401 goes terminal + // with no refresh. + executor.executeSafely { + cachedIdentityToken = null + didAuthRetry = false + } } private fun isWithinBackoffWindow(): Boolean = System.currentTimeMillis() < nextAttemptAtMs