diff --git a/.changeset/strong-moments-strive.md b/.changeset/strong-moments-strive.md new file mode 100644 index 00000000..f8303028 --- /dev/null +++ b/.changeset/strong-moments-strive.md @@ -0,0 +1,6 @@ +--- +"posthog-android": minor +"posthog": minor +--- + +Add support for initiating push notification subscriptions using PostHog Workflows diff --git a/posthog-android/build.gradle.kts b/posthog-android/build.gradle.kts index d97a3787..3e3f4a6a 100644 --- a/posthog-android/build.gradle.kts +++ b/posthog-android/build.gradle.kts @@ -92,7 +92,7 @@ dependencies { implementation(kotlin("stdlib-jdk8", PosthogBuildConfig.Kotlin.KOTLIN)) implementation("androidx.lifecycle:lifecycle-process:${PosthogBuildConfig.Dependencies.LIFECYCLE}") implementation("androidx.lifecycle:lifecycle-common-java8:${PosthogBuildConfig.Dependencies.LIFECYCLE}") - implementation("androidx.core:core:${PosthogBuildConfig.Dependencies.ANDROIDX_CORE}") + implementation("androidx.core:core-ktx:${PosthogBuildConfig.Dependencies.ANDROIDX_CORE}") implementation("com.squareup.curtains:curtains:${PosthogBuildConfig.Dependencies.CURTAINS}") // compile only diff --git a/posthog-android/gradle.lockfile b/posthog-android/gradle.lockfile index 51fa7f24..d01b5b1c 100644 --- a/posthog-android/gradle.lockfile +++ b/posthog-android/gradle.lockfile @@ -17,6 +17,7 @@ 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-ktx:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.core:core:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,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 diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidUtils.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidUtils.kt index ba65b3f9..e46ea606 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidUtils.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidUtils.kt @@ -19,6 +19,7 @@ import android.telephony.TelephonyManager import android.util.Base64 import android.util.DisplayMetrics import android.view.WindowManager +import androidx.core.net.toUri import com.posthog.PostHogInternal import com.posthog.android.PostHogAndroidConfig import java.io.ByteArrayOutputStream @@ -178,7 +179,7 @@ internal fun Intent.getReferrerInfo(config: PostHogAndroidConfig): Map? = null public var captures: Int = 0 public var flushes: Int = 0 + public var pushDeviceToken: String? = null + public var pushAppId: String? = null + public var pushPlatform: String? = null + public var pushRegistrations: Int = 0 override fun setup(config: T) { } @@ -202,6 +206,17 @@ public class PostHogFake : PostHogInterface { return null } + override fun registerPushNotificationToken( + deviceToken: String, + appId: String, + platform: String, + ) { + pushDeviceToken = deviceToken + pushAppId = appId + pushPlatform = platform + pushRegistrations++ + } + override fun getConfig(): T? { return null } diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 6e544840..828937dd 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -48,6 +48,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;Ljava/lang/String;)V public fun reloadFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V public fun reset ()V public fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V @@ -91,6 +92,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;Ljava/lang/String;)V public fun reloadFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V public fun reset ()V public fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V @@ -328,6 +330,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;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 @@ -352,6 +355,7 @@ public final class com/posthog/PostHogInterface$DefaultImpls { public static synthetic fun getFeatureFlagResult$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/posthog/FeatureFlagResult; public static synthetic fun group$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V public static synthetic fun isFeatureEnabled$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;ZLjava/lang/Boolean;ILjava/lang/Object;)Z + public static synthetic fun registerPushNotificationToken$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V public static synthetic fun reloadFeatureFlags$default (Lcom/posthog/PostHogInterface;Lcom/posthog/PostHogOnFeatureFlags;ILjava/lang/Object;)V public static synthetic fun resetGroupPropertiesForFlags$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;ZILjava/lang/Object;)V public static synthetic fun resetPersonPropertiesForFlags$default (Lcom/posthog/PostHogInterface;ZILjava/lang/Object;)V @@ -646,6 +650,7 @@ public final class com/posthog/internal/PostHogApi { public static synthetic fun flags$default (Lcom/posthog/internal/PostHogApi;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lcom/posthog/internal/PostHogFlagsResponse; public final fun localEvaluation (Ljava/lang/String;Ljava/lang/String;)Lcom/posthog/internal/LocalEvaluationApiResponse; public static synthetic fun localEvaluation$default (Lcom/posthog/internal/PostHogApi;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/posthog/internal/LocalEvaluationApiResponse; + public final fun pushSubscription (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 82c6a68c..c4b6245f 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -18,6 +18,7 @@ import com.posthog.internal.PostHogPreferences.Companion.OPT_OUT import com.posthog.internal.PostHogPreferences.Companion.PERSON_PROCESSING import com.posthog.internal.PostHogPreferences.Companion.VERSION import com.posthog.internal.PostHogPrintLogger +import com.posthog.internal.PostHogPushSubscriptionManager import com.posthog.internal.PostHogQueueInterface import com.posthog.internal.PostHogRemoteConfig import com.posthog.internal.PostHogSendCachedEventsIntegration @@ -63,6 +64,8 @@ public class PostHog private constructor( private val cachedPersonPropertiesLock = Any() private var replayQueue: PostHogQueueInterface? = null + private var api: PostHogApi? = null + private var pushSubscriptionManager: PostHogPushSubscriptionManager? = null private val remoteConfig: PostHogRemoteConfig? get() = config?.remoteConfigHolder @@ -169,8 +172,10 @@ public class PostHog private constructor( ) this.config = config + this.api = api this.queue = queue this.replayQueue = replayQueue + this.pushSubscriptionManager = PostHogPushSubscriptionManager(config, api, queueExecutor) if (featureFlags is PostHogRemoteConfig) { config.remoteConfigHolder = featureFlags @@ -190,6 +195,8 @@ public class PostHog private constructor( queue.start() + pushSubscriptionManager?.retryPending() + startSession() config.integrations.forEach { @@ -305,6 +312,9 @@ public class PostHog private constructor( queue?.stop() replayQueue?.stop() + api = null + pushSubscriptionManager?.close() + pushSubscriptionManager = null featureFlagsCalled.clear() @@ -1353,6 +1363,41 @@ public class PostHog private constructor( return PostHogSessionManager.isSessionActive() } + override fun registerPushNotificationToken( + deviceToken: String, + appId: String, + platform: String, + ) { + if (!isEnabled()) { + return + } + if (config?.optOut == true) { + 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 + } + + val currentDistinctId = distinctId + if (currentDistinctId.isBlank()) { + config?.logger?.log("registerPushNotificationToken call not allowed, distinctId is invalid.") + return + } + + pushSubscriptionManager?.register( + distinctId = currentDistinctId, + deviceToken = deviceToken, + appId = appId, + platform = platform, + ) + } + override fun getConfig(): T? { @Suppress("UNCHECKED_CAST") return super.config as? T @@ -1714,5 +1759,13 @@ public class PostHog private constructor( override fun getSessionId(): UUID? { return shared.getSessionId() } + + override fun registerPushNotificationToken( + deviceToken: String, + appId: String, + platform: String, + ) { + shared.registerPushNotificationToken(deviceToken, appId, platform) + } } } diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index 2a694858..e8ee69d2 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -303,6 +303,21 @@ public interface PostHogInterface : PostHogCoreInterface { flagVariant: String? = null, ) + /** + * Registers a push notification device token with PostHog. + * This sends the token to the PostHog push subscriptions API so that + * push notifications can be delivered to this device. + * + * @param deviceToken the device push token (e.g. FCM registration token) + * @param appId the app identifier - Firebase project_id for Android, APNS bundle_id for iOS + * @param platform the platform, defaults to "android" + */ + public fun registerPushNotificationToken( + deviceToken: String, + appId: String, + platform: String = "android", + ) + @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 0211a6e6..949f6b03 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -277,6 +277,41 @@ public class PostHogApi( } } + @Throws(PostHogApiError::class, IOException::class) + public fun pushSubscription( + distinctId: String, + deviceToken: String, + platform: String, + appId: String, + ) { + val request = + PostHogPushSubscriptionRequest( + apiKey = config.apiKey, + distinctId = distinctId, + deviceToken = deviceToken, + platform = platform, + appId = appId, + ) + + val url = "$theHost/api/push_subscriptions/" + logRequest(request, url) + + val httpRequest = + makeRequest(url) { + config.serializer.serialize(request, it.bufferedWriter()) + } + + logRequestHeaders(httpRequest) + + client.newCall(httpRequest).execute().use { + val response = logResponse(it) + + if (!response.isSuccessful) { + throw PostHogApiError(response.code, response.message, response.body) + } + } + } + private fun logResponse(response: Response): Response { if (config.debug) { try { diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt new file mode 100644 index 00000000..f62d7647 --- /dev/null +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt @@ -0,0 +1,231 @@ +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 val RETRYABLE_STATUS_CODES = setOf(429, 500, 502, 503, 504) + +/** + * Persists the latest push subscription registration to disk and retries it on + * transient failures. The wire call is a single POST, so events here are + * latest-wins: each new register() overwrites any pending record. + * + * Recovery across process restarts is driven by [retryPending], which the SDK + * invokes once after setup completes. + */ +internal class PostHogPushSubscriptionManager( + private val config: PostHogConfig, + private val api: PostHogApi, + private val executor: ExecutorService, +) { + private val timerLock = Any() + private val isSending = AtomicBoolean(false) + + @Volatile private var retryCount = 0 + + @Volatile private var timer: Timer? = null + + private val initialRetryDelaySeconds = 1 + private val maxRetryDelaySeconds = 30 + + fun register( + distinctId: String, + deviceToken: String, + appId: String, + platform: String, + ) { + val record = PendingRecord(distinctId, deviceToken, appId, platform) + executor.executeSafely { + val file = pendingFile() + if (file != null) { + writeRecord(file, record) + } + retryCount = 0 + cancelTimer() + attempt(record, file) + } + } + + fun retryPending() { + val file = pendingFile() ?: return + executor.executeSafely { + if (!file.existsSafely(config)) { + return@executeSafely + } + val record = + readRecord(file) ?: run { + file.deleteSafely(config) + return@executeSafely + } + retryCount = 0 + cancelTimer() + attempt(record, file) + } + } + + private fun attempt( + record: PendingRecord, + file: File?, + ) { + if (config.networkStatus?.isConnected() == false) { + config.logger.log("Push subscription deferred: no network.") + return + } + + if (!isSending.compareAndSet(false, true)) { + return + } + + try { + api.pushSubscription( + distinctId = record.distinctId, + deviceToken = record.deviceToken, + platform = record.platform, + appId = record.appId, + ) + config.logger.log("Push notification token registered successfully.") + retryCount = 0 + // Latest-wins: only delete if the on-disk record hasn't been replaced + // by a concurrent register() call. + if (file != null && readRecord(file) == record) { + file.deleteSafely(config) + } + } catch (e: Throwable) { + handleFailure(e, record, file) + } finally { + isSending.set(false) + } + } + + private fun handleFailure( + e: Throwable, + record: PendingRecord, + file: File?, + ) { + if (!isRetryable(e)) { + config.logger.log("Push subscription failed with non-retryable error: $e.") + file?.deleteSafely(config) + retryCount = 0 + return + } + + retryCount++ + if (retryCount > config.maxRetries) { + config.logger.log( + "Push subscription retries exhausted after $retryCount attempts; " + + "will retry on next SDK startup.", + ) + retryCount = 0 + return + } + + val retryAfter = (e as? PostHogApiError)?.retryAfterSeconds + val delay = + if (retryAfter != null && retryAfter > 0) { + retryAfter + } else { + min( + initialRetryDelaySeconds * 2.0.pow((retryCount - 1).toDouble()).toInt(), + maxRetryDelaySeconds, + ) + } + config.logger.log("Push subscription failed: $e. Retrying in ${delay}s (attempt $retryCount).") + scheduleRetry(delay, record, file) + } + + private fun scheduleRetry( + delaySeconds: Int, + record: PendingRecord, + file: File?, + ) { + synchronized(timerLock) { + cancelTimer() + val t = Timer(true) + t.schedule(delaySeconds * 1000L) { + executor.executeSafely { + // Re-read from disk in case the record was replaced while we waited. + val current = file?.let { readRecord(it) } ?: record + attempt(current, file) + } + } + timer = t + } + } + + private fun cancelTimer() { + synchronized(timerLock) { + timer?.cancel() + timer = null + } + } + + fun close() { + cancelTimer() + retryCount = 0 + } + + private fun isRetryable(e: Throwable): Boolean { + return when (e) { + is PostHogApiError -> e.statusCode < 400 || e.statusCode in RETRYABLE_STATUS_CODES + is IOException -> true + else -> false + } + } + + private fun pendingFile(): File? { + val prefix = config.storagePrefix ?: return null + val dir = File(prefix, config.apiKey) + try { + dir.mkdirs() + } catch (e: Throwable) { + config.logger.log("Failed to create push subscription dir: $e.") + return null + } + return File(dir, PENDING_FILE_NAME) + } + + private fun writeRecord( + file: File, + record: PendingRecord, + ) { + try { + 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("distinct_id") + val distinctId: String, + @SerializedName("device_token") + val deviceToken: String, + @SerializedName("app_id") + val appId: String, + val platform: String, + ) +} diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionRequest.kt b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionRequest.kt new file mode 100644 index 00000000..ddec38c2 --- /dev/null +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionRequest.kt @@ -0,0 +1,23 @@ +package com.posthog.internal + +import com.google.gson.annotations.SerializedName + +/** + * The request body for the push subscriptions API + * @property apiKey the PostHog API Key + * @property distinctId the user's distinct ID + * @property deviceToken the device push token (FCM or APNS) + * @property platform the platform ("android" or "ios") + * @property appId the Firebase project_id (for Android) or APNS bundle_id (for iOS) + */ +internal data class PostHogPushSubscriptionRequest( + @SerializedName("api_key") + val apiKey: 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 79b6052e..b94a93b3 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -3222,4 +3222,90 @@ 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", + platform = "android", + ) + + 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", "android") + 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", "android") + 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", "", "android") + 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", "android") + + assertEquals(0, http.requestCount) + } } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt index d68c238e..2ec53f6d 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt @@ -5,6 +5,7 @@ import com.posthog.BuildConfig import com.posthog.PostHogConfig import com.posthog.generateEvent import com.posthog.mockHttp +import com.posthog.unGzip import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.Assert.assertThrows @@ -417,6 +418,55 @@ internal class PostHogApiTest { ) } + @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()) + 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 `remoteConfig logs request headers in debug mode`() { val file = File("src/test/resources/json/basic-remote-config.json") 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 00000000..d906d26b --- /dev/null +++ b/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt @@ -0,0 +1,231 @@ +package com.posthog.internal + +import com.posthog.API_KEY +import com.posthog.PostHogConfig +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.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +internal class PostHogPushSubscriptionManagerTest { + @get:Rule + val tmpDir = TemporaryFolder() + + private val executor: ExecutorService = Executors.newSingleThreadExecutor(PostHogThreadFactory("TestPushSub")) + + @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, + ): Triple { + val config = + PostHogConfig(API_KEY, host = http.url("/").toString()).apply { + this.storagePrefix = storagePrefix + this.networkStatus = networkStatus + this.maxRetries = maxRetries + } + val api = PostHogApi(config) + return Triple(PostHogPushSubscriptionManager(config, api, executor), config, storagePrefix) + } + + private fun pendingFile(storagePrefix: String): File = File(File(storagePrefix, API_KEY), "push_subscription.pending") + + private fun flush() { + executor.submit {}.get() + } + + @Test + fun `register posts subscription and deletes pending file on success`() { + val http = mockHttp() + val (sut, _, storagePrefix) = getSut(http) + + sut.register("distinct-1", "fcm-token", "firebase-project", "android") + flush() + + val request = http.takeRequest() + assertEquals("POST", request.method) + assertEquals("/api/push_subscriptions/", request.path) + assertFalse(pendingFile(storagePrefix).exists()) + } + + @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("distinct-1", "fcm-token", "firebase-project", "android") + flush() + + assertEquals(0, http.requestCount) + assertTrue(pendingFile(storagePrefix).exists()) + } + + @Test + fun `register deletes file on non-retryable failure`() { + val http = mockHttp(response = MockResponse().setResponseCode(400).setBody("bad")) + val (sut, _, storagePrefix) = getSut(http) + + sut.register("distinct-1", "fcm-token", "firebase-project", "android") + flush() + + assertEquals(1, http.requestCount) + assertFalse(pendingFile(storagePrefix).exists()) + } + + @Test + fun `register keeps file after retryable failure exhausts retries`() { + val http = mockHttp(response = MockResponse().setResponseCode(503).setBody("unavailable")) + // maxRetries = 0 means the first failure exhausts retries immediately, + // keeping the file for the next SDK start. + val (sut, _, storagePrefix) = getSut(http, maxRetries = 0) + + sut.register("distinct-1", "fcm-token", "firebase-project", "android") + flush() + + assertEquals(1, http.requestCount) + assertTrue(pendingFile(storagePrefix).exists()) + } + + @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 reads pending record and posts it`() { + val http = MockWebServer() + http.start() + // First response: 503 (retryable; with maxRetries=0 the file stays on disk). + http.enqueue(MockResponse().setResponseCode(503)) + // Second response: success (the retry after "restart"). + http.enqueue(MockResponse().setBody("")) + + val (sut, _, storagePrefix) = getSut(http, maxRetries = 0) + + sut.register("distinct-1", "fcm-token", "firebase-project", "android") + flush() + assertTrue(pendingFile(storagePrefix).exists()) + + sut.retryPending() + flush() + + assertEquals(2, http.requestCount) + assertFalse(pendingFile(storagePrefix).exists()) + http.shutdown() + } + + @Test + fun `register persists each call and deletes the file on success`() { + val http = mockHttp(total = 2, response = MockResponse().setBody("")) + val (sut, _, storagePrefix) = getSut(http) + + sut.register("distinct-1", "token-1", "firebase-project", "android") + flush() + sut.register("distinct-1", "token-2", "firebase-project", "android") + flush() + + assertEquals(2, http.requestCount) + val first = http.takeRequest().body.unGzip() + val second = http.takeRequest().body.unGzip() + assertTrue(first.contains("token-1")) + assertTrue(second.contains("token-2")) + assertFalse(pendingFile(storagePrefix).exists()) + } + + @Test + fun `register writes record that roundtrips through serializer`() { + val http = mockHttp(response = MockResponse().setResponseCode(503)) + val (sut, config, storagePrefix) = getSut(http, maxRetries = 0) + + sut.register("distinct-1", "fcm-token", "firebase-project", "android") + flush() + + val file = pendingFile(storagePrefix) + assertTrue(file.exists()) + + val record = + file.inputStream().use { + config.serializer.deserialize(it.reader().buffered()) + } + assertEquals("distinct-1", record?.distinctId) + assertEquals("fcm-token", record?.deviceToken) + assertEquals("firebase-project", record?.appId) + assertEquals("android", record?.platform) + } + + @Test + fun `retryPending removes corrupt pending file`() { + val http = mockHttp() + val (sut, _, storagePrefix) = getSut(http) + + // Write a file with junk bytes that can't be deserialized. + val file = pendingFile(storagePrefix) + file.parentFile.mkdirs() + file.writeText("{not valid json") + + sut.retryPending() + flush() + + assertEquals(0, http.requestCount) + assertFalse(file.exists()) + } + + @Test + fun `register with null storagePrefix still attempts the request`() { + val http = mockHttp() + val config = + PostHogConfig(API_KEY, host = http.url("/").toString()).apply { + this.storagePrefix = null + } + val api = PostHogApi(config) + val sut = PostHogPushSubscriptionManager(config, api, executor) + + sut.register("distinct-1", "fcm-token", "firebase-project", "android") + flush() + + assertEquals(1, http.requestCount) + } + + @Test + fun `retryPending is a no-op when storagePrefix is null`() { + val http = mockHttp() + val config = + PostHogConfig(API_KEY, host = http.url("/").toString()).apply { + this.storagePrefix = null + } + val api = PostHogApi(config) + val sut = PostHogPushSubscriptionManager(config, api, executor) + + sut.retryPending() + flush() + + assertEquals(0, http.requestCount) + } +}