Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions buildSrc/src/main/java/PosthogBuildConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions posthog-android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>
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<PostHogAndroidConfig>()
config?.logger?.log(
"Firebase Messaging is not available. Add firebase-messaging dependency to use push notifications.",
)
} catch (e: Throwable) {
val config = PostHog.getConfig<PostHogAndroidConfig>()
config?.logger?.log("Failed to fetch FCM token: $e.")
}
}
}
53 changes: 53 additions & 0 deletions posthog/src/main/java/com/posthog/PostHog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -163,6 +164,7 @@ public class PostHog private constructor(
)

this.config = config
this.api = api
this.queue = queue
this.replayQueue = replayQueue

Expand Down Expand Up @@ -294,6 +296,7 @@ public class PostHog private constructor(

queue?.stop()
replayQueue?.stop()
api = null

featureFlagsCalled.clear()

Expand Down Expand Up @@ -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 <T : PostHogConfig> getConfig(): T? {
@Suppress("UNCHECKED_CAST")
return super<PostHogStateless>.config as? T
Expand Down Expand Up @@ -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)
}
}
}
15 changes: 15 additions & 0 deletions posthog/src/main/java/com/posthog/PostHogInterface.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 <T : PostHogConfig> getConfig(): T?
}
35 changes: 35 additions & 0 deletions posthog/src/main/java/com/posthog/internal/PostHogApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
Loading