-
Notifications
You must be signed in to change notification settings - Fork 48
feat(surveys): add displaySurvey to show a survey on demand #643
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -137,6 +137,41 @@ 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 and still running (started and not | ||
| * yet stopped). 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 | ||
| } | ||
| // The cached surveys are the raw remote config list, so they can include surveys that | ||
| // haven't started yet or have already been stopped. Only display conditions are bypassed. | ||
| if (!isSurveyRunning(survey)) { | ||
| config.logger.log("[Surveys] Cannot display survey $surveyId - survey is not running") | ||
| return | ||
| } | ||
| showSurvey(survey) | ||
| } | ||
|
Comment on lines
+151
to
+173
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. displaySurvey() doesn't verify the survey is currently running, despite the documented contract requiring itWhy we think it's a valid issue
Issue descriptionThe public contract in PostHogInterface.kt (lines 327-341) explicitly states: "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." The PR description and changeset also scope the bypass to only "targeting flags, event triggers, and seen/wait-period checks" — the active-window (running) requirement is not listed among the bypassed conditions. However, the actual implementation of As written, calling Suggested fixAdd the same "is running" check that val survey = synchronized(surveysLock) { cachedSurveys.firstOrNull { it.id == surveyId } }
if (survey == null || survey.startDate == null || survey.endDate != null) {
config.logger.log("[Surveys] Cannot display survey $surveyId - survey not found or not running")
return
}Alternatively, if showing not-yet-started/ended surveys on demand is actually intended (e.g. to preview drafts), update the docstring in Prompt to fix with AI (copy-paste)Alternatively, if showing not-yet-started/ended surveys on demand is actually intended (e.g. to preview drafts), update the docstring in
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Valid — fixed in 0f89402.
Comment on lines
+151
to
+173
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. displaySurvey()'s documented 'ignored if another survey is active' guarantee is not enforced atomicallyWhy we think it's a valid issue
Issue descriptionThe docstring for Suggested fixMark the survey as claimed atomically with the decision to render, inside the same internal fun showSurvey(survey: Survey) {
synchronized(activeSurveyLock) {
if (activeSurvey != null) {
config.logger.log("Cannot show survey - another survey is already active")
return
}
activeSurvey = survey
activeSurveyCompleted = false
currentSurveyResponses.clear()
}
// ... existing setup, with onSurveyShown now just sending the 'survey shown' event
// instead of also setting activeSurvey
}Prompt to fix with AI (copy-paste)</potential_solution>
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not fixing here. The race is real but pre-existing in The suggested fix (claim |
||
|
|
||
| /** | ||
| * Resolves the surveys delegate. | ||
| * | ||
|
|
@@ -223,12 +258,17 @@ public class PostHogSurveysIntegration( | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Whether the survey is currently running, i.e. it has been started and not yet stopped. | ||
| */ | ||
| private fun isSurveyRunning(survey: Survey): Boolean = survey.startDate != null && survey.endDate == null | ||
|
|
||
| private fun getActiveMatchingSurveys(surveys: List<Survey>): List<Survey> { | ||
| val postHog = postHog ?: return emptyList() | ||
|
|
||
| return surveys.filter { survey -> | ||
| // 1. Filter out inactive surveys (must have start date and no end date) | ||
| if (survey.startDate == null || survey.endDate != null) return@filter false | ||
| if (!isSurveyRunning(survey)) return@filter false | ||
|
|
||
| // 2. Filter out surveys that don't match device type | ||
| if (!doesSurveyDeviceTypesMatch(survey)) return@filter false | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,201 @@ | ||
| 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<android.content.Context>() | ||
|
|
||
| /** | ||
| * 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<String>() | ||
|
|
||
| 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<PostHogInterface>() | ||
| whenever(fake.isFeatureEnabled(any(), any(), any())).thenReturn(true) | ||
| integration.install(fake) | ||
| return integration | ||
| } | ||
|
|
||
| private fun createSurvey( | ||
| id: String, | ||
| type: SurveyType, | ||
| conditions: SurveyConditions? = null, | ||
| startDate: java.util.Date? = java.util.Date(), | ||
| endDate: java.util.Date? = 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 = startDate, | ||
| endDate = endDate, | ||
| 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 does nothing when the survey has already been stopped`() { | ||
| val delegate = RecordingDelegate() | ||
| val integration = createIntegration(delegate) | ||
| integration.onSurveysLoaded(listOf(createSurvey("api-1", SurveyType.API, endDate = java.util.Date()))) | ||
|
|
||
| integration.displaySurvey("api-1") | ||
|
|
||
| assertEquals(emptyList(), delegate.renderedSurveyIds) | ||
| } | ||
|
|
||
| @Test | ||
| fun `displaySurvey does nothing when the survey has not started`() { | ||
| val delegate = RecordingDelegate() | ||
| val integration = createIntegration(delegate) | ||
| integration.onSurveysLoaded(listOf(createSurvey("api-1", SurveyType.API, startDate = null))) | ||
|
|
||
| integration.displaySurvey("api-1") | ||
|
|
||
| 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) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2016,6 +2016,20 @@ public class PostHog private constructor( | |
| } | ||
| } | ||
|
|
||
| override fun displaySurvey(surveyId: String) { | ||
| if (!isEnabled()) { | ||
| return | ||
| } | ||
|
|
||
| try { | ||
| surveysHandler?.displaySurvey(surveyId) ?: run { | ||
| config?.logger?.log("Cannot display survey $surveyId - surveys integration isn't installed.") | ||
| } | ||
| } catch (e: Throwable) { | ||
| config?.logger?.log("Failed to display survey $surveyId: $e.") | ||
| } | ||
| } | ||
|
Comment on lines
+2019
to
+2031
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. New displaySurvey() entry point lacks the exception guard every other survey-triggering call site usesWhy we think it's a valid issue
Issue descriptionEvery other place in this file that can trigger Suggested fixWrap the delegate call the same way the other call sites in this file do: override fun displaySurvey(surveyId: String) {
if (!isEnabled()) {
return
}
try {
surveysHandler?.displaySurvey(surveyId) ?: run {
config?.logger?.log("Cannot display survey $surveyId - surveys integration isn't installed.")
}
} catch (e: Throwable) {
config?.logger?.log("Failed to display survey $surveyId: $e.")
}
}Prompt to fix with AI (copy-paste)</potential_solution>
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Valid — fixed in 0f89402. |
||
|
|
||
| override fun getSessionId(): UUID? { | ||
| if (!isEnabled()) { | ||
| return null | ||
|
|
@@ -2329,6 +2343,10 @@ public class PostHog private constructor( | |
| shared.stopSessionReplay() | ||
| } | ||
|
|
||
| override fun displaySurvey(surveyId: String) { | ||
| shared.displaySurvey(surveyId) | ||
| } | ||
|
|
||
| override fun getSessionId(): UUID? { | ||
| return shared.getSessionId() | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[suggestion] Skipping
canAutoDisplaySurveyhere is what enables API surveys — but with no type check at all, a survey whose type Gson couldn't parse also gets rendered (unknown future types deserialize to null viaGsonSurveyTypeAdapter, and the survey stays in the cache), and the sheet isn't built for that. Could we allow-list the known types and no-op with a log otherwise? popover/api/widget all seem fine to keep — widget already auto-displays here.