From faec6d901f5e9d14b6e48364f5b3944cedd6ce5c Mon Sep 17 00:00:00 2001 From: Ben Smith <5904336+bs1180@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:24:05 +0200 Subject: [PATCH] feat(surveys): add displaySurvey to show a survey on demand Adds PostHog.displaySurvey(surveyId), the mobile counterpart of the web SDK's posthog.displaySurvey(). It looks the survey up in the cached surveys and routes it through the existing showSurvey() path (translations, active-survey tracking, shown/sent/dismissed events), bypassing display conditions such as targeting flags, event triggers, and the seen/wait-period checks. This also enables API-type surveys, which are never auto-displayed. Generated-By: PostHog Code Task-Id: 96def33a-2460-42b7-9690-0a75ae8383fe --- .changeset/display-survey-manual-api.md | 6 + posthog-android/api/posthog-android.api | 1 + .../surveys/PostHogSurveysIntegration.kt | 29 +++ .../PostHogSurveysDisplaySurveyTest.kt | 177 ++++++++++++++++++ posthog/api/posthog.api | 4 + posthog/src/main/java/com/posthog/PostHog.kt | 14 ++ .../main/java/com/posthog/PostHogInterface.kt | 15 ++ .../internal/surveys/PostHogSurveysHandler.kt | 8 + .../java/com/posthog/PostHogFake.kt | 5 + 9 files changed, 259 insertions(+) create mode 100644 .changeset/display-survey-manual-api.md create mode 100644 posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysDisplaySurveyTest.kt 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 }