diff --git a/buildSrc/src/main/java/PosthogBuildConfig.kt b/buildSrc/src/main/java/PosthogBuildConfig.kt index 6c0d22f3..b906329a 100644 --- a/buildSrc/src/main/java/PosthogBuildConfig.kt +++ b/buildSrc/src/main/java/PosthogBuildConfig.kt @@ -60,6 +60,8 @@ object PosthogBuildConfig { val CURTAINS = "1.2.5" val ANDROIDX_CORE = "1.5.0" val ANDROIDX_COMPOSE = "1.0.0" + val ANDROIDX_ACTIVITY = "1.7.2" + val FIREBASE_MESSAGING = "24.1.0" // tests val ANDROIDX_JUNIT = "1.2.1" diff --git a/posthog-android/build.gradle.kts b/posthog-android/build.gradle.kts index 6758858d..8786ed48 100644 --- a/posthog-android/build.gradle.kts +++ b/posthog-android/build.gradle.kts @@ -90,10 +90,12 @@ dependencies { implementation("androidx.lifecycle:lifecycle-process:${PosthogBuildConfig.Dependencies.LIFECYCLE}") implementation("androidx.lifecycle:lifecycle-common-java8:${PosthogBuildConfig.Dependencies.LIFECYCLE}") implementation("androidx.core:core:${PosthogBuildConfig.Dependencies.ANDROIDX_CORE}") + implementation("androidx.activity:activity-ktx:${PosthogBuildConfig.Dependencies.ANDROIDX_ACTIVITY}") implementation("com.squareup.curtains:curtains:${PosthogBuildConfig.Dependencies.CURTAINS}") // compile only compileOnly("androidx.compose.ui:ui:${PosthogBuildConfig.Dependencies.ANDROIDX_COMPOSE}") + compileOnly("com.google.firebase:firebase-messaging:${PosthogBuildConfig.Dependencies.FIREBASE_MESSAGING}") // compatibility signature("org.codehaus.mojo.signature:java18:${PosthogBuildConfig.Plugins.SIGNATURE_JAVA18}@signature") diff --git a/posthog-android/src/main/java/com/posthog/android/PostHogPushNotifications.kt b/posthog-android/src/main/java/com/posthog/android/PostHogPushNotifications.kt new file mode 100644 index 00000000..11502291 --- /dev/null +++ b/posthog-android/src/main/java/com/posthog/android/PostHogPushNotifications.kt @@ -0,0 +1,172 @@ +package com.posthog.android + +import android.Manifest +import android.app.Activity +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import com.posthog.PostHog + +/** + * Utility class for handling push notification registration with PostHog. + * + * This class provides methods to request push notification permission and + * automatically register the device's FCM token with PostHog when permission is granted. + * + * Requirements: + * - Firebase Messaging must be included in the app's dependencies + * - Firebase must be initialized in the app + * + * Usage: + * ```kotlin + * // Call from a ComponentActivity (AppCompatActivity, FragmentActivity, etc.) + * PostHogPushNotifications.requestPermissionAndRegister(activity) + * ``` + */ +public object PostHogPushNotifications { + /** + * Requests push notification permission (on Android 13+) and automatically + * registers the FCM device token with PostHog when permission is granted. + * + * On Android 12 and below, notifications are allowed by default, so this method + * will directly proceed to register the FCM token. + * + * @param activity the ComponentActivity to use for the permission request. + * Must be a ComponentActivity (e.g. AppCompatActivity) to use the Activity Result API. + * @param onPermissionResult optional callback that receives the permission result. + * `true` if permission was granted (or not needed), `false` if denied. + */ + @JvmStatic + @JvmOverloads + public fun requestPermissionAndRegister( + activity: ComponentActivity, + onPermissionResult: ((Boolean) -> Unit)? = null, + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val hasPermission = + ContextCompat.checkSelfPermission( + activity, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + + if (hasPermission) { + fetchAndRegisterToken(activity) + onPermissionResult?.invoke(true) + return + } + + val launcher = + activity.registerForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) { + fetchAndRegisterToken(activity) + } + onPermissionResult?.invoke(granted) + } + + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + // Below Android 13, notification permission is granted at install time + fetchAndRegisterToken(activity) + onPermissionResult?.invoke(true) + } + } + + /** + * Checks whether push notification permission has already been granted. + * + * @param activity the Activity to check permission against + * @return true if permission is granted or the device is below Android 13 + */ + @JvmStatic + public fun hasPermission(activity: Activity): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission( + activity, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } + } + + /** + * Registers a push notification token directly with PostHog without requesting permission. + * Use this if you already have the FCM token (e.g. from FirebaseMessagingService.onNewToken). + * + * @param deviceToken the FCM device token + * @param firebaseProjectId the Firebase project ID (used as appId for the push subscription) + */ + @JvmStatic + public fun registerToken( + deviceToken: String, + firebaseProjectId: String, + ) { + PostHog.registerPushNotificationToken( + deviceToken = deviceToken, + appId = firebaseProjectId, + platform = "android", + ) + } + + private fun fetchAndRegisterToken(activity: Activity) { + try { + val firebaseMessaging = + Class.forName("com.google.firebase.messaging.FirebaseMessaging") + + val getInstance = firebaseMessaging.getMethod("getInstance") + val instance = getInstance.invoke(null) + + val getToken = firebaseMessaging.getMethod("getToken") + val task = getToken.invoke(instance) + + // Get Firebase project ID + val firebaseApp = Class.forName("com.google.firebase.FirebaseApp") + val getFirebaseInstance = firebaseApp.getMethod("getInstance") + val appInstance = getFirebaseInstance.invoke(null) + val getOptions = firebaseApp.getMethod("getOptions") + val options = getOptions.invoke(appInstance) + + val firebaseOptions = Class.forName("com.google.firebase.FirebaseOptions") + val getProjectId = firebaseOptions.getMethod("getProjectId") + val projectId = getProjectId.invoke(options) as? String ?: "" + + // task is a com.google.android.gms.tasks.Task + val taskClass = Class.forName("com.google.android.gms.tasks.Task") + val addOnSuccessListenerMethod = + taskClass.getMethod( + "addOnSuccessListener", + Class.forName("com.google.android.gms.tasks.OnSuccessListener"), + ) + + val proxy = + java.lang.reflect.Proxy.newProxyInstance( + activity.classLoader, + arrayOf(Class.forName("com.google.android.gms.tasks.OnSuccessListener")), + ) { _, _, args -> + val token = args?.firstOrNull() as? String + if (!token.isNullOrBlank() && projectId.isNotBlank()) { + PostHog.registerPushNotificationToken( + deviceToken = deviceToken, + appId = projectId, + platform = "android", + ) + } + null + } + + addOnSuccessListenerMethod.invoke(task, proxy) + } catch (e: ClassNotFoundException) { + val config = PostHog.getConfig() + config?.logger?.log( + "Firebase Messaging is not available. Add firebase-messaging dependency to use push notifications.", + ) + } catch (e: Throwable) { + val config = PostHog.getConfig() + config?.logger?.log("Failed to fetch FCM token: $e.") + } + } +} diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 690e144f..26b596ff 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -61,6 +61,7 @@ public class PostHog private constructor( private val cachedPersonPropertiesLock = Any() private var replayQueue: PostHogQueueInterface? = null + private var api: PostHogApi? = null private val remoteConfig: PostHogRemoteConfig? get() = config?.remoteConfigHolder @@ -163,6 +164,7 @@ public class PostHog private constructor( ) this.config = config + this.api = api this.queue = queue this.replayQueue = replayQueue @@ -294,6 +296,7 @@ public class PostHog private constructor( queue?.stop() replayQueue?.stop() + api = null featureFlagsCalled.clear() @@ -1307,6 +1310,48 @@ public class PostHog private constructor( return PostHogSessionManager.isSessionActive() } + override fun registerPushNotificationToken( + deviceToken: String, + appId: String, + platform: String, + ) { + if (!isEnabled()) { + return + } + if (config?.optOut == true) { + config?.logger?.log("PostHog is in OptOut state.") + return + } + if (deviceToken.isBlank()) { + config?.logger?.log("registerPushNotificationToken call not allowed, deviceToken is blank.") + return + } + if (appId.isBlank()) { + config?.logger?.log("registerPushNotificationToken call not allowed, appId is blank.") + return + } + + val currentDistinctId = distinctId + if (currentDistinctId.isBlank()) { + config?.logger?.log("registerPushNotificationToken call not allowed, distinctId is invalid.") + return + } + + queueExecutor.execute { + try { + api?.pushSubscription( + distinctId = currentDistinctId, + deviceToken = deviceToken, + platform = platform, + appId = appId, + ) + config?.logger?.log("Push notification token registered successfully.") + } catch (e: Throwable) { + config?.logger?.log("Failed to register push notification token: $e.") + } + } + } + override fun getConfig(): T? { @Suppress("UNCHECKED_CAST") return super.config as? T @@ -1662,5 +1707,13 @@ public class PostHog private constructor( override fun getSessionId(): UUID? { return shared.getSessionId() } + + override fun registerPushNotificationToken( + deviceToken: String, + appId: String, + platform: String, + ) { + shared.registerPushNotificationToken(deviceToken, appId, platform) + } } } diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index 5f3d4522..a10f8678 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -284,6 +284,21 @@ public interface PostHogInterface : PostHogCoreInterface { flagVariant: String? = null, ) + /** + * Registers a push notification device token with PostHog. + * This sends the token to the PostHog push subscriptions API so that + * push notifications can be delivered to this device. + * + * @param deviceToken the device push token (e.g. FCM registration token) + * @param appId the app identifier - Firebase project_id for Android, APNS bundle_id for iOS + * @param platform the platform, defaults to "android" + */ + public fun registerPushNotificationToken( + deviceToken: String, + appId: String, + platform: String = "android", + ) + @PostHogInternal public fun getConfig(): T? } diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index c96e8e90..f31572be 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -275,6 +275,41 @@ public class PostHogApi( } } + @Throws(PostHogApiError::class, IOException::class) + public fun pushSubscription( + distinctId: String, + deviceToken: String, + platform: String, + appId: String, + ) { + val request = + PostHogPushSubscriptionRequest( + apiKey = config.apiKey, + distinctId = distinctId, + deviceToken = deviceToken, + platform = platform, + appId = appId, + ) + + val url = "$theHost/api/push_subscriptions/" + logRequest(request, url) + + val httpRequest = + makeRequest(url) { + config.serializer.serialize(request, it.bufferedWriter()) + } + + logRequestHeaders(httpRequest) + + client.newCall(httpRequest).execute().use { + val response = logResponse(it) + + if (!response.isSuccessful) { + throw PostHogApiError(response.code, response.message, response.body) + } + } + } + private fun logResponse(response: Response): Response { if (config.debug) { try { diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionRequest.kt b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionRequest.kt new file mode 100644 index 00000000..eb5a2b31 --- /dev/null +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionRequest.kt @@ -0,0 +1,22 @@ +package com.posthog.internal + +import com.google.gson.annotations.SerializedName + +/** + * The request body for the push subscriptions API + * @property apiKey the PostHog API Key + * @property distinctId the user's distinct ID + * @property token the device push token (FCM or APNS) + * @property platform the platform ("android" or "ios") + * @property appId the Firebase project_id (for Android) or APNS bundle_id (for iOS) + */ +internal data class PostHogPushSubscriptionRequest( + @SerializedName("api_key") + val apiKey: String, + @SerializedName("distinct_id") + val distinctId: String, + val token: String, + val platform: String, + @SerializedName("app_id") + val appId: String, +)