From 76717dc36f890e215eb6bc398d18168e9fb8447e Mon Sep 17 00:00:00 2001 From: odin kammerloher Date: Tue, 20 Jan 2026 18:11:08 +0100 Subject: [PATCH 01/14] feat(odin): Allow collecting FCM device token in SDK & demonstrate it in sample app --- .gitignore | 3 + .../com/posthog/android/PostHogAndroid.kt | 15 ++ .../posthog-android-sample/README.md | 39 +++ .../posthog-android-sample/build.gradle.kts | 21 ++ .../src/main/AndroidManifest.xml | 10 + .../java/com/posthog/android/sample/MyApp.kt | 141 ++++++++++- .../posthog/android/sample/NormalActivity.kt | 42 ++++ .../sample/PostHogFirebaseMessagingService.kt | 224 ++++++++++++++++++ .../src/main/res/values/strings.xml | 5 + .../main/res/xml/network_security_config.xml | 9 + posthog/src/main/java/com/posthog/PostHog.kt | 76 ++++++ .../main/java/com/posthog/PostHogInterface.kt | 13 + .../java/com/posthog/internal/PostHogApi.kt | 66 ++++++ .../posthog/internal/PostHogPreferences.kt | 4 + .../PostHogPushSubscriptionRequest.kt | 14 ++ .../src/test/java/com/posthog/PostHogTest.kt | 173 ++++++++++++++ .../com/posthog/internal/PostHogApiTest.kt | 63 +++++ 17 files changed, 909 insertions(+), 9 deletions(-) create mode 100644 posthog-samples/posthog-android-sample/README.md create mode 100644 posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/PostHogFirebaseMessagingService.kt create mode 100644 posthog-samples/posthog-android-sample/src/main/res/xml/network_security_config.xml create mode 100644 posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionRequest.kt diff --git a/.gitignore b/.gitignore index 3b4c13aa..4ebb64f3 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,6 @@ gradle-app.setting # vscode .vscode/settings.json + +# Firebase configuration (contains sensitive project info) +**/google-services.json 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 3d82a65c..a35f5e45 100644 --- a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt +++ b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt @@ -71,6 +71,21 @@ public class PostHogAndroid private constructor() { return PostHog.with(config) } + /** + * Registers a push notification token (FCM token) with PostHog. + * The SDK will automatically rate-limit registrations to once per day unless the token has changed. + * + * Users should retrieve the FCM token using: + * - Java: `FirebaseMessaging.getInstance().getToken()` + * - Kotlin: `Firebase.messaging.token` + * + * @param token The FCM registration token + * @return true if registration was successful, false otherwise + */ + public fun registerPushToken(token: String): Boolean { + return PostHog.registerPushToken(token) + } + private fun setAndroidConfig( context: Context, config: T, diff --git a/posthog-samples/posthog-android-sample/README.md b/posthog-samples/posthog-android-sample/README.md new file mode 100644 index 00000000..57ba84f6 --- /dev/null +++ b/posthog-samples/posthog-android-sample/README.md @@ -0,0 +1,39 @@ +# PostHog Android Sample App + +This sample app demonstrates how to integrate the PostHog Android SDK, including push notification support via Firebase Cloud Messaging (FCM). + +## Setup + +### Firebase Configuration (Optional - for Push Notifications) + +To enable push notification features in this sample app, you need to configure Firebase: + +1. **Create a Firebase Project** (if you don't have one): + - Go to [Firebase Console](https://console.firebase.google.com/) + - Click "Add project" and follow the setup wizard + +2. **Add Android App to Firebase**: + - In your Firebase project, click "Add app" and select Android + - Enter the package name: `com.posthog.android.sample` + - Register the app + +3. **Download `google-services.json`**: + - Download the `google-services.json` file from Firebase Console + - Place it in the `posthog-samples/posthog-android-sample/` directory (same level as `build.gradle.kts`) + +4. **Verify the file location**: + ``` + posthog-samples/posthog-android-sample/ + ├── build.gradle.kts + ├── google-services.json ← Place it here + └── src/ + ``` + +**Note:** The `google-services.json` file is gitignored as it contains project-specific configuration. Each developer needs to add their own file. + +### Running Without Firebase + +If you don't have a `google-services.json` file, the app will still run but push notification features will be disabled: +- FCM token registration will be skipped +- Push notifications will not be received +- All other PostHog SDK features will work normally diff --git a/posthog-samples/posthog-android-sample/build.gradle.kts b/posthog-samples/posthog-android-sample/build.gradle.kts index fee9f78d..52251aba 100644 --- a/posthog-samples/posthog-android-sample/build.gradle.kts +++ b/posthog-samples/posthog-android-sample/build.gradle.kts @@ -1,6 +1,18 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.google.gms:google-services:4.4.0") + } +} + plugins { id("com.android.application") kotlin("android") + // TODOdin: Add to gitignore and document that it's needed? + id("com.google.gms.google-services") // so we don't upload mappings from our release builds on CI if (!PosthogBuildConfig.isCI()) { id("com.posthog.android") @@ -86,6 +98,15 @@ dependencies { implementation("androidx.compose.material3:material3") implementation(platform("com.squareup.okhttp3:okhttp-bom:${PosthogBuildConfig.Dependencies.OKHTTP}")) implementation("com.squareup.okhttp3:okhttp") + + // Firebase dependencies for push notifications + implementation(platform("com.google.firebase:firebase-bom:32.7.0")) + implementation("com.google.firebase:firebase-messaging") + // Firebase Analytics (optional - suppresses the analytics library warning) + // Uncomment if you want to use Firebase Analytics + // implementation("com.google.firebase:firebase-analytics") + implementation("com.google.android.gms:play-services-tasks:18.0.2") + debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12") diff --git a/posthog-samples/posthog-android-sample/src/main/AndroidManifest.xml b/posthog-samples/posthog-android-sample/src/main/AndroidManifest.xml index 8ec73477..59f48166 100644 --- a/posthog-samples/posthog-android-sample/src/main/AndroidManifest.xml +++ b/posthog-samples/posthog-android-sample/src/main/AndroidManifest.xml @@ -4,6 +4,8 @@ + + + + + + + + if (!task.isSuccessful) { + Log.w(TAG, "FCM fetching registration token failed", task.exception) + task.exception?.let { + Log.w(TAG, "FCM error details: ${it.message}", it) + } + return@OnCompleteListener + } + + // Get new FCM registration token + val token = task.result + if (token.isNullOrBlank()) { + Log.w(TAG, "FCM received null or blank token") + return@OnCompleteListener + } + + Log.d(TAG, "FCM registration token retrieved: $token") + Log.d(TAG, "FCM registering token with PostHog") + + // Register token with PostHog + val success = PostHogAndroid.registerPushToken(token) + if (success) { + Log.d(TAG, "FCM token successfully registered with PostHog") + } else { + Log.e(TAG, "FCM failed to register token with PostHog") + } + }) + } catch (e: Exception) { + // Firebase might not be initialized or available + Log.w(TAG, "FCM Firebase Messaging not available: ${e.message}", e) + } + } + + /** + * Create notification channel early in Application onCreate + * This ensures the channel exists before any notifications arrive + */ + private fun createNotificationChannelEarly() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channelId = getString(R.string.default_notification_channel_id) + val channelName = getString(R.string.default_notification_channel_name) + val channelDescription = getString(R.string.default_notification_channel_description) + val importance = NotificationManager.IMPORTANCE_HIGH + + val channel = NotificationChannel(channelId, channelName, importance).apply { + description = channelDescription + enableLights(true) + enableVibration(true) + setShowBadge(true) + lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC + } + + val notificationManager = ContextCompat.getSystemService(this, NotificationManager::class.java) + notificationManager?.createNotificationChannel(channel) + Log.d(TAG, "FCM Notification channel created early in Application onCreate: $channelId") + } } private fun enableStrictMode() { @@ -56,4 +175,8 @@ class MyApp : Application() { StrictMode.setVmPolicy(vmPolicyBuilder.build()) } } + + companion object { + private const val TAG = "MyApp" + } } diff --git a/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/NormalActivity.kt b/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/NormalActivity.kt index a9d97982..d1db3851 100644 --- a/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/NormalActivity.kt +++ b/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/NormalActivity.kt @@ -1,9 +1,15 @@ package com.posthog.android.sample +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle +import android.util.Log import android.widget.Button import android.widget.Toast import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat import com.posthog.PostHog import com.posthog.PostHogOkHttpInterceptor import okhttp3.OkHttpClient @@ -15,11 +21,24 @@ class NormalActivity : ComponentActivity() { .addInterceptor(PostHogOkHttpInterceptor(captureNetworkTelemetry = true)) .build() + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean -> + if (isGranted) { + Log.d(TAG, "FCM POST_NOTIFICATIONS permission granted") + } else { + Log.w(TAG, "FCM POST_NOTIFICATIONS permission denied - notifications will not be displayed") + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.normal_activity) + // Request notification permission for Android 13+ (API 33+) + requestNotificationPermission() + // val webview = findViewById(R.id.webview) // webview.loadUrl("https://www.google.com") @@ -75,4 +94,27 @@ class NormalActivity : ComponentActivity() { } } } + + private fun requestNotificationPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + when { + ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED -> { + Log.d(TAG, "FCM POST_NOTIFICATIONS permission already granted") + } + else -> { + Log.d(TAG, "FCM Requesting POST_NOTIFICATIONS permission") + requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + } else { + Log.d(TAG, "FCM POST_NOTIFICATIONS permission not required (Android < 13)") + } + } + + companion object { + private const val TAG = "NormalActivity" + } } diff --git a/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/PostHogFirebaseMessagingService.kt b/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/PostHogFirebaseMessagingService.kt new file mode 100644 index 00000000..13ec56bb --- /dev/null +++ b/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/PostHogFirebaseMessagingService.kt @@ -0,0 +1,224 @@ +package com.posthog.android.sample + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.posthog.android.PostHogAndroid +import com.posthog.android.sample.R + +/** + * Firebase Cloud Messaging service for handling push notifications + * and registering FCM tokens with PostHog + */ +class PostHogFirebaseMessagingService : FirebaseMessagingService() { + + override fun onCreate() { + super.onCreate() + Log.d(TAG, "FCM service created") + try { + createNotificationChannel() + } catch (e: Exception) { + Log.w(TAG, "FCM failed to create notification channel: ${e.message}", e) + } + } + + override fun onNewToken(token: String) { + super.onNewToken(token) + Log.d(TAG, "FCM onNewToken called automatically by Firebase") + Log.d(TAG, "FCM token refreshed: $token") + + if (token.isBlank()) { + Log.w(TAG, "FCM token is blank, skipping registration") + return + } + + // Register the new token with PostHog + Log.d(TAG, "FCM registering token with PostHog") + val success = PostHogAndroid.registerPushToken(token) + if (success) { + Log.d(TAG, "FCM token successfully registered with PostHog") + } else { + Log.e(TAG, "FCM failed to register token with PostHog") + } + } + + override fun onMessageReceived(remoteMessage: RemoteMessage) { + super.onMessageReceived(remoteMessage) + Log.d(TAG, "FCM message received: ${remoteMessage.messageId}") + Log.d(TAG, "FCM From: ${remoteMessage.from}") + Log.d(TAG, "FCM Message data payload: ${remoteMessage.data}") + Log.d(TAG, "FCM Has notification payload: ${remoteMessage.notification != null}") + + // When app is in foreground, onMessageReceived is called for both notification and data messages + // When app is in background: + // - Messages with notification payload: Android auto-displays, onMessageReceived is NOT called + // - Messages with data-only payload: onMessageReceived IS called, we must create notification + + var title: String? = null + var body: String? = null + + // Check if message contains a notification payload + remoteMessage.notification?.let { notification -> + title = notification.title + body = notification.body + Log.d(TAG, "FCM Message Notification Title: $title") + Log.d(TAG, "FCM Message Notification Body: $body") + } ?: run { + // No notification payload - check data payload for title/body + title = remoteMessage.data["title"] + body = remoteMessage.data["body"] ?: remoteMessage.data["message"] + Log.d(TAG, "FCM Data-only message - Title from data: $title, Body from data: $body") + } + + // Display notification if we have title or body + if (!title.isNullOrBlank() || !body.isNullOrBlank()) { + sendNotification(title ?: "PostHog Notification", body ?: "") + } else { + Log.w(TAG, "FCM Message has no title or body to display") + } + + // Process additional data payload if present + if (remoteMessage.data.isNotEmpty()) { + Log.d(TAG, "FCM Message data payload keys: ${remoteMessage.data.keys}") + // You can process the data payload here + // For example, extract custom data and handle it accordingly + } + } + + /** + * Create notification channel for Android 8.0+ + */ + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channelId = getString(R.string.default_notification_channel_id) + val channelName = getString(R.string.default_notification_channel_name) + val channelDescription = getString(R.string.default_notification_channel_description) + // Use HIGH importance to ensure notifications are shown even when app is in background + val importance = NotificationManager.IMPORTANCE_HIGH + + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Check if channel already exists + val existingChannel = notificationManager.getNotificationChannel(channelId) + if (existingChannel != null) { + Log.d(TAG, "FCM Notification channel already exists: $channelId") + // Verify channel importance + if (existingChannel.importance != importance) { + Log.w(TAG, "FCM Notification channel has different importance: ${existingChannel.importance}, expected: $importance") + } + return + } + + val channel = NotificationChannel(channelId, channelName, importance).apply { + description = channelDescription + enableLights(true) + enableVibration(true) + setShowBadge(true) + lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC + } + + notificationManager.createNotificationChannel(channel) + Log.d(TAG, "FCM Notification channel created: $channelId with importance: $importance") + + // Verify channel was created successfully + val createdChannel = notificationManager.getNotificationChannel(channelId) + if (createdChannel != null) { + Log.d(TAG, "FCM Notification channel verified - importance: ${createdChannel.importance}, enabled: ${createdChannel.importance != NotificationManager.IMPORTANCE_NONE}") + } else { + Log.e(TAG, "FCM Notification channel creation failed - channel not found after creation") + } + } + } + + /** + * Display notification when a message is received + */ + private fun sendNotification(title: String, messageBody: String) { + try { + val intent = Intent(this, com.posthog.android.sample.NormalActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + + val pendingIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val channelId = getString(R.string.default_notification_channel_id) + + // Use app icon for notification - fallback to system icon if not available + val smallIcon = try { + // Try to use the app's launcher icon + val appInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getApplicationInfo( + packageName, + PackageManager.ApplicationInfoFlags.of(0) + ) + } else { + @Suppress("DEPRECATION") + packageManager.getApplicationInfo(packageName, 0) + } + val appIcon = appInfo.icon + if (appIcon != 0) appIcon else android.R.drawable.ic_dialog_info + } catch (e: Exception) { + android.R.drawable.ic_dialog_info + } + + val notificationBuilder = NotificationCompat.Builder(this, channelId) + .setSmallIcon(smallIcon) + .setContentTitle(title) + .setContentText(messageBody) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .setPriority(NotificationCompat.PRIORITY_HIGH) // Ensure notification is shown + .setDefaults(NotificationCompat.DEFAULT_ALL) // Sound, vibration, etc. + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) // Show on lock screen + + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Verify notification channel exists and is enabled + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = notificationManager.getNotificationChannel(channelId) + if (channel == null) { + Log.e(TAG, "FCM Notification channel does not exist: $channelId - creating now") + createNotificationChannel() + } else { + Log.d(TAG, "FCM Notification channel exists: $channelId, importance: ${channel.importance}, enabled: ${channel.importance != NotificationManager.IMPORTANCE_NONE}") + if (channel.importance == NotificationManager.IMPORTANCE_NONE) { + Log.w(TAG, "FCM Notification channel is disabled by user") + return + } + } + } + + // Check if notifications are enabled (Android 13+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (!notificationManager.areNotificationsEnabled()) { + Log.w(TAG, "FCM Notifications are disabled by user - cannot display notification") + return + } + } + + // Use a unique ID for each notification + val notificationId = System.currentTimeMillis().toInt() + notificationManager.notify(notificationId, notificationBuilder.build()) + Log.d(TAG, "FCM Notification displayed successfully: $title - $messageBody (ID: $notificationId)") + } catch (e: Exception) { + Log.e(TAG, "FCM Failed to display notification: ${e.message}", e) + } + } + + companion object { + private const val TAG = "PostHogFCMService" + } +} diff --git a/posthog-samples/posthog-android-sample/src/main/res/values/strings.xml b/posthog-samples/posthog-android-sample/src/main/res/values/strings.xml index c3fc1f5d..323e5b4c 100644 --- a/posthog-samples/posthog-android-sample/src/main/res/values/strings.xml +++ b/posthog-samples/posthog-android-sample/src/main/res/values/strings.xml @@ -2,6 +2,11 @@ PostHog Android Sample Text + + posthog_default_channel + PostHog Notifications + Notifications from PostHog + Array Item One Array Item Two diff --git a/posthog-samples/posthog-android-sample/src/main/res/xml/network_security_config.xml b/posthog-samples/posthog-android-sample/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..883b8f7e --- /dev/null +++ b/posthog-samples/posthog-android-sample/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + localhost + 127.0.0.1 + 10.0.2.2 + + diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 94b9ed54..65185902 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -8,6 +8,8 @@ import com.posthog.internal.PostHogPreferences.Companion.ALL_INTERNAL_KEYS import com.posthog.internal.PostHogPreferences.Companion.ANONYMOUS_ID import com.posthog.internal.PostHogPreferences.Companion.BUILD import com.posthog.internal.PostHogPreferences.Companion.DISTINCT_ID +import com.posthog.internal.PostHogPreferences.Companion.FCM_TOKEN +import com.posthog.internal.PostHogPreferences.Companion.FCM_TOKEN_LAST_UPDATED import com.posthog.internal.PostHogPreferences.Companion.GROUPS import com.posthog.internal.PostHogPreferences.Companion.IS_IDENTIFIED import com.posthog.internal.PostHogPreferences.Companion.OPT_OUT @@ -1222,6 +1224,76 @@ public class PostHog private constructor( return PostHogSessionManager.isSessionActive() } + override fun registerPushToken(token: String): Boolean { + if (!isEnabled()) { + return false + } + + if (token.isBlank()) { + config?.logger?.log("registerPushToken called with blank token") + return false + } + + val config = this.config ?: return false + val preferences = getPreferences() + + // Get stored token and last update timestamp + val storedToken = preferences.getValue(FCM_TOKEN) as? String + val lastUpdated = preferences.getValue(FCM_TOKEN_LAST_UPDATED) as? Long ?: 0L + val currentTime = config.dateProvider.currentDate().time + val oneHourInMillis = 60 * 60 * 1000L + + // Check if token has changed or if an hour has passed + val tokenChanged = storedToken != token + val shouldUpdate = tokenChanged || (currentTime - lastUpdated >= oneHourInMillis) + + if (!shouldUpdate) { + config.logger.log("FCM token registration skipped: token unchanged and less than 24 hours since last update") + return true + } + + // Store token and timestamp + preferences.setValue(FCM_TOKEN, token) + preferences.setValue(FCM_TOKEN_LAST_UPDATED, currentTime) + + // Register with backend on a background thread to avoid StrictMode NetworkViolation + // The Firebase callback runs on the main thread, so we need to move the network call off it + return try { + val api = PostHogApi(config) + val distinctId = distinctId() + + // Execute network call on background thread to avoid StrictMode violations + var success = false + var exception: Throwable? = null + val latch = java.util.concurrent.CountDownLatch(1) + + Thread { + try { + api.registerPushSubscription(distinctId, token) + success = true + } catch (e: Throwable) { + exception = e + } finally { + latch.countDown() + } + }.start() + + // Wait for completion (with timeout to avoid blocking indefinitely) + latch.await(5, java.util.concurrent.TimeUnit.SECONDS) + + if (success) { + config.logger.log("FCM token registered successfully") + true + } else { + config.logger.log("Failed to register FCM token: ${exception?.message ?: "Unknown error"}") + false + } + } catch (e: Throwable) { + config.logger.log("Failed to register FCM token: $e") + false + } + } + override fun getConfig(): T? { @Suppress("UNCHECKED_CAST") return super.config as? T @@ -1539,5 +1611,9 @@ public class PostHog private constructor( override fun getSessionId(): UUID? { return shared.getSessionId() } + + override fun registerPushToken(token: String): Boolean { + return shared.registerPushToken(token) + } } } diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index cbf37211..d4d44095 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -223,6 +223,19 @@ public interface PostHogInterface : PostHogCoreInterface { */ public fun resetPersonPropertiesForFlags(reloadFeatureFlags: Boolean = true) + /** + * Registers a push notification token (FCM token) with PostHog. + * The SDK will automatically rate-limit registrations to once per day unless the token has changed. + * + * Users should retrieve the FCM token using: + * - Java: `FirebaseMessaging.getInstance().getToken()` + * - Kotlin: `Firebase.messaging.token` + * + * @param token The FCM registration token + * @return true if registration was successful, false otherwise + */ + public fun registerPushToken(token: String): Boolean + /** * Sets properties for a specific group type to include when evaluating feature flags. * diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index 7e207a48..ddaa5d5e 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -7,6 +7,7 @@ import com.posthog.PostHogConfig.Companion.DEFAULT_US_ASSETS_HOST import com.posthog.PostHogConfig.Companion.DEFAULT_US_HOST import com.posthog.PostHogEvent import com.posthog.PostHogInternal +import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -14,6 +15,7 @@ import okhttp3.RequestBody import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody import okio.BufferedSink +import okio.buffer import java.io.IOException import java.io.OutputStream @@ -63,6 +65,9 @@ public class PostHogApi( config.serializer.serialize(batch, it.bufferedWriter()) } + // Tag thread for StrictMode compliance before making network call + tagThreadForStrictMode() + client.newCall(request).execute().use { val response = logResponse(it) @@ -85,6 +90,9 @@ public class PostHogApi( config.serializer.serialize(events, it.bufferedWriter()) } + // Tag thread for StrictMode compliance before making network call + tagThreadForStrictMode() + client.newCall(request).execute().use { val response = logResponse(it) @@ -141,6 +149,9 @@ public class PostHogApi( config.serializer.serialize(flagsRequest, it.bufferedWriter()) } + // Tag thread for StrictMode compliance before making network call + tagThreadForStrictMode() + client.newCall(request).execute().use { val response = logResponse(it) @@ -177,6 +188,9 @@ public class PostHogApi( .get() .build() + // Tag thread for StrictMode compliance before making network call + tagThreadForStrictMode() + client.newCall(request).execute().use { val response = logResponse(it) @@ -223,6 +237,9 @@ public class PostHogApi( val request = requestBuilder.get().build() + // Tag thread for StrictMode compliance before making network call + tagThreadForStrictMode() + client.newCall(request).execute().use { val response = logResponse(it) @@ -253,6 +270,55 @@ public class PostHogApi( } } + @Throws(PostHogApiError::class, IOException::class) + public fun registerPushSubscription( + distinctId: String, + token: String, + ) { + val pushSubscriptionRequest = + PostHogPushSubscriptionRequest( + api_key = config.apiKey, + distinct_id = distinctId, + token = token, + platform = "android", + ) + + val url = "$theHost/api/sdk/push_subscriptions/register" + + logRequest(pushSubscriptionRequest, url) + + val request = + makeRequest(url) { + config.serializer.serialize(pushSubscriptionRequest, it.bufferedWriter()) + } + + // Tag thread for StrictMode compliance before making network call + tagThreadForStrictMode() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw PostHogApiError(response.code, response.message, response.body) + } + } + } + + /** + * Tags the current thread for StrictMode compliance on Android + * This prevents "Untagged socket detected" warnings + */ + private fun tagThreadForStrictMode() { + try { + // Use reflection to set traffic stats tag if available (Android only) + val trafficStatsClass = Class.forName("android.net.TrafficStats") + val setThreadStatsTagMethod = trafficStatsClass.getMethod("setThreadStatsTag", Int::class.javaPrimitiveType) + setThreadStatsTagMethod.invoke(null, 0xFFFF) // Use a non-zero tag + } catch (e: ClassNotFoundException) { + // TrafficStats not available (not Android) - this is expected on non-Android platforms + } catch (e: Exception) { + // Other exceptions (NoSuchMethodException, etc.) - ignore silently + } + } + private fun logResponse(response: Response): Response { if (config.debug) { try { diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt b/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt index ed06f51d..9186f581 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt @@ -44,6 +44,8 @@ public interface PostHogPreferences { public const val VERSION: String = "version" public const val BUILD: String = "build" public const val STRINGIFIED_KEYS: String = "stringifiedKeys" + internal const val FCM_TOKEN = "fcmToken" + internal const val FCM_TOKEN_LAST_UPDATED = "fcmTokenLastUpdated" public val ALL_INTERNAL_KEYS: Set = setOf( @@ -66,6 +68,8 @@ public interface PostHogPreferences { FLAGS, PERSON_PROPERTIES_FOR_FLAGS, GROUP_PROPERTIES_FOR_FLAGS, + FCM_TOKEN, + FCM_TOKEN_LAST_UPDATED, ) } } diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionRequest.kt b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionRequest.kt new file mode 100644 index 00000000..6d6dd8b4 --- /dev/null +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionRequest.kt @@ -0,0 +1,14 @@ +package com.posthog.internal + +import com.posthog.PostHogInternal + +/** + * Request body for push subscription registration + */ +@PostHogInternal +public data class PostHogPushSubscriptionRequest( + val api_key: String, + val distinct_id: String, + val token: String, + val platform: String, +) diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 1fb808ef..d703606e 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -3,6 +3,8 @@ package com.posthog import com.posthog.internal.PostHogBatchEvent import com.posthog.internal.PostHogContext import com.posthog.internal.PostHogMemoryPreferences +import com.posthog.internal.PostHogPreferences.Companion.FCM_TOKEN +import com.posthog.internal.PostHogPreferences.Companion.FCM_TOKEN_LAST_UPDATED import com.posthog.internal.PostHogPreferences.Companion.GROUPS import com.posthog.internal.PostHogPreferences.Companion.GROUP_PROPERTIES_FOR_FLAGS import com.posthog.internal.PostHogPreferences.Companion.PERSON_PROPERTIES_FOR_FLAGS @@ -2608,4 +2610,175 @@ internal class PostHogTest { sut.close() } + + @Test + fun `registerPushToken successfully registers token`() { + val responseBody = """{"status": "ok", "subscription_id": "test-subscription-id"}""" + val http = mockHttp( + response = + MockResponse() + .setResponseCode(200) + .setBody(responseBody), + ) + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false) + + val result = sut.registerPushToken("test-fcm-token") + + // Wait for background thread to complete + Thread.sleep(100) + + assertTrue(result) + assertEquals(1, http.requestCount) + + val request = http.takeRequest() + assertEquals("/api/sdk/push_subscriptions/register", request.path) + assertEquals("POST", request.method) + + // Verify request body contains expected fields + val requestBody = request.body.readUtf8() + assertTrue(requestBody.contains("\"api_key\"")) + assertTrue(requestBody.contains("\"distinct_id\"")) + assertTrue(requestBody.contains("\"token\":\"test-fcm-token\"")) + assertTrue(requestBody.contains("\"platform\":\"android\"")) + + sut.close() + } + + @Test + fun `registerPushToken returns false when SDK is disabled`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false) + sut.close() + + val result = sut.registerPushToken("test-fcm-token") + + assertFalse(result) + assertEquals(0, http.requestCount) + } + + @Test + fun `registerPushToken returns false for blank token`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false) + + val result = sut.registerPushToken("") + + assertFalse(result) + assertEquals(0, http.requestCount) + + sut.close() + } + + @Test + fun `registerPushToken skips registration when token unchanged and less than 24 hours`() { + val responseBody = """{"status": "ok", "subscription_id": "test-subscription-id"}""" + val http = mockHttp( + total = 2, + response = + MockResponse() + .setResponseCode(200) + .setBody(responseBody), + ) + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false) + + // First registration + val result1 = sut.registerPushToken("test-fcm-token") + Thread.sleep(100) // Wait for background thread + assertTrue(result1) + assertEquals(1, http.requestCount) + + // Second registration with same token immediately - should skip API call + val result2 = sut.registerPushToken("test-fcm-token") + Thread.sleep(100) // Wait for background thread + assertTrue(result2) + // Should not make a second request when token is unchanged and less than 24 hours + assertEquals(1, http.requestCount) + + sut.close() + } + + @Test + fun `registerPushToken registers again when token changes`() { + val responseBody = """{"status": "ok", "subscription_id": "test-subscription-id"}""" + val http = mockHttp( + total = 2, + response = + MockResponse() + .setResponseCode(200) + .setBody(responseBody), + ) + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false) + + // First registration + val result1 = sut.registerPushToken("test-fcm-token-1") + Thread.sleep(100) // Wait for background thread + assertTrue(result1) + assertEquals(1, http.requestCount) + + // Second registration with different token - should register again + val result2 = sut.registerPushToken("test-fcm-token-2") + Thread.sleep(100) // Wait for background thread + assertTrue(result2) + assertEquals(2, http.requestCount) + + sut.close() + } + + @Test + fun `registerPushToken returns false on API error`() { + val http = mockHttp( + response = + MockResponse() + .setResponseCode(400) + .setBody("Bad Request"), + ) + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false) + + val result = sut.registerPushToken("test-fcm-token") + + Thread.sleep(100) // Wait for background thread + assertFalse(result) + assertEquals(1, http.requestCount) + + sut.close() + } + + @Test + fun `registerPushToken stores token and timestamp in preferences`() { + val responseBody = """{"status": "ok", "subscription_id": "test-subscription-id"}""" + val http = mockHttp( + response = + MockResponse() + .setResponseCode(200) + .setBody(responseBody), + ) + val url = http.url("/") + val preferences = PostHogMemoryPreferences() + + val sut = getSut(url.toString(), preloadFeatureFlags = false, cachePreferences = preferences) + + sut.registerPushToken("test-fcm-token") + Thread.sleep(100) // Wait for background thread + + val storedToken = preferences.getValue(FCM_TOKEN) as? String + val lastUpdated = preferences.getValue(FCM_TOKEN_LAST_UPDATED) as? Long + + assertEquals("test-fcm-token", storedToken) + assertNotNull(lastUpdated) + assertTrue(lastUpdated!! > 0) + + sut.close() + } } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt index 0f85fa98..fc0cdff1 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt @@ -310,4 +310,67 @@ internal class PostHogApiTest { } assertEquals(401, exc.statusCode) } + + @Test + fun `registerPushSubscription returns successful response`() { + val responseBody = """{"status": "ok", "subscription_id": "test-subscription-id"}""" + val http = mockHttp( + response = + MockResponse() + .setResponseCode(200) + .setBody(responseBody), + ) + val url = http.url("/") + + val sut = getSut(host = url.toString()) + + sut.registerPushSubscription("test-distinct-id", "test-fcm-token") + + val request = http.takeRequest() + + assertEquals("posthog-java/${BuildConfig.VERSION_NAME}", request.headers["User-Agent"]) + assertEquals("POST", request.method) + assertEquals("/api/sdk/push_subscriptions/register", request.path) + assertEquals("gzip", request.headers["Content-Encoding"]) + assertEquals("gzip", request.headers["Accept-Encoding"]) + assertEquals("application/json; charset=utf-8", request.headers["Content-Type"]) + + // Verify request body contains expected fields + val requestBody = request.body.readUtf8() + assertTrue(requestBody.contains("\"api_key\":\"$API_KEY\"")) + assertTrue(requestBody.contains("\"distinct_id\":\"test-distinct-id\"")) + assertTrue(requestBody.contains("\"token\":\"test-fcm-token\"")) + assertTrue(requestBody.contains("\"platform\":\"android\"")) + } + + @Test + fun `registerPushSubscription throws if not successful`() { + val http = mockHttp(response = MockResponse().setResponseCode(400).setBody("Bad Request")) + val url = http.url("/") + + val sut = getSut(host = url.toString()) + + val exc = + assertThrows(PostHogApiError::class.java) { + sut.registerPushSubscription("test-distinct-id", "test-fcm-token") + } + assertEquals(400, exc.statusCode) + assertEquals("Client Error", exc.message) + assertNotNull(exc.body) + } + + @Test + fun `registerPushSubscription throws on 401 unauthorized`() { + val http = mockHttp(response = MockResponse().setResponseCode(401).setBody("""{"error": "Invalid API key"}""")) + val url = http.url("/") + + val sut = getSut(host = url.toString()) + + val exc = + assertThrows(PostHogApiError::class.java) { + sut.registerPushSubscription("test-distinct-id", "test-fcm-token") + } + assertEquals(401, exc.statusCode) + assertEquals("Client Error", exc.message) + } } From b3a5aee1424d3faee03e73a83a99ff6bd7f75c77 Mon Sep 17 00:00:00 2001 From: odin kammerloher Date: Tue, 20 Jan 2026 18:26:40 +0100 Subject: [PATCH 02/14] Update changelogs --- posthog-android/CHANGELOG.md | 1 + posthog/CHANGELOG.md | 1 + 2 files changed, 2 insertions(+) diff --git a/posthog-android/CHANGELOG.md b/posthog-android/CHANGELOG.md index 2cda2ca8..791e1c70 100644 --- a/posthog-android/CHANGELOG.md +++ b/posthog-android/CHANGELOG.md @@ -1,4 +1,5 @@ ## Next +- Allow collecting FCM device token in SDK ([#376](https://github.com/PostHog/posthog-android/pull/376)) ## 3.29.1 - 2026-01-21 diff --git a/posthog/CHANGELOG.md b/posthog/CHANGELOG.md index d8ead894..afc418cf 100644 --- a/posthog/CHANGELOG.md +++ b/posthog/CHANGELOG.md @@ -1,4 +1,5 @@ ## Next +- Allow collecting FCM device token in SDK ([#376](https://github.com/PostHog/posthog-android/pull/376)) ## 6.3.0 - 2025-01-21 From 2359402ab00c8830a4fb02bc8e51b3fc95692876 Mon Sep 17 00:00:00 2001 From: odin kammerloher Date: Mon, 26 Jan 2026 14:21:04 +0100 Subject: [PATCH 03/14] feat: allow collecting FCM tokens for push notifications in core --- posthog/CHANGELOG.md | 2 + posthog/src/main/java/com/posthog/PostHog.kt | 54 ++++++++++--------- .../src/test/java/com/posthog/PostHogTest.kt | 4 +- 3 files changed, 34 insertions(+), 26 deletions(-) diff --git a/posthog/CHANGELOG.md b/posthog/CHANGELOG.md index afc418cf..16d4a898 100644 --- a/posthog/CHANGELOG.md +++ b/posthog/CHANGELOG.md @@ -1,4 +1,6 @@ ## Next + +### Added - Allow collecting FCM device token in SDK ([#376](https://github.com/PostHog/posthog-android/pull/376)) ## 6.3.0 - 2025-01-21 diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 65185902..878e7c53 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -14,6 +14,7 @@ import com.posthog.internal.PostHogPreferences.Companion.GROUPS import com.posthog.internal.PostHogPreferences.Companion.IS_IDENTIFIED import com.posthog.internal.PostHogPreferences.Companion.OPT_OUT import com.posthog.internal.PostHogPreferences.Companion.PERSON_PROCESSING +import com.posthog.internal.PostHogPreferences.Companion.PERSON_PROPERTIES_FOR_FLAGS import com.posthog.internal.PostHogPreferences.Companion.VERSION import com.posthog.internal.PostHogPrintLogger import com.posthog.internal.PostHogQueueInterface @@ -1248,7 +1249,7 @@ public class PostHog private constructor( val shouldUpdate = tokenChanged || (currentTime - lastUpdated >= oneHourInMillis) if (!shouldUpdate) { - config.logger.log("FCM token registration skipped: token unchanged and less than 24 hours since last update") + config.logger.log("FCM token registration skipped: token unchanged and less than hour since last update") return true } @@ -1262,31 +1263,35 @@ public class PostHog private constructor( val api = PostHogApi(config) val distinctId = distinctId() - // Execute network call on background thread to avoid StrictMode violations - var success = false - var exception: Throwable? = null - val latch = java.util.concurrent.CountDownLatch(1) - - Thread { - try { - api.registerPushSubscription(distinctId, token) - success = true - } catch (e: Throwable) { - exception = e - } finally { - latch.countDown() - } - }.start() - - // Wait for completion (with timeout to avoid blocking indefinitely) - latch.await(5, java.util.concurrent.TimeUnit.SECONDS) - - if (success) { - config.logger.log("FCM token registered successfully") + // Use ExecutorService for more idiomatic async handling with timeout + val executor = Executors.newSingleThreadExecutor( + PostHogThreadFactory("PostHogFCMTokenRegistration") + ) + val future = executor.submit { + api.registerPushSubscription(distinctId, token) true - } else { - config.logger.log("Failed to register FCM token: ${exception?.message ?: "Unknown error"}") + } + try { + // Wait for completion with timeout (5 seconds) + val success = future.get(5, java.util.concurrent.TimeUnit.SECONDS) + + config.logger.log("FCM token registered successfully") + success + } catch (e: java.util.concurrent.TimeoutException) { + config.logger.log("Failed to register FCM token: Timeout after 5 seconds") + // Cancel the task after it timed out + future.cancel(true) + false + } catch (e: java.util.concurrent.ExecutionException) { + // Extract the underlying exception from ExecutionException + val cause = e.cause ?: e + config.logger.log("Failed to register FCM token: ${cause.message ?: "Unknown error"}") false + } catch (e: Throwable) { + config.logger.log("Failed to register FCM token: ${e.message ?: "Unknown error"}") + false + } finally { + executor.shutdown() } } catch (e: Throwable) { config.logger.log("Failed to register FCM token: $e") @@ -1294,6 +1299,7 @@ public class PostHog private constructor( } } + override fun getConfig(): T? { @Suppress("UNCHECKED_CAST") return super.config as? T diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index d703606e..95469541 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -2676,7 +2676,7 @@ internal class PostHogTest { } @Test - fun `registerPushToken skips registration when token unchanged and less than 24 hours`() { + fun `registerPushToken skips registration when token unchanged and less than 1 hour`() { val responseBody = """{"status": "ok", "subscription_id": "test-subscription-id"}""" val http = mockHttp( total = 2, @@ -2699,7 +2699,7 @@ internal class PostHogTest { val result2 = sut.registerPushToken("test-fcm-token") Thread.sleep(100) // Wait for background thread assertTrue(result2) - // Should not make a second request when token is unchanged and less than 24 hours + // Should not make a second request when token is unchanged and less than 1 hour assertEquals(1, http.requestCount) sut.close() From 82ec4a9814c2e189371614d70b7e114f1a7483c2 Mon Sep 17 00:00:00 2001 From: odin kammerloher Date: Mon, 26 Jan 2026 14:24:18 +0100 Subject: [PATCH 04/14] Update PR in changelog --- posthog/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/posthog/CHANGELOG.md b/posthog/CHANGELOG.md index 16d4a898..0f05f2f2 100644 --- a/posthog/CHANGELOG.md +++ b/posthog/CHANGELOG.md @@ -1,7 +1,7 @@ ## Next ### Added -- Allow collecting FCM device token in SDK ([#376](https://github.com/PostHog/posthog-android/pull/376)) +- Allow collecting FCM device token in SDK core ([#396](https://github.com/PostHog/posthog-android/pull/396)) ## 6.3.0 - 2025-01-21 From b70dfe77a34f6fd5369d60be4bf9839c38e45c31 Mon Sep 17 00:00:00 2001 From: odin kammerloher Date: Mon, 26 Jan 2026 14:35:44 +0100 Subject: [PATCH 05/14] chore: remove non-core changes, keep only posthog/ module changes --- .gitignore | 3 - posthog-android/CHANGELOG.md | 1 - .../com/posthog/android/PostHogAndroid.kt | 15 -- .../posthog-android-sample/README.md | 39 --- .../posthog-android-sample/build.gradle.kts | 21 -- .../src/main/AndroidManifest.xml | 10 - .../java/com/posthog/android/sample/MyApp.kt | 141 +---------- .../posthog/android/sample/NormalActivity.kt | 42 ---- .../sample/PostHogFirebaseMessagingService.kt | 224 ------------------ .../src/main/res/values/strings.xml | 5 - .../main/res/xml/network_security_config.xml | 9 - .../java/com/posthog/internal/PostHogApi.kt | 15 -- 12 files changed, 9 insertions(+), 516 deletions(-) delete mode 100644 posthog-samples/posthog-android-sample/README.md delete mode 100644 posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/PostHogFirebaseMessagingService.kt delete mode 100644 posthog-samples/posthog-android-sample/src/main/res/xml/network_security_config.xml diff --git a/.gitignore b/.gitignore index 4ebb64f3..3b4c13aa 100644 --- a/.gitignore +++ b/.gitignore @@ -162,6 +162,3 @@ gradle-app.setting # vscode .vscode/settings.json - -# Firebase configuration (contains sensitive project info) -**/google-services.json diff --git a/posthog-android/CHANGELOG.md b/posthog-android/CHANGELOG.md index 791e1c70..2cda2ca8 100644 --- a/posthog-android/CHANGELOG.md +++ b/posthog-android/CHANGELOG.md @@ -1,5 +1,4 @@ ## Next -- Allow collecting FCM device token in SDK ([#376](https://github.com/PostHog/posthog-android/pull/376)) ## 3.29.1 - 2026-01-21 diff --git a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt index a35f5e45..3d82a65c 100644 --- a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt +++ b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt @@ -71,21 +71,6 @@ public class PostHogAndroid private constructor() { return PostHog.with(config) } - /** - * Registers a push notification token (FCM token) with PostHog. - * The SDK will automatically rate-limit registrations to once per day unless the token has changed. - * - * Users should retrieve the FCM token using: - * - Java: `FirebaseMessaging.getInstance().getToken()` - * - Kotlin: `Firebase.messaging.token` - * - * @param token The FCM registration token - * @return true if registration was successful, false otherwise - */ - public fun registerPushToken(token: String): Boolean { - return PostHog.registerPushToken(token) - } - private fun setAndroidConfig( context: Context, config: T, diff --git a/posthog-samples/posthog-android-sample/README.md b/posthog-samples/posthog-android-sample/README.md deleted file mode 100644 index 57ba84f6..00000000 --- a/posthog-samples/posthog-android-sample/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# PostHog Android Sample App - -This sample app demonstrates how to integrate the PostHog Android SDK, including push notification support via Firebase Cloud Messaging (FCM). - -## Setup - -### Firebase Configuration (Optional - for Push Notifications) - -To enable push notification features in this sample app, you need to configure Firebase: - -1. **Create a Firebase Project** (if you don't have one): - - Go to [Firebase Console](https://console.firebase.google.com/) - - Click "Add project" and follow the setup wizard - -2. **Add Android App to Firebase**: - - In your Firebase project, click "Add app" and select Android - - Enter the package name: `com.posthog.android.sample` - - Register the app - -3. **Download `google-services.json`**: - - Download the `google-services.json` file from Firebase Console - - Place it in the `posthog-samples/posthog-android-sample/` directory (same level as `build.gradle.kts`) - -4. **Verify the file location**: - ``` - posthog-samples/posthog-android-sample/ - ├── build.gradle.kts - ├── google-services.json ← Place it here - └── src/ - ``` - -**Note:** The `google-services.json` file is gitignored as it contains project-specific configuration. Each developer needs to add their own file. - -### Running Without Firebase - -If you don't have a `google-services.json` file, the app will still run but push notification features will be disabled: -- FCM token registration will be skipped -- Push notifications will not be received -- All other PostHog SDK features will work normally diff --git a/posthog-samples/posthog-android-sample/build.gradle.kts b/posthog-samples/posthog-android-sample/build.gradle.kts index 52251aba..fee9f78d 100644 --- a/posthog-samples/posthog-android-sample/build.gradle.kts +++ b/posthog-samples/posthog-android-sample/build.gradle.kts @@ -1,18 +1,6 @@ -buildscript { - repositories { - google() - mavenCentral() - } - dependencies { - classpath("com.google.gms:google-services:4.4.0") - } -} - plugins { id("com.android.application") kotlin("android") - // TODOdin: Add to gitignore and document that it's needed? - id("com.google.gms.google-services") // so we don't upload mappings from our release builds on CI if (!PosthogBuildConfig.isCI()) { id("com.posthog.android") @@ -98,15 +86,6 @@ dependencies { implementation("androidx.compose.material3:material3") implementation(platform("com.squareup.okhttp3:okhttp-bom:${PosthogBuildConfig.Dependencies.OKHTTP}")) implementation("com.squareup.okhttp3:okhttp") - - // Firebase dependencies for push notifications - implementation(platform("com.google.firebase:firebase-bom:32.7.0")) - implementation("com.google.firebase:firebase-messaging") - // Firebase Analytics (optional - suppresses the analytics library warning) - // Uncomment if you want to use Firebase Analytics - // implementation("com.google.firebase:firebase-analytics") - implementation("com.google.android.gms:play-services-tasks:18.0.2") - debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12") diff --git a/posthog-samples/posthog-android-sample/src/main/AndroidManifest.xml b/posthog-samples/posthog-android-sample/src/main/AndroidManifest.xml index 59f48166..8ec73477 100644 --- a/posthog-samples/posthog-android-sample/src/main/AndroidManifest.xml +++ b/posthog-samples/posthog-android-sample/src/main/AndroidManifest.xml @@ -4,8 +4,6 @@ - - - - - - - - if (!task.isSuccessful) { - Log.w(TAG, "FCM fetching registration token failed", task.exception) - task.exception?.let { - Log.w(TAG, "FCM error details: ${it.message}", it) - } - return@OnCompleteListener - } - - // Get new FCM registration token - val token = task.result - if (token.isNullOrBlank()) { - Log.w(TAG, "FCM received null or blank token") - return@OnCompleteListener - } - - Log.d(TAG, "FCM registration token retrieved: $token") - Log.d(TAG, "FCM registering token with PostHog") - - // Register token with PostHog - val success = PostHogAndroid.registerPushToken(token) - if (success) { - Log.d(TAG, "FCM token successfully registered with PostHog") - } else { - Log.e(TAG, "FCM failed to register token with PostHog") - } - }) - } catch (e: Exception) { - // Firebase might not be initialized or available - Log.w(TAG, "FCM Firebase Messaging not available: ${e.message}", e) - } - } - - /** - * Create notification channel early in Application onCreate - * This ensures the channel exists before any notifications arrive - */ - private fun createNotificationChannelEarly() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channelId = getString(R.string.default_notification_channel_id) - val channelName = getString(R.string.default_notification_channel_name) - val channelDescription = getString(R.string.default_notification_channel_description) - val importance = NotificationManager.IMPORTANCE_HIGH - - val channel = NotificationChannel(channelId, channelName, importance).apply { - description = channelDescription - enableLights(true) - enableVibration(true) - setShowBadge(true) - lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC - } - - val notificationManager = ContextCompat.getSystemService(this, NotificationManager::class.java) - notificationManager?.createNotificationChannel(channel) - Log.d(TAG, "FCM Notification channel created early in Application onCreate: $channelId") - } } private fun enableStrictMode() { @@ -175,8 +56,4 @@ class MyApp : Application() { StrictMode.setVmPolicy(vmPolicyBuilder.build()) } } - - companion object { - private const val TAG = "MyApp" - } } diff --git a/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/NormalActivity.kt b/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/NormalActivity.kt index d1db3851..a9d97982 100644 --- a/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/NormalActivity.kt +++ b/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/NormalActivity.kt @@ -1,15 +1,9 @@ package com.posthog.android.sample -import android.Manifest -import android.content.pm.PackageManager -import android.os.Build import android.os.Bundle -import android.util.Log import android.widget.Button import android.widget.Toast import androidx.activity.ComponentActivity -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.ContextCompat import com.posthog.PostHog import com.posthog.PostHogOkHttpInterceptor import okhttp3.OkHttpClient @@ -21,24 +15,11 @@ class NormalActivity : ComponentActivity() { .addInterceptor(PostHogOkHttpInterceptor(captureNetworkTelemetry = true)) .build() - private val requestPermissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestPermission() - ) { isGranted: Boolean -> - if (isGranted) { - Log.d(TAG, "FCM POST_NOTIFICATIONS permission granted") - } else { - Log.w(TAG, "FCM POST_NOTIFICATIONS permission denied - notifications will not be displayed") - } - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.normal_activity) - // Request notification permission for Android 13+ (API 33+) - requestNotificationPermission() - // val webview = findViewById(R.id.webview) // webview.loadUrl("https://www.google.com") @@ -94,27 +75,4 @@ class NormalActivity : ComponentActivity() { } } } - - private fun requestNotificationPermission() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - when { - ContextCompat.checkSelfPermission( - this, - Manifest.permission.POST_NOTIFICATIONS - ) == PackageManager.PERMISSION_GRANTED -> { - Log.d(TAG, "FCM POST_NOTIFICATIONS permission already granted") - } - else -> { - Log.d(TAG, "FCM Requesting POST_NOTIFICATIONS permission") - requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - } - } else { - Log.d(TAG, "FCM POST_NOTIFICATIONS permission not required (Android < 13)") - } - } - - companion object { - private const val TAG = "NormalActivity" - } } diff --git a/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/PostHogFirebaseMessagingService.kt b/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/PostHogFirebaseMessagingService.kt deleted file mode 100644 index 13ec56bb..00000000 --- a/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/PostHogFirebaseMessagingService.kt +++ /dev/null @@ -1,224 +0,0 @@ -package com.posthog.android.sample - -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.os.Build -import android.util.Log -import androidx.core.app.NotificationCompat -import com.google.firebase.messaging.FirebaseMessagingService -import com.google.firebase.messaging.RemoteMessage -import com.posthog.android.PostHogAndroid -import com.posthog.android.sample.R - -/** - * Firebase Cloud Messaging service for handling push notifications - * and registering FCM tokens with PostHog - */ -class PostHogFirebaseMessagingService : FirebaseMessagingService() { - - override fun onCreate() { - super.onCreate() - Log.d(TAG, "FCM service created") - try { - createNotificationChannel() - } catch (e: Exception) { - Log.w(TAG, "FCM failed to create notification channel: ${e.message}", e) - } - } - - override fun onNewToken(token: String) { - super.onNewToken(token) - Log.d(TAG, "FCM onNewToken called automatically by Firebase") - Log.d(TAG, "FCM token refreshed: $token") - - if (token.isBlank()) { - Log.w(TAG, "FCM token is blank, skipping registration") - return - } - - // Register the new token with PostHog - Log.d(TAG, "FCM registering token with PostHog") - val success = PostHogAndroid.registerPushToken(token) - if (success) { - Log.d(TAG, "FCM token successfully registered with PostHog") - } else { - Log.e(TAG, "FCM failed to register token with PostHog") - } - } - - override fun onMessageReceived(remoteMessage: RemoteMessage) { - super.onMessageReceived(remoteMessage) - Log.d(TAG, "FCM message received: ${remoteMessage.messageId}") - Log.d(TAG, "FCM From: ${remoteMessage.from}") - Log.d(TAG, "FCM Message data payload: ${remoteMessage.data}") - Log.d(TAG, "FCM Has notification payload: ${remoteMessage.notification != null}") - - // When app is in foreground, onMessageReceived is called for both notification and data messages - // When app is in background: - // - Messages with notification payload: Android auto-displays, onMessageReceived is NOT called - // - Messages with data-only payload: onMessageReceived IS called, we must create notification - - var title: String? = null - var body: String? = null - - // Check if message contains a notification payload - remoteMessage.notification?.let { notification -> - title = notification.title - body = notification.body - Log.d(TAG, "FCM Message Notification Title: $title") - Log.d(TAG, "FCM Message Notification Body: $body") - } ?: run { - // No notification payload - check data payload for title/body - title = remoteMessage.data["title"] - body = remoteMessage.data["body"] ?: remoteMessage.data["message"] - Log.d(TAG, "FCM Data-only message - Title from data: $title, Body from data: $body") - } - - // Display notification if we have title or body - if (!title.isNullOrBlank() || !body.isNullOrBlank()) { - sendNotification(title ?: "PostHog Notification", body ?: "") - } else { - Log.w(TAG, "FCM Message has no title or body to display") - } - - // Process additional data payload if present - if (remoteMessage.data.isNotEmpty()) { - Log.d(TAG, "FCM Message data payload keys: ${remoteMessage.data.keys}") - // You can process the data payload here - // For example, extract custom data and handle it accordingly - } - } - - /** - * Create notification channel for Android 8.0+ - */ - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channelId = getString(R.string.default_notification_channel_id) - val channelName = getString(R.string.default_notification_channel_name) - val channelDescription = getString(R.string.default_notification_channel_description) - // Use HIGH importance to ensure notifications are shown even when app is in background - val importance = NotificationManager.IMPORTANCE_HIGH - - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - // Check if channel already exists - val existingChannel = notificationManager.getNotificationChannel(channelId) - if (existingChannel != null) { - Log.d(TAG, "FCM Notification channel already exists: $channelId") - // Verify channel importance - if (existingChannel.importance != importance) { - Log.w(TAG, "FCM Notification channel has different importance: ${existingChannel.importance}, expected: $importance") - } - return - } - - val channel = NotificationChannel(channelId, channelName, importance).apply { - description = channelDescription - enableLights(true) - enableVibration(true) - setShowBadge(true) - lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC - } - - notificationManager.createNotificationChannel(channel) - Log.d(TAG, "FCM Notification channel created: $channelId with importance: $importance") - - // Verify channel was created successfully - val createdChannel = notificationManager.getNotificationChannel(channelId) - if (createdChannel != null) { - Log.d(TAG, "FCM Notification channel verified - importance: ${createdChannel.importance}, enabled: ${createdChannel.importance != NotificationManager.IMPORTANCE_NONE}") - } else { - Log.e(TAG, "FCM Notification channel creation failed - channel not found after creation") - } - } - } - - /** - * Display notification when a message is received - */ - private fun sendNotification(title: String, messageBody: String) { - try { - val intent = Intent(this, com.posthog.android.sample.NormalActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - } - - val pendingIntent = PendingIntent.getActivity( - this, - 0, - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - - val channelId = getString(R.string.default_notification_channel_id) - - // Use app icon for notification - fallback to system icon if not available - val smallIcon = try { - // Try to use the app's launcher icon - val appInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager.getApplicationInfo( - packageName, - PackageManager.ApplicationInfoFlags.of(0) - ) - } else { - @Suppress("DEPRECATION") - packageManager.getApplicationInfo(packageName, 0) - } - val appIcon = appInfo.icon - if (appIcon != 0) appIcon else android.R.drawable.ic_dialog_info - } catch (e: Exception) { - android.R.drawable.ic_dialog_info - } - - val notificationBuilder = NotificationCompat.Builder(this, channelId) - .setSmallIcon(smallIcon) - .setContentTitle(title) - .setContentText(messageBody) - .setAutoCancel(true) - .setContentIntent(pendingIntent) - .setPriority(NotificationCompat.PRIORITY_HIGH) // Ensure notification is shown - .setDefaults(NotificationCompat.DEFAULT_ALL) // Sound, vibration, etc. - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) // Show on lock screen - - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - // Verify notification channel exists and is enabled - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = notificationManager.getNotificationChannel(channelId) - if (channel == null) { - Log.e(TAG, "FCM Notification channel does not exist: $channelId - creating now") - createNotificationChannel() - } else { - Log.d(TAG, "FCM Notification channel exists: $channelId, importance: ${channel.importance}, enabled: ${channel.importance != NotificationManager.IMPORTANCE_NONE}") - if (channel.importance == NotificationManager.IMPORTANCE_NONE) { - Log.w(TAG, "FCM Notification channel is disabled by user") - return - } - } - } - - // Check if notifications are enabled (Android 13+) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (!notificationManager.areNotificationsEnabled()) { - Log.w(TAG, "FCM Notifications are disabled by user - cannot display notification") - return - } - } - - // Use a unique ID for each notification - val notificationId = System.currentTimeMillis().toInt() - notificationManager.notify(notificationId, notificationBuilder.build()) - Log.d(TAG, "FCM Notification displayed successfully: $title - $messageBody (ID: $notificationId)") - } catch (e: Exception) { - Log.e(TAG, "FCM Failed to display notification: ${e.message}", e) - } - } - - companion object { - private const val TAG = "PostHogFCMService" - } -} diff --git a/posthog-samples/posthog-android-sample/src/main/res/values/strings.xml b/posthog-samples/posthog-android-sample/src/main/res/values/strings.xml index 323e5b4c..c3fc1f5d 100644 --- a/posthog-samples/posthog-android-sample/src/main/res/values/strings.xml +++ b/posthog-samples/posthog-android-sample/src/main/res/values/strings.xml @@ -2,11 +2,6 @@ PostHog Android Sample Text - - posthog_default_channel - PostHog Notifications - Notifications from PostHog - Array Item One Array Item Two diff --git a/posthog-samples/posthog-android-sample/src/main/res/xml/network_security_config.xml b/posthog-samples/posthog-android-sample/src/main/res/xml/network_security_config.xml deleted file mode 100644 index 883b8f7e..00000000 --- a/posthog-samples/posthog-android-sample/src/main/res/xml/network_security_config.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - localhost - 127.0.0.1 - 10.0.2.2 - - diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index ddaa5d5e..0d5fd9ee 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -65,9 +65,6 @@ public class PostHogApi( config.serializer.serialize(batch, it.bufferedWriter()) } - // Tag thread for StrictMode compliance before making network call - tagThreadForStrictMode() - client.newCall(request).execute().use { val response = logResponse(it) @@ -90,9 +87,6 @@ public class PostHogApi( config.serializer.serialize(events, it.bufferedWriter()) } - // Tag thread for StrictMode compliance before making network call - tagThreadForStrictMode() - client.newCall(request).execute().use { val response = logResponse(it) @@ -149,9 +143,6 @@ public class PostHogApi( config.serializer.serialize(flagsRequest, it.bufferedWriter()) } - // Tag thread for StrictMode compliance before making network call - tagThreadForStrictMode() - client.newCall(request).execute().use { val response = logResponse(it) @@ -188,9 +179,6 @@ public class PostHogApi( .get() .build() - // Tag thread for StrictMode compliance before making network call - tagThreadForStrictMode() - client.newCall(request).execute().use { val response = logResponse(it) @@ -237,9 +225,6 @@ public class PostHogApi( val request = requestBuilder.get().build() - // Tag thread for StrictMode compliance before making network call - tagThreadForStrictMode() - client.newCall(request).execute().use { val response = logResponse(it) From 3fe9042c9eb9d8799f1e76ea4bda4471766b9afa Mon Sep 17 00:00:00 2001 From: odin kammerloher Date: Mon, 26 Jan 2026 14:45:42 +0100 Subject: [PATCH 06/14] Fix up minor issues --- posthog/src/main/java/com/posthog/PostHog.kt | 15 +++++--------- .../main/java/com/posthog/PostHogInterface.kt | 2 +- .../java/com/posthog/internal/PostHogApi.kt | 20 ------------------- 3 files changed, 6 insertions(+), 31 deletions(-) diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 878e7c53..d2d0ec2e 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -14,7 +14,6 @@ import com.posthog.internal.PostHogPreferences.Companion.GROUPS import com.posthog.internal.PostHogPreferences.Companion.IS_IDENTIFIED import com.posthog.internal.PostHogPreferences.Companion.OPT_OUT import com.posthog.internal.PostHogPreferences.Companion.PERSON_PROCESSING -import com.posthog.internal.PostHogPreferences.Companion.PERSON_PROPERTIES_FOR_FLAGS import com.posthog.internal.PostHogPreferences.Companion.VERSION import com.posthog.internal.PostHogPrintLogger import com.posthog.internal.PostHogQueueInterface @@ -1253,17 +1252,12 @@ public class PostHog private constructor( return true } - // Store token and timestamp - preferences.setValue(FCM_TOKEN, token) - preferences.setValue(FCM_TOKEN_LAST_UPDATED, currentTime) - // Register with backend on a background thread to avoid StrictMode NetworkViolation // The Firebase callback runs on the main thread, so we need to move the network call off it return try { val api = PostHogApi(config) val distinctId = distinctId() - // Use ExecutorService for more idiomatic async handling with timeout val executor = Executors.newSingleThreadExecutor( PostHogThreadFactory("PostHogFCMTokenRegistration") ) @@ -1272,18 +1266,19 @@ public class PostHog private constructor( true } try { - // Wait for completion with timeout (5 seconds) val success = future.get(5, java.util.concurrent.TimeUnit.SECONDS) - config.logger.log("FCM token registered successfully") + if (success) { + preferences.setValue(FCM_TOKEN, token) + preferences.setValue(FCM_TOKEN_LAST_UPDATED, currentTime) + config.logger.log("FCM token registered successfully") + } success } catch (e: java.util.concurrent.TimeoutException) { config.logger.log("Failed to register FCM token: Timeout after 5 seconds") - // Cancel the task after it timed out future.cancel(true) false } catch (e: java.util.concurrent.ExecutionException) { - // Extract the underlying exception from ExecutionException val cause = e.cause ?: e config.logger.log("Failed to register FCM token: ${cause.message ?: "Unknown error"}") false diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index d4d44095..042fa9fc 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -225,7 +225,7 @@ public interface PostHogInterface : PostHogCoreInterface { /** * Registers a push notification token (FCM token) with PostHog. - * The SDK will automatically rate-limit registrations to once per day unless the token has changed. + * The SDK will automatically rate-limit registrations to once per hour unless the token has changed. * * Users should retrieve the FCM token using: * - Java: `FirebaseMessaging.getInstance().getToken()` diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index 0d5fd9ee..4e058334 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -277,32 +277,12 @@ public class PostHogApi( config.serializer.serialize(pushSubscriptionRequest, it.bufferedWriter()) } - // Tag thread for StrictMode compliance before making network call - tagThreadForStrictMode() - client.newCall(request).execute().use { response -> if (!response.isSuccessful) { throw PostHogApiError(response.code, response.message, response.body) } } } - - /** - * Tags the current thread for StrictMode compliance on Android - * This prevents "Untagged socket detected" warnings - */ - private fun tagThreadForStrictMode() { - try { - // Use reflection to set traffic stats tag if available (Android only) - val trafficStatsClass = Class.forName("android.net.TrafficStats") - val setThreadStatsTagMethod = trafficStatsClass.getMethod("setThreadStatsTag", Int::class.javaPrimitiveType) - setThreadStatsTagMethod.invoke(null, 0xFFFF) // Use a non-zero tag - } catch (e: ClassNotFoundException) { - // TrafficStats not available (not Android) - this is expected on non-Android platforms - } catch (e: Exception) { - // Other exceptions (NoSuchMethodException, etc.) - ignore silently - } - } private fun logResponse(response: Response): Response { if (config.debug) { From 837f6c4c091e4e5b4c399f056067e6363ff08390 Mon Sep 17 00:00:00 2001 From: odin kammerloher Date: Mon, 26 Jan 2026 15:13:12 +0100 Subject: [PATCH 07/14] Fix CI issues (formatting, code cleanliness) --- posthog/src/main/java/com/posthog/PostHog.kt | 21 +++--- .../java/com/posthog/internal/PostHogApi.kt | 4 +- .../src/test/java/com/posthog/PostHogTest.kt | 71 ++++++++++--------- .../com/posthog/internal/PostHogApiTest.kt | 13 ++-- 4 files changed, 57 insertions(+), 52 deletions(-) diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index d2d0ec2e..8e22756d 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -1257,17 +1257,19 @@ public class PostHog private constructor( return try { val api = PostHogApi(config) val distinctId = distinctId() - - val executor = Executors.newSingleThreadExecutor( - PostHogThreadFactory("PostHogFCMTokenRegistration") - ) - val future = executor.submit { - api.registerPushSubscription(distinctId, token) - true - } + + val executor = + Executors.newSingleThreadExecutor( + PostHogThreadFactory("PostHogFCMTokenRegistration"), + ) + val future = + executor.submit { + api.registerPushSubscription(distinctId, token) + true + } try { val success = future.get(5, java.util.concurrent.TimeUnit.SECONDS) - + if (success) { preferences.setValue(FCM_TOKEN, token) preferences.setValue(FCM_TOKEN_LAST_UPDATED, currentTime) @@ -1294,7 +1296,6 @@ public class PostHog private constructor( } } - override fun getConfig(): T? { @Suppress("UNCHECKED_CAST") return super.config as? T diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index 4e058334..94f25be3 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -7,7 +7,6 @@ import com.posthog.PostHogConfig.Companion.DEFAULT_US_ASSETS_HOST import com.posthog.PostHogConfig.Companion.DEFAULT_US_HOST import com.posthog.PostHogEvent import com.posthog.PostHogInternal -import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -15,7 +14,6 @@ import okhttp3.RequestBody import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody import okio.BufferedSink -import okio.buffer import java.io.IOException import java.io.OutputStream @@ -269,7 +267,7 @@ public class PostHogApi( ) val url = "$theHost/api/sdk/push_subscriptions/register" - + logRequest(pushSubscriptionRequest, url) val request = diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 95469541..9d56d026 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -2614,12 +2614,13 @@ internal class PostHogTest { @Test fun `registerPushToken successfully registers token`() { val responseBody = """{"status": "ok", "subscription_id": "test-subscription-id"}""" - val http = mockHttp( - response = - MockResponse() - .setResponseCode(200) - .setBody(responseBody), - ) + val http = + mockHttp( + response = + MockResponse() + .setResponseCode(200) + .setBody(responseBody), + ) val url = http.url("/") val sut = getSut(url.toString(), preloadFeatureFlags = false) @@ -2678,13 +2679,14 @@ internal class PostHogTest { @Test fun `registerPushToken skips registration when token unchanged and less than 1 hour`() { val responseBody = """{"status": "ok", "subscription_id": "test-subscription-id"}""" - val http = mockHttp( - total = 2, - response = - MockResponse() - .setResponseCode(200) - .setBody(responseBody), - ) + val http = + mockHttp( + total = 2, + response = + MockResponse() + .setResponseCode(200) + .setBody(responseBody), + ) val url = http.url("/") val sut = getSut(url.toString(), preloadFeatureFlags = false) @@ -2708,13 +2710,14 @@ internal class PostHogTest { @Test fun `registerPushToken registers again when token changes`() { val responseBody = """{"status": "ok", "subscription_id": "test-subscription-id"}""" - val http = mockHttp( - total = 2, - response = - MockResponse() - .setResponseCode(200) - .setBody(responseBody), - ) + val http = + mockHttp( + total = 2, + response = + MockResponse() + .setResponseCode(200) + .setBody(responseBody), + ) val url = http.url("/") val sut = getSut(url.toString(), preloadFeatureFlags = false) @@ -2736,12 +2739,13 @@ internal class PostHogTest { @Test fun `registerPushToken returns false on API error`() { - val http = mockHttp( - response = - MockResponse() - .setResponseCode(400) - .setBody("Bad Request"), - ) + val http = + mockHttp( + response = + MockResponse() + .setResponseCode(400) + .setBody("Bad Request"), + ) val url = http.url("/") val sut = getSut(url.toString(), preloadFeatureFlags = false) @@ -2758,12 +2762,13 @@ internal class PostHogTest { @Test fun `registerPushToken stores token and timestamp in preferences`() { val responseBody = """{"status": "ok", "subscription_id": "test-subscription-id"}""" - val http = mockHttp( - response = - MockResponse() - .setResponseCode(200) - .setBody(responseBody), - ) + val http = + mockHttp( + response = + MockResponse() + .setResponseCode(200) + .setBody(responseBody), + ) val url = http.url("/") val preferences = PostHogMemoryPreferences() @@ -2777,7 +2782,7 @@ internal class PostHogTest { assertEquals("test-fcm-token", storedToken) assertNotNull(lastUpdated) - assertTrue(lastUpdated!! > 0) + assertTrue(lastUpdated > 0) sut.close() } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt index fc0cdff1..c52afcf7 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt @@ -314,12 +314,13 @@ internal class PostHogApiTest { @Test fun `registerPushSubscription returns successful response`() { val responseBody = """{"status": "ok", "subscription_id": "test-subscription-id"}""" - val http = mockHttp( - response = - MockResponse() - .setResponseCode(200) - .setBody(responseBody), - ) + val http = + mockHttp( + response = + MockResponse() + .setResponseCode(200) + .setBody(responseBody), + ) val url = http.url("/") val sut = getSut(host = url.toString()) From bd63f11459740c91fc74174175e37521f3431bfa Mon Sep 17 00:00:00 2001 From: odin kammerloher Date: Mon, 26 Jan 2026 15:24:35 +0100 Subject: [PATCH 08/14] Update API file --- posthog/api/posthog.api | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 2aa9aaeb..d052a0e1 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -29,6 +29,7 @@ public final class com/posthog/PostHog : com/posthog/PostHogStateless, com/posth public fun optIn ()V public fun optOut ()V public fun register (Ljava/lang/String;Ljava/lang/Object;)V + public fun registerPushToken (Ljava/lang/String;)Z public fun reloadFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V public fun reset ()V public fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V @@ -67,6 +68,7 @@ public final class com/posthog/PostHog$Companion : com/posthog/PostHogInterface public fun optOut ()V public final fun overrideSharedInstance (Lcom/posthog/PostHogInterface;)V public fun register (Ljava/lang/String;Ljava/lang/Object;)V + public fun registerPushToken (Ljava/lang/String;)Z public fun reloadFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V public fun reset ()V public fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V @@ -284,6 +286,7 @@ public abstract interface class com/posthog/PostHogInterface : com/posthog/PostH public abstract fun isSessionActive ()Z public abstract fun isSessionReplayActive ()Z public abstract fun register (Ljava/lang/String;Ljava/lang/Object;)V + public abstract fun registerPushToken (Ljava/lang/String;)Z public abstract fun reloadFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V public abstract fun reset ()V public abstract fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V @@ -585,6 +588,7 @@ public final class com/posthog/internal/PostHogApi { public static synthetic fun flags$default (Lcom/posthog/internal/PostHogApi;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lcom/posthog/internal/PostHogFlagsResponse; public final fun localEvaluation (Ljava/lang/String;Ljava/lang/String;)Lcom/posthog/internal/LocalEvaluationApiResponse; public static synthetic fun localEvaluation$default (Lcom/posthog/internal/PostHogApi;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/posthog/internal/LocalEvaluationApiResponse; + public final fun registerPushSubscription (Ljava/lang/String;Ljava/lang/String;)V public final fun remoteConfig ()Lcom/posthog/internal/PostHogRemoteConfigResponse; public final fun snapshot (Ljava/util/List;)V } @@ -737,6 +741,23 @@ public final class com/posthog/internal/PostHogPrintLogger : com/posthog/interna public fun log (Ljava/lang/String;)V } +public final class com/posthog/internal/PostHogPushSubscriptionRequest { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/posthog/internal/PostHogPushSubscriptionRequest; + public static synthetic fun copy$default (Lcom/posthog/internal/PostHogPushSubscriptionRequest;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/posthog/internal/PostHogPushSubscriptionRequest; + public fun equals (Ljava/lang/Object;)Z + public final fun getApi_key ()Ljava/lang/String; + public final fun getDistinct_id ()Ljava/lang/String; + public final fun getPlatform ()Ljava/lang/String; + public final fun getToken ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class com/posthog/internal/PostHogQueueInterface { public abstract fun add (Lcom/posthog/PostHogEvent;)V public abstract fun clear ()V From 328d51b7f056f17f1c08e76216821d0301b0017c Mon Sep 17 00:00:00 2001 From: odin kammerloher Date: Mon, 26 Jan 2026 15:33:38 +0100 Subject: [PATCH 09/14] Add registerPushToken to PostHogFake.kt --- .../src/test/java/com/posthog/android/PostHogFake.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt b/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt index 813b1bf3..94f032bd 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt @@ -89,6 +89,10 @@ public class PostHogFake : PostHogInterface { override fun resetPersonPropertiesForFlags(reloadFeatureFlags: Boolean) { } + override fun registerPushToken(token: String): Boolean { + return true + } + override fun setGroupPropertiesForFlags( type: String, groupProperties: Map, From d71e58c56d9b56a74e18e15b2e37a9d6b0f9268a Mon Sep 17 00:00:00 2001 From: odin kammerloher Date: Mon, 26 Jan 2026 15:48:02 +0100 Subject: [PATCH 10/14] Fix tests --- posthog/src/test/java/com/posthog/PostHogTest.kt | 2 +- posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 9d56d026..f8e234b8 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -2638,7 +2638,7 @@ internal class PostHogTest { assertEquals("POST", request.method) // Verify request body contains expected fields - val requestBody = request.body.readUtf8() + val requestBody = request.body.unGzip() assertTrue(requestBody.contains("\"api_key\"")) assertTrue(requestBody.contains("\"distinct_id\"")) assertTrue(requestBody.contains("\"token\":\"test-fcm-token\"")) diff --git a/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt index c52afcf7..f433699d 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt @@ -5,6 +5,7 @@ import com.posthog.BuildConfig import com.posthog.PostHogConfig import com.posthog.generateEvent import com.posthog.mockHttp +import com.posthog.unGzip import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.Assert.assertThrows @@ -337,7 +338,7 @@ internal class PostHogApiTest { assertEquals("application/json; charset=utf-8", request.headers["Content-Type"]) // Verify request body contains expected fields - val requestBody = request.body.readUtf8() + val requestBody = request.body.unGzip() assertTrue(requestBody.contains("\"api_key\":\"$API_KEY\"")) assertTrue(requestBody.contains("\"distinct_id\":\"test-distinct-id\"")) assertTrue(requestBody.contains("\"token\":\"test-fcm-token\"")) From f8a4c90a00180d61c2a0a043f6c89267a0bcae95 Mon Sep 17 00:00:00 2001 From: odin kammerloher Date: Tue, 20 Jan 2026 18:26:40 +0100 Subject: [PATCH 11/14] Update changelogs --- posthog-android/CHANGELOG.md | 1 + posthog/CHANGELOG.md | 1 + 2 files changed, 2 insertions(+) diff --git a/posthog-android/CHANGELOG.md b/posthog-android/CHANGELOG.md index 2cda2ca8..791e1c70 100644 --- a/posthog-android/CHANGELOG.md +++ b/posthog-android/CHANGELOG.md @@ -1,4 +1,5 @@ ## Next +- Allow collecting FCM device token in SDK ([#376](https://github.com/PostHog/posthog-android/pull/376)) ## 3.29.1 - 2026-01-21 diff --git a/posthog/CHANGELOG.md b/posthog/CHANGELOG.md index 0f05f2f2..2cea073a 100644 --- a/posthog/CHANGELOG.md +++ b/posthog/CHANGELOG.md @@ -1,4 +1,5 @@ ## Next +- Allow collecting FCM device token in SDK ([#376](https://github.com/PostHog/posthog-android/pull/376)) ### Added - Allow collecting FCM device token in SDK core ([#396](https://github.com/PostHog/posthog-android/pull/396)) From 09a500b8fba9c8cf94b203592ddcdc96f0e3195c Mon Sep 17 00:00:00 2001 From: odin kammerloher Date: Mon, 26 Jan 2026 18:15:58 +0100 Subject: [PATCH 12/14] Address PR comments --- .../java/com/posthog/android/PostHogFake.kt | 8 +- posthog/CHANGELOG.md | 3 +- posthog/src/main/java/com/posthog/PostHog.kt | 123 +++++++++--------- .../main/java/com/posthog/PostHogInterface.kt | 15 ++- .../com/posthog/PostHogPersonProfilesTest.kt | 2 +- .../src/test/java/com/posthog/PostHogTest.kt | 65 ++++++--- 6 files changed, 131 insertions(+), 85 deletions(-) diff --git a/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt b/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt index 94f032bd..6ca5e2cc 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt @@ -3,6 +3,7 @@ package com.posthog.android import com.posthog.PostHogConfig import com.posthog.PostHogInterface import com.posthog.PostHogOnFeatureFlags +import com.posthog.PostHogPushTokenCallback import java.util.Date import java.util.UUID @@ -89,8 +90,11 @@ public class PostHogFake : PostHogInterface { override fun resetPersonPropertiesForFlags(reloadFeatureFlags: Boolean) { } - override fun registerPushToken(token: String): Boolean { - return true + override fun registerPushToken( + token: String, + callback: PostHogPushTokenCallback?, + ) { + callback?.invoke(true) } override fun setGroupPropertiesForFlags( diff --git a/posthog/CHANGELOG.md b/posthog/CHANGELOG.md index 2cea073a..16d4a898 100644 --- a/posthog/CHANGELOG.md +++ b/posthog/CHANGELOG.md @@ -1,8 +1,7 @@ ## Next -- Allow collecting FCM device token in SDK ([#376](https://github.com/PostHog/posthog-android/pull/376)) ### Added -- Allow collecting FCM device token in SDK core ([#396](https://github.com/PostHog/posthog-android/pull/396)) +- Allow collecting FCM device token in SDK ([#376](https://github.com/PostHog/posthog-android/pull/376)) ## 6.3.0 - 2025-01-21 diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 8e22756d..dfcae9b6 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -3,6 +3,7 @@ package com.posthog import com.posthog.errortracking.PostHogErrorTrackingAutoCaptureIntegration import com.posthog.internal.PostHogApi import com.posthog.internal.PostHogApiEndpoint +import com.posthog.internal.PostHogApiError import com.posthog.internal.PostHogNoOpLogger import com.posthog.internal.PostHogPreferences.Companion.ALL_INTERNAL_KEYS import com.posthog.internal.PostHogPreferences.Companion.ANONYMOUS_ID @@ -22,6 +23,7 @@ import com.posthog.internal.PostHogSendCachedEventsIntegration import com.posthog.internal.PostHogSerializer import com.posthog.internal.PostHogSessionManager import com.posthog.internal.PostHogThreadFactory +import com.posthog.internal.executeSafely import com.posthog.internal.personPropertiesContext import com.posthog.internal.replay.PostHogSessionReplayHandler import com.posthog.internal.sortMapRecursively @@ -49,6 +51,10 @@ public class PostHog private constructor( Executors.newSingleThreadScheduledExecutor( PostHogThreadFactory("PostHogSendCachedEventsThread"), ), + private val pushTokenExecutor: ExecutorService = + Executors.newSingleThreadExecutor( + PostHogThreadFactory("PostHogFCMTokenRegistration"), + ), private val reloadFeatureFlags: Boolean = true, ) : PostHogInterface, PostHogStateless() { private val anonymousLock = Any() @@ -58,9 +64,11 @@ public class PostHog private constructor( private val featureFlagsCalledLock = Any() private val cachedPersonPropertiesLock = Any() + private val pushTokenLock = Any() private var remoteConfig: PostHogRemoteConfig? = null private var replayQueue: PostHogQueueInterface? = null + private lateinit var api: PostHogApi private val featureFlagsCalled = mutableMapOf>() // Used to deduplicate setPersonProperties calls @@ -99,11 +107,11 @@ public class PostHog private constructor( val cachePreferences = config.cachePreferences ?: memoryPreferences config.cachePreferences = cachePreferences - val api = PostHogApi(config) + this.api = PostHogApi(config) val queue = config.queueProvider( config, - api, + this.api, PostHogApiEndpoint.BATCH, config.storagePrefix, queueExecutor, @@ -111,13 +119,13 @@ public class PostHog private constructor( val replayQueue = config.queueProvider( config, - api, + this.api, PostHogApiEndpoint.SNAPSHOT, config.replayStoragePrefix, replayExecutor, ) val featureFlags = - config.remoteConfigProvider(config, api, remoteConfigExecutor) { + config.remoteConfigProvider(config, this.api, remoteConfigExecutor) { getDefaultPersonProperties() } @@ -135,7 +143,7 @@ public class PostHog private constructor( val sendCachedEventsIntegration = PostHogSendCachedEventsIntegration( config, - api, + this.api, startDate, cachedEventsExecutor, ) @@ -1224,75 +1232,63 @@ public class PostHog private constructor( return PostHogSessionManager.isSessionActive() } - override fun registerPushToken(token: String): Boolean { + override fun registerPushToken( + token: String, + callback: PostHogPushTokenCallback?, + ) { if (!isEnabled()) { - return false + callback?.invoke(false) + return } if (token.isBlank()) { config?.logger?.log("registerPushToken called with blank token") - return false + callback?.invoke(false) + return } - val config = this.config ?: return false + val config = this.config ?: run { + callback?.invoke(false) + return + } val preferences = getPreferences() - // Get stored token and last update timestamp - val storedToken = preferences.getValue(FCM_TOKEN) as? String - val lastUpdated = preferences.getValue(FCM_TOKEN_LAST_UPDATED) as? Long ?: 0L - val currentTime = config.dateProvider.currentDate().time - val oneHourInMillis = 60 * 60 * 1000L + synchronized(pushTokenLock) { + val storedToken = preferences.getValue(FCM_TOKEN) as? String + val lastUpdated = preferences.getValue(FCM_TOKEN_LAST_UPDATED) as? Long ?: 0L + val currentTime = config.dateProvider.currentDate().time - // Check if token has changed or if an hour has passed - val tokenChanged = storedToken != token - val shouldUpdate = tokenChanged || (currentTime - lastUpdated >= oneHourInMillis) + val tokenChanged = storedToken != token + val shouldUpdate = tokenChanged || (currentTime - lastUpdated >= ONE_HOUR_IN_MILLIS) - if (!shouldUpdate) { - config.logger.log("FCM token registration skipped: token unchanged and less than hour since last update") - return true - } + if (!shouldUpdate) { + config.logger.log("FCM token registration skipped: token unchanged and less than hour since last update") + callback?.invoke(null) + return + } - // Register with backend on a background thread to avoid StrictMode NetworkViolation - // The Firebase callback runs on the main thread, so we need to move the network call off it - return try { - val api = PostHogApi(config) - val distinctId = distinctId() + preferences.setValue(FCM_TOKEN, token) + preferences.setValue(FCM_TOKEN_LAST_UPDATED, currentTime) + } - val executor = - Executors.newSingleThreadExecutor( - PostHogThreadFactory("PostHogFCMTokenRegistration"), - ) - val future = - executor.submit { - api.registerPushSubscription(distinctId, token) - true - } + val distinctId = distinctId() + pushTokenExecutor.executeSafely { + if (!isEnabled()) { + config.logger.log("FCM token registration skipped: SDK is disabled") + callback?.invoke(false) + return@executeSafely + } try { - val success = future.get(5, java.util.concurrent.TimeUnit.SECONDS) - - if (success) { - preferences.setValue(FCM_TOKEN, token) - preferences.setValue(FCM_TOKEN_LAST_UPDATED, currentTime) - config.logger.log("FCM token registered successfully") - } - success - } catch (e: java.util.concurrent.TimeoutException) { - config.logger.log("Failed to register FCM token: Timeout after 5 seconds") - future.cancel(true) - false - } catch (e: java.util.concurrent.ExecutionException) { - val cause = e.cause ?: e - config.logger.log("Failed to register FCM token: ${cause.message ?: "Unknown error"}") - false + this.api.registerPushSubscription(distinctId, token) + config.logger.log("FCM token registered successfully") + callback?.invoke(true) + } catch (e: PostHogApiError) { + config.logger.log("Failed to register FCM token: ${e.message} (code: ${e.statusCode})") + callback?.invoke(false) } catch (e: Throwable) { config.logger.log("Failed to register FCM token: ${e.message ?: "Unknown error"}") - false - } finally { - executor.shutdown() + callback?.invoke(false) } - } catch (e: Throwable) { - config.logger.log("Failed to register FCM token: $e") - false } } @@ -1379,6 +1375,8 @@ public class PostHog private constructor( private var defaultSharedInstance = shared private val apiKeys = mutableSetOf() + + private const val ONE_HOUR_IN_MILLIS = 60 * 60 * 1000L @PostHogVisibleForTesting public fun overrideSharedInstance(postHog: PostHogInterface) { @@ -1409,6 +1407,9 @@ public class PostHog private constructor( featureFlagsExecutor: ExecutorService, cachedEventsExecutor: ExecutorService, reloadFeatureFlags: Boolean, + pushTokenExecutor: ExecutorService = Executors.newSingleThreadExecutor( + PostHogThreadFactory("PostHogFCMTokenRegistration"), + ), ): PostHogInterface { val instance = PostHog( @@ -1416,6 +1417,7 @@ public class PostHog private constructor( replayExecutor, featureFlagsExecutor, cachedEventsExecutor, + pushTokenExecutor, reloadFeatureFlags = reloadFeatureFlags, ) instance.setup(config) @@ -1614,8 +1616,11 @@ public class PostHog private constructor( return shared.getSessionId() } - override fun registerPushToken(token: String): Boolean { - return shared.registerPushToken(token) + override fun registerPushToken( + token: String, + callback: PostHogPushTokenCallback?, + ) { + shared.registerPushToken(token, callback) } } } diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index 042fa9fc..c1db410b 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -3,6 +3,12 @@ package com.posthog import java.util.Date import java.util.UUID +/** + * Callback for push token registration results. + * @param success `true` if registration succeeded, `false` if it failed, or `null` if registration was skipped (e.g., token unchanged). + */ +public typealias PostHogPushTokenCallback = (success: Boolean?) -> Unit + /** * The PostHog SDK entry point */ @@ -227,14 +233,19 @@ public interface PostHogInterface : PostHogCoreInterface { * Registers a push notification token (FCM token) with PostHog. * The SDK will automatically rate-limit registrations to once per hour unless the token has changed. * + * Registration is performed asynchronously. Use the optional callback to be notified of success or failure. + * * Users should retrieve the FCM token using: * - Java: `FirebaseMessaging.getInstance().getToken()` * - Kotlin: `Firebase.messaging.token` * * @param token The FCM registration token - * @return true if registration was successful, false otherwise + * @param callback Optional callback to be notified when registration completes. Called with `true` on success, `false` on failure, or `null` if registration was skipped (e.g., token unchanged). */ - public fun registerPushToken(token: String): Boolean + public fun registerPushToken( + token: String, + callback: PostHogPushTokenCallback? = null, + ) /** * Sets properties for a specific group type to include when evaluating feature flags. diff --git a/posthog/src/test/java/com/posthog/PostHogPersonProfilesTest.kt b/posthog/src/test/java/com/posthog/PostHogPersonProfilesTest.kt index 385a65d3..5958aed6 100644 --- a/posthog/src/test/java/com/posthog/PostHogPersonProfilesTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogPersonProfilesTest.kt @@ -48,7 +48,7 @@ internal class PostHogPersonProfilesTest { replayQueueExecutor, remoteConfigExecutor, cachedEventsExecutor, - false, + reloadFeatureFlags = false, ) } diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index f8e234b8..30e83fbb 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -38,6 +38,7 @@ internal class PostHogTest { private val replayQueueExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestReplayQueue")) private val remoteConfigExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestRemoteConfig")) private val cachedEventsExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestCachedEvents")) + private val pushTokenExecutor = Executors.newSingleThreadExecutor(PostHogThreadFactory("TestPushToken")) private val serializer = PostHogSerializer(PostHogConfig(API_KEY)) private lateinit var config: PostHogConfig @@ -94,6 +95,7 @@ internal class PostHogTest { remoteConfigExecutor, cachedEventsExecutor, reloadFeatureFlags, + pushTokenExecutor, ) } @@ -2052,6 +2054,7 @@ internal class PostHogTest { remoteConfigExecutor, cachedEventsExecutor, true, + pushTokenExecutor, ) // Manually trigger flags reload @@ -2625,12 +2628,15 @@ internal class PostHogTest { val sut = getSut(url.toString(), preloadFeatureFlags = false) - val result = sut.registerPushToken("test-fcm-token") + var callbackResult: Boolean? = null + sut.registerPushToken("test-fcm-token") { success -> + callbackResult = success + } // Wait for background thread to complete Thread.sleep(100) - assertTrue(result) + assertEquals(true, callbackResult) assertEquals(1, http.requestCount) val request = http.takeRequest() @@ -2648,29 +2654,35 @@ internal class PostHogTest { } @Test - fun `registerPushToken returns false when SDK is disabled`() { + fun `registerPushToken calls callback with false when SDK is disabled`() { val http = mockHttp() val url = http.url("/") val sut = getSut(url.toString(), preloadFeatureFlags = false) sut.close() - val result = sut.registerPushToken("test-fcm-token") + var callbackResult: Boolean? = null + sut.registerPushToken("test-fcm-token") { success -> + callbackResult = success + } - assertFalse(result) + assertEquals(false, callbackResult) assertEquals(0, http.requestCount) } @Test - fun `registerPushToken returns false for blank token`() { + fun `registerPushToken calls callback with false for blank token`() { val http = mockHttp() val url = http.url("/") val sut = getSut(url.toString(), preloadFeatureFlags = false) - val result = sut.registerPushToken("") + var callbackResult: Boolean? = null + sut.registerPushToken("") { success -> + callbackResult = success + } - assertFalse(result) + assertEquals(false, callbackResult) assertEquals(0, http.requestCount) sut.close() @@ -2692,15 +2704,21 @@ internal class PostHogTest { val sut = getSut(url.toString(), preloadFeatureFlags = false) // First registration - val result1 = sut.registerPushToken("test-fcm-token") + var callbackResult1: Boolean? = null + sut.registerPushToken("test-fcm-token") { success -> + callbackResult1 = success + } Thread.sleep(100) // Wait for background thread - assertTrue(result1) + assertEquals(true, callbackResult1) assertEquals(1, http.requestCount) // Second registration with same token immediately - should skip API call - val result2 = sut.registerPushToken("test-fcm-token") + var callbackResult2: Boolean? = null + sut.registerPushToken("test-fcm-token") { success -> + callbackResult2 = success + } Thread.sleep(100) // Wait for background thread - assertTrue(result2) + assertEquals(null, callbackResult2) // null means skipped // Should not make a second request when token is unchanged and less than 1 hour assertEquals(1, http.requestCount) @@ -2723,22 +2741,28 @@ internal class PostHogTest { val sut = getSut(url.toString(), preloadFeatureFlags = false) // First registration - val result1 = sut.registerPushToken("test-fcm-token-1") + var callbackResult1: Boolean? = null + sut.registerPushToken("test-fcm-token-1") { success -> + callbackResult1 = success + } Thread.sleep(100) // Wait for background thread - assertTrue(result1) + assertEquals(true, callbackResult1) assertEquals(1, http.requestCount) // Second registration with different token - should register again - val result2 = sut.registerPushToken("test-fcm-token-2") + var callbackResult2: Boolean? = null + sut.registerPushToken("test-fcm-token-2") { success -> + callbackResult2 = success + } Thread.sleep(100) // Wait for background thread - assertTrue(result2) + assertEquals(true, callbackResult2) assertEquals(2, http.requestCount) sut.close() } @Test - fun `registerPushToken returns false on API error`() { + fun `registerPushToken calls callback with false on API error`() { val http = mockHttp( response = @@ -2750,10 +2774,13 @@ internal class PostHogTest { val sut = getSut(url.toString(), preloadFeatureFlags = false) - val result = sut.registerPushToken("test-fcm-token") + var callbackResult: Boolean? = null + sut.registerPushToken("test-fcm-token") { success -> + callbackResult = success + } Thread.sleep(100) // Wait for background thread - assertFalse(result) + assertEquals(false, callbackResult) assertEquals(1, http.requestCount) sut.close() From bd713e638cae09eb4ed062112dfaa28fb4900f5d Mon Sep 17 00:00:00 2001 From: odin kammerloher Date: Mon, 26 Jan 2026 18:30:37 +0100 Subject: [PATCH 13/14] Apply spotless formatting --- posthog/src/main/java/com/posthog/PostHog.kt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index dfcae9b6..9acde310 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -1247,10 +1247,11 @@ public class PostHog private constructor( return } - val config = this.config ?: run { - callback?.invoke(false) - return - } + val config = + this.config ?: run { + callback?.invoke(false) + return + } val preferences = getPreferences() synchronized(pushTokenLock) { @@ -1375,7 +1376,7 @@ public class PostHog private constructor( private var defaultSharedInstance = shared private val apiKeys = mutableSetOf() - + private const val ONE_HOUR_IN_MILLIS = 60 * 60 * 1000L @PostHogVisibleForTesting @@ -1407,9 +1408,10 @@ public class PostHog private constructor( featureFlagsExecutor: ExecutorService, cachedEventsExecutor: ExecutorService, reloadFeatureFlags: Boolean, - pushTokenExecutor: ExecutorService = Executors.newSingleThreadExecutor( - PostHogThreadFactory("PostHogFCMTokenRegistration"), - ), + pushTokenExecutor: ExecutorService = + Executors.newSingleThreadExecutor( + PostHogThreadFactory("PostHogFCMTokenRegistration"), + ), ): PostHogInterface { val instance = PostHog( From 7100007e9b9612695a5e5f100beac44034be2b15 Mon Sep 17 00:00:00 2001 From: odin kammerloher Date: Mon, 26 Jan 2026 18:34:27 +0100 Subject: [PATCH 14/14] Update api file --- posthog/api/posthog.api | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index d052a0e1..25dc3013 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -8,7 +8,7 @@ public final class com/posthog/PersonProfiles : java/lang/Enum { public final class com/posthog/PostHog : com/posthog/PostHogStateless, com/posthog/PostHogInterface { public static final field Companion Lcom/posthog/PostHog$Companion; - public synthetic fun (Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V public fun alias (Ljava/lang/String;)V public fun capture (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;)V public fun captureException (Ljava/lang/Throwable;Ljava/util/Map;)V @@ -29,7 +29,7 @@ public final class com/posthog/PostHog : com/posthog/PostHogStateless, com/posth public fun optIn ()V public fun optOut ()V public fun register (Ljava/lang/String;Ljava/lang/Object;)V - public fun registerPushToken (Ljava/lang/String;)Z + public fun registerPushToken (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V public fun reloadFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V public fun reset ()V public fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V @@ -68,7 +68,7 @@ public final class com/posthog/PostHog$Companion : com/posthog/PostHogInterface public fun optOut ()V public final fun overrideSharedInstance (Lcom/posthog/PostHogInterface;)V public fun register (Ljava/lang/String;Ljava/lang/Object;)V - public fun registerPushToken (Ljava/lang/String;)Z + public fun registerPushToken (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V public fun reloadFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V public fun reset ()V public fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V @@ -286,7 +286,7 @@ public abstract interface class com/posthog/PostHogInterface : com/posthog/PostH public abstract fun isSessionActive ()Z public abstract fun isSessionReplayActive ()Z public abstract fun register (Ljava/lang/String;Ljava/lang/Object;)V - public abstract fun registerPushToken (Ljava/lang/String;)Z + public abstract fun registerPushToken (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V public abstract fun reloadFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V public abstract fun reset ()V public abstract fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V @@ -308,6 +308,7 @@ public final class com/posthog/PostHogInterface$DefaultImpls { public static synthetic fun getFeatureFlagPayload$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/Object;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun group$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V public static synthetic fun isFeatureEnabled$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;ZLjava/lang/Boolean;ILjava/lang/Object;)Z + public static synthetic fun registerPushToken$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V public static synthetic fun reloadFeatureFlags$default (Lcom/posthog/PostHogInterface;Lcom/posthog/PostHogOnFeatureFlags;ILjava/lang/Object;)V public static synthetic fun resetGroupPropertiesForFlags$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;ZILjava/lang/Object;)V public static synthetic fun resetPersonPropertiesForFlags$default (Lcom/posthog/PostHogInterface;ZILjava/lang/Object;)V