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/CHANGELOG.md b/posthog-android/CHANGELOG.md index ca36f58b..9789927b 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.0 - 2026-01-19 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/CHANGELOG.md b/posthog/CHANGELOG.md index bfb9a6b1..c8ae00f5 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)) ### Changed - Renamed `evaluationEnvironments` to `evaluationContexts` for clearer semantics ([#368](https://github.com/PostHog/posthog-android/pull/368)). The term "contexts" better reflects that this feature is for specifying evaluation contexts (e.g., "web", "mobile", "checkout") rather than deployment environments (e.g., "staging", "production"). diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 556d8289..748c6ae5 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 @@ -1192,6 +1194,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 @@ -1509,5 +1581,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 63ee565c..2ada74bf 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 @@ -2488,4 +2490,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) + } }