From fee074b342b8e263af57b087b0c0d284cd7b504d Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 24 Jul 2026 10:52:31 +0300 Subject: [PATCH 01/14] feat: add push notification support for PostHog Workflows (#642) * feat: add push notification token registration and open capture * feat(android): auto-register push tokens and capture notification opens * fix: stop push token registration after opt-out and guard cold-start crash * feat: unregister push token on reset and add unregisterPushNotificationToken Claude-Session: https://claude.ai/code/session_013yuqz2SN7ZfLr3kwQN2evU * refactor: align push notification API with iOS and gate on isOptedOut Claude-Session: https://claude.ai/code/session_01DLdpQV47JafPqUqGMzcQBk * fix: halt in-session push subscription retries after a non-retryable failure * docs: clarify reset moves push token independently of capture flag * fix: skip push notification open capture on activity restore * fix: bail push retry when the pending record was cleared mid-unregister --- .changeset/quirky-melons-jog.md | 6 + buildSrc/src/main/java/PosthogBuildConfig.kt | 3 + posthog-android/api/posthog-android.api | 8 +- posthog-android/build.gradle.kts | 9 + posthog-android/consumer-rules.pro | 12 +- posthog-android/gradle.lockfile | 81 ++- .../com/posthog/android/PostHogAndroid.kt | 8 +- .../posthog/android/PostHogAndroidConfig.kt | 12 + ...HogActivityLifecycleCallbackIntegration.kt | 48 ++ .../PostHogPushSubscriptionIntegration.kt | 108 +++ .../com/posthog/android/PostHogAndroidTest.kt | 55 ++ ...ctivityLifecycleCallbackIntegrationTest.kt | 105 +++ .../PostHogPushSubscriptionIntegrationTest.kt | 159 +++++ posthog/api/posthog.api | 13 +- posthog/src/main/java/com/posthog/PostHog.kt | 132 ++++ .../main/java/com/posthog/PostHogInterface.kt | 53 ++ .../java/com/posthog/internal/PostHogApi.kt | 58 +- .../PostHogPushSubscriptionManager.kt | 421 ++++++++++++ .../PostHogPushSubscriptionRequest.kt | 16 + .../src/test/java/com/posthog/PostHogTest.kt | 268 ++++++++ .../com/posthog/internal/PostHogApiTest.kt | 98 +++ .../PostHogPushSubscriptionManagerTest.kt | 637 ++++++++++++++++++ .../java/com/posthog/PostHogFake.kt | 35 + 23 files changed, 2324 insertions(+), 21 deletions(-) create mode 100644 .changeset/quirky-melons-jog.md create mode 100644 posthog-android/src/main/java/com/posthog/android/internal/PostHogPushSubscriptionIntegration.kt create mode 100644 posthog-android/src/test/java/com/posthog/android/internal/PostHogPushSubscriptionIntegrationTest.kt create mode 100644 posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt create mode 100644 posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionRequest.kt create mode 100644 posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt diff --git a/.changeset/quirky-melons-jog.md b/.changeset/quirky-melons-jog.md new file mode 100644 index 000000000..bcb6638c5 --- /dev/null +++ b/.changeset/quirky-melons-jog.md @@ -0,0 +1,6 @@ +--- +"posthog-android": minor +"posthog": minor +--- + +Add push notification support for PostHog Workflows. Register device push tokens with `registerPushNotificationToken(...)` (auto-registered from Firebase Cloud Messaging when `firebase-messaging` is on the classpath and `capturePushNotificationSubscriptions` is enabled), and capture opens with `capturePushNotificationOpened(...)` (cold-start tray taps are auto-detected when `capturePushNotificationOpened` is enabled). On `reset()` the token is unregistered for the logged-out user and re-registered under the new anonymous id, and `unregisterPushNotificationToken()` lets you unregister manually. diff --git a/buildSrc/src/main/java/PosthogBuildConfig.kt b/buildSrc/src/main/java/PosthogBuildConfig.kt index 97cea7f7e..5ff99ce74 100644 --- a/buildSrc/src/main/java/PosthogBuildConfig.kt +++ b/buildSrc/src/main/java/PosthogBuildConfig.kt @@ -69,6 +69,9 @@ object PosthogBuildConfig { val ANDROIDX_CORE = "1.5.0" val ANDROIDX_COMPOSE = "1.0.0" + // matches firebase-bom 34.16.0 + val FIREBASE_MESSAGING = "25.1.1" + // Provides ComponentDialog (LifecycleOwner + OnBackPressedDispatcher) for the surveys-compose UI. val ANDROIDX_ACTIVITY = "1.8.2" diff --git a/posthog-android/api/posthog-android.api b/posthog-android/api/posthog-android.api index 153a871c2..faee5009b 100644 --- a/posthog-android/api/posthog-android.api +++ b/posthog-android/api/posthog-android.api @@ -22,13 +22,19 @@ public class com/posthog/android/PostHogAndroidConfig : com/posthog/PostHogConfi public fun (Ljava/lang/String;Ljava/lang/String;ZZ)V public fun (Ljava/lang/String;Ljava/lang/String;ZZZ)V public fun (Ljava/lang/String;Ljava/lang/String;ZZZLcom/posthog/android/replay/PostHogSessionReplayConfig;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;ZZZLcom/posthog/android/replay/PostHogSessionReplayConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;ZZZLcom/posthog/android/replay/PostHogSessionReplayConfig;Z)V + public fun (Ljava/lang/String;Ljava/lang/String;ZZZLcom/posthog/android/replay/PostHogSessionReplayConfig;ZZ)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;ZZZLcom/posthog/android/replay/PostHogSessionReplayConfig;ZZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getCaptureApplicationLifecycleEvents ()Z public final fun getCaptureDeepLinks ()Z + public final fun getCapturePushNotificationOpened ()Z + public final fun getCapturePushNotificationSubscriptions ()Z public final fun getCaptureScreenViews ()Z public final fun getSessionReplayConfig ()Lcom/posthog/android/replay/PostHogSessionReplayConfig; public final fun setCaptureApplicationLifecycleEvents (Z)V public final fun setCaptureDeepLinks (Z)V + public final fun setCapturePushNotificationOpened (Z)V + public final fun setCapturePushNotificationSubscriptions (Z)V public final fun setCaptureScreenViews (Z)V public final fun setSessionReplayConfig (Lcom/posthog/android/replay/PostHogSessionReplayConfig;)V } diff --git a/posthog-android/build.gradle.kts b/posthog-android/build.gradle.kts index 97f1b7c08..06a5e8148 100644 --- a/posthog-android/build.gradle.kts +++ b/posthog-android/build.gradle.kts @@ -86,6 +86,12 @@ tasks.withType().configureEach { compilerOptions.postHogConfig(false) } +configurations.configureEach { + // empty artifact since Kotlin 1.9 (merged into kotlin-stdlib); its alignment + // constraint resolves unstably under dependency locking + exclude(group = "org.jetbrains.kotlin", module = "kotlin-stdlib-common") +} + dependencies { // runtime api(project(mapOf("path" to ":posthog"))) @@ -97,6 +103,7 @@ dependencies { // compile only compileOnly("androidx.compose.ui:ui:${PosthogBuildConfig.Dependencies.ANDROIDX_COMPOSE}") + compileOnly("com.google.firebase:firebase-messaging:${PosthogBuildConfig.Dependencies.FIREBASE_MESSAGING}") // compatibility signature("org.codehaus.mojo.signature:java18:${PosthogBuildConfig.Plugins.SIGNATURE_JAVA18}@signature") @@ -109,6 +116,8 @@ dependencies { // tests testImplementation(testFixtures(project(":posthog"))) + // exercises the Firebase-present token fetch path via mockStatic + testImplementation("com.google.firebase:firebase-messaging:${PosthogBuildConfig.Dependencies.FIREBASE_MESSAGING}") testImplementation("org.mockito.kotlin:mockito-kotlin:${PosthogBuildConfig.Dependencies.MOCKITO}") testImplementation("org.mockito:mockito-inline:${PosthogBuildConfig.Dependencies.MOCKITO_INLINE}") testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${PosthogBuildConfig.Kotlin.KOTLIN}") diff --git a/posthog-android/consumer-rules.pro b/posthog-android/consumer-rules.pro index 1f5bb51fa..7022cbefd 100644 --- a/posthog-android/consumer-rules.pro +++ b/posthog-android/consumer-rules.pro @@ -101,4 +101,14 @@ # debugging stack traces. -keepattributes SourceFile,LineNumberTable -##---------------End: proguard configuration for okhttp3 ---------- \ No newline at end of file +##---------------End: proguard configuration for okhttp3 ---------- + +##---------------Begin: proguard configuration for Firebase Messaging (compileOnly) ---------- +-dontwarn com.google.firebase.messaging.** +-dontwarn com.google.firebase.FirebaseApp +-dontwarn com.google.firebase.FirebaseOptions +-dontwarn com.google.android.gms.tasks.** + +# used in reflection to check if Firebase Messaging is available at runtime +-keepnames class com.google.firebase.messaging.FirebaseMessaging +##---------------End: proguard configuration for Firebase Messaging (compileOnly) ---------- \ No newline at end of file diff --git a/posthog-android/gradle.lockfile b/posthog-android/gradle.lockfile index 51fa7f248..5e82e3228 100644 --- a/posthog-android/gradle.lockfile +++ b/posthog-android/gradle.lockfile @@ -1,13 +1,17 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -androidx.annotation:annotation-experimental:1.1.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata -androidx.annotation:annotation-jvm:1.7.0-beta01=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.activity:activity:1.0.0=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.annotation:annotation-experimental:1.1.0=testImplementationDependenciesMetadata +androidx.annotation:annotation-experimental:1.3.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.annotation:annotation-jvm:1.7.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.annotation:annotation:1.2.0=compileOnlyDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath -androidx.annotation:annotation:1.7.0-beta01=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata +androidx.annotation:annotation:1.7.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.annotation:annotation:1.7.0-beta01=testImplementationDependenciesMetadata androidx.arch.core:core-common:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.arch.core:core-runtime:2.2.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -androidx.collection:collection:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.arch.core:core-runtime:2.2.0=debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.collection:collection:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath +androidx.collection:collection:1.1.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.runtime:runtime-saveable:1.0.0=compileOnlyDependenciesMetadata,debugCompileClasspath,releaseCompileClasspath androidx.compose.runtime:runtime:1.0.0=compileOnlyDependenciesMetadata,debugCompileClasspath,releaseCompileClasspath androidx.compose.ui:ui-geometry:1.0.0=compileOnlyDependenciesMetadata,debugCompileClasspath,releaseCompileClasspath @@ -17,13 +21,37 @@ androidx.compose.ui:ui-unit:1.0.0=compileOnlyDependenciesMetadata,debugCompileCl androidx.compose.ui:ui:1.0.0=compileOnlyDependenciesMetadata,debugCompileClasspath,releaseCompileClasspath androidx.concurrent:concurrent-futures-ktx:1.1.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata androidx.concurrent:concurrent-futures:1.1.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata -androidx.core:core:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.core:core:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath +androidx.core:core:1.9.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.customview:customview:1.0.0=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-android:1.1.7=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-core-android:1.1.7=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-core-okio-jvm:1.1.7=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-core-okio:1.1.7=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-core:1.1.7=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-preferences-android:1.1.7=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-preferences-core-android:1.1.7=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-preferences-core:1.1.7=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-preferences-external-protobuf:1.1.7=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-preferences-proto:1.1.7=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore-preferences:1.1.7=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.datastore:datastore:1.1.7=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.documentfile:documentfile:1.0.0=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.fragment:fragment:1.1.0=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.legacy:legacy-support-core-utils:1.0.0=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-common-java8:2.6.2=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-common:2.3.1=testImplementationDependenciesMetadata androidx.lifecycle:lifecycle-common:2.6.2=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core:2.6.2=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-livedata:2.6.2=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-process:2.6.2=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-runtime:2.6.2=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel:2.6.2=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.loader:loader:1.0.0=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.localbroadcastmanager:localbroadcastmanager:1.0.0=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.print:print:1.0.0=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.profileinstaller:profileinstaller:1.3.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.savedstate:savedstate:1.0.0=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.startup:startup-runtime:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.test.espresso:espresso-idling-resource:3.6.1=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.test.ext:junit:1.2.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata @@ -37,6 +65,7 @@ androidx.test:runner:1.6.2=debugUnitTestCompileClasspath,debugUnitTestRuntimeCla androidx.tracing:tracing:1.0.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,releaseRuntimeClasspath androidx.tracing:tracing:1.1.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.viewpager:viewpager:1.0.0=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.almworks.sqlite4java:sqlite4java:1.0.392=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata com.android.tools.ddms:ddmlib:31.9.1=_internal-unified-test-platform-android-device-provider-ddmlib com.android.tools.emulator:proto:31.9.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention @@ -73,6 +102,16 @@ com.fasterxml.jackson.module:jackson-module-jaxb-annotations:2.12.7=dokkaGfmPart com.fasterxml.jackson.module:jackson-module-kotlin:2.12.7=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime com.fasterxml.jackson:jackson-bom:2.12.7=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime com.fasterxml.woodstox:woodstox-core:6.2.4=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime +com.google.android.datatransport:transport-api:3.0.0=debugCompileClasspath,releaseCompileClasspath +com.google.android.datatransport:transport-api:3.1.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.android.datatransport:transport-backend-cct:3.1.9=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.android.datatransport:transport-runtime:3.1.9=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.android.gms:play-services-base:18.9.0=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +com.google.android.gms:play-services-basement:18.8.0=debugCompileClasspath,releaseCompileClasspath +com.google.android.gms:play-services-basement:18.9.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.android.gms:play-services-cloud-messaging:17.4.0=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +com.google.android.gms:play-services-stats:17.0.2=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +com.google.android.gms:play-services-tasks:18.4.0=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.google.android:annotations:4.1.1.4=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core com.google.api.grpc:proto-google-common-protos:2.17.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core com.google.auto.service:auto-service-annotations:1.1.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle @@ -87,6 +126,18 @@ com.google.dagger:dagger:2.48=_internal-unified-test-platform-android-device-pro com.google.errorprone:error_prone_annotation:2.34.0=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath com.google.errorprone:error_prone_annotations:2.23.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher com.google.errorprone:error_prone_annotations:2.28.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata +com.google.firebase:firebase-annotations:17.0.0=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.firebase:firebase-common:22.0.1=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.firebase:firebase-components:19.0.0=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.firebase:firebase-datatransport:18.2.0=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.firebase:firebase-encoders-json:18.0.0=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.firebase:firebase-encoders-proto:16.0.0=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.firebase:firebase-encoders:17.0.0=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.firebase:firebase-iid-interop:17.1.0=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.firebase:firebase-installations-interop:17.3.0=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.firebase:firebase-installations:19.1.1=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.firebase:firebase-measurement-connector:19.0.0=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.firebase:firebase-messaging:25.1.1=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.google.guava:failureaccess:1.0.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher com.google.guava:failureaccess:1.0.2=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata com.google.guava:guava:32.0.1-jre=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher @@ -140,7 +191,7 @@ it.unimi.dsi:fastutil-core:8.5.12=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtml jakarta.activation:jakarta.activation-api:1.2.1=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime javax.annotation:javax.annotation-api:1.3.2=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata -javax.inject:javax.inject:1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata +javax.inject:javax.inject:1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata junit:junit:4.13.2=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata net.bytebuddy:byte-buddy-agent:1.12.19=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata net.bytebuddy:byte-buddy:1.12.19=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata @@ -165,6 +216,7 @@ org.jetbrains.dokka:analysis-markdown:1.9.20=dokkaGfmPartialPlugin,dokkaGfmPlugi org.jetbrains.dokka:dokka-base:1.9.20=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin org.jetbrains.dokka:dokka-core:1.9.20=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime org.jetbrains.intellij.deps:trove4j:1.0.20200330=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-android-extensions-runtime:1.9.22=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.kotlin:kotlin-build-common:2.1.10=kotlinBuildToolsApiClasspath org.jetbrains.kotlin:kotlin-build-tools-api:2.1.10=kotlinBuildToolsApiClasspath org.jetbrains.kotlin:kotlin-build-tools-impl:2.1.10=kotlinBuildToolsApiClasspath @@ -174,6 +226,7 @@ org.jetbrains.kotlin:kotlin-daemon-client:2.1.10=kotlinBuildToolsApiClasspath org.jetbrains.kotlin:kotlin-daemon-embeddable:2.1.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:2.1.10=kotlinKlibCommonizerClasspath org.jetbrains.kotlin:kotlin-metadata-jvm:2.1.10=bcv-rt-jvm-cp-resolver +org.jetbrains.kotlin:kotlin-parcelize-runtime:1.9.22=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath org.jetbrains.kotlin:kotlin-reflect:1.8.21=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher org.jetbrains.kotlin:kotlin-reflect:1.9.22=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime @@ -187,7 +240,7 @@ org.jetbrains.kotlin:kotlin-stdlib-common:1.8.21=_internal-unified-test-platform org.jetbrains.kotlin:kotlin-stdlib-common:1.9.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-launcher org.jetbrains.kotlin:kotlin-stdlib-common:1.9.22=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime org.jetbrains.kotlin:kotlin-stdlib-common:2.1.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle -org.jetbrains.kotlin:kotlin-stdlib-common:2.1.10=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata +org.jetbrains.kotlin:kotlin-stdlib-common:2.1.10=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,implementationDependenciesMetadata,testImplementationDependenciesMetadata org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.5.0=compileOnlyDependenciesMetadata org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.20=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime,testImplementationDependenciesMetadata org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin @@ -213,17 +266,21 @@ org.jetbrains.kotlinx:atomicfu:0.20.2=testImplementationDependenciesMetadata org.jetbrains.kotlinx:atomicfu:0.22.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-launcher org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0=compileOnlyDependenciesMetadata org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.6.4=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.1=testImplementationDependenciesMetadata org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.4=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath,releaseCompileClasspath,releaseRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.1=testImplementationDependenciesMetadata org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0=compileOnlyDependenciesMetadata org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1=testImplementationDependenciesMetadata org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0=debugCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.kotlinx:kotlinx-html-jvm:0.9.1=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin org.jetbrains.kotlinx:kover-jvm-agent:0.9.0=koverJvmAgent,koverJvmReporter org.jetbrains:annotations:13.0=bcv-rt-jvm-cp-resolver,compileOnlyDependenciesMetadata,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath,releaseCompileClasspath,releaseRuntimeClasspath diff --git a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt index 2a8d61e84..6cff400fa 100644 --- a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt +++ b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt @@ -14,6 +14,7 @@ import com.posthog.android.internal.PostHogAndroidNetworkStatus import com.posthog.android.internal.PostHogAppInstallIntegration import com.posthog.android.internal.PostHogLifecycleObserverIntegration import com.posthog.android.internal.PostHogMetaPropertiesApplier +import com.posthog.android.internal.PostHogPushSubscriptionIntegration import com.posthog.android.internal.PostHogSharedPreferences import com.posthog.android.internal.PostHogTouchActivityIntegration import com.posthog.android.internal.appContext @@ -144,7 +145,9 @@ public class PostHogAndroid private constructor() { config.addIntegration(PostHogTouchActivityIntegration(config)) config.addIntegration(PostHogLogCatIntegration(config)) if (context is Application) { - if (config.captureDeepLinks || config.captureScreenViews || config.sessionReplay) { + if (config.captureDeepLinks || config.captureScreenViews || config.sessionReplay || + config.capturePushNotificationOpened + ) { config.addIntegration( PostHogActivityLifecycleCallbackIntegration( context, @@ -160,6 +163,9 @@ public class PostHogAndroid private constructor() { if (config.surveys) { config.addIntegration(PostHogSurveysIntegration(context, config)) } + if (config.capturePushNotificationSubscriptions) { + config.addIntegration(PostHogPushSubscriptionIntegration(config)) + } } } } diff --git a/posthog-android/src/main/java/com/posthog/android/PostHogAndroidConfig.kt b/posthog-android/src/main/java/com/posthog/android/PostHogAndroidConfig.kt index 86c913bf9..f3508dffa 100644 --- a/posthog-android/src/main/java/com/posthog/android/PostHogAndroidConfig.kt +++ b/posthog-android/src/main/java/com/posthog/android/PostHogAndroidConfig.kt @@ -21,6 +21,15 @@ import com.posthog.internal.PostHogQueue * the SDK. To opt out of `$screen_name` stamping entirely, set this to `false` and avoid calling * `PostHog.screen(...)` manually. Default: `true`. * @property sessionReplayConfig Android-specific session replay capture options. + * @property capturePushNotificationSubscriptions Whether to auto-register this device's push + * token at startup. When enabled and Firebase Messaging is on the classpath, the SDK fetches the + * FCM token + Firebase `project_id` and registers them so PostHog Workflows can deliver push + * notifications. No effect when Firebase is absent (a single debug log). Instant token refresh + * still needs the app to forward `onNewToken` to `registerPushNotificationToken(...)`. Default: `true`. + * @property capturePushNotificationOpened Whether to auto-capture `$push_notification_opened` for + * cold-start taps on a tray notification (detected via the launch intent's `google.message_id` + * extra). Foreground data messages and warm-start `onNewIntent` are not observable here — forward + * those to `capturePushNotificationOpened(...)` manually. Default: `true`. */ public open class PostHogAndroidConfig @JvmOverloads @@ -31,6 +40,9 @@ public open class PostHogAndroidConfig public var captureDeepLinks: Boolean = true, public var captureScreenViews: Boolean = true, public var sessionReplayConfig: PostHogSessionReplayConfig = PostHogSessionReplayConfig(), + // appended after sessionReplayConfig so @JvmOverloads keeps the pre-existing overloads + public var capturePushNotificationSubscriptions: Boolean = true, + public var capturePushNotificationOpened: Boolean = true, ) : PostHogConfig( apiKey = apiKey, host = host, diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegration.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegration.kt index ec085cc54..2e4cf558e 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegration.kt @@ -19,15 +19,25 @@ internal class PostHogActivityLifecycleCallbackIntegration( ) : ActivityLifecycleCallbacks, PostHogIntegration { private var postHog: PostHogInterface? = null + @Volatile + private var lastHandledPushMessageId: String? = null + private companion object { @Volatile private var integrationInstalled = false + + private const val GOOGLE_MESSAGE_ID = "google.message_id" } override fun onActivityCreated( activity: Activity, savedInstanceState: Bundle?, ) { + // A non-null savedInstanceState redelivers the original launch intent (config-change recreation + // or process-kill restore). The in-memory id guard resets with the process, so gate on a fresh launch. + if (config.capturePushNotificationOpened && savedInstanceState == null) { + capturePushNotificationOpenedIfNeeded(activity) + } if (config.captureDeepLinks) { activity.intent?.let { intent -> val props = mutableMapOf() @@ -55,6 +65,44 @@ internal class PostHogActivityLifecycleCallbackIntegration( } } + /** + * Captures `$push_notification_opened` for a cold-start tray tap, detected via the launch intent's + * `google.message_id`. Title/body aren't in the tray intent (only the `posthog` JSON extra is); + * warm-start `onNewIntent` and foreground data messages need the manual API. The message-id guard + * dedupes repeat reads within a process; the caller gates on a fresh launch to skip recreations. + */ + private fun capturePushNotificationOpenedIfNeeded(activity: Activity) { + val intent = activity.intent ?: return + // Reading extras unmarshals the whole Bundle; a launch intent carrying a Serializable/Parcelable + // extra whose class isn't on this app's classloader throws BadParcelableException here. This runs + // inside the framework onActivityCreated callback, so an uncaught throw crashes the host app. + try { + val messageId = intent.getStringExtra(GOOGLE_MESSAGE_ID) ?: return + + if (messageId == lastHandledPushMessageId) { + return + } + lastHandledPushMessageId = messageId + + postHog?.capturePushNotificationOpened( + title = null, + body = null, + payload = intent.extras?.toMap(), + ) + } catch (e: Throwable) { + config.logger.log("Failed to capture push notification opened: $e.") + } + } + + private fun Bundle.toMap(): Map { + val map = mutableMapOf() + for (key in keySet()) { + @Suppress("DEPRECATION") + map[key] = get(key) + } + return map + } + override fun onActivityStarted(activity: Activity) { if (config.captureScreenViews) { val screenName = activity.activityLabelOrName(config) diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogPushSubscriptionIntegration.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogPushSubscriptionIntegration.kt new file mode 100644 index 000000000..1a9d89c01 --- /dev/null +++ b/posthog-android/src/main/java/com/posthog/android/internal/PostHogPushSubscriptionIntegration.kt @@ -0,0 +1,108 @@ +package com.posthog.android.internal + +import com.google.firebase.FirebaseApp +import com.google.firebase.messaging.FirebaseMessaging +import com.posthog.PostHogIntegration +import com.posthog.PostHogInterface +import com.posthog.android.PostHogAndroidConfig + +/** + * Auto-registers this device's push token at startup so PostHog Workflows can deliver push + * notifications to it. + * + * Firebase Messaging is a `compileOnly` dependency — the SDK never ships its own messaging + * service and never hard-depends on Firebase. When Firebase is absent, [PushTokenFetcher] no-ops + * with a debug log. Instant token refresh still requires the host app to forward Firebase's + * `onNewToken` to [PostHogInterface.registerPushNotificationToken]; the startup fetch here only + * covers tokens refreshed since the last launch. + */ +internal class PostHogPushSubscriptionIntegration( + private val config: PostHogAndroidConfig, + private val tokenFetcher: PushTokenFetcher = FirebasePushTokenFetcher(config), +) : PostHogIntegration { + private var postHog: PostHogInterface? = null + + override fun install(postHog: PostHogInterface) { + this.postHog = postHog + tokenFetcher.fetchToken { token, appId -> + this.postHog?.registerPushNotificationToken(token, appId) + } + } + + override fun uninstall() { + postHog = null + } +} + +/** + * Resolves this device's push token and app identifier. Kept behind an interface so the Firebase + * dependency stays isolated to [FirebasePushTokenFetcher] and can be faked in tests. + */ +internal fun interface PushTokenFetcher { + fun fetchToken(onToken: (token: String, appId: String) -> Unit) +} + +private const val FIREBASE_MESSAGING_CLASS = "com.google.firebase.messaging.FirebaseMessaging" + +/** + * Fetches the FCM token and Firebase `project_id` (used as the push `app_id`). + * + * Presence is checked reflectively first, so an app without Firebase on the classpath sees a + * single debug log and nothing else. All direct Firebase references are confined to + * [fetchFirebaseToken], which only runs once the class is confirmed present. + */ +internal class FirebasePushTokenFetcher( + private val config: PostHogAndroidConfig, + // test seam: firebase-messaging is on the unit-test classpath, so the absent-classpath + // path is exercised by probing for a class name that doesn't resolve + private val messagingClassName: String = FIREBASE_MESSAGING_CLASS, +) : PushTokenFetcher { + override fun fetchToken(onToken: (token: String, appId: String) -> Unit) { + try { + Class.forName(messagingClassName) + } catch (e: Throwable) { + config.logger.log("Firebase Messaging not found on the classpath, skipping push token registration.") + return + } + fetchFirebaseToken(onToken) + } + + // firebase-messaging 25.1.0 deprecates getToken in favor of FID-based registration; + // PostHog delivers via FCM HTTP v1 registration tokens, so we stay on it until the + // backend supports FIDs. + @Suppress("DEPRECATION") + private fun fetchFirebaseToken(onToken: (token: String, appId: String) -> Unit) { + val projectId = + try { + FirebaseApp.getInstance().options.projectId + } catch (e: IllegalStateException) { + config.logger.log("Firebase is not initialized, skipping push token registration: $e.") + return + } catch (e: Throwable) { + config.logger.log("Failed to read Firebase project id, skipping push token registration: $e.") + return + } + + if (projectId.isNullOrBlank()) { + config.logger.log("Firebase project id is missing, skipping push token registration.") + return + } + + try { + FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> + if (task.isSuccessful) { + val token = task.result + if (!token.isNullOrBlank()) { + onToken(token, projectId) + } else { + config.logger.log("Firebase returned a blank push token, skipping registration.") + } + } else { + config.logger.log("Failed to fetch Firebase push token: ${task.exception}.") + } + } + } catch (e: Throwable) { + config.logger.log("Failed to request Firebase push token: $e.") + } + } +} diff --git a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt index d05f23881..2ff1e11d7 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt @@ -9,6 +9,7 @@ import com.posthog.android.internal.PostHogAndroidLogger import com.posthog.android.internal.PostHogAndroidNetworkStatus import com.posthog.android.internal.PostHogAppInstallIntegration import com.posthog.android.internal.PostHogLifecycleObserverIntegration +import com.posthog.android.internal.PostHogPushSubscriptionIntegration import com.posthog.android.internal.PostHogSharedPreferences import com.posthog.internal.PostHogLogger import org.junit.Rule @@ -229,6 +230,7 @@ internal class PostHogAndroidTest { captureDeepLinks = false captureScreenViews = false sessionReplay = false + capturePushNotificationOpened = false } mockContextAppStart(context, tmpDir) @@ -242,6 +244,59 @@ internal class PostHogAndroidTest { ) } + @Test + fun `adds activity lifecycle integration when only capturePushNotificationOpened is enabled`() { + val config = + PostHogAndroidConfig(API_KEY).apply { + captureDeepLinks = false + captureScreenViews = false + sessionReplay = false + } + + mockContextAppStart(context, tmpDir) + + PostHogAndroid.setup(context, config) + + assertNotNull( + config.integrations.find { + it is PostHogActivityLifecycleCallbackIntegration + }, + ) + } + + @Test + fun `adds capturePushNotificationSubscriptions integration`() { + val config = PostHogAndroidConfig(API_KEY) + + mockContextAppStart(context, tmpDir) + + PostHogAndroid.setup(context, config) + + assertNotNull( + config.integrations.find { + it is PostHogPushSubscriptionIntegration + }, + ) + } + + @Test + fun `does not add capturePushNotificationSubscriptions integration if disabled`() { + val config = + PostHogAndroidConfig(API_KEY).apply { + capturePushNotificationSubscriptions = false + } + + mockContextAppStart(context, tmpDir) + + PostHogAndroid.setup(context, config) + + assertNull( + config.integrations.find { + it is PostHogPushSubscriptionIntegration + }, + ) + } + @Test fun `with creates new single instance`() { val config = PostHogAndroidConfig(API_KEY) diff --git a/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt index 117fdc15e..b213baaff 100644 --- a/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt @@ -1,6 +1,9 @@ package com.posthog.android.internal +import android.app.Activity import android.app.Application +import android.content.Intent +import android.os.Bundle import androidx.test.ext.junit.runners.AndroidJUnit4 import com.posthog.PostHog import com.posthog.PostHogFake @@ -13,6 +16,7 @@ import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -25,15 +29,27 @@ internal class PostHogActivityLifecycleCallbackIntegrationTest { private fun getSut( captureDeepLinks: Boolean = true, captureScreenViews: Boolean = true, + capturePushNotificationOpened: Boolean = true, ): PostHogActivityLifecycleCallbackIntegration { val config = PostHogAndroidConfig(API_KEY).apply { this.captureDeepLinks = captureDeepLinks this.captureScreenViews = captureScreenViews + this.capturePushNotificationOpened = capturePushNotificationOpened } return PostHogActivityLifecycleCallbackIntegration(application, config) } + private fun mockActivityWithExtras(vararg extras: Pair): Activity { + val activity = mock() + val intent = + Intent().apply { + extras.forEach { (key, value) -> putExtra(key, value) } + } + whenever(activity.intent).thenReturn(intent) + return activity + } + @BeforeTest fun `set up`() { PostHog.resetSharedInstance() @@ -214,4 +230,93 @@ internal class PostHogActivityLifecycleCallbackIntegrationTest { assertEquals("MyActivity", fake.screenTitle) } + + @Test + fun `onActivityCreated captures push notification opened from tray intent`() { + val sut = getSut() + val activity = + mockActivityWithExtras( + "google.message_id" to "m1", + "posthog" to """{"campaign":"summer"}""", + ) + val fake = createPostHogFake() + + sut.install(fake) + sut.onActivityCreated(activity, null) + sut.uninstall() + + assertEquals(1, fake.pushOpenedCaptures) + assertNull(fake.pushOpenedTitle) + assertNull(fake.pushOpenedBody) + assertEquals("m1", fake.pushOpenedPayload?.get("google.message_id")) + assertEquals("""{"campaign":"summer"}""", fake.pushOpenedPayload?.get("posthog")) + } + + @Test + fun `onActivityCreated dedups push notification by message id across recreations`() { + val sut = getSut() + val activity = mockActivityWithExtras("google.message_id" to "m1") + val fake = createPostHogFake() + + sut.install(fake) + sut.onActivityCreated(activity, null) + sut.onActivityCreated(activity, null) + sut.uninstall() + + assertEquals(1, fake.pushOpenedCaptures) + } + + @Test + fun `onActivityCreated does not re-capture push after process death restore`() { + // A process kill resets the in-memory dedup, so a fresh integration models the restored process. + // The redelivered launch intent arrives with non-null saved state, so it must not re-capture. + val sut = getSut() + val activity = mockActivityWithExtras("google.message_id" to "m1") + val fake = createPostHogFake() + + sut.install(fake) + sut.onActivityCreated(activity, Bundle()) + sut.uninstall() + + assertEquals(0, fake.pushOpenedCaptures) + } + + @Test + fun `onActivityCreated captures again for a new message id`() { + val sut = getSut() + val fake = createPostHogFake() + + sut.install(fake) + sut.onActivityCreated(mockActivityWithExtras("google.message_id" to "m1"), null) + sut.onActivityCreated(mockActivityWithExtras("google.message_id" to "m2"), null) + sut.uninstall() + + assertEquals(2, fake.pushOpenedCaptures) + } + + @Test + fun `onActivityCreated does not capture push when no google message id`() { + val sut = getSut() + val activity = mockActivityWithExtras("some_key" to "value") + val fake = createPostHogFake() + + sut.install(fake) + sut.onActivityCreated(activity, null) + sut.uninstall() + + assertEquals(0, fake.pushOpenedCaptures) + } + + @Test + fun `onActivityCreated does not capture push when disabled`() { + val sut = getSut(capturePushNotificationOpened = false) + val activity = mockActivityWithExtras("google.message_id" to "m1") + val fake = createPostHogFake() + + sut.install(fake) + sut.onActivityCreated(activity, null) + sut.uninstall() + + assertEquals(0, fake.pushOpenedCaptures) + } } diff --git a/posthog-android/src/test/java/com/posthog/android/internal/PostHogPushSubscriptionIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/PostHogPushSubscriptionIntegrationTest.kt new file mode 100644 index 000000000..821d6c307 --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/internal/PostHogPushSubscriptionIntegrationTest.kt @@ -0,0 +1,159 @@ +package com.posthog.android.internal + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.gms.tasks.OnCompleteListener +import com.google.android.gms.tasks.Task +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.messaging.FirebaseMessaging +import com.posthog.PostHog +import com.posthog.android.API_KEY +import com.posthog.android.PostHogAndroidConfig +import com.posthog.android.createPostHogFake +import com.posthog.internal.PostHogLogger +import org.junit.runner.RunWith +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +internal class PostHogPushSubscriptionIntegrationTest { + private class CapturingLogger : PostHogLogger { + val messages = mutableListOf() + + override fun log(message: String) { + messages.add(message) + } + + override fun isEnabled(): Boolean = true + } + + @BeforeTest + fun `set up`() { + PostHog.resetSharedInstance() + } + + @Test + fun `install registers the fetched token and app id`() { + val config = PostHogAndroidConfig(API_KEY) + val fetcher = PushTokenFetcher { onToken -> onToken("fcm-token", "firebase-project") } + val sut = PostHogPushSubscriptionIntegration(config, fetcher) + val fake = createPostHogFake() + + sut.install(fake) + + assertEquals(1, fake.pushRegistrations) + assertEquals("fcm-token", fake.pushDeviceToken) + assertEquals("firebase-project", fake.pushAppId) + + sut.uninstall() + } + + @Test + fun `install does not register when the fetcher yields no token`() { + val config = PostHogAndroidConfig(API_KEY) + val fetcher = PushTokenFetcher { /* no token available */ } + val sut = PostHogPushSubscriptionIntegration(config, fetcher) + val fake = createPostHogFake() + + sut.install(fake) + + assertEquals(0, fake.pushRegistrations) + + sut.uninstall() + } + + @Test + fun `FirebasePushTokenFetcher skips and logs when Firebase is absent from the classpath`() { + val logger = CapturingLogger() + val config = PostHogAndroidConfig(API_KEY).apply { this.logger = logger } + // firebase-messaging is a test dependency, so simulate absence via an unresolvable name. + val fetcher = FirebasePushTokenFetcher(config, messagingClassName = "com.google.firebase.messaging.NotOnClasspath") + + var called = false + fetcher.fetchToken { _, _ -> called = true } + + assertEquals(false, called) + assertTrue(logger.messages.any { it.contains("Firebase Messaging not found") }) + } + + @Suppress("DEPRECATION") + @Test + fun `FirebasePushTokenFetcher fetches the token and project id when Firebase is present`() { + val config = PostHogAndroidConfig(API_KEY) + + val options = mock() + whenever(options.projectId).thenReturn("firebase-project") + val firebaseApp = mock() + whenever(firebaseApp.options).thenReturn(options) + + val task = mock>() + whenever(task.isSuccessful).thenReturn(true) + whenever(task.result).thenReturn("fcm-token") + whenever(task.addOnCompleteListener(any>())).thenAnswer { invocation -> + @Suppress("UNCHECKED_CAST") + (invocation.arguments[0] as OnCompleteListener).onComplete(task) + task + } + val messaging = mock() + whenever(messaging.token).thenReturn(task) + + mockStatic(FirebaseApp::class.java).use { staticApp -> + staticApp.`when` { FirebaseApp.getInstance() }.thenReturn(firebaseApp) + mockStatic(FirebaseMessaging::class.java).use { staticMessaging -> + staticMessaging.`when` { FirebaseMessaging.getInstance() }.thenReturn(messaging) + + var receivedToken: String? = null + var receivedAppId: String? = null + FirebasePushTokenFetcher(config).fetchToken { token, appId -> + receivedToken = token + receivedAppId = appId + } + + assertEquals("fcm-token", receivedToken) + assertEquals("firebase-project", receivedAppId) + } + } + } + + @Test + fun `FirebasePushTokenFetcher skips when the Firebase project id is missing`() { + val logger = CapturingLogger() + val config = PostHogAndroidConfig(API_KEY).apply { this.logger = logger } + + val options = mock() + whenever(options.projectId).thenReturn(null) + val firebaseApp = mock() + whenever(firebaseApp.options).thenReturn(options) + + mockStatic(FirebaseApp::class.java).use { staticApp -> + staticApp.`when` { FirebaseApp.getInstance() }.thenReturn(firebaseApp) + + var called = false + FirebasePushTokenFetcher(config).fetchToken { _, _ -> called = true } + + assertEquals(false, called) + assertTrue(logger.messages.any { it.contains("project id is missing") }) + } + } + + @Test + fun `FirebasePushTokenFetcher skips when Firebase is not initialized`() { + val logger = CapturingLogger() + val config = PostHogAndroidConfig(API_KEY).apply { this.logger = logger } + + // No FirebaseApp.initializeApp ran in this process, so getInstance throws. + var called = false + FirebasePushTokenFetcher(config).fetchToken { _, _ -> called = true } + + assertEquals(false, called) + assertTrue(logger.messages.any { it.contains("Firebase is not initialized") }) + assertNull(logger.messages.find { it.contains("Firebase Messaging not found") }) + } +} diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 116af9bc6..331565ba7 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -22,7 +22,6 @@ 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;Ljava/util/concurrent/ExecutorService;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V public fun addExceptionStep (Ljava/lang/String;Ljava/util/Map;)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 @@ -30,6 +29,7 @@ public final class com/posthog/PostHog : com/posthog/PostHogStateless, com/posth public fun captureFeatureInteraction (Ljava/lang/String;Ljava/lang/String;)V public fun captureFeatureView (Ljava/lang/String;Ljava/lang/String;)V public fun captureLog (Ljava/lang/String;Lcom/posthog/logs/PostHogLogSeverity;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;)V + public fun capturePushNotificationOpened (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/lang/String;)V public fun close ()V public fun distinctId ()Ljava/lang/String; public fun endSession ()V @@ -52,6 +52,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 registerPushNotificationToken (Ljava/lang/String;Ljava/lang/String;)V public fun reloadFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V public fun reset ()V public fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V @@ -65,6 +66,7 @@ public final class com/posthog/PostHog : com/posthog/PostHogStateless, com/posth public fun startSessionReplay (Z)V public fun stopSessionReplay ()V public fun unregister (Ljava/lang/String;)V + public fun unregisterPushNotificationToken ()V } public final class com/posthog/PostHog$Companion : com/posthog/PostHogInterface { @@ -75,6 +77,7 @@ public final class com/posthog/PostHog$Companion : com/posthog/PostHogInterface public fun captureFeatureInteraction (Ljava/lang/String;Ljava/lang/String;)V public fun captureFeatureView (Ljava/lang/String;Ljava/lang/String;)V public fun captureLog (Ljava/lang/String;Lcom/posthog/logs/PostHogLogSeverity;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;)V + public fun capturePushNotificationOpened (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/lang/String;)V public fun close ()V public fun debug (Z)V public fun distinctId ()Ljava/lang/String; @@ -99,6 +102,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 registerPushNotificationToken (Ljava/lang/String;Ljava/lang/String;)V public fun reloadFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V public fun reset ()V public fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V @@ -113,6 +117,7 @@ public final class com/posthog/PostHog$Companion : com/posthog/PostHogInterface public fun startSessionReplay (Z)V public fun stopSessionReplay ()V public fun unregister (Ljava/lang/String;)V + public fun unregisterPushNotificationToken ()V public final fun with (Lcom/posthog/PostHogConfig;)Lcom/posthog/PostHogInterface; } @@ -352,6 +357,7 @@ public abstract interface class com/posthog/PostHogInterface : com/posthog/PostH public abstract fun captureFeatureInteraction (Ljava/lang/String;Ljava/lang/String;)V public abstract fun captureFeatureView (Ljava/lang/String;Ljava/lang/String;)V public abstract fun captureLog (Ljava/lang/String;Lcom/posthog/logs/PostHogLogSeverity;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;)V + public abstract fun capturePushNotificationOpened (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/lang/String;)V public abstract fun distinctId ()Ljava/lang/String; public abstract fun endSession ()V public abstract fun getAllFeatureFlags ()Ljava/util/List; @@ -368,6 +374,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 registerPushNotificationToken (Ljava/lang/String;Ljava/lang/String;)V public abstract fun reloadFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V public abstract fun reset ()V public abstract fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V @@ -380,6 +387,7 @@ public abstract interface class com/posthog/PostHogInterface : com/posthog/PostH public abstract fun startSessionReplay (Z)V public abstract fun stopSessionReplay ()V public abstract fun unregister (Ljava/lang/String;)V + public abstract fun unregisterPushNotificationToken ()V } public final class com/posthog/PostHogInterface$DefaultImpls { @@ -389,6 +397,7 @@ public final class com/posthog/PostHogInterface$DefaultImpls { public static synthetic fun captureFeatureInteraction$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V public static synthetic fun captureFeatureView$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V public static synthetic fun captureLog$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Lcom/posthog/logs/PostHogLogSeverity;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;ILjava/lang/Object;)V + public static synthetic fun capturePushNotificationOpened$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/lang/String;ILjava/lang/Object;)V public static synthetic fun getFeatureFlag$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Boolean;ILjava/lang/Object;)Ljava/lang/Object; 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 getFeatureFlagResult$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/posthog/FeatureFlagResult; @@ -728,6 +737,8 @@ 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 remoteConfig ()Lcom/posthog/internal/PostHogRemoteConfigResponse; public final fun snapshot (Ljava/util/List;)V } diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 7b452a567..f985130cb 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -23,6 +23,7 @@ import com.posthog.internal.PostHogPreferences.Companion.SESSION_REPLAY import com.posthog.internal.PostHogPreferences.Companion.SURVEYS import com.posthog.internal.PostHogPreferences.Companion.VERSION import com.posthog.internal.PostHogPrintLogger +import com.posthog.internal.PostHogPushSubscriptionManager import com.posthog.internal.PostHogQueue import com.posthog.internal.PostHogQueueInterface import com.posthog.internal.PostHogRemoteConfig @@ -44,6 +45,8 @@ import java.util.UUID import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +private const val PUSH_NOTIFICATION_OPENED_EVENT = "\$push_notification_opened" + public class PostHog private constructor( private val queueExecutor: ExecutorService = Executors.newSingleThreadScheduledExecutor( @@ -65,6 +68,10 @@ public class PostHog private constructor( Executors.newSingleThreadScheduledExecutor( PostHogThreadFactory("PostHogSendCachedEventsThread"), ), + private val pushExecutor: ExecutorService = + Executors.newSingleThreadScheduledExecutor( + PostHogThreadFactory("PostHogPushSubscriptionThread"), + ), private val reloadFeatureFlags: Boolean = true, ) : PostHogInterface, PostHogStateless() { private val anonymousLock = Any() @@ -80,6 +87,8 @@ public class PostHog private constructor( private var logsQueue: PostHogQueueInterface? = null + private var pushSubscriptionManager: PostHogPushSubscriptionManager? = null + /** * Captures application log records into PostHog's logs product * (separate from product analytics events). Use the severity-specific @@ -233,6 +242,7 @@ public class PostHog private constructor( this.queue = queue this.replayQueue = replayQueue this.logsQueue = logsQueue + this.pushSubscriptionManager = PostHogPushSubscriptionManager(config, api, pushExecutor) { distinctId } if (config.errorTrackingConfig.exceptionSteps.enabled) { val maxBytes = config.errorTrackingConfig.exceptionSteps.maxBytes @@ -269,6 +279,8 @@ public class PostHog private constructor( queue.start() logsQueue.start() + pushSubscriptionManager?.retryPending() + PostHogSessionManager.setOnSessionIdChangedListener { try { sessionReplayHandler?.onSessionIdChanged() @@ -491,6 +503,8 @@ public class PostHog private constructor( queue?.stop() replayQueue?.stop() logsQueue?.stop() + pushSubscriptionManager?.close() + pushSubscriptionManager = null featureFlagsCalled.clear() lastScreenName = null @@ -1091,6 +1105,9 @@ public class PostHog private constructor( optOutLoaded = true exceptionStepsBuffer?.clear() } + // Stop any pending push-token retry/offline-poll timer; the send guard in the manager + // already blocks sends while opted out, this just tears the timer down immediately. + pushSubscriptionManager?.onOptOut() } /** @@ -1302,6 +1319,8 @@ public class PostHog private constructor( // Automatically set person properties for feature flags during identify() call setPersonPropertiesForFlagsIfNeeded(userProperties, userPropertiesSetOnce) + pushSubscriptionManager?.resendIfDistinctIdChanged() + // only because of testing in isolation, this flag is always enabled if (reloadFeatureFlags) { reloadFeatureFlags(config?.onFeatureFlags) @@ -1747,6 +1766,7 @@ public class PostHog private constructor( super.flush() replayQueue?.flush() logsQueue?.flush() + pushSubscriptionManager?.retryPending() } public override fun setPersonPropertiesForFlags( @@ -1807,6 +1827,10 @@ public class PostHog private constructor( return } + // Capture the logging-out identity before preferences are cleared, so the push token can be + // unregistered for it and re-registered under the new anonymous id (decision 5/6). + val previousDistinctId = distinctId + // Preserve BUILD and VERSION to prevent over-sending "Application Installed" events // and under-sending "Application Updated" events. Preserve DEVICE_ID to maintain // stable feature flag bucketing across identity changes. @@ -1838,6 +1862,8 @@ public class PostHog private constructor( endSession() startSession() + pushSubscriptionManager?.handleReset(previousDistinctId) + // reload flags as anon user // only because of testing in isolation, this flag is always enabled if (reloadFeatureFlags) { @@ -1927,6 +1953,92 @@ public class PostHog private constructor( return PostHogSessionManager.isSessionActive() } + override fun registerPushNotificationToken( + deviceToken: String, + appId: String, + ) { + if (!isEnabled()) { + return + } + if (isOptedOut()) { + config?.logger?.log("PostHog is in OptOut state.") + return + } + if (deviceToken.isBlank()) { + config?.logger?.log("registerPushNotificationToken call not allowed, deviceToken is blank.") + return + } + if (appId.isBlank()) { + config?.logger?.log("registerPushNotificationToken call not allowed, appId is blank.") + return + } + + pushSubscriptionManager?.register( + deviceToken = deviceToken, + appId = appId, + platform = "android", + ) + } + + override fun unregisterPushNotificationToken() { + if (!isEnabled()) { + return + } + if (isOptedOut()) { + config?.logger?.log("PostHog is in OptOut state.") + return + } + + pushSubscriptionManager?.unregisterCurrent() + } + + override fun capturePushNotificationOpened( + title: String?, + body: String?, + payload: Map?, + action: String?, + ) { + if (!isEnabled()) { + return + } + if (isOptedOut()) { + config?.logger?.log("PostHog is in OptOut state.") + return + } + + val props = mutableMapOf() + title?.takeIf { it.isNotEmpty() }?.let { props["\$notification_title"] = it } + body?.takeIf { it.isNotEmpty() }?.let { props["\$notification_body"] = it } + action?.takeIf { it.isNotEmpty() }?.let { props["\$notification_action"] = it } + + payload?.get("posthog")?.let { raw -> + posthogPayloadMap(raw)?.forEach { (key, value) -> + value?.let { props["\$notification_$key"] = it } + } + } + + capture(PUSH_NOTIFICATION_OPENED_EVENT, properties = props) + } + + /** + * Coerces the `posthog` entry of a push payload into a map. FCM data maps are string→string, + * so the value is accepted either as a nested [Map] or as a JSON string. Parse failures are + * logged and swallowed so a malformed payload never drops the open event. + */ + private fun posthogPayloadMap(raw: Any?): Map? { + return when (raw) { + is Map<*, *> -> raw.entries.associate { (key, value) -> key.toString() to value } + is String -> + try { + config?.serializer?.deserialize?>(raw.reader()) + } catch (e: Throwable) { + config?.logger?.log("Failed to parse push notification posthog payload: $e.") + null + } + else -> null + } + } + override fun getConfig(): T? { @Suppress("UNCHECKED_CAST") return super.config as? T @@ -2332,5 +2444,25 @@ public class PostHog private constructor( override fun getSessionId(): UUID? { return shared.getSessionId() } + + override fun registerPushNotificationToken( + deviceToken: String, + appId: String, + ) { + shared.registerPushNotificationToken(deviceToken, appId) + } + + override fun unregisterPushNotificationToken() { + shared.unregisterPushNotificationToken() + } + + override fun capturePushNotificationOpened( + title: String?, + body: String?, + payload: Map?, + action: String?, + ) { + shared.capturePushNotificationOpened(title, body, payload, action) + } } } diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index f257cceb3..3b2b9c397 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -412,6 +412,59 @@ public interface PostHogInterface : PostHogCoreInterface { flagVariant: String? = null, ) + /** + * Registers a push notification device token with PostHog so the backend can deliver + * push notifications sent from PostHog Workflows to this device. + * + * The token is persisted and (re)sent with the current [distinctId]; transient failures + * are retried automatically. On Android, when Firebase Messaging is on the classpath and + * `capturePushNotificationSubscriptions` is enabled, this is called for you at startup. + * Call it manually to forward tokens refreshed via Firebase's `onNewToken`. + * + * @param deviceToken the device push token (e.g. the FCM registration token) + * @param appId the app identifier — the Firebase `project_id` on Android, the APNS `bundle_id` on iOS + */ + public fun registerPushNotificationToken( + deviceToken: String, + appId: String, + ) + + /** + * Unregisters this device's push token from PostHog so Workflows stop targeting it — for example + * from your logout flow. + * + * Sends a best-effort `DELETE /api/push_subscriptions` for the current [distinctId] (the backend + * unsets the subscription property) and forgets the locally stored token. Unlike registration + * this is not retried. On [reset] the SDK already moves any registered token to the new anonymous + * identity (unregister then re-register), independently of `capturePushNotificationSubscriptions` — + * that flag only gates automatic token subscription at startup. Call it directly if you manage + * push subscriptions yourself. + */ + public fun unregisterPushNotificationToken() + + /** + * Captures a `$push_notification_opened` event when the user opens a push notification. + * + * On Android, cold-start taps on a tray notification are captured automatically (from the + * launch intent) when `capturePushNotificationOpened` is enabled. Call this manually for the + * paths auto-detection can't observe — foreground data messages and warm-start `onNewIntent`. + * + * Each key of `payload["posthog"]` (accepted as a `Map` or a JSON string) is attached as a + * `$notification_` property. + * + * @param title the notification title, attached as `$notification_title` when non-empty + * @param body the notification body, attached as `$notification_body` when non-empty + * @param payload the notification data payload; its `posthog` entry is spread into `$notification_*` props + * @param action the tapped notification action identifier, attached as `$notification_action`; leave + * `null` for a plain tap/open (the default action carries no identifier) + */ + public fun capturePushNotificationOpened( + title: String? = null, + body: String? = null, + payload: Map? = null, + action: String? = null, + ) + @PostHogInternal public fun getConfig(): T? } diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index 0df02a615..a6c8afacc 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -133,6 +133,50 @@ public class PostHogApi( executeNoBody(request) } + @Throws(PostHogApiError::class, IOException::class) + public fun pushSubscription( + distinctId: String, + deviceToken: String, + platform: String, + appId: String, + ): Unit = sendPushSubscription("POST", distinctId, deviceToken, platform, appId) + + @Throws(PostHogApiError::class, IOException::class) + public fun pushUnsubscription( + distinctId: String, + deviceToken: String, + platform: String, + appId: String, + ): Unit = sendPushSubscription("DELETE", distinctId, deviceToken, platform, appId) + + @Throws(PostHogApiError::class, IOException::class) + private fun sendPushSubscription( + method: String, + distinctId: String, + deviceToken: String, + platform: String, + appId: String, + ) { + val pushSubscription = + PostHogPushSubscriptionRequest( + projectToken = config.apiKey, + distinctId = distinctId, + deviceToken = deviceToken, + platform = platform, + appId = appId, + ) + + val url = "$theHost/api/push_subscriptions/" + val request = + makeRequest(url, method = method) { + logRequest(pushSubscription, url) + + config.serializer.serialize(pushSubscription, it.bufferedWriter()) + } + + executeNoBody(request) + } + @Throws(PostHogApiError::class, IOException::class) private fun executeNoBody(request: Request) { logRequestHeaders(request) @@ -156,6 +200,7 @@ public class PostHogApi( private fun makeRequest( url: String, + method: String = "POST", serializer: (outputStream: OutputStream) -> Unit, ): Request { val requestBody = @@ -169,11 +214,14 @@ public class PostHogApi( } } - return Request.Builder() - .url(url) - .header("User-Agent", config.userAgent) - .post(requestBody) - .build() + val builder = + Request.Builder() + .url(url) + .header("User-Agent", config.userAgent) + return when (method) { + "DELETE" -> builder.delete(requestBody) + else -> builder.post(requestBody) + }.build() } @Throws(PostHogApiError::class, IOException::class) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt new file mode 100644 index 000000000..48327c3f2 --- /dev/null +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt @@ -0,0 +1,421 @@ +package com.posthog.internal + +import com.google.gson.annotations.SerializedName +import com.posthog.PostHogConfig +import java.io.File +import java.io.IOException +import java.util.Timer +import java.util.concurrent.ExecutorService +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.concurrent.schedule +import kotlin.math.min +import kotlin.math.pow + +private const val PENDING_FILE_NAME = "push_subscription.pending" +private const val INITIAL_RETRY_DELAY_SECONDS = 5 +private const val MAX_RETRY_DELAY_SECONDS = 30 + +/** + * Persists the latest push subscription registration and retries it on transient failures. + * + * A single latest-wins record `{deviceToken, appId, platform}` is stored before the first + * attempt; every new [register] overwrites it and resets the retry counter. The distinct id + * is read at send time, never persisted with the record — only the id a successful send was + * delivered for is remembered ([PendingRecord.deliveredForDistinctId]) so [resendIfDistinctIdChanged] + * can re-register the token whenever the user identifies as someone new. + * + * The record survives success (kept, not deleted) and non-retryable failures alike, so + * [retryPending] can pick it back up on the next process start. In-session resume: offline + * deferrals re-poll on a timer, and [retryPending] is also invoked from `flush()` (which the + * Android SDK calls on app background), so an undelivered record doesn't wait for a relaunch. + */ +internal class PostHogPushSubscriptionManager( + private val config: PostHogConfig, + private val api: PostHogApi, + private val executor: ExecutorService, + private val distinctIdProvider: () -> String, +) { + private val timerLock = Any() + private val isSending = AtomicBoolean(false) + + // Authoritative record within a process; disk is only the cross-launch backing store, + // read once (lazily) to hydrate this field. Also the sole store when storagePrefix is null. + @Volatile private var pendingRecord: PendingRecord? = null + + @Volatile private var hydratedFromDisk = false + + @Volatile private var retryCount = 0 + + @Volatile private var timer: Timer? = null + + // Not-before gate for server-driven backoff (Retry-After / 429 / 5xx): while now < nextAttemptAtMs + // the scheduled timer owns the next send, so resume paths (flush/identify) must not cancel it and + // re-hit the endpoint early. Zero when no backoff window is active. + @Volatile private var nextAttemptAtMs: Long = 0L + + // Set after a non-retryable failure or once retries are exhausted: no more attempts this session, + // but the record is kept for one retry on the next launch. In-memory only (not persisted), so a + // fresh process starts clear — this is what stops flush()-driven [retryPending] from re-POSTing a + // doomed request on every app background. Cleared by a new [register] or an identity-change resend. + @Volatile private var halted = false + + @Volatile private var closed = false + + private val pendingFile: File? by lazy { + val prefix = config.storagePrefix ?: return@lazy null + // Must stay out of /: PostHogQueue scans that whole directory as + // cached event files and would send the record as an empty event, then delete it. + File(File(File(prefix, "push"), config.apiKey), PENDING_FILE_NAME) + } + + // Test seam: computed backoff seconds are multiplied by this to get the scheduled delay in + // millis. Production keeps the real 1000; tests shrink it so retries fire near-instantly. + internal var retryDelayMillisPerSecond: Long = 1_000L + + fun register( + deviceToken: String, + appId: String, + platform: String, + ) { + executor.executeSafely { + performRegister(deviceToken, appId, platform) + } + } + + // Executor-thread body of [register]. Kept separate so [handleReset] can chain a DELETE and a + // re-register in a single executor task (ordered, and drained by one flush in tests). + private fun performRegister( + deviceToken: String, + appId: String, + platform: String, + ) { + val existing = currentRecord() + if (existing != null && + existing.deviceToken == deviceToken && + existing.appId == appId && + existing.platform == platform && + existing.deliveredForDistinctId != null && + existing.deliveredForDistinctId == distinctIdProvider() + ) { + // Same token already delivered for this user; don't re-POST it on every cold start. + return + } + val record = PendingRecord(deviceToken, appId, platform) + pendingRecord = record + hydratedFromDisk = true + pendingFile?.let { writeRecord(it, record) } + retryCount = 0 + nextAttemptAtMs = 0L + halted = false + cancelTimer() + attempt() + } + + fun retryPending() { + executor.executeSafely { + val record = currentRecord() ?: return@executeSafely + if (record.deliveredForDistinctId != null && record.deliveredForDistinctId == distinctIdProvider()) { + return@executeSafely + } + if (isWithinBackoffWindow()) { + // A Retry-After/backoff retry is already scheduled; let it fire instead of re-hitting now. + return@executeSafely + } + retryCount = 0 + cancelTimer() + attempt() + } + } + + fun resendIfDistinctIdChanged() { + executor.executeSafely { + val record = currentRecord() ?: return@executeSafely + val currentDistinctId = distinctIdProvider() + if (currentDistinctId.isBlank() || record.deliveredForDistinctId == currentDistinctId) { + return@executeSafely + } + if (isWithinBackoffWindow()) { + // Honor the active Retry-After/backoff window; the scheduled timer reads the current + // distinctId at send time, so the id change is still picked up when it fires. + return@executeSafely + } + retryCount = 0 + halted = false + cancelTimer() + attempt() + } + } + + /** + * Best-effort unregister: a single DELETE for [distinctId]. Unlike [register] there is no + * pending record, timer, or backoff — a failure is logged and dropped (the backend also unsets + * a dead token on the next send, and the durable path is the re-register POST). + */ + fun unregister( + distinctId: String, + deviceToken: String, + appId: String, + platform: String, + ) { + executor.executeSafely { + performUnregister(distinctId, deviceToken, appId, platform) + } + } + + // Executor-thread body of [unregister]. + private fun performUnregister( + distinctId: String, + deviceToken: String, + appId: String, + platform: String, + ) { + if (closed || config.optOut) { + return + } + if (distinctId.isBlank() || deviceToken.isBlank() || appId.isBlank()) { + 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).") + } + } + + /** + * 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. + */ + fun handleReset(oldDistinctId: String) { + executor.executeSafely { + val record = currentRecord() ?: return@executeSafely + // Only unregister the OLD identity when it actually differs from the new one. When they + // match (e.g. reuseAnonymousId on an anonymous user, where reset() keeps the same id) a + // 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) + } + performRegister(record.deviceToken, record.appId, record.platform) + } + } + + /** + * Public-API unregister: DELETE for the current distinct id, then forget the local record so a + * later launch won't re-send it. + */ + fun unregisterCurrent() { + executor.executeSafely { + val record = currentRecord() + if (record == null) { + config.logger.log("Push unregister skipped: no registered token.") + return@executeSafely + } + performUnregister(distinctIdProvider(), record.deviceToken, record.appId, record.platform) + clearRecord() + } + } + + private fun clearRecord() { + pendingRecord = null + hydratedFromDisk = true + cancelTimer() + pendingFile?.deleteSafely(config) + } + + fun close() { + closed = true + cancelTimer() + retryCount = 0 + } + + /** Opt-out: stop the retry/offline-poll timer now. The guard in [attempt] blocks any actual send. */ + fun onOptOut() { + cancelTimer() + } + + private fun isWithinBackoffWindow(): Boolean = System.currentTimeMillis() < nextAttemptAtMs + + private fun attempt() { + if (closed || config.optOut) { + // Opt-out and shutdown both stop every send. Guarding at this single choke point covers + // all callers: register, startup/flush retryPending, identify resend, and the retry timer. + cancelTimer() + return + } + if (halted) { + // Session halt set in handleFailure; this choke point makes flush()-driven retryPending() a no-op. + return + } + // Read the record here, not from the caller: a retry timer may fire after unregisterCurrent() + // cleared it (cancelTimer can't un-queue an already-fired callback), so a null means "don't send". + val record = currentRecord() ?: return + if (config.networkStatus?.isConnected() == false) { + config.logger.log("Push subscription deferred: no network.") + // Deferral burns no retry attempt; poll again so registration resumes + // within the session once connectivity returns. + scheduleRetry(MAX_RETRY_DELAY_SECONDS) + return + } + + val distinctId = distinctIdProvider() + if (distinctId.isBlank()) { + config.logger.log("Push subscription deferred: distinctId is blank.") + return + } + + if (!isSending.compareAndSet(false, true)) { + return + } + + try { + api.pushSubscription( + distinctId = distinctId, + deviceToken = record.deviceToken, + platform = record.platform, + appId = record.appId, + ) + config.logger.log("Push notification token registered successfully.") + retryCount = 0 + nextAttemptAtMs = 0L + // Keep the record with the delivered marker so a later identify() can re-register. + val delivered = record.copy(deliveredForDistinctId = distinctId) + pendingRecord = delivered + pendingFile?.let { writeRecord(it, delivered) } + } catch (e: Throwable) { + handleFailure(e) + } finally { + isSending.set(false) + } + } + + private fun handleFailure(e: Throwable) { + if (!isRetryable(e)) { + // 400/401 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 + } + + retryCount++ + if (retryCount > config.maxRetries) { + config.logger.log( + "Push subscription retries exhausted after $retryCount attempts; " + + "will retry on next SDK startup.", + ) + haltForSession() + return + } + + val delay = nextBackoffSeconds(retryCount, (e as? PostHogApiError)?.retryAfterSeconds) + // Server-driven backoff: gate resume paths so flush()/identify() don't cancel this window + // and immediately re-hit the endpoint, ignoring the server's Retry-After. + nextAttemptAtMs = System.currentTimeMillis() + delay * retryDelayMillisPerSecond + config.logger.log("Push subscription failed: $e. Retrying in ${delay}s (attempt $retryCount).") + scheduleRetry(delay) + } + + // Stop retrying for this process; the persisted record is kept so the next launch (fresh + // instance, halted cleared) retries once. Callers log their own branch-specific reason first. + private fun haltForSession() { + retryCount = 0 + nextAttemptAtMs = 0L + halted = true + } + + internal fun nextBackoffSeconds( + attempt: Int, + retryAfterSeconds: Int?, + ): Int { + if (retryAfterSeconds != null && retryAfterSeconds > 0) { + return retryAfterSeconds + } + val exponential = INITIAL_RETRY_DELAY_SECONDS * 2.0.pow((attempt - 1).toDouble()).toInt() + return min(exponential, MAX_RETRY_DELAY_SECONDS) + } + + private fun scheduleRetry(delaySeconds: Int) { + synchronized(timerLock) { + cancelTimer() + val t = Timer(true) + t.schedule(delaySeconds * retryDelayMillisPerSecond) { + executor.executeSafely { attempt() } + } + timer = t + } + } + + private fun cancelTimer() { + synchronized(timerLock) { + timer?.cancel() + timer = null + } + } + + private fun isRetryable(e: Throwable): Boolean { + return when (e) { + is PostHogApiError -> e.statusCode == 429 || e.statusCode in 500..599 + is IOException -> true + else -> false + } + } + + private fun currentRecord(): PendingRecord? { + if (pendingRecord == null && !hydratedFromDisk) { + hydratedFromDisk = true + pendingFile?.takeIf { it.existsSafely(config) }?.let { file -> + pendingRecord = + readRecord(file) ?: run { + file.deleteSafely(config) + null + } + } + } + return pendingRecord + } + + private fun writeRecord( + file: File, + record: PendingRecord, + ) { + try { + file.parentFile?.mkdirs() + val os = config.encryption?.encrypt(file.outputStream()) ?: file.outputStream() + os.use { theOutputStream -> + config.serializer.serialize(record, theOutputStream.writer().buffered()) + } + } catch (e: Throwable) { + config.logger.log("Failed to persist push subscription: $e.") + } + } + + private fun readRecord(file: File): PendingRecord? { + return try { + val input = config.encryption?.decrypt(file.inputStream()) ?: file.inputStream() + input.use { + config.serializer.deserialize(it.reader().buffered()) + } + } catch (e: Throwable) { + config.logger.log("Failed to read pending push subscription: $e.") + null + } + } + + internal data class PendingRecord( + @SerializedName("device_token") + val deviceToken: String, + @SerializedName("app_id") + val appId: String, + val platform: String, + @SerializedName("delivered_for_distinct_id") + val deliveredForDistinctId: String? = null, + ) +} 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 000000000..9c17db22d --- /dev/null +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionRequest.kt @@ -0,0 +1,16 @@ +package com.posthog.internal + +import com.google.gson.annotations.SerializedName + +/** Request body for the push subscriptions API. `appId` is the Firebase project_id (Android) or APNS bundle_id (iOS). */ +internal data class PostHogPushSubscriptionRequest( + @SerializedName("api_key") + val projectToken: String, + @SerializedName("distinct_id") + val distinctId: String, + @SerializedName("device_token") + val deviceToken: String, + val platform: String, + @SerializedName("app_id") + val appId: String, +) diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 3bf0e1962..94617a2f2 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -3,6 +3,7 @@ package com.posthog import com.posthog.internal.PostHogBatchEvent import com.posthog.internal.PostHogContext import com.posthog.internal.PostHogMemoryPreferences +import com.posthog.internal.PostHogNetworkStatus import com.posthog.internal.PostHogPreferences import com.posthog.internal.PostHogPreferences.Companion.ANONYMOUS_ID import com.posthog.internal.PostHogPreferences.Companion.CAPTURE_PERFORMANCE @@ -4104,4 +4105,271 @@ internal class PostHogTest { sut.close() } + + @Test + fun `registerPushNotificationToken posts to push_subscriptions endpoint`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false) + + sut.registerPushNotificationToken( + deviceToken = "fcm-token", + appId = "firebase-project", + ) + + queueExecutor.awaitExecution() + + val request = http.takeRequest() + assertEquals("POST", request.method) + assertEquals("/api/push_subscriptions/", request.path) + + val parsed = serializer.deserialize>(request.body.unGzip().reader()) + assertEquals("fcm-token", parsed["device_token"]) + assertEquals("firebase-project", parsed["app_id"]) + assertEquals("android", parsed["platform"]) + assertEquals(sut.distinctId(), parsed["distinct_id"]) + + sut.close() + } + + @Test + fun `registerPushNotificationToken is a no-op when optOut`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), optOut = true, preloadFeatureFlags = false, reloadFeatureFlags = false) + + sut.registerPushNotificationToken("fcm-token", "firebase-project") + queueExecutor.awaitExecution() + + assertEquals(0, http.requestCount) + + sut.close() + } + + @Test + fun `registerPushNotificationToken is a no-op when deviceToken is blank`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false) + + sut.registerPushNotificationToken(" ", "firebase-project") + queueExecutor.awaitExecution() + + assertEquals(0, http.requestCount) + + sut.close() + } + + @Test + fun `registerPushNotificationToken is a no-op when appId is blank`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false) + + sut.registerPushNotificationToken("fcm-token", "") + queueExecutor.awaitExecution() + + assertEquals(0, http.requestCount) + + sut.close() + } + + @Test + fun `registerPushNotificationToken after close does not crash and sends nothing`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false) + sut.close() + + sut.registerPushNotificationToken("fcm-token", "firebase-project") + + assertEquals(0, http.requestCount) + } + + @Test + fun `capturePushNotificationOpened maps title body and posthog map into notification props`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false) + + // Vector 1: posthog map spread into $notification_* props alongside title/body. + sut.capturePushNotificationOpened( + title = "Hello", + body = "World", + payload = mapOf("posthog" to mapOf("campaign" to "summer", "message_id" to "42")), + ) + + queueExecutor.shutdownAndAwaitTermination() + + val theEvent = firstBatchEvent(http) + assertEquals("\$push_notification_opened", theEvent.event) + assertEquals("Hello", theEvent.properties!!["\$notification_title"]) + assertEquals("World", theEvent.properties!!["\$notification_body"]) + assertEquals("summer", theEvent.properties!!["\$notification_campaign"]) + assertEquals("42", theEvent.properties!!["\$notification_message_id"]) + + sut.close() + } + + @Test + fun `capturePushNotificationOpened parses posthog data supplied as a json string`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false) + + // FCM data maps are string->string, so the posthog entry arrives as a JSON string. + sut.capturePushNotificationOpened( + title = "Hello", + body = "World", + payload = mapOf("posthog" to """{"campaign":"summer","message_id":"42"}"""), + ) + + queueExecutor.shutdownAndAwaitTermination() + + val theEvent = firstBatchEvent(http) + assertEquals("summer", theEvent.properties!!["\$notification_campaign"]) + assertEquals("42", theEvent.properties!!["\$notification_message_id"]) + + sut.close() + } + + @Test + fun `capturePushNotificationOpened without posthog key only sets title and body`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false) + + // Vector 2: no posthog key -> only title/body props. + sut.capturePushNotificationOpened( + title = "Hello", + body = "World", + payload = mapOf("google.message_id" to "abc"), + ) + + queueExecutor.shutdownAndAwaitTermination() + + val theEvent = firstBatchEvent(http) + assertEquals("Hello", theEvent.properties!!["\$notification_title"]) + assertEquals("World", theEvent.properties!!["\$notification_body"]) + assertTrue( + theEvent.properties!!.keys.none { + it.startsWith("\$notification_") && it !in setOf("\$notification_title", "\$notification_body") + }, + ) + + sut.close() + } + + @Test + fun `capturePushNotificationOpened captures even when all params are null`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false) + + sut.capturePushNotificationOpened() + + queueExecutor.shutdownAndAwaitTermination() + + val theEvent = firstBatchEvent(http) + assertEquals("\$push_notification_opened", theEvent.event) + + sut.close() + } + + @Test + fun `capturePushNotificationOpened sets notification_action for a non-default action`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false) + + sut.capturePushNotificationOpened(title = "Hello", body = "World", action = "reply") + + queueExecutor.shutdownAndAwaitTermination() + + val theEvent = firstBatchEvent(http) + assertEquals("reply", theEvent.properties!!["\$notification_action"]) + + sut.close() + } + + @Test + fun `capturePushNotificationOpened omits empty title body and action`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false) + + // Empty strings are treated as absent (matches iOS), so no empty-valued props are sent. + sut.capturePushNotificationOpened(title = "", body = "", action = "") + + queueExecutor.shutdownAndAwaitTermination() + + val theEvent = firstBatchEvent(http) + assertEquals("\$push_notification_opened", theEvent.event) + assertTrue(theEvent.properties!!.keys.none { it.startsWith("\$notification_") }) + + sut.close() + } + + @Test + fun `capturePushNotificationOpened is a no-op when optOut`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), optOut = true, preloadFeatureFlags = false, reloadFeatureFlags = false) + + // Vector 6: opted out -> no event. + sut.capturePushNotificationOpened(title = "Hello", body = "World") + + queueExecutor.shutdownAndAwaitTermination() + + assertEquals(0, http.requestCount) + + sut.close() + } + + @Test + fun `flush retries a push subscription registration deferred while offline`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false) + + var connected = false + config.networkStatus = + object : PostHogNetworkStatus { + override fun isConnected(): Boolean = connected + } + + sut.registerPushNotificationToken("fcm-token", "firebase-project") + queueExecutor.awaitExecution() + assertEquals(0, http.requestCount) + + connected = true + sut.flush() + queueExecutor.awaitExecution() + + val request = http.takeRequest() + // exactly one request: the queue flush must not have consumed the pending record + // (regression: the pending file used to live inside the queue's scan directory) + assertEquals("/api/push_subscriptions/", request.path) + assertEquals(1, http.requestCount) + + sut.close() + } + + private fun firstBatchEvent(http: okhttp3.mockwebserver.MockWebServer): PostHogEvent { + val content = http.takeRequest().body.unGzip() + return serializer.deserialize(content.reader())!!.batch.first() + } } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt index 6eabfa4e1..b6d74aedf 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt @@ -763,6 +763,104 @@ internal class PostHogApiTest { } assertEquals(408, exc.statusCode) } + + @Test + fun `pushSubscription posts request with expected body and path`() { + 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", + ) + + val request = http.takeRequest() + + assertEquals("POST", request.method) + assertEquals("/api/push_subscriptions/", request.path) + assertEquals("gzip", request.headers["Content-Encoding"]) + assertEquals("application/json; charset=utf-8", request.headers["Content-Type"]) + + val body = request.body.unGzip() + val parsed = PostHogSerializer(PostHogConfig(API_KEY)).deserialize>(body.reader()) + // Vector 3: the body serializes exactly the five snake_case names. + assertEquals(API_KEY, parsed["api_key"]) + assertEquals("distinctId", parsed["distinct_id"]) + assertEquals("fcm-token-123", parsed["device_token"]) + assertEquals("android", parsed["platform"]) + assertEquals("firebase-project-id", parsed["app_id"]) + } + + @Test + fun `pushSubscription throws if not successful`() { + val http = mockHttp(response = MockResponse().setResponseCode(400).setBody("error")) + val url = http.url("/") + + val sut = getSut(host = url.toString()) + + val exc = + assertThrows(PostHogApiError::class.java) { + sut.pushSubscription( + distinctId = "distinctId", + deviceToken = "fcm-token-123", + platform = "android", + appId = "firebase-project-id", + ) + } + assertEquals(400, exc.statusCode) + } + + @Test + fun `pushUnsubscription sends a DELETE with the same 5-field body and path`() { + 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", + ) + + val request = http.takeRequest() + + assertEquals("DELETE", request.method) + assertEquals("/api/push_subscriptions/", request.path) + + val body = request.body.unGzip() + val parsed = PostHogSerializer(PostHogConfig(API_KEY)).deserialize>(body.reader()) + // Same 5 snake_case fields as the POST register (decision 6). + assertEquals(API_KEY, parsed["api_key"]) + assertEquals("distinctId", parsed["distinct_id"]) + assertEquals("fcm-token-123", parsed["device_token"]) + assertEquals("android", parsed["platform"]) + assertEquals("firebase-project-id", parsed["app_id"]) + } + + @Test + fun `pushUnsubscription throws if not successful`() { + val http = mockHttp(response = MockResponse().setResponseCode(400).setBody("error")) + val url = http.url("/") + + val sut = getSut(host = url.toString()) + + val exc = + assertThrows(PostHogApiError::class.java) { + sut.pushUnsubscription( + distinctId = "distinctId", + deviceToken = "fcm-token-123", + platform = "android", + appId = "firebase-project-id", + ) + } + assertEquals(400, exc.statusCode) + } } @RunWith(Parameterized::class) diff --git a/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt new file mode 100644 index 000000000..573e637c6 --- /dev/null +++ b/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt @@ -0,0 +1,637 @@ +package com.posthog.internal + +import com.posthog.API_KEY +import com.posthog.PostHogConfig +import com.posthog.PostHogEncryption +import com.posthog.mockHttp +import com.posthog.unGzip +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +internal class PostHogPushSubscriptionManagerTest { + @get:Rule + val tmpDir = TemporaryFolder() + + private val executor: ExecutorService = Executors.newSingleThreadExecutor(PostHogThreadFactory("TestPushSub")) + + @Volatile + private var distinctId: String = "distinct-1" + + @AfterTest + fun `set down`() { + executor.shutdownNow() + tmpDir.root.deleteRecursively() + } + + private fun getSut( + http: MockWebServer, + storagePrefix: String? = tmpDir.newFolder().absolutePath, + networkStatus: PostHogNetworkStatus? = null, + maxRetries: Int = 3, + encryption: PostHogEncryption? = null, + ): Triple { + val config = + PostHogConfig(API_KEY, host = http.url("/").toString()).apply { + this.storagePrefix = storagePrefix + this.networkStatus = networkStatus + this.maxRetries = maxRetries + this.encryption = encryption + } + val api = PostHogApi(config) + val manager = PostHogPushSubscriptionManager(config, api, executor) { distinctId } + return Triple(manager, config, storagePrefix) + } + + private fun pendingFile(storagePrefix: String): File = File(File(File(storagePrefix, "push"), API_KEY), "push_subscription.pending") + + private fun flush() { + executor.submit {}.get() + } + + private fun readRecord( + config: PostHogConfig, + file: File, + ): PostHogPushSubscriptionManager.PendingRecord? { + val input = config.encryption?.decrypt(file.inputStream()) ?: file.inputStream() + return input.use { + config.serializer.deserialize(it.reader().buffered()) + } + } + + @Test + fun `register posts subscription and keeps record with delivered marker on success`() { + val http = mockHttp() + val (sut, config, storagePrefix) = getSut(http) + + sut.register("fcm-token", "firebase-project", "android") + flush() + + val request = http.takeRequest() + assertEquals("POST", request.method) + assertEquals("/api/push_subscriptions/", request.path) + + val file = pendingFile(storagePrefix!!) + assertTrue(file.exists()) + // Decision 5: the record is kept with the distinct id it was delivered for. + assertEquals("distinct-1", readRecord(config, file)?.deliveredForDistinctId) + } + + @Test + fun `register defers when network is disconnected and keeps pending file`() { + val http = mockHttp() + val offline = + object : PostHogNetworkStatus { + override fun isConnected() = false + } + val (sut, _, storagePrefix) = getSut(http, networkStatus = offline) + + sut.register("fcm-token", "firebase-project", "android") + flush() + + assertEquals(0, http.requestCount) + assertTrue(pendingFile(storagePrefix!!).exists()) + } + + @Test + fun `register resumes via the offline poll once connectivity returns`() { + val http = mockHttp() + var connected = false + val network = + object : PostHogNetworkStatus { + override fun isConnected() = connected + } + val (sut, config, storagePrefix) = getSut(http, networkStatus = network) + sut.retryDelayMillisPerSecond = 1L + + sut.register("fcm-token", "firebase-project", "android") + flush() + assertEquals(0, http.requestCount) + + connected = true + + // The offline deferral scheduled a poll without burning a retry attempt. + assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) + flush() // let the attempt finish writing the delivered marker + assertEquals(1, http.requestCount) + assertEquals("distinct-1", readRecord(config, pendingFile(storagePrefix!!))?.deliveredForDistinctId) + } + + @Test + fun `register defers when distinctId is blank`() { + val http = mockHttp() + distinctId = " " + val (sut, _, storagePrefix) = getSut(http) + + sut.register("fcm-token", "firebase-project", "android") + flush() + + assertEquals(0, http.requestCount) + assertTrue(pendingFile(storagePrefix!!).exists()) + } + + @Test + fun `register keeps file on non-retryable 400 without further in-session retries`() { + val http = mockHttp(total = 5, response = MockResponse().setResponseCode(400).setBody("bad")) + val (sut, config, storagePrefix) = getSut(http) + sut.retryDelayMillisPerSecond = 1L + + sut.register("fcm-token", "firebase-project", "android") + flush() + + // Vector 5: 400 -> no in-session retry, record kept (no delivered marker). + assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) // the single 400 + assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) // no retry scheduled + assertEquals(1, http.requestCount) + val file = pendingFile(storagePrefix!!) + assertTrue(file.exists()) + assertNull(readRecord(config, file)?.deliveredForDistinctId) + } + + @Test + fun `retryPending does not re-send after a non-retryable failure halts the session`() { + val http = mockHttp(total = 5, response = MockResponse().setResponseCode(400).setBody("bad")) + val (sut, _, storagePrefix) = getSut(http) + sut.retryDelayMillisPerSecond = 1L + + sut.register("fcm-token", "firebase-project", "android") + assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) // the single 400 + assertEquals(1, http.requestCount) + assertTrue(pendingFile(storagePrefix!!).exists()) + + // flush() fires retryPending() on every app background; a 400-halted record must stay put and + // not re-POST the doomed request each cycle. + repeat(3) { + sut.retryPending() + flush() + } + + assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) + assertEquals(1, http.requestCount) + } + + @Test + fun `register retries on 500 then succeeds without duplicating`() { + val http = MockWebServer() + http.start() + http.enqueue(MockResponse().setResponseCode(500)) + http.enqueue(MockResponse().setBody("")) + + val (sut, config, storagePrefix) = getSut(http) + sut.retryDelayMillisPerSecond = 1L + + sut.register("fcm-token", "firebase-project", "android") + + assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) // initial 500 + assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) // retry ~5ms -> 200 + assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) // no duplicate + assertEquals(2, http.requestCount) + + val file = pendingFile(storagePrefix!!) + assertTrue(file.exists()) + assertEquals("distinct-1", readRecord(config, file)?.deliveredForDistinctId) + http.shutdown() + } + + @Test + fun `register gives up after maxRetries then halts in-session but a fresh instance retries once`() { + val http = MockWebServer() + http.start() + repeat(3) { http.enqueue(MockResponse().setResponseCode(500)) } // initial + 2 retries + http.enqueue(MockResponse().setBody("")) // the single retry a relaunch is allowed + + val storagePrefix = tmpDir.newFolder().absolutePath + val (sut, _, _) = getSut(http, storagePrefix = storagePrefix, maxRetries = 2) + sut.retryDelayMillisPerSecond = 1L + + sut.register("fcm-token", "firebase-project", "android") + + // Vector 4: 500 -> retry, 500 -> retry, then give up after maxRetries. + assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) // initial + assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) // retry 1 + assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) // retry 2 + assertNull(http.takeRequest(1, TimeUnit.SECONDS)) // gave up, record kept + assertTrue(pendingFile(storagePrefix).exists()) + + // Halted for the rest of this session: flush()-driven retryPending() must not re-hit the endpoint. + sut.retryPending() + flush() + assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) + assertEquals(3, http.requestCount) + + // Relaunch: a fresh manager over the same storage clears the in-memory halt and retries exactly once. + val (relaunched, _, _) = getSut(http, storagePrefix = storagePrefix, maxRetries = 2) + relaunched.retryPending() + assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) + assertEquals(4, http.requestCount) + http.shutdown() + } + + @Test + fun `nextBackoffSeconds follows 5-10-20-30 with cap and honours Retry-After`() { + val http = mockHttp() + val (sut, _, _) = getSut(http) + + assertEquals(5, sut.nextBackoffSeconds(1, null)) + assertEquals(10, sut.nextBackoffSeconds(2, null)) + assertEquals(20, sut.nextBackoffSeconds(3, null)) + assertEquals(30, sut.nextBackoffSeconds(4, null)) + assertEquals(30, sut.nextBackoffSeconds(5, null)) + + // Retry-After wins when present and positive; 0/absent falls back to the formula. + assertEquals(7, sut.nextBackoffSeconds(1, 7)) + assertEquals(5, sut.nextBackoffSeconds(1, 0)) + } + + @Test + fun `retryPending is a no-op when there is no pending file`() { + val http = mockHttp() + val (sut, _, _) = getSut(http) + + sut.retryPending() + flush() + + assertEquals(0, http.requestCount) + } + + @Test + fun `retryPending is a no-op when already delivered for current distinct id`() { + val http = mockHttp(total = 2) + val (sut, _, _) = getSut(http) + + sut.register("fcm-token", "firebase-project", "android") + flush() + assertEquals(1, http.requestCount) + + sut.retryPending() + flush() + + assertEquals(1, http.requestCount) + } + + @Test + fun `resendIfDistinctIdChanged re-registers when the distinct id changes`() { + val http = mockHttp(total = 2, response = MockResponse().setBody("")) + val (sut, config, storagePrefix) = getSut(http) + + sut.register("fcm-token", "firebase-project", "android") + flush() + assertEquals("distinct-1", parsedDistinctId(http.takeRequest())) + + distinctId = "distinct-2" + sut.resendIfDistinctIdChanged() + flush() + + assertEquals(2, http.requestCount) + assertEquals("distinct-2", parsedDistinctId(http.takeRequest())) + assertEquals("distinct-2", readRecord(config, pendingFile(storagePrefix!!))?.deliveredForDistinctId) + } + + @Test + fun `resendIfDistinctIdChanged is a no-op when the distinct id is unchanged`() { + val http = mockHttp(total = 2) + val (sut, _, _) = getSut(http) + + sut.register("fcm-token", "firebase-project", "android") + flush() + + sut.resendIfDistinctIdChanged() + flush() + + assertEquals(1, http.requestCount) + } + + @Test + fun `unregister sends one DELETE and does not retry on failure`() { + val http = mockHttp(total = 5, response = MockResponse().setResponseCode(500)) + val (sut, _, _) = getSut(http) + sut.retryDelayMillisPerSecond = 1L + + sut.unregister("distinct-1", "fcm-token", "firebase-project", "android") + flush() + + val request = http.takeRequest() + assertEquals("DELETE", request.method) + assertEquals("/api/push_subscriptions/", request.path) + // Best-effort: exactly one request, no retry even on 500. + assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) + assertEquals(1, http.requestCount) + } + + @Test + fun `handleReset unregisters the old identity then re-registers under the new anonymous id`() { + val http = mockHttp(total = 3, response = MockResponse().setBody("")) + val (sut, config, storagePrefix) = getSut(http) + + distinctId = "user-A" + sut.register("fcm-token", "firebase-project", "android") + flush() + assertEquals("POST", http.takeRequest().method) + + // Log out: a new anonymous id is now current. + distinctId = "anon-2" + sut.handleReset("user-A") + flush() + + // Vector 8: DELETE for the old identity, then a POST re-register under the new anon id. + val del = http.takeRequest() + assertEquals("DELETE", del.method) + assertEquals("user-A", parsedDistinctId(del)) + + val post = http.takeRequest() + assertEquals("POST", post.method) + assertEquals("anon-2", parsedDistinctId(post)) + + assertEquals(3, http.requestCount) + assertEquals("anon-2", readRecord(config, pendingFile(storagePrefix!!))?.deliveredForDistinctId) + } + + @Test + fun `handleReset does not unregister when the identity is unchanged`() { + val http = mockHttp(total = 3, response = MockResponse().setBody("")) + val (sut, _, storagePrefix) = getSut(http) + + sut.register("fcm-token", "firebase-project", "android") + flush() + assertEquals("POST", http.takeRequest().method) + + // reuseAnonymousId keeps the same id across reset(): old == new. A DELETE here would unset + // the id we stay on, and the re-register dedup guard would then skip the POST. + sut.handleReset("distinct-1") + flush() + + assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) + assertEquals(1, http.requestCount) + assertTrue(pendingFile(storagePrefix!!).exists()) + } + + @Test + fun `unregisterCurrent deletes for the current id and clears the pending record`() { + val http = mockHttp(total = 2, response = MockResponse().setBody("")) + val (sut, _, storagePrefix) = getSut(http) + + sut.register("fcm-token", "firebase-project", "android") + flush() + assertEquals("POST", http.takeRequest().method) + assertTrue(pendingFile(storagePrefix!!).exists()) + + sut.unregisterCurrent() + flush() + + val del = http.takeRequest() + assertEquals("DELETE", del.method) + assertEquals("distinct-1", parsedDistinctId(del)) + // Record forgotten so a later retryPending won't re-send it. + assertFalse(pendingFile(storagePrefix).exists()) + } + + @Test + fun `a retry that fires during an unregister does not re-subscribe the cleared token`() { + // Race: unregisterCurrent()'s DELETE holds the single-thread executor while the pending retry timer + // fires and queues behind it; that attempt must see the cleared record and bail, not re-POST the token. + val http = MockWebServer() + http.start() + http.enqueue(MockResponse().setResponseCode(500)) // initial register fails -> schedules a retry + http.enqueue(MockResponse().setBody("").setHeadersDelay(300, TimeUnit.MILLISECONDS)) // DELETE holds the executor + http.enqueue(MockResponse().setBody("")) // only consumed if the bug re-POSTs + + val (sut, _, storagePrefix) = getSut(http) + sut.retryDelayMillisPerSecond = 1L // 5s backoff -> ~5ms, fires well inside the 300ms DELETE + + sut.register("fcm-token", "firebase-project", "android") + sut.unregisterCurrent() + + assertEquals("POST", http.takeRequest(2, TimeUnit.SECONDS)?.method) + assertEquals("DELETE", http.takeRequest(2, TimeUnit.SECONDS)?.method) + assertNull(http.takeRequest(1, TimeUnit.SECONDS)) // no re-POST: the retry saw a cleared record and bailed + assertEquals(2, http.requestCount) + assertFalse(pendingFile(storagePrefix!!).exists()) // record stays cleared, not rewritten by a re-send + http.shutdown() + } + + @Test + fun `unregister does not send after optOut`() { + val http = mockHttp() + val (sut, config, _) = getSut(http) + config.optOut = true + + sut.unregister("distinct-1", "fcm-token", "firebase-project", "android") + flush() + + assertEquals(0, http.requestCount) + } + + @Test + fun `register overwrites the pending record latest-wins`() { + val http = mockHttp(total = 2, response = MockResponse().setBody("")) + val (sut, _, storagePrefix) = getSut(http) + + sut.register("token-1", "firebase-project", "android") + flush() + sut.register("token-2", "firebase-project", "android") + flush() + + assertEquals(2, http.requestCount) + assertTrue(http.takeRequest().body.unGzip().contains("token-1")) + assertTrue(http.takeRequest().body.unGzip().contains("token-2")) + assertTrue(pendingFile(storagePrefix!!).exists()) + } + + @Test + fun `register writes an encrypted record that roundtrips through the serializer`() { + val encryption = XorEncryption() + val http = mockHttp(response = MockResponse().setResponseCode(503)) + val (sut, config, storagePrefix) = getSut(http, maxRetries = 0, encryption = encryption) + + sut.register("fcm-token", "firebase-project", "android") + flush() + + val file = pendingFile(storagePrefix!!) + assertTrue(file.exists()) + // Raw bytes are not the plaintext token; decryption yields the record. + assertFalse(file.readBytes().decodeToString().contains("fcm-token")) + val record = readRecord(config, file) + assertEquals("fcm-token", record?.deviceToken) + assertEquals("firebase-project", record?.appId) + assertEquals("android", record?.platform) + assertNull(record?.deliveredForDistinctId) + } + + @Test + fun `register with null storagePrefix still attempts the request`() { + val http = mockHttp() + val (sut, _, _) = getSut(http, storagePrefix = null) + + sut.register("fcm-token", "firebase-project", "android") + flush() + + assertEquals(1, http.requestCount) + } + + @Test + fun `retryPending hydrates a persisted record from disk on a fresh instance`() { + val http = MockWebServer() + http.start() + http.enqueue(MockResponse().setResponseCode(503)) // first launch: fails, keeps file + http.enqueue(MockResponse().setBody("")) // next launch: succeeds + + val storagePrefix = tmpDir.newFolder().absolutePath + val (first, _, _) = getSut(http, storagePrefix = storagePrefix, maxRetries = 0) + first.register("fcm-token", "firebase-project", "android") + flush() + assertTrue(pendingFile(storagePrefix).exists()) + + // Next launch: a brand-new manager with empty in-memory state must hydrate from disk. + val (second, config, _) = getSut(http, storagePrefix = storagePrefix) + second.retryPending() + flush() + + assertEquals(2, http.requestCount) + assertEquals("distinct-1", readRecord(config, pendingFile(storagePrefix))?.deliveredForDistinctId) + http.shutdown() + } + + @Test + fun `retryPending deletes a corrupt pending file`() { + val http = mockHttp() + val (sut, _, storagePrefix) = getSut(http) + + val file = pendingFile(storagePrefix!!) + file.parentFile.mkdirs() + file.writeText("{not valid json") + + sut.retryPending() + flush() + + assertEquals(0, http.requestCount) + assertFalse(file.exists()) + } + + @Test + fun `retryPending does not send after optOut`() { + val http = mockHttp() + var connected = false + val network = + object : PostHogNetworkStatus { + override fun isConnected() = connected + } + val (sut, config, storagePrefix) = getSut(http, networkStatus = network) + + // Persist an undelivered record (register defers while offline). + sut.register("fcm-token", "firebase-project", "android") + flush() + assertEquals(0, http.requestCount) + assertTrue(pendingFile(storagePrefix!!).exists()) + + connected = true + config.optOut = true + sut.retryPending() + flush() + + assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) + assertEquals(0, http.requestCount) + } + + @Test + fun `resendIfDistinctIdChanged does not send after optOut`() { + val http = mockHttp() + val (sut, config, _) = getSut(http) + + sut.register("fcm-token", "firebase-project", "android") + flush() + assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) // delivered for distinct-1 + assertEquals(1, http.requestCount) + + config.optOut = true + distinctId = "distinct-2" // identify as someone new + sut.resendIfDistinctIdChanged() + flush() + + assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) + assertEquals(1, http.requestCount) + } + + @Test + fun `retryPending honours an active backoff window instead of re-hitting immediately`() { + val http = MockWebServer() + http.start() + http.enqueue(MockResponse().setResponseCode(500)) // opens a 5s backoff window + http.enqueue(MockResponse().setBody("")) // the re-hit that must NOT happen during the window + + // Real 1000ms/sec so the 5s window stays open for the whole test. + val (sut, _, _) = getSut(http) + + sut.register("fcm-token", "firebase-project", "android") + assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) // the 500 + assertEquals(1, http.requestCount) + + // A background flush lands mid-backoff: it must let the scheduled retry fire, not re-POST now. + sut.retryPending() + flush() + + assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) + assertEquals(1, http.requestCount) + http.shutdown() + } + + @Test + fun `register skips re-sending a token already delivered for the current distinct id`() { + val http = mockHttp() + val (sut, _, _) = getSut(http) + + sut.register("fcm-token", "firebase-project", "android") + flush() + assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) + assertEquals(1, http.requestCount) + + // Cold-start auto-register forwards the same cached token again for the same user. + sut.register("fcm-token", "firebase-project", "android") + flush() + + 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()) + return parsed["distinct_id"] as? String + } + + private class XorEncryption : PostHogEncryption { + private val key = 0x5A.toByte() + + override fun encrypt(outputStream: OutputStream): OutputStream = + object : OutputStream() { + override fun write(b: Int) = outputStream.write(b xor key.toInt()) + + override fun flush() = outputStream.flush() + + override fun close() = outputStream.close() + } + + override fun decrypt(inputStream: InputStream): InputStream = + object : InputStream() { + override fun read(): Int { + val next = inputStream.read() + return if (next == -1) -1 else next xor key.toInt() + } + + override fun close() = inputStream.close() + } + } +} diff --git a/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt b/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt index d2ecd8ee3..8fbdca562 100644 --- a/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt +++ b/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt @@ -15,6 +15,15 @@ public class PostHogFake : PostHogInterface { public var sessionReplayActive: Boolean = false public var startSessionReplayCalls: Int = 0 public var stopSessionReplayCalls: Int = 0 + public var pushDeviceToken: String? = null + public var pushAppId: String? = null + public var pushRegistrations: Int = 0 + public var pushUnregistrations: Int = 0 + public var pushOpenedTitle: String? = null + public var pushOpenedBody: String? = null + public var pushOpenedPayload: Map? = null + public var pushOpenedAction: String? = null + public var pushOpenedCaptures: Int = 0 // `PostHogLogger`'s constructor is `internal`. Test fixtures live in the // same module, so we can construct a silent no-op here without exposing @@ -236,6 +245,32 @@ public class PostHogFake : PostHogInterface { return null } + override fun registerPushNotificationToken( + deviceToken: String, + appId: String, + ) { + pushDeviceToken = deviceToken + pushAppId = appId + pushRegistrations++ + } + + override fun unregisterPushNotificationToken() { + pushUnregistrations++ + } + + override fun capturePushNotificationOpened( + title: String?, + body: String?, + payload: Map?, + action: String?, + ) { + pushOpenedTitle = title + pushOpenedBody = body + pushOpenedPayload = payload + pushOpenedAction = action + pushOpenedCaptures++ + } + override fun getConfig(): T? { return null } From f3e30bd9906c01dcf154f0bd7f057f1135a1b7ea Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 29 Jul 2026 09:36:32 +0300 Subject: [PATCH 02/14] feat: attach optional identity token to push subscription requests (#656) * feat: attach optional identity token to push subscription requests * chore: drop changeset until release * fix: do not cache identity token minted after opt-out * fix: harden async push identity token mint against races, hangs, and leaks * fix: clear didAuthRetry on opt-out and order cache clear on executor --- posthog/api/posthog.api | 12 +- .../main/java/com/posthog/PostHogConfig.kt | 20 + .../java/com/posthog/internal/PostHogApi.kt | 15 +- .../PostHogPushSubscriptionManager.kt | 225 ++++++++++- .../PostHogPushSubscriptionRequest.kt | 3 + .../com/posthog/internal/PostHogApiTest.kt | 45 +++ .../PostHogPushSubscriptionManagerTest.kt | 368 ++++++++++++++++++ 7 files changed, 666 insertions(+), 22 deletions(-) diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 331565ba7..1dad48369 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 e01f3d69e..fb9cd492b 100644 --- a/posthog/src/main/java/com/posthog/PostHogConfig.kt +++ b/posthog/src/main/java/com/posthog/PostHogConfig.kt @@ -328,6 +328,26 @@ 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. + * + * 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, ) { @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 a6c8afacc..7bd1ec4fd 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,12 +167,18 @@ public class PostHogApi( deviceToken = deviceToken, platform = platform, appId = appId, + identityToken = identityToken, ) 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 48327c3f2..62ebf8586 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt @@ -61,6 +61,20 @@ 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 + + // 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 @@ -72,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. + // 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, @@ -107,6 +127,7 @@ internal class PostHogPushSubscriptionManager( retryCount = 0 nextAttemptAtMs = 0L halted = false + didAuthRetry = false cancelTimer() attempt() } @@ -141,6 +162,7 @@ internal class PostHogPushSubscriptionManager( } retryCount = 0 halted = false + didAuthRetry = false cancelTimer() attempt() } @@ -162,37 +184,53 @@ 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 } - 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) { + onComplete?.invoke() + 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).") + } finally { + onComplete?.invoke() + } } } /** * 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 { @@ -202,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) } } @@ -235,11 +276,21 @@ 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() + // 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 @@ -273,19 +324,49 @@ 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 } + // 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?, + ) { 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, 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 @@ -294,12 +375,55 @@ 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 + 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 +484,71 @@ 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, + ) { + // 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("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 + // 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("Attaching freshly minted identity token to push request.") + } else { + 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) + } + } + } + private fun isRetryable(e: Throwable): Boolean { return when (e) { is PostHogApiError -> e.statusCode == 429 || e.statusCode in 500..599 @@ -418,4 +607,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 9c17db22d..fb63094b4 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 b6d74aedf..ff7aa1f89 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 573e637c6..ce0654895 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt @@ -332,6 +332,127 @@ 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 `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. + 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 +727,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 a7534c4aed2a98d60914dc3c0b0b01ca2b68e9a2 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 30 Jul 2026 08:10:25 +0300 Subject: [PATCH 03/14] feat(push): durable unregister and passive offline retry (parity with iOS) --- .../PostHogPushSubscriptionManager.kt | 183 ++++++++++++++---- .../PostHogPushSubscriptionManagerTest.kt | 86 +++++++- 2 files changed, 222 insertions(+), 47 deletions(-) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt index 62ebf8586..04e407fbf 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt @@ -12,6 +12,7 @@ import kotlin.math.min import kotlin.math.pow private const val PENDING_FILE_NAME = "push_subscription.pending" +private const val PENDING_UNREGISTER_FILE_NAME = "push_subscription.unregister.pending" private const val INITIAL_RETRY_DELAY_SECONDS = 5 private const val MAX_RETRY_DELAY_SECONDS = 30 @@ -25,9 +26,15 @@ private const val MAX_RETRY_DELAY_SECONDS = 30 * can re-register the token whenever the user identifies as someone new. * * The record survives success (kept, not deleted) and non-retryable failures alike, so - * [retryPending] can pick it back up on the next process start. In-session resume: offline - * deferrals re-poll on a timer, and [retryPending] is also invoked from `flush()` (which the - * Android SDK calls on app background), so an undelivered record doesn't wait for a relaunch. + * [retryPending] can pick it back up on the next process start. In-session resume: [retryPending] + * is invoked from `flush()` (which the Android SDK calls on app background), so an undelivered + * record doesn't wait for a relaunch. An offline deferral schedules no timer — recovery is driven + * by flush()/identify()/relaunch, matching iOS's passive model. + * + * Unregister (logout/reset) is durable the same way: a [PendingUnregister] intent is persisted + * before the DELETE and only cleared on a 2xx or a terminal 4xx, so an offline or failed logout + * is retried on flush()/next launch instead of leaving the device associated with the logged-out + * user. Like registration it carries no backoff machine of its own — [retryPending] replays it. */ internal class PostHogPushSubscriptionManager( private val config: PostHogConfig, @@ -44,6 +51,11 @@ internal class PostHogPushSubscriptionManager( @Volatile private var hydratedFromDisk = false + // Single-slot, last-write-wins: only one identity's unregister can be pending at a time. + @Volatile private var pendingUnregister: PendingUnregister? = null + + @Volatile private var hydratedUnregisterFromDisk = false + @Volatile private var retryCount = 0 @Volatile private var timer: Timer? = null @@ -82,6 +94,11 @@ internal class PostHogPushSubscriptionManager( File(File(File(prefix, "push"), config.apiKey), PENDING_FILE_NAME) } + private val pendingUnregisterFile: File? by lazy { + val prefix = config.storagePrefix ?: return@lazy null + File(File(File(prefix, "push"), config.apiKey), PENDING_UNREGISTER_FILE_NAME) + } + // Test seam: computed backoff seconds are multiplied by this to get the scheduled delay in // millis. Production keeps the real 1000; tests shrink it so retries fire near-instantly. internal var retryDelayMillisPerSecond: Long = 1_000L @@ -123,7 +140,7 @@ internal class PostHogPushSubscriptionManager( val record = PendingRecord(deviceToken, appId, platform) pendingRecord = record hydratedFromDisk = true - pendingFile?.let { writeRecord(it, record) } + pendingFile?.let { writePending(it, record, "Failed to persist push subscription") } retryCount = 0 nextAttemptAtMs = 0L halted = false @@ -134,6 +151,10 @@ internal class PostHogPushSubscriptionManager( fun retryPending() { executor.executeSafely { + // Drain any pending unregister first: independent of the send record (usually absent after + // a logout) and ordered before a re-register so a DELETE can't land after the POST it precedes. + currentPendingUnregister()?.let { performUnregister(it) } + val record = currentRecord() ?: return@executeSafely if (record.deliveredForDistinctId != null && record.deliveredForDistinctId == distinctIdProvider()) { return@executeSafely @@ -169,9 +190,10 @@ internal class PostHogPushSubscriptionManager( } /** - * Best-effort unregister: a single DELETE for [distinctId]. Unlike [register] there is no - * pending record, timer, or backoff — a failure is logged and dropped (the backend also unsets - * a dead token on the next send, and the durable path is the re-register POST). + * Unregister: `DELETE` for [distinctId]. The intent is persisted first, so an offline or failed + * attempt is retried on flush()/next launch (see [PendingUnregister]) rather than dropped — + * otherwise a logout while offline, or with an expired identity token, would leave the device + * associated with the logged-out user on the backend. Cleared on a 2xx or a terminal 4xx. */ fun unregister( distinctId: String, @@ -180,52 +202,85 @@ internal class PostHogPushSubscriptionManager( platform: String, ) { executor.executeSafely { - performUnregister(distinctId, deviceToken, appId, platform) + if (distinctId.isBlank() || deviceToken.isBlank() || appId.isBlank()) { + config.logger.log("Push unregister skipped: missing distinctId, token, or appId.") + return@executeSafely + } + val pending = PendingUnregister(distinctId, deviceToken, appId, platform) + writePendingUnregister(pending) + performUnregister(pending) } } - // 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]. + // Executor-thread body of [unregister]. On a 401 with a provider, re-mints once and retries; + // [isRetry] caps that to one re-mint. [onComplete] runs after the first DELETE round-trip (or a + // skip/offline defer) so callers can chain work that must not race the DELETE — see [handleReset]; + // the 401 re-mint retry does not re-invoke it. private fun performUnregister( - distinctId: String, - deviceToken: String, - appId: String, - platform: String, + pending: PendingUnregister, + isRetry: Boolean = false, onComplete: (() -> Unit)? = null, ) { if (closed || config.optOut) { onComplete?.invoke() return } - if (distinctId.isBlank() || deviceToken.isBlank() || appId.isBlank()) { + if (pending.distinctId.isBlank() || pending.deviceToken.isBlank() || pending.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 (config.networkStatus?.isConnected() == false) { + config.logger.log("Push unregister deferred: no network. Will retry on flush/next launch.") + onComplete?.invoke() + return + } + resolveIdentityToken(pending.distinctId, pending.appId) { identityToken -> if (closed || config.optOut) { onComplete?.invoke() return@resolveIdentityToken } try { api.pushUnsubscription( - distinctId = distinctId, - deviceToken = deviceToken, - platform = platform, - appId = appId, + distinctId = pending.distinctId, + deviceToken = pending.deviceToken, + platform = pending.platform, + appId = pending.appId, identityToken = identityToken, ) + clearPendingUnregister(pending) config.logger.log("Push notification token unregistered successfully.") } catch (e: Throwable) { - config.logger.log("Push unregister failed: $e. Ignoring (best-effort).") + handleUnregisterFailure(e, pending, isRetry) } finally { onComplete?.invoke() } } } + private fun handleUnregisterFailure( + e: Throwable, + pending: PendingUnregister, + isRetry: Boolean, + ) { + val statusCode = (e as? PostHogApiError)?.statusCode + // 401: identity verification failed. Re-mint once and retry, mirroring the send-path 401 refresh. + if (statusCode == 401 && config.pushIdentityProvider != null && !isRetry) { + cachedIdentityToken = null + config.logger.log("Push unregister rejected (401): refreshing identity token and retrying once.") + executor.executeSafely { performUnregister(pending, isRetry = true) } + return + } + // Retryable (transport error, 429, 5xx): keep the intent for flush/next launch. + if (isRetryable(e)) { + config.logger.log("Push unregister failed: $e. Will retry on flush/next launch.") + return + } + // Terminal (post-retry 401, 404 already gone, other 4xx): drop the intent, best-effort ceiling. + clearPendingUnregister(pending) + config.logger.log("Push unregister failed (status ${statusCode ?: "none"}): $e. Dropping (best-effort).") + } + /** * 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 @@ -240,7 +295,9 @@ 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) { + val pending = PendingUnregister(oldDistinctId, record.deviceToken, record.appId, record.platform) + writePendingUnregister(pending) + performUnregister(pending) { performRegister(record.deviceToken, record.appId, record.platform) } } else { @@ -260,8 +317,10 @@ internal class PostHogPushSubscriptionManager( config.logger.log("Push unregister skipped: no registered token.") return@executeSafely } - performUnregister(distinctIdProvider(), record.deviceToken, record.appId, record.platform) + val pending = PendingUnregister(distinctIdProvider(), record.deviceToken, record.appId, record.platform) + writePendingUnregister(pending) clearRecord() + performUnregister(pending) } } @@ -279,7 +338,7 @@ internal class PostHogPushSubscriptionManager( cachedIdentityToken = null } - /** Opt-out: stop the retry/offline-poll timer now. The guard in [attempt] blocks any actual send. */ + /** Opt-out: stop the retry timer now. The guard in [attempt] blocks any actual send. */ fun onOptOut() { cancelTimer() // Order both clears on the executor with the mint-completion cache write so an opt-out @@ -311,9 +370,8 @@ internal class PostHogPushSubscriptionManager( val record = currentRecord() ?: return if (config.networkStatus?.isConnected() == false) { config.logger.log("Push subscription deferred: no network.") - // Deferral burns no retry attempt; poll again so registration resumes - // within the session once connectivity returns. - scheduleRetry(MAX_RETRY_DELAY_SECONDS) + // Deferral burns no retry attempt and schedules no timer; recovery is driven by + // flush()/identify()/relaunch, matching iOS's passive model. return } @@ -370,7 +428,11 @@ internal class PostHogPushSubscriptionManager( // Keep the record with the delivered marker so a later identify() can re-register. val delivered = record.copy(deliveredForDistinctId = distinctId) pendingRecord = delivered - pendingFile?.let { writeRecord(it, delivered) } + pendingFile?.let { writePending(it, delivered, "Failed to persist push subscription") } + // A fresh registration delivered to this identity supersedes any queued logout-DELETE for + // it (log out of A, then back into A): otherwise the next retryPending() drain would + // unregister the subscription we just re-registered. + clearPendingUnregister(PendingUnregister(distinctId, record.deviceToken, record.appId, record.platform)) } catch (e: Throwable) { handleFailure(e) } finally { @@ -562,7 +624,7 @@ internal class PostHogPushSubscriptionManager( hydratedFromDisk = true pendingFile?.takeIf { it.existsSafely(config) }?.let { file -> pendingRecord = - readRecord(file) ?: run { + readPending(file, "Failed to read pending push subscription") ?: run { file.deleteSafely(config) null } @@ -571,29 +633,64 @@ internal class PostHogPushSubscriptionManager( return pendingRecord } - private fun writeRecord( + private fun currentPendingUnregister(): PendingUnregister? { + if (pendingUnregister == null && !hydratedUnregisterFromDisk) { + hydratedUnregisterFromDisk = true + pendingUnregisterFile?.takeIf { it.existsSafely(config) }?.let { file -> + pendingUnregister = + readPending(file, "Failed to read pending push unregister") ?: run { + file.deleteSafely(config) + null + } + } + } + return pendingUnregister + } + + private fun writePendingUnregister(pending: PendingUnregister) { + pendingUnregister = pending + hydratedUnregisterFromDisk = true + pendingUnregisterFile?.let { writePending(it, pending, "Failed to persist push unregister") } + } + + // Clears the intent only if it's still the one that just resolved — a newer unregister (a second + // logout while this DELETE was in flight) may have overwritten the slot, and its intent must not + // be dropped by this stale completion. + private fun clearPendingUnregister(matching: PendingUnregister) { + if (currentPendingUnregister() != matching) { + return + } + pendingUnregister = null + pendingUnregisterFile?.deleteSafely(config) + } + + private inline fun writePending( file: File, - record: PendingRecord, + value: T, + failureLog: String, ) { try { file.parentFile?.mkdirs() val os = config.encryption?.encrypt(file.outputStream()) ?: file.outputStream() os.use { theOutputStream -> - config.serializer.serialize(record, theOutputStream.writer().buffered()) + config.serializer.serialize(value, theOutputStream.writer().buffered()) } } catch (e: Throwable) { - config.logger.log("Failed to persist push subscription: $e.") + config.logger.log("$failureLog: $e.") } } - private fun readRecord(file: File): PendingRecord? { + private inline fun readPending( + file: File, + failureLog: String, + ): T? { return try { val input = config.encryption?.decrypt(file.inputStream()) ?: file.inputStream() input.use { - config.serializer.deserialize(it.reader().buffered()) + config.serializer.deserialize(it.reader().buffered()) } } catch (e: Throwable) { - config.logger.log("Failed to read pending push subscription: $e.") + config.logger.log("$failureLog: $e.") null } } @@ -608,6 +705,16 @@ internal class PostHogPushSubscriptionManager( val deliveredForDistinctId: String? = null, ) + internal data class PendingUnregister( + @SerializedName("distinct_id") + val distinctId: String, + @SerializedName("device_token") + val deviceToken: String, + @SerializedName("app_id") + val appId: String, + val platform: String, + ) + private data class CachedIdentityToken( val token: String, val distinctId: String, diff --git a/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt index ce0654895..8f19549a0 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt @@ -59,6 +59,9 @@ internal class PostHogPushSubscriptionManagerTest { private fun pendingFile(storagePrefix: String): File = File(File(File(storagePrefix, "push"), API_KEY), "push_subscription.pending") + private fun pendingUnregisterFile(storagePrefix: String): File = + File(File(File(storagePrefix, "push"), API_KEY), "push_subscription.unregister.pending") + private fun flush() { executor.submit {}.get() } @@ -67,6 +70,18 @@ internal class PostHogPushSubscriptionManagerTest { config: PostHogConfig, file: File, ): PostHogPushSubscriptionManager.PendingRecord? { + if (!file.exists()) return null + val input = config.encryption?.decrypt(file.inputStream()) ?: file.inputStream() + return input.use { + config.serializer.deserialize(it.reader().buffered()) + } + } + + private fun readUnregister( + config: PostHogConfig, + file: File, + ): PostHogPushSubscriptionManager.PendingUnregister? { + if (!file.exists()) return null val input = config.encryption?.decrypt(file.inputStream()) ?: file.inputStream() return input.use { config.serializer.deserialize(it.reader().buffered()) @@ -108,7 +123,7 @@ internal class PostHogPushSubscriptionManagerTest { } @Test - fun `register resumes via the offline poll once connectivity returns`() { + fun `register resumes on retryPending once connectivity returns, without an offline poll`() { val http = mockHttp() var connected = false val network = @@ -122,11 +137,15 @@ internal class PostHogPushSubscriptionManagerTest { flush() assertEquals(0, http.requestCount) + // Passive model (parity with iOS): the offline deferral schedules no timer, so nothing is + // sent even after connectivity returns until a resume trigger fires. connected = true + assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) - // The offline deferral scheduled a poll without burning a retry attempt. + // flush()/relaunch drives recovery via retryPending(). + sut.retryPending() assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) - flush() // let the attempt finish writing the delivered marker + flush() assertEquals(1, http.requestCount) assertEquals("distinct-1", readRecord(config, pendingFile(storagePrefix!!))?.deliveredForDistinctId) } @@ -437,10 +456,10 @@ internal class PostHogPushSubscriptionManagerTest { } @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) + fun `unregister DELETE refreshes the identity token once on a 401`() { + // Durable parity with the send path: a 401 re-mints and retries the DELETE exactly once. + val http = mockHttp(total = 3, response = MockResponse().setResponseCode(401)) + val (sut, config, storagePrefix) = getSut(http) val minted = java.util.concurrent.atomic.AtomicInteger(0) config.pushIdentityProvider = { _, _, completion -> completion("jwt-${minted.incrementAndGet()}") } @@ -448,9 +467,58 @@ internal class PostHogPushSubscriptionManagerTest { flush() assertEquals("DELETE", http.takeRequest().method) + // One fresh-token retry after the 401, then terminal — no infinite loop. + assertEquals("DELETE", http.takeRequest(2, TimeUnit.SECONDS)!!.method) assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) - assertEquals(1, http.requestCount) - assertEquals(1, minted.get()) + assertEquals(2, http.requestCount) + assertEquals(2, minted.get()) + // Post-retry 401 is terminal: the intent is dropped (best-effort ceiling), not left to loop. + assertNull(readUnregister(config, pendingUnregisterFile(storagePrefix!!))) + } + + @Test + fun `unregister persists the intent and replays it on retryPending after a transient failure`() { + val http = mockHttp(total = 1, response = MockResponse().setResponseCode(503)) + val (sut, config, storagePrefix) = getSut(http) + sut.retryDelayMillisPerSecond = 1L + + sut.unregister("distinct-1", "fcm-token", "firebase-project", "android") + flush() + + // 503 is retryable: the DELETE fired but the intent is kept for a later drain. + assertEquals("DELETE", http.takeRequest().method) + assertNotNull(readUnregister(config, pendingUnregisterFile(storagePrefix!!))) + + // A later flush()/relaunch replays it; server now accepts, intent cleared. + http.enqueue(MockResponse().setBody("")) + sut.retryPending() + flush() + assertEquals("DELETE", http.takeRequest(2, TimeUnit.SECONDS)!!.method) + assertNull(readUnregister(config, pendingUnregisterFile(storagePrefix!!))) + } + + @Test + fun `unregister while offline defers the DELETE and replays it on retryPending`() { + val http = mockHttp() + var connected = false + val network = + object : PostHogNetworkStatus { + override fun isConnected() = connected + } + val (sut, config, storagePrefix) = getSut(http, networkStatus = network) + + sut.unregister("distinct-1", "fcm-token", "firebase-project", "android") + flush() + + // Offline: nothing sent, but the intent is persisted so the logout isn't dropped. + assertEquals(0, http.requestCount) + assertNotNull(readUnregister(config, pendingUnregisterFile(storagePrefix!!))) + + connected = true + sut.retryPending() + flush() + assertEquals("DELETE", http.takeRequest(2, TimeUnit.SECONDS)!!.method) + assertNull(readUnregister(config, pendingUnregisterFile(storagePrefix!!))) } @Test From 33531ab582e8ff27539cd600197f3219ea2da79e Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 30 Jul 2026 09:07:03 +0300 Subject: [PATCH 04/14] refactor(push): un-chain reset DELETE and re-register (order-independent, parity with iOS) --- .../PostHogPushSubscriptionManager.kt | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt index 04e407fbf..6b18e47b4 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt @@ -119,8 +119,7 @@ internal class PostHogPushSubscriptionManager( } } - // Executor-thread body of [register]. Kept separate so [handleReset] can chain a DELETE and a - // re-register in a single executor task (ordered, and drained by one flush in tests). + // Executor-thread body of [register], shared with [handleReset]'s re-register leg. private fun performRegister( deviceToken: String, appId: String, @@ -213,31 +212,24 @@ internal class PostHogPushSubscriptionManager( } // Executor-thread body of [unregister]. On a 401 with a provider, re-mints once and retries; - // [isRetry] caps that to one re-mint. [onComplete] runs after the first DELETE round-trip (or a - // skip/offline defer) so callers can chain work that must not race the DELETE — see [handleReset]; - // the 401 re-mint retry does not re-invoke it. + // [isRetry] caps that to one re-mint. private fun performUnregister( pending: PendingUnregister, isRetry: Boolean = false, - onComplete: (() -> Unit)? = null, ) { if (closed || config.optOut) { - onComplete?.invoke() return } if (pending.distinctId.isBlank() || pending.deviceToken.isBlank() || pending.appId.isBlank()) { config.logger.log("Push unregister skipped: missing distinctId, token, or appId.") - onComplete?.invoke() return } if (config.networkStatus?.isConnected() == false) { config.logger.log("Push unregister deferred: no network. Will retry on flush/next launch.") - onComplete?.invoke() return } resolveIdentityToken(pending.distinctId, pending.appId) { identityToken -> if (closed || config.optOut) { - onComplete?.invoke() return@resolveIdentityToken } try { @@ -252,8 +244,6 @@ internal class PostHogPushSubscriptionManager( config.logger.log("Push notification token unregistered successfully.") } catch (e: Throwable) { handleUnregisterFailure(e, pending, isRetry) - } finally { - onComplete?.invoke() } } } @@ -284,8 +274,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. 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. + * is stored. The two legs are independent — the backend keys subscriptions per (person, app_id), + * so the old-id DELETE and new-id POST can't affect each other in any order (matches iOS). */ fun handleReset(oldDistinctId: String) { executor.executeSafely { @@ -297,12 +287,9 @@ internal class PostHogPushSubscriptionManager( if (oldDistinctId != distinctIdProvider()) { val pending = PendingUnregister(oldDistinctId, record.deviceToken, record.appId, record.platform) writePendingUnregister(pending) - performUnregister(pending) { - performRegister(record.deviceToken, record.appId, record.platform) - } - } else { - performRegister(record.deviceToken, record.appId, record.platform) + performUnregister(pending) } + performRegister(record.deviceToken, record.appId, record.platform) } } From fc5d6285e7defd8453ebe265200449db95e2d9f5 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 30 Jul 2026 15:10:08 +0300 Subject: [PATCH 05/14] fix(push): treat unclassified send errors as retryable --- .../com/posthog/internal/PostHogPushSubscriptionManager.kt | 5 +++-- 1 file changed, 3 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 6b18e47b4..6a454b9fc 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt @@ -601,8 +601,9 @@ internal class PostHogPushSubscriptionManager( private fun isRetryable(e: Throwable): Boolean { return when (e) { is PostHogApiError -> e.statusCode == 429 || e.statusCode in 500..599 - is IOException -> true - else -> false + // Anything without an HTTP status (transport failures, unexpected throwables) is + // retryable — unknown means keep trying. + else -> true } } From fae8c2bd4067567f8ede72722ba1f910b590be86 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 30 Jul 2026 15:18:03 +0300 Subject: [PATCH 06/14] feat(push): passive failure retry without timer, persistent backoff ladder --- .../PostHogPushSubscriptionManager.kt | 69 ++++++------------- .../PostHogPushSubscriptionManagerTest.kt | 20 ++++-- 2 files changed, 37 insertions(+), 52 deletions(-) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt index 6a454b9fc..33caba4dd 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt @@ -42,7 +42,6 @@ internal class PostHogPushSubscriptionManager( private val executor: ExecutorService, private val distinctIdProvider: () -> String, ) { - private val timerLock = Any() private val isSending = AtomicBoolean(false) // Authoritative record within a process; disk is only the cross-launch backing store, @@ -58,11 +57,10 @@ internal class PostHogPushSubscriptionManager( @Volatile private var retryCount = 0 - @Volatile private var timer: Timer? = null - // Not-before gate for server-driven backoff (Retry-After / 429 / 5xx): while now < nextAttemptAtMs - // the scheduled timer owns the next send, so resume paths (flush/identify) must not cancel it and - // re-hit the endpoint early. Zero when no backoff window is active. + // resume paths (flush/identify) must not re-hit the endpoint early. No timer is scheduled; the + // next attempt happens when flush()/identify()/relaunch calls in after the window elapses. + // Zero when no backoff window is active. @Volatile private var nextAttemptAtMs: Long = 0L // Set after a non-retryable failure or once retries are exhausted: no more attempts this session, @@ -144,7 +142,6 @@ internal class PostHogPushSubscriptionManager( nextAttemptAtMs = 0L halted = false didAuthRetry = false - cancelTimer() attempt() } @@ -159,11 +156,11 @@ internal class PostHogPushSubscriptionManager( return@executeSafely } if (isWithinBackoffWindow()) { - // A Retry-After/backoff retry is already scheduled; let it fire instead of re-hitting now. + // Honor the active Retry-After/backoff window; a later trigger retries once it elapses. return@executeSafely } - retryCount = 0 - cancelTimer() + // retryCount deliberately not reset: the exponential ladder persists across triggers, so + // repeated failures still exhaust maxRetries and halt for the session. attempt() } } @@ -172,18 +169,17 @@ internal class PostHogPushSubscriptionManager( executor.executeSafely { val record = currentRecord() ?: return@executeSafely val currentDistinctId = distinctIdProvider() - if (currentDistinctId.isBlank() || record.deliveredForDistinctId == currentDistinctId) { - return@executeSafely - } - if (isWithinBackoffWindow()) { - // Honor the active Retry-After/backoff window; the scheduled timer reads the current - // distinctId at send time, so the id change is still picked up when it fires. + // Only a record already delivered to a DIFFERENT id is fresh state; an undelivered + // record keeps its backoff/halt and is driven by retryPending() instead. + val delivered = record.deliveredForDistinctId + if (currentDistinctId.isBlank() || delivered == null || delivered == currentDistinctId) { return@executeSafely } + // An identity change is fresh state: clear any backoff window and retry immediately. retryCount = 0 + nextAttemptAtMs = 0L halted = false didAuthRetry = false - cancelTimer() attempt() } } @@ -314,20 +310,17 @@ internal class PostHogPushSubscriptionManager( private fun clearRecord() { pendingRecord = null hydratedFromDisk = true - cancelTimer() pendingFile?.deleteSafely(config) } fun close() { closed = true - cancelTimer() retryCount = 0 cachedIdentityToken = null } - /** Opt-out: stop the retry timer now. The guard in [attempt] blocks any actual send. */ + /** Opt-out: the guard in [attempt] blocks any actual send. */ fun onOptOut() { - cancelTimer() // 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 @@ -344,16 +337,15 @@ internal class PostHogPushSubscriptionManager( private fun attempt() { if (closed || config.optOut) { // Opt-out and shutdown both stop every send. Guarding at this single choke point covers - // all callers: register, startup/flush retryPending, identify resend, and the retry timer. - cancelTimer() + // all callers: register, startup/flush retryPending, and identify resend. return } if (halted) { // Session halt set in handleFailure; this choke point makes flush()-driven retryPending() a no-op. return } - // Read the record here, not from the caller: a retry timer may fire after unregisterCurrent() - // cleared it (cancelTimer can't un-queue an already-fired callback), so a null means "don't send". + // Read the record here, not from the caller: an already-queued executor task can run after + // unregisterCurrent() cleared it, so a null means "don't send". val record = currentRecord() ?: return if (config.networkStatus?.isConnected() == false) { config.logger.log("Push subscription deferred: no network.") @@ -443,7 +435,6 @@ internal class PostHogPushSubscriptionManager( nextAttemptAtMs = 0L halted = false didAuthRetry = false - cancelTimer() attempt() } @@ -489,11 +480,13 @@ internal class PostHogPushSubscriptionManager( } val delay = nextBackoffSeconds(retryCount, (e as? PostHogApiError)?.retryAfterSeconds) - // Server-driven backoff: gate resume paths so flush()/identify() don't cancel this window - // and immediately re-hit the endpoint, ignoring the server's Retry-After. + // Server-driven backoff: gate resume paths so flush() doesn't immediately re-hit the + // endpoint, ignoring the server's Retry-After. No timer — the next attempt is driven by + // flush()/identify()/relaunch once the window elapses. nextAttemptAtMs = System.currentTimeMillis() + delay * retryDelayMillisPerSecond - config.logger.log("Push subscription failed: $e. Retrying in ${delay}s (attempt $retryCount).") - scheduleRetry(delay) + config.logger.log( + "Push subscription failed: $e. Will retry on flush/identify/next launch after ${delay}s (attempt $retryCount).", + ) } // Stop retrying for this process; the persisted record is kept so the next launch (fresh @@ -515,24 +508,6 @@ internal class PostHogPushSubscriptionManager( return min(exponential, MAX_RETRY_DELAY_SECONDS) } - private fun scheduleRetry(delaySeconds: Int) { - synchronized(timerLock) { - cancelTimer() - val t = Timer(true) - t.schedule(delaySeconds * retryDelayMillisPerSecond) { - executor.executeSafely { attempt() } - } - timer = t - } - } - - private fun cancelTimer() { - synchronized(timerLock) { - timer?.cancel() - timer = null - } - } - // 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. diff --git a/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt index 8f19549a0..62c593fb2 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt @@ -216,7 +216,11 @@ internal class PostHogPushSubscriptionManagerTest { sut.register("fcm-token", "firebase-project", "android") assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) // initial 500 - assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) // retry ~5ms -> 200 + // No self-firing timer: nothing retries until an external trigger lands after the window. + assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) + Thread.sleep(50) // backoff window is ~5ms at the test's 1ms/s scale + sut.retryPending() + assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) // flush-driven retry -> 200 assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) // no duplicate assertEquals(2, http.requestCount) @@ -239,14 +243,18 @@ internal class PostHogPushSubscriptionManagerTest { sut.register("fcm-token", "firebase-project", "android") - // Vector 4: 500 -> retry, 500 -> retry, then give up after maxRetries. + // Vector 4: the retry ladder persists across flush-driven triggers, so repeated failures + // exhaust maxRetries: 500, then flush retry 500, flush retry 500, halt. assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) // initial - assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) // retry 1 - assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) // retry 2 - assertNull(http.takeRequest(1, TimeUnit.SECONDS)) // gave up, record kept + repeat(2) { + Thread.sleep(50) // let the ms-scaled backoff window elapse + sut.retryPending() + assertNotNull(http.takeRequest(2, TimeUnit.SECONDS)) + } assertTrue(pendingFile(storagePrefix).exists()) // Halted for the rest of this session: flush()-driven retryPending() must not re-hit the endpoint. + Thread.sleep(50) sut.retryPending() flush() assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) @@ -909,6 +917,8 @@ internal class PostHogPushSubscriptionManagerTest { sut.register("fcm-token", "firebase-project", "android") assertTrue(http.takeRequest(2, TimeUnit.SECONDS)!!.body.unGzip().contains("\"identity_token\":\"jwt-1\"")) + Thread.sleep(50) // let the ms-scaled backoff window elapse + sut.retryPending() assertTrue(http.takeRequest(2, TimeUnit.SECONDS)!!.body.unGzip().contains("\"identity_token\":\"jwt-1\"")) assertEquals(2, http.requestCount) assertEquals(1, minted.get()) From 221bc8520cc8a000c36f1fe8791f10c689075606 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 30 Jul 2026 15:55:49 +0300 Subject: [PATCH 07/14] fix(push): drop pending unregister for the current identity when a registration is queued --- .../PostHogPushSubscriptionManager.kt | 14 +++++++--- .../PostHogPushSubscriptionManagerTest.kt | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt index 33caba4dd..3ede4e1f9 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt @@ -3,7 +3,6 @@ package com.posthog.internal import com.google.gson.annotations.SerializedName import com.posthog.PostHogConfig import java.io.File -import java.io.IOException import java.util.Timer import java.util.concurrent.ExecutorService import java.util.concurrent.atomic.AtomicBoolean @@ -147,9 +146,16 @@ internal class PostHogPushSubscriptionManager( fun retryPending() { executor.executeSafely { - // Drain any pending unregister first: independent of the send record (usually absent after - // a logout) and ordered before a re-register so a DELETE can't land after the POST it precedes. - currentPendingUnregister()?.let { performUnregister(it) } + // Drain any pending unregister first (independent of the send record, usually absent after + // a logout). If a same-identity registration is queued (logged out of A offline, then back + // into A), drop the DELETE — completing after the POST it would kill the subscription just delivered. + currentPendingUnregister()?.let { pending -> + if (currentRecord() != null && pending.distinctId == distinctIdProvider()) { + clearPendingUnregister(pending) + } else { + performUnregister(pending) + } + } val record = currentRecord() ?: return@executeSafely if (record.deliveredForDistinctId != null && record.deliveredForDistinctId == distinctIdProvider()) { diff --git a/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt index 62c593fb2..63712db64 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt @@ -529,6 +529,34 @@ internal class PostHogPushSubscriptionManagerTest { assertNull(readUnregister(config, pendingUnregisterFile(storagePrefix!!))) } + @Test + fun `retryPending drops a pending unregister for the current identity when a registration is queued`() { + val http = mockHttp() + var connected = false + val network = + object : PostHogNetworkStatus { + override fun isConnected() = connected + } + val (sut, config, storagePrefix) = getSut(http, networkStatus = network) + + // Log out of distinct-1 while offline, then re-register for the same identity. + sut.unregister("distinct-1", "fcm-token", "firebase-project", "android") + sut.register("fcm-token", "firebase-project", "android") + flush() + assertEquals(0, http.requestCount) + assertNotNull(readUnregister(config, pendingUnregisterFile(storagePrefix!!))) + + connected = true + sut.retryPending() + flush() + + // Register supersedes the queued DELETE: only the POST goes out, the intent is dropped. + val request = http.takeRequest(2, TimeUnit.SECONDS)!! + assertEquals("POST", request.method) + assertEquals(1, http.requestCount) + assertNull(readUnregister(config, pendingUnregisterFile(storagePrefix))) + } + @Test fun `handleReset unregisters the old identity then re-registers under the new anonymous id`() { val http = mockHttp(total = 3, response = MockResponse().setBody("")) From 1396f32fa54554ab9e0cd5e44ad356eb8256ac89 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 30 Jul 2026 16:15:32 +0300 Subject: [PATCH 08/14] style: apply spotless formatting --- .../PostHogActivityLifecycleCallbackIntegrationTest.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt index bf8704e92..3b25e4672 100644 --- a/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt @@ -18,9 +18,8 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify -import java.util.concurrent.CountDownLatch - import org.mockito.kotlin.whenever +import java.util.concurrent.CountDownLatch import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals From b310c1079713b211d63c37315a66322e3f474e74 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 30 Jul 2026 16:15:32 +0300 Subject: [PATCH 09/14] chore: add changeset for push subscription reliability fixes --- .changeset/brave-otters-listen.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/brave-otters-listen.md diff --git a/.changeset/brave-otters-listen.md b/.changeset/brave-otters-listen.md new file mode 100644 index 000000000..952b19b41 --- /dev/null +++ b/.changeset/brave-otters-listen.md @@ -0,0 +1,6 @@ +--- +"posthog-android": patch +"posthog": patch +--- + +Make push subscription delivery more reliable: retries are driven passively by flush, identify, and app launch (no self-firing timer) with a backoff ladder that persists across triggers; unclassified send errors are treated as retryable; and a queued logout DELETE is dropped when the same identity re-registers, so it can no longer cancel the fresh subscription. From 078f02d3bc7b6b100e46fa8da6a7d40dea7612f2 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 30 Jul 2026 16:37:28 +0300 Subject: [PATCH 10/14] chore: collapse push changesets into the single feature changeset --- .changeset/brave-otters-listen.md | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 .changeset/brave-otters-listen.md diff --git a/.changeset/brave-otters-listen.md b/.changeset/brave-otters-listen.md deleted file mode 100644 index 952b19b41..000000000 --- a/.changeset/brave-otters-listen.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"posthog-android": patch -"posthog": patch ---- - -Make push subscription delivery more reliable: retries are driven passively by flush, identify, and app launch (no self-firing timer) with a backoff ladder that persists across triggers; unclassified send errors are treated as retryable; and a queued logout DELETE is dropped when the same identity re-registers, so it can no longer cancel the fresh subscription. From af5c462ddf12914f750859acc820f65754cc5602 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 30 Jul 2026 21:34:13 +0300 Subject: [PATCH 11/14] fix(push): re-check identity and supersede rules around the identity token mint --- .../main/java/com/posthog/PostHogInterface.kt | 9 +- .../PostHogPushSubscriptionManager.kt | 50 ++++++++-- .../PostHogPushSubscriptionManagerTest.kt | 97 ++++++++++++++++++- 3 files changed, 144 insertions(+), 12 deletions(-) diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index 3b2b9c397..c9a43b0b3 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -433,10 +433,11 @@ public interface PostHogInterface : PostHogCoreInterface { * Unregisters this device's push token from PostHog so Workflows stop targeting it — for example * from your logout flow. * - * Sends a best-effort `DELETE /api/push_subscriptions` for the current [distinctId] (the backend - * unsets the subscription property) and forgets the locally stored token. Unlike registration - * this is not retried. On [reset] the SDK already moves any registered token to the new anonymous - * identity (unregister then re-register), independently of `capturePushNotificationSubscriptions` — + * Sends a `DELETE /api/push_subscriptions` for the current [distinctId] (the backend unsets the + * subscription property) and forgets the locally stored token. The DELETE intent is durable: an + * offline or transiently failed attempt is persisted and retried passively on the next flush or + * app launch, the same way registration is. On [reset] the SDK already moves any registered token + * to the new anonymous identity (unregister then re-register), independently of `capturePushNotificationSubscriptions` — * that flag only gates automatic token subscription at startup. Call it directly if you manage * push subscriptions yourself. */ diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt index 3ede4e1f9..77c6ab626 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt @@ -133,14 +133,24 @@ internal class PostHogPushSubscriptionManager( // Same token already delivered for this user; don't re-POST it on every cold start. return } + // Same (token, appId) as the current undelivered record: this is register spam (e.g. FCM + // redelivering onNewToken), not a genuinely new registration. Keep the existing retry state + // so it can't be used to defeat backoff by re-registering on a loop. + val isIdenticalUndelivered = + existing != null && + existing.deviceToken == deviceToken && + existing.appId == appId && + existing.platform == platform val record = PendingRecord(deviceToken, appId, platform) pendingRecord = record hydratedFromDisk = true pendingFile?.let { writePending(it, record, "Failed to persist push subscription") } - retryCount = 0 - nextAttemptAtMs = 0L - halted = false - didAuthRetry = false + if (!isIdenticalUndelivered) { + retryCount = 0 + nextAttemptAtMs = 0L + halted = false + didAuthRetry = false + } attempt() } @@ -150,7 +160,8 @@ internal class PostHogPushSubscriptionManager( // a logout). If a same-identity registration is queued (logged out of A offline, then back // into A), drop the DELETE — completing after the POST it would kill the subscription just delivered. currentPendingUnregister()?.let { pending -> - if (currentRecord() != null && pending.distinctId == distinctIdProvider()) { + val record = currentRecord() + if (record != null && pending.distinctId == distinctIdProvider() && pending.appId == record.appId) { clearPendingUnregister(pending) } else { performUnregister(pending) @@ -234,6 +245,18 @@ internal class PostHogPushSubscriptionManager( if (closed || config.optOut) { return@resolveIdentityToken } + // A same-identity same-app registration can queue and even complete while this DELETE's + // identity token was minting; firing now would kill the subscription just re-registered. + // Mirrors the drain-time supersede rule in retryPending. + val currentPending = currentPendingUnregister() + if (currentPending != pending) { + return@resolveIdentityToken + } + val record = currentRecord() + if (record != null && pending.distinctId == distinctIdProvider() && pending.appId == record.appId) { + clearPendingUnregister(pending) + return@resolveIdentityToken + } try { api.pushUnsubscription( distinctId = pending.distinctId, @@ -268,7 +291,16 @@ internal class PostHogPushSubscriptionManager( config.logger.log("Push unregister failed: $e. Will retry on flush/next launch.") return } - // Terminal (post-retry 401, 404 already gone, other 4xx): drop the intent, best-effort ceiling. + // A non-retryable 401 (no provider to mint with, or the one re-mint already burned) means we + // couldn't prove identity this session — not that the logout intent is invalid. Dropping it + // here would permanently strand the device on the logged-out user. Keep the intent persisted; + // retryPending() re-attempts with a fresh mint on the next flush/relaunch, mirroring the send + // path's haltForSession semantics. + if (statusCode == 401) { + config.logger.log("Push unregister failed (401): $e. Keeping intent for next launch.") + return + } + // Genuinely permanent (400/403/404/other 4xx): drop the intent, best-effort ceiling. clearPendingUnregister(pending) config.logger.log("Push unregister failed (status ${statusCode ?: "none"}): $e. Dropping (best-effort).") } @@ -399,6 +431,12 @@ internal class PostHogPushSubscriptionManager( config.logger.log("Push subscription send skipped: record changed during identity token mint.") return } + // The identity itself can also change during the async mint (logout/login while minting); + // a stale identity's token must not be sent under a new user's distinctId. + if (distinctIdProvider() != distinctId) { + config.logger.log("Push subscription send skipped: distinctId changed during identity token mint.") + return + } api.pushSubscription( distinctId = distinctId, deviceToken = record.deviceToken, diff --git a/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt index 63712db64..9ac08db10 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt @@ -480,8 +480,9 @@ internal class PostHogPushSubscriptionManagerTest { assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) assertEquals(2, http.requestCount) assertEquals(2, minted.get()) - // Post-retry 401 is terminal: the intent is dropped (best-effort ceiling), not left to loop. - assertNull(readUnregister(config, pendingUnregisterFile(storagePrefix!!))) + // Post-retry 401 stops for this session but keeps the intent: dropping it here would + // permanently strand the device on the logged-out user. retryPending() re-attempts later. + assertNotNull(readUnregister(config, pendingUnregisterFile(storagePrefix!!))) } @Test @@ -1080,6 +1081,98 @@ internal class PostHogPushSubscriptionManagerTest { assertEquals(1, http.requestCount) } + @Test + fun `performSend skips when distinctId changes during identity token mint`() { + val http = mockHttp(total = 1, 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", "firebase-project", "android") + flush() + + // Identity changes (login/logout) while the token minted for the original user is still + // outstanding. + distinctId = "distinct-2" + + val completion = mints.poll(2, TimeUnit.SECONDS)!! + completion("jwt-stale") + flush() + + // The stale send must bail: nothing is posted under the new user's distinctId for a token + // minted under the old one. + assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) + } + + @Test + fun `retryPending still fires the DELETE for a different appId even when a same-identity registration is queued`() { + val http = mockHttp(total = 2, response = MockResponse().setBody("")) + var connected = false + val network = + object : PostHogNetworkStatus { + override fun isConnected() = connected + } + val (sut, config, storagePrefix) = getSut(http, networkStatus = network) + + // Log out of app-a while offline, then register for app-b under the same identity. + sut.unregister("distinct-1", "fcm-token-a", "app-a", "android") + sut.register("fcm-token-b", "app-b", "android") + flush() + assertEquals(0, http.requestCount) + assertNotNull(readUnregister(config, pendingUnregisterFile(storagePrefix!!))) + + connected = true + sut.retryPending() + flush() + + // Different appId: the backend keys subscriptions per (person, app_id), so app-a's DELETE + // is independent of the app-b registration and must still fire — unlike the same-appId case. + val delete = http.takeRequest(2, TimeUnit.SECONDS)!! + assertEquals("DELETE", delete.method) + assertNull(readUnregister(config, pendingUnregisterFile(storagePrefix))) + } + + @Test + fun `unregister keeps the intent on a 401 with no identity provider and retryPending re-attempts`() { + val http = mockHttp(total = 1, response = MockResponse().setResponseCode(401)) + val (sut, config, storagePrefix) = getSut(http) + // No pushIdentityProvider configured: the 401 can't be re-minted against. + + sut.unregister("distinct-1", "fcm-token", "firebase-project", "android") + flush() + + assertEquals("DELETE", http.takeRequest().method) + // Stops for this session, but the logout intent survives so a later launch can retry once + // identity can be proven — dropping it here would permanently strand the device. + assertNotNull(readUnregister(config, pendingUnregisterFile(storagePrefix!!))) + + http.enqueue(MockResponse().setBody("")) + sut.retryPending() + flush() + + assertEquals("DELETE", http.takeRequest(2, TimeUnit.SECONDS)!!.method) + assertNull(readUnregister(config, pendingUnregisterFile(storagePrefix))) + } + + @Test + fun `register does not reset backoff when the incoming registration is identical to the current undelivered record`() { + val http = mockHttp(total = 1, response = MockResponse().setResponseCode(500)) + val (sut, _, _) = getSut(http, maxRetries = 0) + + sut.register("fcm-token", "firebase-project", "android") + flush() + assertEquals("POST", http.takeRequest().method) + + // maxRetries is 0, so the single failure above already halted the session. + // Re-registering the exact same (token, appId, platform) must not clear that halt — it's + // register spam (e.g. FCM redelivering onNewToken), not a genuinely new registration. + sut.register("fcm-token", "firebase-project", "android") + flush() + + 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 73e1817634364be9be76bc37a7f92a9466156e6e Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 30 Jul 2026 22:26:15 +0300 Subject: [PATCH 12/14] fix(push): address platform-specific review findings (host safety, opt-out hydration, mid-send fold halt) --- .changeset/quirky-melons-jog.md | 2 +- ...HogActivityLifecycleCallbackIntegration.kt | 5 +- .../PostHogPushSubscriptionIntegration.kt | 56 +++++-- .../PostHogPushSubscriptionIntegrationTest.kt | 44 ++++- posthog/src/main/java/com/posthog/PostHog.kt | 14 +- .../PostHogPushSubscriptionManager.kt | 38 +++-- .../PostHogPushSubscriptionManagerTest.kt | 156 ++++++++++++++++++ 7 files changed, 288 insertions(+), 27 deletions(-) diff --git a/.changeset/quirky-melons-jog.md b/.changeset/quirky-melons-jog.md index bcb6638c5..090ea3f2a 100644 --- a/.changeset/quirky-melons-jog.md +++ b/.changeset/quirky-melons-jog.md @@ -3,4 +3,4 @@ "posthog": minor --- -Add push notification support for PostHog Workflows. Register device push tokens with `registerPushNotificationToken(...)` (auto-registered from Firebase Cloud Messaging when `firebase-messaging` is on the classpath and `capturePushNotificationSubscriptions` is enabled), and capture opens with `capturePushNotificationOpened(...)` (cold-start tray taps are auto-detected when `capturePushNotificationOpened` is enabled). On `reset()` the token is unregistered for the logged-out user and re-registered under the new anonymous id, and `unregisterPushNotificationToken()` lets you unregister manually. +Add push notification support for PostHog Workflows. Register device push tokens with `registerPushNotificationToken(...)` (auto-registered from Firebase Cloud Messaging when `firebase-messaging` is on the classpath), and capture opens with `capturePushNotificationOpened(...)` (cold-start tray taps are auto-detected). Both are on by default and can be turned off with the new `capturePushNotificationSubscriptions` and `capturePushNotificationOpened` config flags. On `reset()` the token is unregistered for the logged-out user and re-registered under the new anonymous id, and `unregisterPushNotificationToken()` lets you unregister manually. diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegration.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegration.kt index 5b0ed7df2..87b312bfe 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegration.kt @@ -83,12 +83,15 @@ internal class PostHogActivityLifecycleCallbackIntegration( if (messageId == lastHandledPushMessageId) { return } + // Read the risky full Bundle before marking handled: if toMap() throws, the id must + // stay unmarked so a later activity (e.g. a trampoline) with a clean Bundle can retry. + val payload = intent.extras?.toMap() lastHandledPushMessageId = messageId postHog?.capturePushNotificationOpened( title = null, body = null, - payload = intent.extras?.toMap(), + payload = payload, ) } catch (e: Throwable) { config.logger.log("Failed to capture push notification opened: $e.") diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogPushSubscriptionIntegration.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogPushSubscriptionIntegration.kt index 1a9d89c01..17accdbd5 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/PostHogPushSubscriptionIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/PostHogPushSubscriptionIntegration.kt @@ -5,6 +5,11 @@ import com.google.firebase.messaging.FirebaseMessaging import com.posthog.PostHogIntegration import com.posthog.PostHogInterface import com.posthog.android.PostHogAndroidConfig +import com.posthog.internal.PostHogThreadFactory +import com.posthog.internal.executeSafely +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean /** * Auto-registers this device's push token at startup so PostHog Workflows can deliver push @@ -19,18 +24,43 @@ import com.posthog.android.PostHogAndroidConfig internal class PostHogPushSubscriptionIntegration( private val config: PostHogAndroidConfig, private val tokenFetcher: PushTokenFetcher = FirebasePushTokenFetcher(config), + // Mirrors PostHogReplayIntegration's injectable executor: install() runs on setup()'s caller + // thread (typically main, inside Application.onCreate()), and FirebaseMessaging's first-touch + // init is synchronous, so the fetch is hopped off-thread. Tests inject a synchronous executor. + private val executor: ExecutorService = Executors.newSingleThreadExecutor(PostHogThreadFactory("PostHogPushSub")), ) : PostHogIntegration { private var postHog: PostHogInterface? = null + private var ownsInstallation = false + private companion object { + private val integrationInstalled = AtomicBoolean(false) + } + + @Synchronized override fun install(postHog: PostHogInterface) { + if (!integrationInstalled.compareAndSet(false, true)) { + return + } + ownsInstallation = true this.postHog = postHog - tokenFetcher.fetchToken { token, appId -> - this.postHog?.registerPushNotificationToken(token, appId) + executor.executeSafely { + tokenFetcher.fetchToken { token, appId -> + this.postHog?.registerPushNotificationToken(token, appId) + } } } + @Synchronized override fun uninstall() { - postHog = null + if (!ownsInstallation) { + return + } + try { + postHog = null + } finally { + ownsInstallation = false + integrationInstalled.set(false) + } } } @@ -90,15 +120,21 @@ internal class FirebasePushTokenFetcher( try { FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> - if (task.isSuccessful) { - val token = task.result - if (!token.isNullOrBlank()) { - onToken(token, projectId) + // The completion runs later, on Play Services' Tasks executor — outside this + // try/catch's stack frame, so an uncaught throw here would crash the host app. + try { + if (task.isSuccessful) { + val token = task.result + if (!token.isNullOrBlank()) { + onToken(token, projectId) + } else { + config.logger.log("Firebase returned a blank push token, skipping registration.") + } } else { - config.logger.log("Firebase returned a blank push token, skipping registration.") + config.logger.log("Failed to fetch Firebase push token: ${task.exception}.") } - } else { - config.logger.log("Failed to fetch Firebase push token: ${task.exception}.") + } catch (e: Throwable) { + config.logger.log("Failed to handle Firebase push token completion: $e.") } } } catch (e: Throwable) { diff --git a/posthog-android/src/test/java/com/posthog/android/internal/PostHogPushSubscriptionIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/PostHogPushSubscriptionIntegrationTest.kt index 821d6c307..1b017cab4 100644 --- a/posthog-android/src/test/java/com/posthog/android/internal/PostHogPushSubscriptionIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/internal/PostHogPushSubscriptionIntegrationTest.kt @@ -16,12 +16,32 @@ import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import java.util.concurrent.AbstractExecutorService +import java.util.concurrent.TimeUnit import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue +/** Runs submitted tasks inline so install()'s async token fetch is deterministic in tests. */ +private class SameThreadExecutorService : AbstractExecutorService() { + override fun execute(command: Runnable) = command.run() + + override fun shutdown() {} + + override fun shutdownNow(): MutableList = mutableListOf() + + override fun isShutdown() = false + + override fun isTerminated() = false + + override fun awaitTermination( + timeout: Long, + unit: TimeUnit, + ) = true +} + @RunWith(AndroidJUnit4::class) internal class PostHogPushSubscriptionIntegrationTest { private class CapturingLogger : PostHogLogger { @@ -43,7 +63,7 @@ internal class PostHogPushSubscriptionIntegrationTest { fun `install registers the fetched token and app id`() { val config = PostHogAndroidConfig(API_KEY) val fetcher = PushTokenFetcher { onToken -> onToken("fcm-token", "firebase-project") } - val sut = PostHogPushSubscriptionIntegration(config, fetcher) + val sut = PostHogPushSubscriptionIntegration(config, fetcher, SameThreadExecutorService()) val fake = createPostHogFake() sut.install(fake) @@ -55,11 +75,31 @@ internal class PostHogPushSubscriptionIntegrationTest { sut.uninstall() } + @Test + fun `a second install is a no-op while already installed`() { + val config = PostHogAndroidConfig(API_KEY) + var fetchCount = 0 + val fetcher = + PushTokenFetcher { onToken -> + fetchCount++ + onToken("fcm-token", "firebase-project") + } + val sut = PostHogPushSubscriptionIntegration(config, fetcher, SameThreadExecutorService()) + val fake = createPostHogFake() + + sut.install(fake) + sut.install(fake) + + assertEquals(1, fetchCount) + + sut.uninstall() + } + @Test fun `install does not register when the fetcher yields no token`() { val config = PostHogAndroidConfig(API_KEY) val fetcher = PushTokenFetcher { /* no token available */ } - val sut = PostHogPushSubscriptionIntegration(config, fetcher) + val sut = PostHogPushSubscriptionIntegration(config, fetcher, SameThreadExecutorService()) val fake = createPostHogFake() sut.install(fake) diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index f985130cb..048dcea74 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -279,6 +279,10 @@ public class PostHog private constructor( queue.start() logsQueue.start() + // Force the persisted OPT_OUT to hydrate before this passive push trigger: the + // manager reads config.optOut directly, and it is otherwise resolved lazily on + // first isOptedOut() call, which retryPending() here would race ahead of. + isOptedOut() pushSubscriptionManager?.retryPending() PostHogSessionManager.setOnSessionIdChangedListener { @@ -1105,8 +1109,8 @@ public class PostHog private constructor( optOutLoaded = true exceptionStepsBuffer?.clear() } - // Stop any pending push-token retry/offline-poll timer; the send guard in the manager - // already blocks sends while opted out, this just tears the timer down immediately. + // Clear cached identity-token state so a stale token/401 flag isn't reused after + // re-opting-in; the send guard in the manager already blocks sends while opted out. pushSubscriptionManager?.onOptOut() } @@ -1319,6 +1323,8 @@ public class PostHog private constructor( // Automatically set person properties for feature flags during identify() call setPersonPropertiesForFlagsIfNeeded(userProperties, userPropertiesSetOnce) + // See the setup() call site: hydrate opt-out before the manager reads the raw config field. + isOptedOut() pushSubscriptionManager?.resendIfDistinctIdChanged() // only because of testing in isolation, this flag is always enabled @@ -1766,6 +1772,8 @@ public class PostHog private constructor( super.flush() replayQueue?.flush() logsQueue?.flush() + // See the setup() call site: hydrate opt-out before the manager reads the raw config field. + isOptedOut() pushSubscriptionManager?.retryPending() } @@ -1862,6 +1870,8 @@ public class PostHog private constructor( endSession() startSession() + // See the setup() call site: hydrate opt-out before the manager reads the raw config field. + isOptedOut() pushSubscriptionManager?.handleReset(previousDistinctId) // reload flags as anon user diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt index 77c6ab626..8b7e51883 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt @@ -84,6 +84,11 @@ internal class PostHogPushSubscriptionManager( // so the latest token isn't stranded behind the finished send. Executor-thread only. @Volatile private var pendingResend = false + // Whether the folded-in caller (above) wanted retry state reset before its replay — carries + // performRegister/resendIfDistinctIdChanged's reset decision through the fold so a mid-send + // identical re-register can't silently undo the halt this send's own failure just set. + @Volatile private var pendingResendResetsState = false + private val pendingFile: File? by lazy { val prefix = config.storagePrefix ?: return@lazy null // Must stay out of /: PostHogQueue scans that whole directory as @@ -151,7 +156,7 @@ internal class PostHogPushSubscriptionManager( halted = false didAuthRetry = false } - attempt() + attempt(resetStateOnFold = !isIdenticalUndelivered) } fun retryPending() { @@ -178,7 +183,7 @@ internal class PostHogPushSubscriptionManager( } // retryCount deliberately not reset: the exponential ladder persists across triggers, so // repeated failures still exhaust maxRetries and halt for the session. - attempt() + attempt(resetStateOnFold = false) } } @@ -197,7 +202,7 @@ internal class PostHogPushSubscriptionManager( nextAttemptAtMs = 0L halted = false didAuthRetry = false - attempt() + attempt(resetStateOnFold = true) } } @@ -372,7 +377,7 @@ internal class PostHogPushSubscriptionManager( private fun isWithinBackoffWindow(): Boolean = System.currentTimeMillis() < nextAttemptAtMs - private fun attempt() { + private fun attempt(resetStateOnFold: Boolean) { if (closed || config.optOut) { // Opt-out and shutdown both stop every send. Guarding at this single choke point covers // all callers: register, startup/flush retryPending, and identify resend. @@ -400,8 +405,11 @@ 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. + // in: [performSend] replays one fresh attempt on release so this token isn't dropped. Carry + // this caller's reset decision through the fold — if any folded-in caller wanted a reset, + // the replay must honor it, so OR rather than overwrite. pendingResend = true + pendingResendResetsState = pendingResendResetsState || resetStateOnFold return } @@ -472,14 +480,22 @@ internal class PostHogPushSubscriptionManager( return } pendingResend = false + val resetState = pendingResendResetsState + pendingResendResetsState = false if (closed || config.optOut || currentRecord() == null) { return } - retryCount = 0 - nextAttemptAtMs = 0L - halted = false - didAuthRetry = false - attempt() + // Only reset retry state if a folded-in caller actually wanted a reset (a new token, or an + // identity change). A folded-in retryPending()/servicePendingResend replay must not clear a + // halt this same send's failure just set — otherwise a mid-send identical re-register would + // silently undo the halt. + if (resetState) { + retryCount = 0 + nextAttemptAtMs = 0L + halted = false + didAuthRetry = false + } + attempt(resetStateOnFold = resetState) } private fun handleFailure(e: Throwable) { @@ -491,7 +507,7 @@ internal class PostHogPushSubscriptionManager( didAuthRetry = true cachedIdentityToken = null config.logger.log("Push subscription rejected (401): refreshing identity token and retrying once.") - executor.executeSafely { attempt() } + executor.executeSafely { attempt(resetStateOnFold = false) } return } config.logger.log( diff --git a/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt index 9ac08db10..765e36838 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt @@ -1173,6 +1173,162 @@ internal class PostHogPushSubscriptionManagerTest { assertEquals(1, http.requestCount) } + @Test + fun `a successful send clears a still-pending unregister for the same identity outside retryPending`() { + val http = mockHttp(total = 1, response = MockResponse().setResponseCode(503)) + val (sut, config, storagePrefix) = getSut(http) + distinctId = "user-A" + + // A DELETE for user-A is queued and fails (retryable), so the intent stays persisted. + sut.unregister("user-A", "fcm-token", "firebase-project", "android") + flush() + assertEquals("DELETE", http.takeRequest().method) + assertNotNull(readUnregister(config, pendingUnregisterFile(storagePrefix!!))) + + // A fresh register() for the same identity succeeds via attempt()/performSend directly + // (not retryPending()'s drain) and must clear the still-pending same-identity DELETE. + http.enqueue(MockResponse().setBody("")) + sut.register("fcm-token", "firebase-project", "android") + flush() + assertEquals("POST", http.takeRequest(2, TimeUnit.SECONDS)!!.method) + assertNull(readUnregister(config, pendingUnregisterFile(storagePrefix))) + + // A later retryPending() must not replay the now-cleared DELETE. + sut.retryPending() + flush() + assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) + } + + @Test + fun `a transport-level IOException is treated as retryable`() { + val http = mockHttp(total = 0) + val (sut, _, storagePrefix) = getSut(http) + sut.retryDelayMillisPerSecond = 1L + + // Force a raw transport failure (no HTTP response at all) rather than a status code. + http.shutdown() + + sut.register("fcm-token", "firebase-project", "android") + flush() + + // Kept for retry, not halted: the pending file survives an unclassified send error. + assertTrue(pendingFile(storagePrefix!!).exists()) + } + + @Test + fun `retryPending replays a differing-identity unregister deferred during handleReset`() { + val http = mockHttp(total = 1, response = MockResponse().setResponseCode(503)) + var connected = true + val network = + object : PostHogNetworkStatus { + override fun isConnected() = connected + } + val (sut, config, storagePrefix) = getSut(http, networkStatus = network) + + distinctId = "user-A" + sut.register("fcm-token", "firebase-project", "android") + flush() + assertEquals("POST", http.takeRequest().method) + + // reset() to a new identity while the old identity's DELETE fails/defers: the intent for + // user-A survives, and a fresh record exists for anon-2 (currentRecord() != null). + distinctId = "anon-2" + http.enqueue(MockResponse().setResponseCode(503)) + http.enqueue(MockResponse().setBody("")) + sut.handleReset("user-A") + flush() + assertEquals("DELETE", http.takeRequest().method) + assertEquals("POST", http.takeRequest().method) + assertNotNull(readUnregister(config, pendingUnregisterFile(storagePrefix!!))) + + // retryPending()'s drain must take the differing-identity replay branch (not the + // same-identity drop), since the pending DELETE is for user-A while distinctId is anon-2. + http.enqueue(MockResponse().setBody("")) + sut.retryPending() + flush() + val replayed = http.takeRequest(2, TimeUnit.SECONDS) + assertEquals("DELETE", replayed!!.method) + assertEquals("user-A", parsedDistinctId(replayed)) + assertNull(readUnregister(config, pendingUnregisterFile(storagePrefix))) + } + + @Test + fun `a folded identical re-register does not clear a halt set by the in-flight send's own failure`() { + val http = mockHttp(total = 1, response = MockResponse().setResponseCode(400)) + val (sut, config, storagePrefix) = getSut(http) + var holdNext = true + var heldCompletion: ((String?) -> Unit)? = null + config.pushIdentityProvider = { _, _, completion -> + if (holdNext) { + heldCompletion = completion + } else { + completion(null) + } + } + distinctId = "user-A" + + // register()'s identity mint is held in flight (isSending claimed, no HTTP call yet). + sut.register("fcm-token", "firebase-project", "android") + flush() + assertNotNull(heldCompletion) + holdNext = false + + // An identical re-register arrives mid-mint: isIdenticalUndelivered is true, so it folds in + // without resetting state (resetStateOnFold = false). + sut.register("fcm-token", "firebase-project", "android") + flush() + + // The in-flight send's mint completes; performSend hits the 400 and halts the session. + heldCompletion?.invoke(null) + flush() + assertEquals("POST", http.takeRequest(2, TimeUnit.SECONDS)!!.method) + assertEquals(1, http.requestCount) + + // The folded replay must not have undone the halt: no second POST, and a later retryPending() + // stays a no-op. + sut.retryPending() + flush() + assertNull(http.takeRequest(500, TimeUnit.MILLISECONDS)) + assertTrue(pendingFile(storagePrefix!!).exists()) + } + + @Test + fun `a new token folded in mid-send resets state and sends once the in-flight mint clears`() { + val http = mockHttp(total = 2) + val (sut, config, _) = getSut(http) + var holdNext = true + var heldCompletion: ((String?) -> Unit)? = null + config.pushIdentityProvider = { _, _, completion -> + if (holdNext) { + heldCompletion = completion + } else { + completion(null) + } + } + distinctId = "user-A" + + // token-A's identity mint is held in flight. + sut.register("token-A", "firebase-project", "android") + flush() + assertNotNull(heldCompletion) + holdNext = false + + // A different token folds in mid-mint: isIdenticalUndelivered is false, so it resets state + // synchronously and asks the fold to reset again (resetStateOnFold = true). + sut.register("token-B", "firebase-project", "android") + flush() + + // token-A's stale mint completes; performSend detects the record changed underneath it and + // skips sending, then releases isSending and replays the folded token-B attempt with reset state. + heldCompletion?.invoke(null) + flush() + + val sent = http.takeRequest(2, TimeUnit.SECONDS) + assertEquals("POST", sent!!.method) + assertTrue(sent.body.unGzip().contains("token-B")) + 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 43fdc80c0368afd4fe05c9535c78d5dd530f9510 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 30 Jul 2026 23:52:29 +0300 Subject: [PATCH 13/14] docs(push): document PendingUnregister's single-slot last-write-wins limitation --- .../internal/PostHogPushSubscriptionManager.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt index 8b7e51883..47b705ae9 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt @@ -728,6 +728,17 @@ internal class PostHogPushSubscriptionManager( val deliveredForDistinctId: String? = null, ) + /** + * A durable "delete this subscription" intent for the identity it names, persisted so an + * offline or failed unregister (logout/reset) is retried on flush()/next launch instead of + * silently leaving the device associated with a logged-out user. Separate key from + * [PendingRecord] so it coexists with a fresh registration the reset path writes under the + * new anonymous id. + * + * Single-slot, last-write-wins: only one identity's unregister can be pending at a time. Two + * overlapping logouts (rapid double-reset) drop the older intent — acceptable given one device + * token and how rare that is; a per-identity queue would fix it if it ever matters. + */ internal data class PendingUnregister( @SerializedName("distinct_id") val distinctId: String, From a60d9e1d3e6b2d1ba5c2acc93c2c776ffbdfd8e4 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 31 Jul 2026 00:08:42 +0300 Subject: [PATCH 14/14] chore(push): drop cross-SDK attributions from code comments --- .../posthog/internal/PostHogPushSubscriptionManager.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt index 47b705ae9..b4a4fa292 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt @@ -28,7 +28,7 @@ private const val MAX_RETRY_DELAY_SECONDS = 30 * [retryPending] can pick it back up on the next process start. In-session resume: [retryPending] * is invoked from `flush()` (which the Android SDK calls on app background), so an undelivered * record doesn't wait for a relaunch. An offline deferral schedules no timer — recovery is driven - * by flush()/identify()/relaunch, matching iOS's passive model. + * by flush()/identify()/relaunch. * * Unregister (logout/reset) is durable the same way: a [PendingUnregister] intent is persisted * before the DELETE and only cleared on a 2xx or a terminal 4xx, so an offline or failed logout @@ -108,7 +108,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. // 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. + // token-less (401 re-mints). Tune here if that proves too tight. internal var identityTokenMintTimeoutMillis: Long = 10_000L fun register( @@ -314,7 +314,7 @@ 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. The two legs are independent — the backend keys subscriptions per (person, app_id), - * so the old-id DELETE and new-id POST can't affect each other in any order (matches iOS). + * so the old-id DELETE and new-id POST can't affect each other in any order. */ fun handleReset(oldDistinctId: String) { executor.executeSafely { @@ -393,7 +393,7 @@ internal class PostHogPushSubscriptionManager( if (config.networkStatus?.isConnected() == false) { config.logger.log("Push subscription deferred: no network.") // Deferral burns no retry attempt and schedules no timer; recovery is driven by - // flush()/identify()/relaunch, matching iOS's passive model. + // flush()/identify()/relaunch. return } @@ -577,7 +577,7 @@ internal class PostHogPushSubscriptionManager( onResolved: (String?) -> Unit, ) { // 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). + // attaching tokens now", so a stale cached credential must not outlive it. val provider = config.pushIdentityProvider if (provider == null) { config.logger.log("No identity token attached to push request (no pushIdentityProvider).")