diff --git a/.changeset/display-survey-manual-api.md b/.changeset/display-survey-manual-api.md new file mode 100644 index 000000000..b9780f15e --- /dev/null +++ b/.changeset/display-survey-manual-api.md @@ -0,0 +1,6 @@ +--- +"posthog": minor +"posthog-android": minor +--- + +Add `PostHog.displaySurvey(surveyId)` to display a survey on demand, bypassing display conditions (targeting flags, event triggers, and seen/wait-period checks). This is the mobile counterpart of the web SDK's `posthog.displaySurvey()` and also enables API-type surveys, which are never auto-displayed. diff --git a/posthog-android/api/posthog-android.api b/posthog-android/api/posthog-android.api index 153a871c2..eb2de20e9 100644 --- a/posthog-android/api/posthog-android.api +++ b/posthog-android/api/posthog-android.api @@ -129,6 +129,7 @@ public class com/posthog/android/replay/internal/LogcatParser { public final class com/posthog/android/surveys/PostHogSurveysIntegration : com/posthog/PostHogIntegration, com/posthog/internal/surveys/PostHogSurveysHandler { public fun (Landroid/content/Context;Lcom/posthog/PostHogConfig;)V + public fun displaySurvey (Ljava/lang/String;)V public fun install (Lcom/posthog/PostHogInterface;)V public fun onEvent (Ljava/lang/String;Ljava/util/Map;)V public fun onRemoteConfig (Z)V diff --git a/posthog-android/src/main/java/com/posthog/android/surveys/PostHogSurveysIntegration.kt b/posthog-android/src/main/java/com/posthog/android/surveys/PostHogSurveysIntegration.kt index 5362cb3b8..752eb4e07 100644 --- a/posthog-android/src/main/java/com/posthog/android/surveys/PostHogSurveysIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/surveys/PostHogSurveysIntegration.kt @@ -137,6 +137,35 @@ public class PostHogSurveysIntegration( } } + /** + * Displays the survey with the given ID on demand. + * + * Unlike automatic display, this bypasses display conditions (targeting flags, + * event triggers, and the seen/wait-period checks), so it also works for + * API-type surveys, which are never auto-displayed. The survey must be present + * in the surveys loaded from remote config. If another survey is already being + * displayed, this call is ignored. + * + * @param surveyId The ID of the survey to display + */ + public override fun displaySurvey(surveyId: String) { + if (!config.surveys) { + config.logger.log("[Surveys] Cannot display survey $surveyId - surveys are disabled in the config") + return + } + val isIntegrationStarted = synchronized(lifecycleLock) { isStarted } + if (!isIntegrationStarted) { + config.logger.log("[Surveys] Cannot display survey $surveyId - surveys integration is not started") + return + } + val survey = synchronized(surveysLock) { cachedSurveys.firstOrNull { it.id == surveyId } } + if (survey == null) { + config.logger.log("[Surveys] Cannot display survey $surveyId - survey not found") + return + } + showSurvey(survey) + } + /** * Resolves the surveys delegate. * diff --git a/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysDisplaySurveyTest.kt b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysDisplaySurveyTest.kt new file mode 100644 index 000000000..72222c636 --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysDisplaySurveyTest.kt @@ -0,0 +1,177 @@ +package com.posthog.android.surveys + +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.posthog.PostHogConfig +import com.posthog.PostHogInterface +import com.posthog.surveys.OnPostHogSurveyClosed +import com.posthog.surveys.OnPostHogSurveyResponse +import com.posthog.surveys.OnPostHogSurveyShown +import com.posthog.surveys.PostHogDisplaySurvey +import com.posthog.surveys.PostHogSurveysDelegate +import com.posthog.surveys.Survey +import com.posthog.surveys.SurveyConditions +import com.posthog.surveys.SurveyEventCondition +import com.posthog.surveys.SurveyEventConditions +import com.posthog.surveys.SurveyType +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Tests for the manual displaySurvey() API, which displays a survey by ID on demand, + * bypassing display conditions (event triggers, seen checks) — the mobile counterpart + * of the web SDK's posthog.displaySurvey(). + */ +@RunWith(AndroidJUnit4::class) +internal class PostHogSurveysDisplaySurveyTest { + private val context = ApplicationProvider.getApplicationContext() + + /** + * A delegate that records rendered survey IDs and reports each survey as shown, + * so the integration tracks it as the active survey (like the real UI delegates do). + */ + private class RecordingDelegate : PostHogSurveysDelegate { + val renderedSurveyIds = mutableListOf() + + override fun renderSurvey( + survey: PostHogDisplaySurvey, + onSurveyShown: OnPostHogSurveyShown, + onSurveyResponse: OnPostHogSurveyResponse, + onSurveyClosed: OnPostHogSurveyClosed, + ) { + renderedSurveyIds.add(survey.id) + onSurveyShown(survey) + } + + override fun cleanupSurveys() {} + } + + private fun createIntegration( + delegate: RecordingDelegate, + surveysEnabled: Boolean = true, + ): PostHogSurveysIntegration { + val config = + PostHogConfig("test-api-key").apply { + surveys = surveysEnabled + surveysConfig.surveysDelegate = delegate + } + val integration = PostHogSurveysIntegration(context, config) + val fake = mock() + whenever(fake.isFeatureEnabled(any(), any(), any())).thenReturn(true) + integration.install(fake) + return integration + } + + private fun createSurvey( + id: String, + type: SurveyType, + conditions: SurveyConditions? = null, + ): Survey { + return Survey( + id = id, + name = "Test Survey $id", + type = type, + questions = emptyList(), + description = null, + featureFlagKeys = null, + linkedFlagKey = null, + targetingFlagKey = null, + internalTargetingFlagKey = null, + conditions = conditions, + appearance = null, + currentIteration = null, + currentIterationStartDate = null, + startDate = java.util.Date(), + endDate = null, + schedule = null, + ) + } + + private fun eventConditions(eventName: String): SurveyConditions { + return SurveyConditions( + url = null, + urlMatchType = null, + selector = null, + deviceTypes = null, + deviceTypesMatchType = null, + seenSurveyWaitPeriodInDays = null, + events = SurveyEventConditions(repeatedActivation = null, values = listOf(SurveyEventCondition(name = eventName))), + ) + } + + @Test + fun `displaySurvey renders an API-type survey that is never auto-displayed`() { + val delegate = RecordingDelegate() + val integration = createIntegration(delegate) + integration.onSurveysLoaded(listOf(createSurvey("api-1", SurveyType.API))) + assertFalse(delegate.renderedSurveyIds.contains("api-1"), "API survey should not be auto-displayed") + + integration.displaySurvey("api-1") + + assertTrue(delegate.renderedSurveyIds.contains("api-1"), "displaySurvey should render the API survey") + } + + @Test + fun `displaySurvey bypasses event trigger conditions`() { + val delegate = RecordingDelegate() + val integration = createIntegration(delegate) + val survey = createSurvey("popover-1", SurveyType.POPOVER, conditions = eventConditions("some_event")) + integration.onSurveysLoaded(listOf(survey)) + assertFalse( + delegate.renderedSurveyIds.contains("popover-1"), + "Survey with an unfired event trigger should not be auto-displayed", + ) + + integration.displaySurvey("popover-1") + + assertTrue( + delegate.renderedSurveyIds.contains("popover-1"), + "displaySurvey should render the survey even though its event trigger never fired", + ) + } + + @Test + fun `displaySurvey does nothing when the survey ID is unknown`() { + val delegate = RecordingDelegate() + val integration = createIntegration(delegate) + integration.onSurveysLoaded(listOf(createSurvey("api-1", SurveyType.API))) + + integration.displaySurvey("unknown-id") + + assertEquals(emptyList(), delegate.renderedSurveyIds) + } + + @Test + fun `displaySurvey is ignored while another survey is active`() { + val delegate = RecordingDelegate() + val integration = createIntegration(delegate) + val surveys = + listOf( + createSurvey("popover-1", SurveyType.POPOVER), + createSurvey("api-1", SurveyType.API), + ) + integration.onSurveysLoaded(surveys) + assertEquals(listOf("popover-1"), delegate.renderedSurveyIds) + + integration.displaySurvey("api-1") + + assertEquals(listOf("popover-1"), delegate.renderedSurveyIds) + } + + @Test + fun `displaySurvey does nothing when surveys are disabled in config`() { + val delegate = RecordingDelegate() + val integration = createIntegration(delegate, surveysEnabled = false) + integration.onSurveysLoaded(listOf(createSurvey("api-1", SurveyType.API))) + + integration.displaySurvey("api-1") + + assertEquals(emptyList(), delegate.renderedSurveyIds) + } +} diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 116af9bc6..303ce1f40 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -31,6 +31,7 @@ public final class com/posthog/PostHog : com/posthog/PostHogStateless, com/posth public fun captureFeatureView (Ljava/lang/String;Ljava/lang/String;)V public fun captureLog (Ljava/lang/String;Lcom/posthog/logs/PostHogLogSeverity;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;)V public fun close ()V + public fun displaySurvey (Ljava/lang/String;)V public fun distinctId ()Ljava/lang/String; public fun endSession ()V public fun flush ()V @@ -77,6 +78,7 @@ public final class com/posthog/PostHog$Companion : com/posthog/PostHogInterface public fun captureLog (Ljava/lang/String;Lcom/posthog/logs/PostHogLogSeverity;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;)V public fun close ()V public fun debug (Z)V + public fun displaySurvey (Ljava/lang/String;)V public fun distinctId ()Ljava/lang/String; public fun endSession ()V public fun flush ()V @@ -352,6 +354,7 @@ public abstract interface class com/posthog/PostHogInterface : com/posthog/PostH public abstract fun captureFeatureInteraction (Ljava/lang/String;Ljava/lang/String;)V public abstract fun captureFeatureView (Ljava/lang/String;Ljava/lang/String;)V public abstract fun captureLog (Ljava/lang/String;Lcom/posthog/logs/PostHogLogSeverity;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;)V + public abstract fun displaySurvey (Ljava/lang/String;)V public abstract fun distinctId ()Ljava/lang/String; public abstract fun endSession ()V public abstract fun getAllFeatureFlags ()Ljava/util/List; @@ -1428,6 +1431,7 @@ public final class com/posthog/internal/replay/RRWireframe { } public abstract interface class com/posthog/internal/surveys/PostHogSurveysHandler { + public abstract fun displaySurvey (Ljava/lang/String;)V public abstract fun onEvent (Ljava/lang/String;Ljava/util/Map;)V public abstract fun onSurveysLoaded (Ljava/util/List;)V } diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 7b452a567..e03a6840e 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -2016,6 +2016,16 @@ public class PostHog private constructor( } } + override fun displaySurvey(surveyId: String) { + if (!isEnabled()) { + return + } + + surveysHandler?.displaySurvey(surveyId) ?: run { + config?.logger?.log("Cannot display survey $surveyId - surveys integration isn't installed.") + } + } + override fun getSessionId(): UUID? { if (!isEnabled()) { return null @@ -2329,6 +2339,10 @@ public class PostHog private constructor( shared.stopSessionReplay() } + override fun displaySurvey(surveyId: String) { + shared.displaySurvey(surveyId) + } + override fun getSessionId(): UUID? { return shared.getSessionId() } diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index f257cceb3..a12b3d966 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -324,6 +324,21 @@ public interface PostHogInterface : PostHogCoreInterface { */ public fun stopSessionReplay() + /** + * Displays the survey with the given ID on demand, regardless of its display conditions. + * + * The survey must be running and returned by the project's remote config; targeting flags, + * event triggers, and the seen/wait-period checks are bypassed. Use this to display surveys + * of type API, or to trigger a popover survey from your own logic. If another survey is + * already being displayed, this call is ignored. + * + * Requires the surveys integration to be enabled via `PostHogConfig.surveys`. + * Android only. + * + * @param surveyId The ID of the survey to display + */ + public fun displaySurvey(surveyId: String) + /** * Returns the session Id if a session is active * @return The current session ID, or null if no session is active. diff --git a/posthog/src/main/java/com/posthog/internal/surveys/PostHogSurveysHandler.kt b/posthog/src/main/java/com/posthog/internal/surveys/PostHogSurveysHandler.kt index 052cf3594..53295fa4e 100644 --- a/posthog/src/main/java/com/posthog/internal/surveys/PostHogSurveysHandler.kt +++ b/posthog/src/main/java/com/posthog/internal/surveys/PostHogSurveysHandler.kt @@ -20,4 +20,12 @@ public interface PostHogSurveysHandler { * @param surveys List of surveys loaded from remote config (may be empty) */ public fun onSurveysLoaded(surveys: List) + + /** + * Displays the survey with the given ID on demand, bypassing display conditions + * such as targeting flags, event triggers, and the seen/wait-period checks. + * + * @param surveyId The ID of the survey to display + */ + public fun displaySurvey(surveyId: String) } diff --git a/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt b/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt index d2ecd8ee3..f4744dbd1 100644 --- a/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt +++ b/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt @@ -15,6 +15,7 @@ public class PostHogFake : PostHogInterface { public var sessionReplayActive: Boolean = false public var startSessionReplayCalls: Int = 0 public var stopSessionReplayCalls: Int = 0 + public val displaySurveyCalls: MutableList = mutableListOf() // `PostHogLogger`'s constructor is `internal`. Test fixtures live in the // same module, so we can construct a silent no-op here without exposing @@ -232,6 +233,10 @@ public class PostHogFake : PostHogInterface { sessionReplayActive = false } + override fun displaySurvey(surveyId: String) { + displaySurveyCalls.add(surveyId) + } + override fun getSessionId(): UUID? { return null }