From 7336c90d7fbdf2c742c45480ebf3c8cb6b104373 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Tue, 8 Sep 2026 16:54:24 -0300 Subject: [PATCH 1/6] feat(surveys): collect partial responses incrementally Decode enable_partial_responses and emit cumulative answers after each submitted question when enabled. Keep a submission UUID across sent/dismissed events and set $survey_completed using branching. Preserve completion-only behavior by default and legacy response keys. Verified regression tests fail before and pass after implementation. Passed CI=true make compile, SDK/Compose module builds, API and formatting checks. Core: 943 tests; Android debug/release: 359 each (3 skipped); Compose: 9. CodeScene passed with stable legacy integration-file health. --- .changeset/smooth-birds-cheat.md | 6 + .../surveys/PostHogSurveysIntegration.kt | 57 +++++---- .../surveys/PostHogSurveysEventPayloadTest.kt | 113 ++++++++++++++++++ posthog/api/posthog.api | 10 +- .../main/java/com/posthog/surveys/Survey.kt | 2 + 5 files changed, 162 insertions(+), 26 deletions(-) create mode 100644 .changeset/smooth-birds-cheat.md diff --git a/.changeset/smooth-birds-cheat.md b/.changeset/smooth-birds-cheat.md new file mode 100644 index 000000000..3099b988b --- /dev/null +++ b/.changeset/smooth-birds-cheat.md @@ -0,0 +1,6 @@ +--- +"posthog": minor +"posthog-android": minor +--- + +Support survey partial response collection. When enabled, submit cumulative answers after each question with a stable submission ID and completion status, matching posthog-js. 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 9a8410b7b..f802a6aea 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 @@ -34,6 +34,7 @@ import com.posthog.surveys.SurveyQuestionTranslation import com.posthog.surveys.SurveyType import java.util.Date import java.util.Locale +import java.util.UUID public class PostHogSurveysIntegration( context: Context, @@ -309,14 +310,13 @@ public class PostHogSurveysIntegration( val displayLanguage = resolveDisplayLanguage() val translations = resolveSurveyTranslations(survey, displayLanguage) - val resolvedLanguage = translations.matchedKey - val resolvedQuestionTranslations = translations.questions + val responseContext = SurveyResponseContext(survey, translations.matchedKey, translations.questions) val displaySurvey = PostHogDisplaySurvey.toDisplaySurvey( survey, surveyTranslation = translations.survey, - questionTranslations = resolvedQuestionTranslations, + questionTranslations = responseContext.questionTranslations, ) // Store the original survey for branching logic @@ -336,7 +336,7 @@ public class PostHogSurveysIntegration( } // Send survey shown event - sendSurveyShownEvent(originalSurvey, resolvedLanguage) + sendSurveyShownEvent(originalSurvey, responseContext.language) // Clear up event-activated surveys if this survey has events if (hasEvents(originalSurvey)) { @@ -372,12 +372,12 @@ public class PostHogSurveysIntegration( activeSurveyCompleted = nextQuestion.isSurveyCompleted // Send completion event if survey is finished - if (activeSurveyCompleted) { + if (shouldSendResponse(originalSurvey, activeSurveyCompleted)) { responsesToSend = currentSurveyResponses.toMap() } } - responsesToSend?.let { sendSurveySentEvent(originalSurvey, it, resolvedLanguage, resolvedQuestionTranslations) } + responsesToSend?.let { sendSurveySentEvent(responseContext, it, nextQuestion.isSurveyCompleted) } nextQuestion } @@ -405,7 +405,7 @@ public class PostHogSurveysIntegration( // Send survey dismissed event if survey was not completed if (!wasSurveyCompleted) { - sendSurveyDismissedEvent(originalSurvey, surveyResponses, resolvedLanguage, resolvedQuestionTranslations) + sendSurveyDismissedEvent(responseContext, surveyResponses) } // Mark survey as seen @@ -721,6 +721,18 @@ public class PostHogSurveysIntegration( } } + private data class SurveyResponseContext( + val survey: Survey, + val language: String?, + val questionTranslations: List?, + val submissionId: String = UUID.randomUUID().toString(), + ) + + private fun shouldSendResponse( + survey: Survey, + isCompleted: Boolean, + ): Boolean = survey.enablePartialResponses == true || isCompleted + // Survey Event Methods /** @@ -740,29 +752,31 @@ public class PostHogSurveysIntegration( /** * Sends a "survey sent" event to PostHog instance * Sends a survey completion event to PostHog with all collected responses - * @param survey The completed survey + * @param context The survey submission and display language * @param responses Map of collected responses for each question */ private fun sendSurveySentEvent( - survey: Survey, + context: SurveyResponseContext, responses: Map, - language: String?, - questionTranslations: List?, + isCompleted: Boolean, ) { val additionalProperties = - buildSurveyResponseProperties(survey, responses, questionTranslations) + + buildSurveyResponseProperties(context.survey, responses, context.questionTranslations) + mapOf( + "\$survey_submission_id" to context.submissionId, + "\$survey_completed" to isCompleted, "\$set" to mapOf( - getSurveyInteractionProperty(survey, "responded") to true, + getSurveyInteractionProperty(context.survey, "responded") to true, ), ) + setSurveySeen(context.survey) sendSurveyEvent( event = "survey sent", - survey = survey, + survey = context.survey, additionalProperties = additionalProperties, - language = language, + language = context.language, ) } @@ -770,26 +784,25 @@ public class PostHogSurveysIntegration( * Sends a "survey dismissed" event to PostHog instance */ private fun sendSurveyDismissedEvent( - survey: Survey, + context: SurveyResponseContext, responses: Map, - language: String?, - questionTranslations: List?, ) { val additionalProperties = - buildSurveyResponseProperties(survey, responses, questionTranslations) + + buildSurveyResponseProperties(context.survey, responses, context.questionTranslations) + mapOf( + "\$survey_submission_id" to context.submissionId, "\$survey_partially_completed" to surveyHasResponses(responses), "\$set" to mapOf( - getSurveyInteractionProperty(survey, "dismissed") to true, + getSurveyInteractionProperty(context.survey, "dismissed") to true, ), ) sendSurveyEvent( event = "survey dismissed", - survey = survey, + survey = context.survey, additionalProperties = additionalProperties, - language = language, + language = context.language, ) } diff --git a/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysEventPayloadTest.kt b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysEventPayloadTest.kt index af9b180a2..c721ccb89 100644 --- a/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysEventPayloadTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysEventPayloadTest.kt @@ -111,6 +111,119 @@ internal class PostHogSurveysEventPayloadTest { ) } + private fun partialResponseSurvey( + enabled: Boolean?, + endAfterFirst: Boolean = false, + ): Survey { + val questions = + listOf( + mapOf( + "id" to "first", + "type" to "open", + "question" to "First?", + "optional" to true, + "branching" to if (endAfterFirst) mapOf("type" to "end") else null, + ), + mapOf("id" to "second", "type" to "open", "question" to "Second?"), + ) + return assertNotNull( + serializer.deserializeList( + listOf( + mapOf( + "id" to "partial-survey", + "name" to "Partial survey", + "type" to "popover", + "questions" to questions, + "enable_partial_responses" to enabled, + ), + ), + )?.firstOrNull(), + ) + } + + @Test + fun `partial responses emit cumulative answers with one submission id`() { + for (enabled in listOf(true, false, null)) { + val delegate = RecordingDelegate() + val (integration, postHog) = createIntegration(delegate) + try { + integration.showSurvey(partialResponseSurvey(enabled)) + val survey = assertNotNull(delegate.shownSurvey) + assertNotNull(delegate.onSurveyShown).invoke(survey) + val respond = assertNotNull(delegate.onSurveyResponse) + val first = assertNotNull(respond(survey, 0, PostHogSurveyResponse.Text("First answer"))) + assertEquals(false, first.isSurveyCompleted) + assertEquals(if (enabled == true) 2 else 1, postHog.captures) + val partial = postHog.properties + if (enabled == true) { + assertEquals("survey sent", postHog.event) + assertEquals(false, partial?.get("\$survey_completed")) + assertEquals("First answer", partial?.get("\$survey_response_first")) + assertNull(partial?.get("\$survey_response_second")) + } + respond(survey, 1, PostHogSurveyResponse.Text("Second answer")) + assertEquals(if (enabled == true) 3 else 2, postHog.captures) + assertEquals("survey sent", postHog.event) + val completed = assertNotNull(postHog.properties) + assertEquals(true, completed["\$survey_completed"]) + assertEquals("First answer", completed["\$survey_response_first"]) + assertEquals("Second answer", completed["\$survey_response_second"]) + val submissionId = assertNotNull(completed["\$survey_submission_id"] as? String) + java.util.UUID.fromString(submissionId) + if (enabled == true) assertEquals(submissionId, partial?.get("\$survey_submission_id")) + assertNotNull(delegate.onSurveyClosed).invoke(survey) + assertEquals(if (enabled == true) 3 else 2, postHog.captures) + } finally { + integration.uninstall() + } + } + } + + @Test + fun `dismissal keeps submission id and a new attempt gets a new id`() { + val delegate = RecordingDelegate() + val (integration, postHog) = createIntegration(delegate) + try { + val original = partialResponseSurvey(true) + integration.showSurvey(original) + val survey = assertNotNull(delegate.shownSurvey) + assertNotNull(delegate.onSurveyShown).invoke(survey) + assertNotNull(delegate.onSurveyResponse).invoke(survey, 0, PostHogSurveyResponse.Text("Saved")) + assertEquals("survey sent", postHog.event) + val submissionId = assertNotNull(postHog.properties?.get("\$survey_submission_id")) + assertNotNull(delegate.onSurveyClosed).invoke(survey) + assertEquals("survey dismissed", postHog.event) + assertEquals(submissionId, postHog.properties?.get("\$survey_submission_id")) + assertEquals(true, postHog.properties?.get("\$survey_partially_completed")) + assertEquals("Saved", postHog.properties?.get("\$survey_response_first")) + integration.showSurvey(original) + assertNotNull(delegate.onSurveyShown).invoke(assertNotNull(delegate.shownSurvey)) + assertNotNull(delegate.onSurveyResponse).invoke(assertNotNull(delegate.shownSurvey), 0, PostHogSurveyResponse.Text("New")) + val nextId = assertNotNull(postHog.properties?.get("\$survey_submission_id")) + kotlin.test.assertNotEquals(submissionId, nextId) + } finally { + integration.uninstall() + } + } + + @Test + fun `branching to end completes a partial-enabled survey even with a skipped optional answer`() { + val delegate = RecordingDelegate() + val (integration, postHog) = createIntegration(delegate) + try { + integration.showSurvey(partialResponseSurvey(true, endAfterFirst = true)) + val survey = assertNotNull(delegate.shownSurvey) + assertNotNull(delegate.onSurveyShown).invoke(survey) + val next = assertNotNull(assertNotNull(delegate.onSurveyResponse).invoke(survey, 0, PostHogSurveyResponse.Text(null))) + assertEquals(true, next.isSurveyCompleted) + assertEquals(2, postHog.captures) + assertEquals(true, postHog.properties?.get("\$survey_completed")) + assertNull(postHog.properties?.get("\$survey_response_second")) + } finally { + integration.uninstall() + } + } + @Test fun `survey sent includes legacy and question id response keys`() { val delegate = RecordingDelegate() diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index ec32a74d9..134106b1c 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -1910,8 +1910,8 @@ public final class com/posthog/surveys/SingleSurveyQuestion : com/posthog/survey } public final class com/posthog/surveys/Survey { - public fun (Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyType;Ljava/util/List;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyConditions;Lcom/posthog/surveys/SurveyAppearance;Ljava/lang/Integer;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lcom/posthog/surveys/SurveySchedule;Ljava/util/Map;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyType;Ljava/util/List;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyConditions;Lcom/posthog/surveys/SurveyAppearance;Ljava/lang/Integer;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lcom/posthog/surveys/SurveySchedule;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyType;Ljava/util/List;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyConditions;Lcom/posthog/surveys/SurveyAppearance;Ljava/lang/Integer;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lcom/posthog/surveys/SurveySchedule;Ljava/util/Map;Ljava/lang/Boolean;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyType;Ljava/util/List;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyConditions;Lcom/posthog/surveys/SurveyAppearance;Ljava/lang/Integer;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lcom/posthog/surveys/SurveySchedule;Ljava/util/Map;Ljava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()Lcom/posthog/surveys/SurveyConditions; public final fun component11 ()Lcom/posthog/surveys/SurveyAppearance; @@ -1921,6 +1921,7 @@ public final class com/posthog/surveys/Survey { public final fun component15 ()Ljava/util/Date; public final fun component16 ()Lcom/posthog/surveys/SurveySchedule; public final fun component17 ()Ljava/util/Map; + public final fun component18 ()Ljava/lang/Boolean; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Lcom/posthog/surveys/SurveyType; public final fun component4 ()Ljava/util/List; @@ -1929,14 +1930,15 @@ public final class com/posthog/surveys/Survey { public final fun component7 ()Ljava/lang/String; public final fun component8 ()Ljava/lang/String; public final fun component9 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyType;Ljava/util/List;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyConditions;Lcom/posthog/surveys/SurveyAppearance;Ljava/lang/Integer;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lcom/posthog/surveys/SurveySchedule;Ljava/util/Map;)Lcom/posthog/surveys/Survey; - public static synthetic fun copy$default (Lcom/posthog/surveys/Survey;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyType;Ljava/util/List;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyConditions;Lcom/posthog/surveys/SurveyAppearance;Ljava/lang/Integer;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lcom/posthog/surveys/SurveySchedule;Ljava/util/Map;ILjava/lang/Object;)Lcom/posthog/surveys/Survey; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyType;Ljava/util/List;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyConditions;Lcom/posthog/surveys/SurveyAppearance;Ljava/lang/Integer;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lcom/posthog/surveys/SurveySchedule;Ljava/util/Map;Ljava/lang/Boolean;)Lcom/posthog/surveys/Survey; + public static synthetic fun copy$default (Lcom/posthog/surveys/Survey;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyType;Ljava/util/List;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyConditions;Lcom/posthog/surveys/SurveyAppearance;Ljava/lang/Integer;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lcom/posthog/surveys/SurveySchedule;Ljava/util/Map;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/posthog/surveys/Survey; public fun equals (Ljava/lang/Object;)Z public final fun getAppearance ()Lcom/posthog/surveys/SurveyAppearance; public final fun getConditions ()Lcom/posthog/surveys/SurveyConditions; public final fun getCurrentIteration ()Ljava/lang/Integer; public final fun getCurrentIterationStartDate ()Ljava/util/Date; public final fun getDescription ()Ljava/lang/String; + public final fun getEnablePartialResponses ()Ljava/lang/Boolean; public final fun getEndDate ()Ljava/util/Date; public final fun getFeatureFlagKeys ()Ljava/util/List; public final fun getId ()Ljava/lang/String; diff --git a/posthog/src/main/java/com/posthog/surveys/Survey.kt b/posthog/src/main/java/com/posthog/surveys/Survey.kt index 0759f7f28..69a5fe7c7 100644 --- a/posthog/src/main/java/com/posthog/surveys/Survey.kt +++ b/posthog/src/main/java/com/posthog/surveys/Survey.kt @@ -29,4 +29,6 @@ public data class Survey( val endDate: Date?, val schedule: SurveySchedule?, val translations: Map? = null, + @SerializedName("enable_partial_responses") + val enablePartialResponses: Boolean? = null, ) From ec1ad02b0a8e2eb8f71f5b537795fabe3d17d839 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Tue, 8 Sep 2026 17:22:16 -0300 Subject: [PATCH 2/6] fix(surveys): retain legacy Survey constructor and copy signatures Preserve the previous JVM constructor, copy, and Kotlin default-argument signatures when adding enablePartialResponses. Forward copies with the new setting intact. Keep overloads visible for Java source compatibility too. Verification: reflection regression tests exercise all four old descriptors; Kotlin copy calls preserve/override the new field. Format/API snapshot and focused core/event-payload tests pass. CodeScene exception: the 17-argument copy compatibility overload triggers the argument-count gate. Its exact signature is required for binary compatibility; shortening it would restore the runtime failure. --- posthog/api/posthog.api | 4 + .../main/java/com/posthog/surveys/Survey.kt | 83 ++++++++++++++++++- .../surveys/SurveyBinaryCompatibilityTest.kt | 68 +++++++++++++++ .../surveys/SurveyJavaCompatibilityTest.java | 21 +++++ 4 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 posthog/src/test/java/com/posthog/surveys/SurveyBinaryCompatibilityTest.kt create mode 100644 posthog/src/test/java/com/posthog/surveys/SurveyJavaCompatibilityTest.java diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 134106b1c..c5d260217 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -1910,6 +1910,8 @@ public final class com/posthog/surveys/SingleSurveyQuestion : com/posthog/survey } public final class com/posthog/surveys/Survey { + public fun (Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyType;Ljava/util/List;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyConditions;Lcom/posthog/surveys/SurveyAppearance;Ljava/lang/Integer;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lcom/posthog/surveys/SurveySchedule;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyType;Ljava/util/List;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyConditions;Lcom/posthog/surveys/SurveyAppearance;Ljava/lang/Integer;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lcom/posthog/surveys/SurveySchedule;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyType;Ljava/util/List;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyConditions;Lcom/posthog/surveys/SurveyAppearance;Ljava/lang/Integer;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lcom/posthog/surveys/SurveySchedule;Ljava/util/Map;Ljava/lang/Boolean;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyType;Ljava/util/List;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyConditions;Lcom/posthog/surveys/SurveyAppearance;Ljava/lang/Integer;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lcom/posthog/surveys/SurveySchedule;Ljava/util/Map;Ljava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; @@ -1930,7 +1932,9 @@ public final class com/posthog/surveys/Survey { public final fun component7 ()Ljava/lang/String; public final fun component8 ()Ljava/lang/String; public final fun component9 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyType;Ljava/util/List;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyConditions;Lcom/posthog/surveys/SurveyAppearance;Ljava/lang/Integer;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lcom/posthog/surveys/SurveySchedule;Ljava/util/Map;)Lcom/posthog/surveys/Survey; public final fun copy (Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyType;Ljava/util/List;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyConditions;Lcom/posthog/surveys/SurveyAppearance;Ljava/lang/Integer;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lcom/posthog/surveys/SurveySchedule;Ljava/util/Map;Ljava/lang/Boolean;)Lcom/posthog/surveys/Survey; + public static synthetic fun copy$default (Lcom/posthog/surveys/Survey;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyType;Ljava/util/List;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyConditions;Lcom/posthog/surveys/SurveyAppearance;Ljava/lang/Integer;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lcom/posthog/surveys/SurveySchedule;Ljava/util/Map;ILjava/lang/Object;)Lcom/posthog/surveys/Survey; public static synthetic fun copy$default (Lcom/posthog/surveys/Survey;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyType;Ljava/util/List;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/SurveyConditions;Lcom/posthog/surveys/SurveyAppearance;Ljava/lang/Integer;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lcom/posthog/surveys/SurveySchedule;Ljava/util/Map;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/posthog/surveys/Survey; public fun equals (Ljava/lang/Object;)Z public final fun getAppearance ()Lcom/posthog/surveys/SurveyAppearance; diff --git a/posthog/src/main/java/com/posthog/surveys/Survey.kt b/posthog/src/main/java/com/posthog/surveys/Survey.kt index 69a5fe7c7..43ab70229 100644 --- a/posthog/src/main/java/com/posthog/surveys/Survey.kt +++ b/posthog/src/main/java/com/posthog/surveys/Survey.kt @@ -31,4 +31,85 @@ public data class Survey( val translations: Map? = null, @SerializedName("enable_partial_responses") val enablePartialResponses: Boolean? = null, -) +) { + // Kotlin default arguments also have JVM signatures; retain both the old constructor + // and copy overload so already-compiled consumers can upgrade without recompiling. + public constructor( + id: String, + name: String, + type: SurveyType, + questions: List, + description: String?, + featureFlagKeys: List?, + linkedFlagKey: String?, + targetingFlagKey: String?, + internalTargetingFlagKey: String?, + conditions: SurveyConditions?, + appearance: SurveyAppearance?, + currentIteration: Int?, + currentIterationStartDate: Date?, + startDate: Date?, + endDate: Date?, + schedule: SurveySchedule?, + translations: Map? = null, + ) : this( + id = id, + name = name, + type = type, + questions = questions, + description = description, + featureFlagKeys = featureFlagKeys, + linkedFlagKey = linkedFlagKey, + targetingFlagKey = targetingFlagKey, + internalTargetingFlagKey = internalTargetingFlagKey, + conditions = conditions, + appearance = appearance, + currentIteration = currentIteration, + currentIterationStartDate = currentIterationStartDate, + startDate = startDate, + endDate = endDate, + schedule = schedule, + translations = translations, + enablePartialResponses = null, + ) + + public fun copy( + id: String = this.id, + name: String = this.name, + type: SurveyType = this.type, + questions: List = this.questions, + description: String? = this.description, + featureFlagKeys: List? = this.featureFlagKeys, + linkedFlagKey: String? = this.linkedFlagKey, + targetingFlagKey: String? = this.targetingFlagKey, + internalTargetingFlagKey: String? = this.internalTargetingFlagKey, + conditions: SurveyConditions? = this.conditions, + appearance: SurveyAppearance? = this.appearance, + currentIteration: Int? = this.currentIteration, + currentIterationStartDate: Date? = this.currentIterationStartDate, + startDate: Date? = this.startDate, + endDate: Date? = this.endDate, + schedule: SurveySchedule? = this.schedule, + translations: Map? = this.translations, + ): Survey = + Survey( + id = id, + name = name, + type = type, + questions = questions, + description = description, + featureFlagKeys = featureFlagKeys, + linkedFlagKey = linkedFlagKey, + targetingFlagKey = targetingFlagKey, + internalTargetingFlagKey = internalTargetingFlagKey, + conditions = conditions, + appearance = appearance, + currentIteration = currentIteration, + currentIterationStartDate = currentIterationStartDate, + startDate = startDate, + endDate = endDate, + schedule = schedule, + translations = translations, + enablePartialResponses = enablePartialResponses, + ) +} diff --git a/posthog/src/test/java/com/posthog/surveys/SurveyBinaryCompatibilityTest.kt b/posthog/src/test/java/com/posthog/surveys/SurveyBinaryCompatibilityTest.kt new file mode 100644 index 000000000..0370b80c8 --- /dev/null +++ b/posthog/src/test/java/com/posthog/surveys/SurveyBinaryCompatibilityTest.kt @@ -0,0 +1,68 @@ +package com.posthog.surveys + +import org.junit.Test +import java.util.Date +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +internal class SurveyBinaryCompatibilityTest { + private val legacyParameterTypes = + arrayOf( + String::class.java, String::class.java, SurveyType::class.java, List::class.java, + String::class.java, List::class.java, String::class.java, String::class.java, + String::class.java, SurveyConditions::class.java, SurveyAppearance::class.java, + Integer::class.java, Date::class.java, Date::class.java, Date::class.java, + SurveySchedule::class.java, Map::class.java, + ) + + private fun legacyArguments(): Array = + arrayOf( + "survey", "Survey", SurveyType.POPOVER, emptyList(), + null, null, null, null, null, null, null, null, null, null, null, null, null, + ) + + @Test + fun `legacy constructor and its Kotlin defaults remain callable`() { + val constructor = Survey::class.java.getDeclaredConstructor(*legacyParameterTypes) + val survey = constructor.newInstance(*legacyArguments()) + assertEquals("survey", survey.id) + assertEquals(null, survey.enablePartialResponses) + + val defaultConstructor = + Survey::class.java.getDeclaredConstructor( + *legacyParameterTypes, + Int::class.javaPrimitiveType, + Class.forName("kotlin.jvm.internal.DefaultConstructorMarker"), + ) + val withDefaults = defaultConstructor.newInstance(*legacyArguments(), 1 shl 16, null) + assertEquals(survey, withDefaults) + } + + @Test + fun `legacy copy and Kotlin default copy preserve partial responses`() { + val survey = + Survey( + "survey", "Survey", SurveyType.POPOVER, emptyList(), + null, null, null, null, null, null, null, null, null, null, null, null, + enablePartialResponses = true, + ) + assertEquals(true, survey.copy(name = "Renamed").enablePartialResponses) + assertEquals(false, survey.copy(enablePartialResponses = false).enablePartialResponses) + val copy = Survey::class.java.getDeclaredMethod("copy", *legacyParameterTypes) + val copied = copy.invoke(survey, *legacyArguments()) as Survey + assertEquals(survey, copied) + assertTrue(copied.enablePartialResponses == true) + + val defaultCopy = + Survey::class.java.getDeclaredMethod( + "copy\$default", + Survey::class.java, + *legacyParameterTypes, + Int::class.javaPrimitiveType, + Any::class.java, + ) + val copiedWithDefaults = + defaultCopy.invoke(null, survey, *arrayOfNulls(17), (1 shl 17) - 1, null) as Survey + assertEquals(survey, copiedWithDefaults) + } +} diff --git a/posthog/src/test/java/com/posthog/surveys/SurveyJavaCompatibilityTest.java b/posthog/src/test/java/com/posthog/surveys/SurveyJavaCompatibilityTest.java new file mode 100644 index 000000000..62b7e2a05 --- /dev/null +++ b/posthog/src/test/java/com/posthog/surveys/SurveyJavaCompatibilityTest.java @@ -0,0 +1,21 @@ +package com.posthog.surveys; + +import java.util.Collections; +import org.junit.Test; +import static org.junit.Assert.assertEquals; + +public class SurveyJavaCompatibilityTest { + @Test + public void legacyConstructorAndCopyRemainAvailableToJava() { + Survey survey = new Survey( + "survey", "Survey", SurveyType.POPOVER, Collections.emptyList(), + null, null, null, null, null, null, null, null, null, null, null, null, null + ); + Survey copy = survey.copy( + "survey", "Renamed", SurveyType.POPOVER, Collections.emptyList(), + null, null, null, null, null, null, null, null, null, null, null, null, null + ); + assertEquals("survey", survey.getId()); + assertEquals("Renamed", copy.getName()); + } +} From 8d7e4499af64afca7ffef3845c1296db4efcf3a5 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Tue, 8 Sep 2026 18:49:15 -0300 Subject: [PATCH 3/6] feat(surveys): persist and restore unfinished responses Save progress after nonterminal answers and restore the submission ID, answers, answer-time text/language and branching destination. Clear state on completion, dismissal, reset and incompatible survey updates. Keep unfinished attempts eligible and honor initialQuestionIndex in Compose. Preserve legacy display-model constructor/copy descriptors and API 23 hash compatibility. Fix jumps to the final question completing too early. Verified: CI=true make compile, full Android debug tests after final callback simplification, make format, make checkFormat, make api, legacy ABI regression tests and API descriptor comparison. CodeScene exceptions: legacy integration size/aggregate complexity remains degraded despite lower showSurvey complexity; broader extraction is deferred. Compatibility copy argument counts preserve existing ABI. --- .changeset/smooth-birds-cheat.md | 3 + .../compose/internal/ui/SurveySheet.kt | 4 +- posthog-android/consumer-rules.pro | 3 + .../surveys/PostHogSurveysIntegration.kt | 174 +++++++++++++----- .../android/surveys/SurveyProgressStore.kt | 111 +++++++++++ .../surveys/PostHogSurveysEventPayloadTest.kt | 156 +++++++++++++++- .../surveys/SurveyProgressStoreTest.kt | 93 ++++++++++ posthog/api/posthog.api | 8 + .../posthog/internal/PostHogPreferences.kt | 2 + .../posthog/surveys/PostHogDisplaySurvey.kt | 32 ++++ .../posthog/surveys/PostHogSurveysDelegate.kt | 2 + .../surveys/SurveyBinaryCompatibilityTest.kt | 28 +++ 12 files changed, 569 insertions(+), 47 deletions(-) create mode 100644 posthog-android/src/main/java/com/posthog/android/surveys/SurveyProgressStore.kt create mode 100644 posthog-android/src/test/java/com/posthog/android/surveys/SurveyProgressStoreTest.kt diff --git a/.changeset/smooth-birds-cheat.md b/.changeset/smooth-birds-cheat.md index 3099b988b..a6b7268af 100644 --- a/.changeset/smooth-birds-cheat.md +++ b/.changeset/smooth-birds-cheat.md @@ -1,6 +1,9 @@ --- "posthog": minor "posthog-android": minor +"posthog-android-surveys-compose": minor --- Support survey partial response collection. When enabled, submit cumulative answers after each question with a stable submission ID and completion status, matching posthog-js. + +Persist unfinished survey progress across app restarts and restore the submission ID, collected answers, and next question. Clear progress on completion, dismissal, SDK reset, and incompatible survey updates. The Compose renderer starts at the restored question. diff --git a/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/internal/ui/SurveySheet.kt b/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/internal/ui/SurveySheet.kt index e39ffd400..efdd26e6d 100644 --- a/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/internal/ui/SurveySheet.kt +++ b/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/internal/ui/SurveySheet.kt @@ -93,7 +93,7 @@ internal fun SurveySheet( confirmValueChange = { it != SheetValue.Hidden }, ) - var currentQuestionIndex by rememberSaveable { mutableStateOf(0) } + var currentQuestionIndex by rememberSaveable(survey.id) { mutableStateOf(survey.initialQuestionIndex) } var showingConfirmation by rememberSaveable { mutableStateOf(false) } // Advancing past the intro is a pure UI transition: no response is recorded and no // survey event is sent. The X button keeps dismissing the whole survey as usual. @@ -101,7 +101,7 @@ internal fun SurveySheet( // of drawing an empty sheet with a lone button (resolve() normalizes blank copy to null). var showingIntroScreen by rememberSaveable { mutableStateOf( - appearance.displayIntroScreen && + survey.initialQuestionIndex == 0 && appearance.displayIntroScreen && (appearance.introScreenHeader != null || appearance.introScreenDescription != null), ) } diff --git a/posthog-android/consumer-rules.pro b/posthog-android/consumer-rules.pro index 7022cbefd..6b8edc7db 100644 --- a/posthog-android/consumer-rules.pro +++ b/posthog-android/consumer-rules.pro @@ -40,6 +40,9 @@ -keep class com.posthog.internal.replay.** { (); } # Surveys +# Persisted progress must remain readable after an app update with a different R8 mapping. +-keep class com.posthog.android.surveys.SurveyProgress { *; } +-keep class com.posthog.android.surveys.StoredSurveyResponse { *; } -keep class com.posthog.surveys.** { *; } -keep class com.posthog.surveys.** { (); } 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 f802a6aea..9f62ae55a 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 @@ -68,6 +68,9 @@ public class PostHogSurveysIntegration( private val seenSurveysLock = Any() private val eventActivationLock = Any() private val activeSurveyLock = Any() + private val progressStore = SurveyProgressStore(config) + private var activeSubmissionId: String? = null + private var activeProgressWasPersisted = false private val lifecycleLock = Any() private var postHog: PostHogInterface? = null @@ -116,10 +119,10 @@ public class PostHogSurveysIntegration( // Tear down any survey UI still on screen so its dialog window doesn't outlive the // integration; clearActiveSurvey() only resets our bookkeeping, not the delegate's UI. - cleanupSurveys() - clearActiveSurvey() + cleanupSurveys() + this.postHog = null } @@ -128,6 +131,7 @@ public class PostHogSurveysIntegration( synchronized(surveysLock) { cachedSurveys = surveys } + progressStore.reconcile(surveys) synchronized(eventActivationLock) { rebuildEventsToSurveysMap(surveys) } @@ -264,7 +268,7 @@ public class PostHogSurveysIntegration( survey.targetingFlagKey?.takeIf { it.isNotEmpty() }?.let { allKeys.add(it) } // Internal targeting flag key (only if survey cannot activate repeatedly) - if (!canActivateRepeatedly(survey)) { + if (!canActivateOrResume(survey)) { survey.internalTargetingFlagKey?.takeIf { it.isNotEmpty() }?.let { allKeys.add(it) } } @@ -288,9 +292,13 @@ public class PostHogSurveysIntegration( } featureFlagsMatch && eventActivationCheck - } + }.sortedByDescending(::hasProgress) } + private fun canActivateOrResume(survey: Survey): Boolean = canActivateRepeatedly(survey) || hasProgress(survey) + + private fun hasProgress(survey: Survey): Boolean = progressStore.load(survey) != null + /** * Shows a survey to the user using the configured delegate. * @@ -310,14 +318,23 @@ public class PostHogSurveysIntegration( val displayLanguage = resolveDisplayLanguage() val translations = resolveSurveyTranslations(survey, displayLanguage) - val responseContext = SurveyResponseContext(survey, translations.matchedKey, translations.questions) + val progress = progressStore.getOrCreate(survey) + val responseContext = + SurveyResponseContext( + survey, + translations.matchedKey, + translations.questions, + progress.submissionId, + progress.questionText.toMutableMap(), + progress.language, + ) val displaySurvey = PostHogDisplaySurvey.toDisplaySurvey( survey, surveyTranslation = translations.survey, questionTranslations = responseContext.questionTranslations, - ) + ).copy(initialQuestionIndex = progress.questionIndex) // Store the original survey for branching logic val originalSurvey = survey @@ -327,22 +344,14 @@ public class PostHogSurveysIntegration( // Check if shownSurvey is originalSurvey if (shownSurvey.id == originalSurvey.id) { // If no survey is active, set this originalSurvey as active - synchronized(activeSurveyLock) { - if (activeSurvey == null) { - activeSurvey = originalSurvey - activeSurveyCompleted = false - currentSurveyResponses.clear() - } - } + activateSurvey(originalSurvey, progress) // Send survey shown event sendSurveyShownEvent(originalSurvey, responseContext.language) // Clear up event-activated surveys if this survey has events - if (hasEvents(originalSurvey)) { - synchronized(eventActivationLock) { - eventActivatedSurveys.remove(originalSurvey.id) - } + synchronized(eventActivationLock) { + eventActivatedSurveys.remove(originalSurvey.id) } } else { config.logger.log("Received a show event for a non-matching survey: ${shownSurvey.id} vs ${originalSurvey.id}") @@ -356,20 +365,16 @@ public class PostHogSurveysIntegration( synchronized(activeSurveyLock) { // Validate that this survey matches the currently active survey - val currentActiveSurvey = activeSurvey - if (currentActiveSurvey == null || responseSurvey.id != currentActiveSurvey.id) { + if (!isActiveAttempt(responseSurvey.id, responseContext.submissionId)) { config.logger.log("Received a response event for a non-active survey") return@onSurveyResponse null } - // Store the response for survey completion tracking - currentSurveyResponses[getLegacyResponseKey(questionIndex)] = response - originalSurvey.questions.getOrNull(questionIndex)?.id?.takeIf { it.isNotEmpty() }?.let { questionId -> - currentSurveyResponses[getQuestionIdResponseKey(questionId)] = response + if (!canRecordResponse(responseContext)) { + return@onSurveyResponse null } - // Check if survey is completed (needed on close event) - activeSurveyCompleted = nextQuestion.isSurveyCompleted + recordResponse(responseContext, questionIndex, response, nextQuestion) // Send completion event if survey is finished if (shouldSendResponse(originalSurvey, activeSurveyCompleted)) { @@ -388,12 +393,16 @@ public class PostHogSurveysIntegration( synchronized(activeSurveyLock) { // Validate that this survey matches the currently active survey - val currentActiveSurvey = activeSurvey - if (currentActiveSurvey == null || originalSurvey.id != currentActiveSurvey.id) { + if (!isActiveAttempt(originalSurvey.id, responseContext.submissionId)) { config.logger.log("[Surveys] Received a close event for a non-active survey") return@onSurveyClosed } + if (!canCloseAttempt(responseContext)) { + return@onSurveyClosed + } + progressStore.remove(originalSurvey) + // Get current active survey and completion state surveyResponses = currentSurveyResponses.toMap() wasSurveyCompleted = activeSurveyCompleted @@ -422,6 +431,77 @@ public class PostHogSurveysIntegration( getSurveysDelegate().renderSurvey(displaySurvey, onSurveyShown, onSurveyResponse, onSurveyClosed) } + private fun activateSurvey( + survey: Survey, + progress: SurveyProgress, + ) { + synchronized(activeSurveyLock) { + if (activeSurvey == null) { + activeSurvey = survey + activeSurveyCompleted = false + currentSurveyResponses.clear() + currentSurveyResponses.putAll(progress.responses.mapValues { checkNotNull(it.value.toResponse()) }) + activeSubmissionId = progress.submissionId + activeProgressWasPersisted = hasProgress(survey) + } + } + } + + private fun isActiveAttempt( + surveyId: String, + submissionId: String, + ): Boolean = activeSurvey?.id == surveyId && activeSubmissionId == submissionId + + private fun canCloseAttempt(context: SurveyResponseContext): Boolean = activeSurveyCompleted || canRecordResponse(context) + + private fun canRecordResponse(context: SurveyResponseContext): Boolean { + if (!activeProgressWasPersisted) return true + if (progressStore.load(context.survey)?.submissionId == context.submissionId) return true + clearActiveSurvey() + return false + } + + private fun recordResponse( + context: SurveyResponseContext, + questionIndex: Int, + response: PostHogSurveyResponse, + nextQuestion: PostHogNextSurveyQuestion, + ) { + // Store the response for survey completion tracking + currentSurveyResponses[getLegacyResponseKey(questionIndex)] = response + context.survey.questions.getOrNull(questionIndex)?.id?.takeIf { it.isNotEmpty() }?.let { questionId -> + currentSurveyResponses[getQuestionIdResponseKey(questionId)] = response + } + + val text = + context.questionTranslations?.getOrNull(questionIndex)?.question + ?: context.survey.questions.getOrNull(questionIndex)?.question + text?.let { + context.questionText[questionIndex] = it + } + + context.responseLanguage = context.language + + // Check if survey is completed (needed on close event) + activeSurveyCompleted = nextQuestion.isSurveyCompleted + if (activeSurveyCompleted) { + progressStore.remove(context.survey) + } else { + progressStore.save( + context.survey, + SurveyProgress( + submissionId = context.submissionId, + questionOrder = progressStore.questionOrder(context.survey), + questionIndex = nextQuestion.questionIndex, + responses = currentSurveyResponses.mapValues { StoredSurveyResponse.from(it.value) }, + questionText = context.questionText.toMap(), + language = context.language, + ), + ) + activeProgressWasPersisted = hasProgress(context.survey) + } + } + /** * Cleans up any active surveys by calling the delegate's cleanupSurveys method. */ @@ -469,11 +549,7 @@ public class PostHogSurveysIntegration( ) } is SurveyQuestionBranching.SpecificQuestion -> { - val targetIndex = minOf(branching.index, originalSurvey.questions.size - 1) - PostHogNextSurveyQuestion( - questionIndex = targetIndex, - isSurveyCompleted = targetIndex == originalSurvey.questions.size - 1, - ) + questionDestination(branching.index, originalSurvey.questions.size) } is SurveyQuestionBranching.ResponseBased -> { getResponseBasedNextQuestion( @@ -483,7 +559,7 @@ public class PostHogSurveysIntegration( branching.responseValues, ) ?: PostHogNextSurveyQuestion( questionIndex = nextQuestionIndex, - isSurveyCompleted = nextQuestionIndex == originalSurvey.questions.size - 1, + isSurveyCompleted = currentIndex == originalSurvey.questions.size - 1, ) } } @@ -578,6 +654,15 @@ public class PostHogSurveysIntegration( return null } + private fun questionDestination( + index: Int, + totalQuestions: Int, + ): PostHogNextSurveyQuestion = + PostHogNextSurveyQuestion( + questionIndex = index.coerceIn(0, maxOf(totalQuestions - 1, 0)), + isSurveyCompleted = index !in 0 until totalQuestions, + ) + /** * Processes a branching step result, handling both Int indices and "end" string values. */ @@ -587,11 +672,7 @@ public class PostHogSurveysIntegration( ): PostHogNextSurveyQuestion? { return when { nextIndex is Int -> { - val safeIndex = minOf(nextIndex, totalQuestions - 1) - PostHogNextSurveyQuestion( - questionIndex = safeIndex, - isSurveyCompleted = safeIndex >= totalQuestions, - ) + questionDestination(nextIndex, totalQuestions) } nextIndex is String && nextIndex.lowercase() == "end" -> { PostHogNextSurveyQuestion( @@ -667,7 +748,7 @@ public class PostHogSurveysIntegration( */ internal fun canShowNextSurvey(): Boolean { return synchronized(activeSurveyLock) { - activeSurvey == null + config.cachePreferences?.isAvailable() != false && activeSurvey == null } } @@ -716,6 +797,8 @@ public class PostHogSurveysIntegration( private fun clearActiveSurvey() { synchronized(activeSurveyLock) { activeSurvey = null + activeSubmissionId = null + activeProgressWasPersisted = false activeSurveyCompleted = false currentSurveyResponses.clear() } @@ -726,6 +809,8 @@ public class PostHogSurveysIntegration( val language: String?, val questionTranslations: List?, val submissionId: String = UUID.randomUUID().toString(), + val questionText: MutableMap = mutableMapOf(), + var responseLanguage: String? = language, ) private fun shouldSendResponse( @@ -761,7 +846,7 @@ public class PostHogSurveysIntegration( isCompleted: Boolean, ) { val additionalProperties = - buildSurveyResponseProperties(context.survey, responses, context.questionTranslations) + + buildSurveyResponseProperties(context.survey, responses, context.questionTranslations, context.questionText) + mapOf( "\$survey_submission_id" to context.submissionId, "\$survey_completed" to isCompleted, @@ -788,7 +873,7 @@ public class PostHogSurveysIntegration( responses: Map, ) { val additionalProperties = - buildSurveyResponseProperties(context.survey, responses, context.questionTranslations) + + buildSurveyResponseProperties(context.survey, responses, context.questionTranslations, context.questionText) + mapOf( "\$survey_submission_id" to context.submissionId, "\$survey_partially_completed" to surveyHasResponses(responses), @@ -802,7 +887,7 @@ public class PostHogSurveysIntegration( event = "survey dismissed", survey = context.survey, additionalProperties = additionalProperties, - language = context.language, + language = if (responses.isEmpty()) context.language else context.responseLanguage, ) } @@ -810,6 +895,7 @@ public class PostHogSurveysIntegration( survey: Survey, responses: Map, questionTranslations: List?, + questionText: Map, ): Map { val responsesProperties = responses.mapNotNull { (key, response) -> @@ -824,7 +910,7 @@ public class PostHogSurveysIntegration( question.id?.let { put("id", it) } // Use translated question text (if applied) so $survey_questions matches what the user saw. val translatedText = questionTranslations?.getOrNull(index)?.question - val effectiveQuestion = translatedText ?: question.question + val effectiveQuestion = questionText[index] ?: translatedText ?: question.question effectiveQuestion?.let { put("question", it) } val responseKey = @@ -952,7 +1038,7 @@ public class PostHogSurveysIntegration( * Note: if the survey can be repeatedly activated by its events, this value will default to false */ private fun getSurveySeen(survey: Survey): Boolean { - if (canActivateRepeatedly(survey)) { + if (canActivateOrResume(survey)) { // if this survey can activate repeatedly, we override this return value return false } diff --git a/posthog-android/src/main/java/com/posthog/android/surveys/SurveyProgressStore.kt b/posthog-android/src/main/java/com/posthog/android/surveys/SurveyProgressStore.kt new file mode 100644 index 000000000..08313124c --- /dev/null +++ b/posthog-android/src/main/java/com/posthog/android/surveys/SurveyProgressStore.kt @@ -0,0 +1,111 @@ +package com.posthog.android.surveys + +import com.posthog.PostHogConfig +import com.posthog.internal.PostHogPreferences +import com.posthog.internal.PostHogSerializer +import com.posthog.surveys.PostHogSurveyResponse +import com.posthog.surveys.Survey +import java.io.StringReader +import java.util.UUID + +internal data class SurveyProgress( + val submissionId: String, + val questionOrder: List, + val version: Int = 1, + val questionIndex: Int = 0, + val responses: Map = emptyMap(), + val questionText: Map = emptyMap(), + val language: String? = null, +) + +internal data class StoredSurveyResponse( + val kind: String, + val text: String? = null, + val rating: Int? = null, + val choices: List? = null, + val clicked: Boolean = false, +) { + fun toResponse(): PostHogSurveyResponse? = + when (kind) { + "text" -> PostHogSurveyResponse.Text(text) + "rating" -> PostHogSurveyResponse.Rating(rating) + "single" -> PostHogSurveyResponse.SingleChoice(text) + "multiple" -> PostHogSurveyResponse.MultipleChoice(choices) + "link" -> PostHogSurveyResponse.Link(clicked) + else -> null + } + + companion object { + fun from(response: PostHogSurveyResponse): StoredSurveyResponse = + when (response) { + is PostHogSurveyResponse.Text -> StoredSurveyResponse("text", text = response.text) + is PostHogSurveyResponse.Rating -> StoredSurveyResponse("rating", rating = response.rating) + is PostHogSurveyResponse.SingleChoice -> StoredSurveyResponse("single", text = response.selectedChoice) + is PostHogSurveyResponse.MultipleChoice -> StoredSurveyResponse("multiple", choices = response.selectedChoices) + is PostHogSurveyResponse.Link -> StoredSurveyResponse("link", clicked = response.clicked) + } + } +} + +internal class SurveyProgressStore(private val config: PostHogConfig) { + private val serializer = PostHogSerializer(config) + private val lock = Any() + + private fun key(survey: Survey): String = "${survey.id}/${survey.currentIteration ?: 0}" + + fun getOrCreate(survey: Survey): SurveyProgress = load(survey) ?: SurveyProgress(UUID.randomUUID().toString(), questionOrder(survey)) + + fun questionOrder(survey: Survey): List = survey.questions.map { "${it.type}:${it.id.orEmpty()}" } + + private fun records(): MutableMap { + val stored = config.cachePreferences?.getValue(PostHogPreferences.SURVEY_PROGRESS) as? Map<*, *> + return stored?.entries?.mapNotNull { (key, value) -> + if (key is String && value != null) key to value else null + }?.toMap()?.toMutableMap() ?: mutableMapOf() + } + + fun load(survey: Survey): SurveyProgress? = + synchronized(lock) { + val json = records()[key(survey)] as? String ?: return@synchronized null + try { + val progress = serializer.deserialize(StringReader(json)) + if (progress.version == 1 && progress.submissionId.isNotEmpty() && + progress.questionIndex in survey.questions.indices && + progress.questionOrder == questionOrder(survey) && + progress.responses.values.all { it.toResponse() != null } + ) { + return@synchronized progress + } + } catch (_: Exception) { + config.logger.log("Discarding invalid saved survey progress") + } + remove(survey) + null + } + + fun save( + survey: Survey, + progress: SurveyProgress, + ) = synchronized(lock) { + if (config.cachePreferences?.isAvailable() == false) return@synchronized + val records = records() + records[key(survey)] = serializer.serializeObject(progress) ?: return@synchronized + config.cachePreferences?.setValue(PostHogPreferences.SURVEY_PROGRESS, records) + Unit + } + + fun reconcile(surveys: List) = + synchronized(lock) { + val keys = surveys.filter { it.startDate != null && it.endDate == null }.map(::key).toSet() + config.cachePreferences?.setValue(PostHogPreferences.SURVEY_PROGRESS, records().filterKeys { it in keys }) + Unit + } + + fun remove(survey: Survey) = + synchronized(lock) { + val records = records() + records.remove(key(survey)) + config.cachePreferences?.setValue(PostHogPreferences.SURVEY_PROGRESS, records) + Unit + } +} diff --git a/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysEventPayloadTest.kt b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysEventPayloadTest.kt index c721ccb89..871045d36 100644 --- a/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysEventPayloadTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysEventPayloadTest.kt @@ -4,6 +4,10 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.posthog.PostHogConfig import com.posthog.PostHogFake +import com.posthog.android.PostHogAndroidConfig +import com.posthog.android.internal.PostHogSharedPreferences +import com.posthog.internal.PostHogMemoryPreferences +import com.posthog.internal.PostHogPreferences import com.posthog.internal.PostHogSerializer import com.posthog.surveys.OnPostHogSurveyClosed import com.posthog.surveys.OnPostHogSurveyResponse @@ -46,9 +50,13 @@ internal class PostHogSurveysEventPayloadTest { override fun cleanupSurveys() {} } - private fun createIntegration(delegate: RecordingDelegate): Pair { + private fun createIntegration( + delegate: RecordingDelegate, + preferences: PostHogPreferences = PostHogMemoryPreferences(), + ): Pair { val config = PostHogConfig("test-api-key").apply { + cachePreferences = preferences surveys = true surveysConfig.surveysDelegate = delegate } @@ -141,6 +149,152 @@ internal class PostHogSurveysEventPayloadTest { ) } + @Test + fun `unfinished responses survive integration restart`() { + for (enabled in listOf(true, false, null)) { + val preferences = PostHogSharedPreferences(context, PostHogAndroidConfig("survey-resume-test")) + preferences.clear() + val delegate = RecordingDelegate() + val survey = partialResponseSurvey(enabled) + val (first, firstPostHog) = createIntegration(delegate, preferences) + first.showSurvey(survey) + val display = assertNotNull(delegate.shownSurvey) + assertNotNull(delegate.onSurveyShown).invoke(display) + assertNotNull(delegate.onSurveyResponse).invoke(display, 0, PostHogSurveyResponse.Text("Saved")) + val submissionId = firstPostHog.properties?.get("\$survey_submission_id") + first.uninstall() + + val reloadedPreferences = PostHogSharedPreferences(context, PostHogAndroidConfig("survey-resume-test")) + val (resumed, resumedPostHog) = createIntegration(delegate, reloadedPreferences) + try { + resumed.showSurvey(survey) + val restored = assertNotNull(delegate.shownSurvey) + assertEquals(1, restored.initialQuestionIndex) + assertNotNull(delegate.onSurveyShown).invoke(restored) + assertNotNull(delegate.onSurveyResponse).invoke(restored, 1, PostHogSurveyResponse.Text("Final")) + val properties = assertNotNull(resumedPostHog.properties) + assertEquals("Saved", properties["\$survey_response_first"]) + assertEquals(true, properties["\$survey_completed"]) + if (enabled == true) assertEquals(submissionId, properties["\$survey_submission_id"]) + assertNotNull(delegate.onSurveyClosed).invoke(restored) + resumed.showSurvey(survey) + assertEquals(0, assertNotNull(delegate.shownSurvey).initialQuestionIndex) + } finally { + resumed.uninstall() + preferences.clear() + } + } + } + + @Test + fun `dismissal and reset clear saved progress without stale callbacks restoring it`() { + for (reset in listOf(false, true)) { + val preferences = PostHogMemoryPreferences() + val delegate = RecordingDelegate() + val (integration, postHog) = createIntegration(delegate, preferences) + try { + integration.showSurvey(partialResponseSurvey(true)) + val display = assertNotNull(delegate.shownSurvey) + assertNotNull(delegate.onSurveyShown).invoke(display) + assertNotNull(delegate.onSurveyResponse).invoke(display, 0, PostHogSurveyResponse.Text("Saved")) + if (reset) { + preferences.clear() + val count = postHog.captures + assertNull(assertNotNull(delegate.onSurveyResponse).invoke(display, 1, PostHogSurveyResponse.Text("Stale"))) + assertEquals(count, postHog.captures) + } else { + assertNotNull(delegate.onSurveyClosed).invoke(display) + } + integration.showSurvey(partialResponseSurvey(true)) + assertEquals(0, assertNotNull(delegate.shownSurvey).initialQuestionIndex) + } finally { + integration.uninstall() + preferences.clear() + } + } + } + + @Test + fun `unfinished surveys bypass seen and internal targeting but honor linked flags`() { + val preferences = PostHogMemoryPreferences() + val delegate = RecordingDelegate() + val (integration, _) = createIntegration(delegate, preferences) + val survey = partialResponseSurvey(true).copy(startDate = java.util.Date()) + integration.showSurvey(survey) + val display = assertNotNull(delegate.shownSurvey) + assertNotNull(delegate.onSurveyShown).invoke(display) + assertNotNull(delegate.onSurveyResponse).invoke(display, 0, PostHogSurveyResponse.Text("Saved")) + integration.uninstall() + delegate.shownSurvey = null + val (resumed, _) = createIntegration(delegate, preferences) + try { + resumed.onSurveysLoaded(listOf(survey.copy(internalTargetingFlagKey = "already-answered"))) + assertEquals(1, assertNotNull(delegate.shownSurvey).initialQuestionIndex) + delegate.shownSurvey = null + resumed.onSurveysLoaded(listOf(survey.copy(linkedFlagKey = "disabled-product-flag"))) + assertNull(delegate.shownSurvey) + } finally { + resumed.uninstall() + preferences.clear() + } + } + + @Test + fun `restart restores the branching destination and omits skipped answers`() { + val preferences = PostHogMemoryPreferences() + val delegate = RecordingDelegate() + val questions = + assertNotNull( + serializer.deserializeList( + listOf( + mapOf( + "id" to "first", + "type" to "open", + "question" to "First?", + "branching" to mapOf("type" to "specific_question", "index" to 2), + ), + mapOf("id" to "skipped", "type" to "open", "question" to "Skipped?"), + mapOf("id" to "last", "type" to "open", "question" to "Last?"), + ), + ), + ) + val survey = partialResponseSurvey(true).copy(questions = questions) + val (first, _) = createIntegration(delegate, preferences) + first.showSurvey(survey) + val display = assertNotNull(delegate.shownSurvey) + assertNotNull(delegate.onSurveyShown).invoke(display) + assertNotNull(delegate.onSurveyResponse).invoke(display, 0, PostHogSurveyResponse.Text("Saved")) + first.uninstall() + val (resumed, postHog) = createIntegration(delegate, preferences) + try { + resumed.showSurvey(survey) + val restored = assertNotNull(delegate.shownSurvey) + assertEquals(2, restored.initialQuestionIndex) + assertNotNull(delegate.onSurveyShown).invoke(restored) + assertNotNull(delegate.onSurveyResponse).invoke(restored, 2, PostHogSurveyResponse.Text("Final")) + assertEquals("Saved", postHog.properties?.get("\$survey_response_first")) + assertNull(postHog.properties?.get("\$survey_response_skipped")) + } finally { + resumed.uninstall() + preferences.clear() + } + } + + @Test + fun `showing a survey alone does not create resumable progress`() { + val preferences = PostHogMemoryPreferences() + val delegate = RecordingDelegate() + val (integration, _) = createIntegration(delegate, preferences) + try { + integration.showSurvey(partialResponseSurvey(true)) + assertNotNull(delegate.onSurveyShown).invoke(assertNotNull(delegate.shownSurvey)) + assertNull(preferences.getValue(PostHogPreferences.SURVEY_PROGRESS)) + } finally { + integration.uninstall() + preferences.clear() + } + } + @Test fun `partial responses emit cumulative answers with one submission id`() { for (enabled in listOf(true, false, null)) { diff --git a/posthog-android/src/test/java/com/posthog/android/surveys/SurveyProgressStoreTest.kt b/posthog-android/src/test/java/com/posthog/android/surveys/SurveyProgressStoreTest.kt new file mode 100644 index 000000000..dd58d0d4d --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/surveys/SurveyProgressStoreTest.kt @@ -0,0 +1,93 @@ +package com.posthog.android.surveys + +import com.posthog.PostHogConfig +import com.posthog.internal.PostHogMemoryPreferences +import com.posthog.internal.PostHogPreferences +import com.posthog.internal.PostHogSerializer +import com.posthog.surveys.PostHogSurveyResponse +import com.posthog.surveys.Survey +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +internal class SurveyProgressStoreTest { + private val preferences = PostHogMemoryPreferences() + private val config = PostHogConfig("progress-test").apply { cachePreferences = preferences } + private val serializer = PostHogSerializer(config) + private val store = SurveyProgressStore(config) + private val survey = + checkNotNull( + serializer.deserializeList( + listOf( + mapOf( + "id" to "survey", + "name" to "Survey", + "type" to "popover", + "current_iteration" to 1, + "questions" to listOf(mapOf("id" to "first", "type" to "open", "question" to "First?")), + ), + ), + )?.first(), + ).copy(startDate = java.util.Date()) + + @Test + fun `all response kinds round trip including skipped optional answers`() { + val responses = + listOf( + PostHogSurveyResponse.Text("Saved"), PostHogSurveyResponse.Text(null), + PostHogSurveyResponse.Rating(4), PostHogSurveyResponse.Rating(null), + PostHogSurveyResponse.SingleChoice("A"), PostHogSurveyResponse.SingleChoice(null), + PostHogSurveyResponse.MultipleChoice(listOf("A", "B")), PostHogSurveyResponse.MultipleChoice(null), + PostHogSurveyResponse.Link(true), PostHogSurveyResponse.Link(false), + ) + for (response in responses) { + val progress = + SurveyProgress( + "submission", + store.questionOrder(survey), + responses = mapOf("answer" to StoredSurveyResponse.from(response)), + questionText = mapOf(0 to "Original text"), + language = "fr", + ) + store.save(survey, progress) + val restored = assertNotNull(SurveyProgressStore(config).load(survey)) + assertEquals(response, restored.responses["answer"]?.toResponse()) + assertEquals("Original text", restored.questionText[0]) + assertEquals("fr", restored.language) + } + } + + @Test + fun `corrupt or incompatible progress is discarded`() { + val valid = assertNotNull(serializer.serializeObject(SurveyProgress("submission", store.questionOrder(survey)))) + for (invalid in listOf( + "broken json", + "null", + valid.replace("\"version\":1", "\"version\":99"), + valid.replace("\"questionIndex\":0", "\"questionIndex\":-1"), + valid.replace("\"questionIndex\":0", "\"questionIndex\":10"), + valid.replace("first", "removed"), + )) { + preferences.setValue(PostHogPreferences.SURVEY_PROGRESS, mapOf("survey/1" to invalid)) + assertNull(store.load(survey)) + assertEquals(emptyMap(), preferences.getValue(PostHogPreferences.SURVEY_PROGRESS)) + } + } + + @Test + fun `new iterations and ended or removed surveys clear old progress`() { + val progress = SurveyProgress("submission", store.questionOrder(survey)) + store.save(survey, progress) + val nextIteration = survey.copy(currentIteration = 2) + assertNull(store.load(nextIteration)) + store.reconcile(listOf(nextIteration)) + assertNull(store.load(survey)) + store.save(survey, progress) + store.reconcile(listOf(survey.copy(endDate = java.util.Date()))) + assertNull(store.load(survey)) + store.save(survey, progress) + store.reconcile(emptyList()) + assertNull(store.load(survey)) + } +} diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index c5d260217..5dc03d434 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -915,6 +915,7 @@ public abstract interface class com/posthog/internal/PostHogPreferences { public static final field GROUPS Ljava/lang/String; public static final field LAST_SEEN_SURVEY_DATE Ljava/lang/String; public static final field STRINGIFIED_KEYS Ljava/lang/String; + public static final field SURVEY_PROGRESS Ljava/lang/String; public static final field SURVEY_SEEN Ljava/lang/String; public static final field VERSION Ljava/lang/String; public abstract fun clear (Ljava/util/List;)V @@ -933,6 +934,7 @@ public final class com/posthog/internal/PostHogPreferences$Companion { public static final field GROUPS Ljava/lang/String; public static final field LAST_SEEN_SURVEY_DATE Ljava/lang/String; public static final field STRINGIFIED_KEYS Ljava/lang/String; + public static final field SURVEY_PROGRESS Ljava/lang/String; public static final field SURVEY_SEEN Ljava/lang/String; public static final field VERSION Ljava/lang/String; public final fun getALL_INTERNAL_KEYS ()Ljava/util/Set; @@ -1687,6 +1689,8 @@ public final class com/posthog/surveys/PostHogDisplayRatingQuestion : com/postho public final class com/posthog/surveys/PostHogDisplaySurvey { public static final field Companion Lcom/posthog/surveys/PostHogDisplaySurvey$Companion; public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lcom/posthog/surveys/PostHogDisplaySurveyAppearance;Ljava/util/Date;Ljava/util/Date;)V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lcom/posthog/surveys/PostHogDisplaySurveyAppearance;Ljava/util/Date;Ljava/util/Date;I)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lcom/posthog/surveys/PostHogDisplaySurveyAppearance;Ljava/util/Date;Ljava/util/Date;IILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lcom/posthog/surveys/PostHogDisplaySurveyAppearance;Ljava/util/Date;Ljava/util/Date;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; @@ -1694,12 +1698,16 @@ public final class com/posthog/surveys/PostHogDisplaySurvey { public final fun component4 ()Lcom/posthog/surveys/PostHogDisplaySurveyAppearance; public final fun component5 ()Ljava/util/Date; public final fun component6 ()Ljava/util/Date; + public final fun component7 ()I public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lcom/posthog/surveys/PostHogDisplaySurveyAppearance;Ljava/util/Date;Ljava/util/Date;)Lcom/posthog/surveys/PostHogDisplaySurvey; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lcom/posthog/surveys/PostHogDisplaySurveyAppearance;Ljava/util/Date;Ljava/util/Date;I)Lcom/posthog/surveys/PostHogDisplaySurvey; + public static synthetic fun copy$default (Lcom/posthog/surveys/PostHogDisplaySurvey;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lcom/posthog/surveys/PostHogDisplaySurveyAppearance;Ljava/util/Date;Ljava/util/Date;IILjava/lang/Object;)Lcom/posthog/surveys/PostHogDisplaySurvey; public static synthetic fun copy$default (Lcom/posthog/surveys/PostHogDisplaySurvey;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lcom/posthog/surveys/PostHogDisplaySurveyAppearance;Ljava/util/Date;Ljava/util/Date;ILjava/lang/Object;)Lcom/posthog/surveys/PostHogDisplaySurvey; public fun equals (Ljava/lang/Object;)Z public final fun getAppearance ()Lcom/posthog/surveys/PostHogDisplaySurveyAppearance; public final fun getEndDate ()Ljava/util/Date; public final fun getId ()Ljava/lang/String; + public final fun getInitialQuestionIndex ()I public final fun getName ()Ljava/lang/String; public final fun getQuestions ()Ljava/util/List; public final fun getStartDate ()Ljava/util/Date; diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt b/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt index 46e455401..82d86fa1d 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt @@ -54,6 +54,7 @@ public interface PostHogPreferences { internal const val PUSH = "push" internal const val PERSON_PROPERTIES_FOR_FLAGS = "personPropertiesForFlags" internal const val GROUP_PROPERTIES_FOR_FLAGS = "groupPropertiesForFlags" + public const val SURVEY_PROGRESS: String = "surveyProgress" public const val SURVEY_SEEN: String = "surveySeen" public const val LAST_SEEN_SURVEY_DATE: String = "lastSeenSurveyDate" public const val VERSION: String = "version" @@ -74,6 +75,7 @@ public interface PostHogPreferences { SESSION_REPLAY, SURVEYS, SURVEY_SEEN, + SURVEY_PROGRESS, LAST_SEEN_SURVEY_DATE, VERSION, BUILD, diff --git a/posthog/src/main/java/com/posthog/surveys/PostHogDisplaySurvey.kt b/posthog/src/main/java/com/posthog/surveys/PostHogDisplaySurvey.kt index 108676d35..874a3cda8 100644 --- a/posthog/src/main/java/com/posthog/surveys/PostHogDisplaySurvey.kt +++ b/posthog/src/main/java/com/posthog/surveys/PostHogDisplaySurvey.kt @@ -12,6 +12,7 @@ import java.util.Date * @property appearance The appearance configuration for the survey * @property startDate Optional date indicating when the survey should start being shown * @property endDate Optional date indicating when the survey should stop being shown + * @property initialQuestionIndex The question to show when restoring unfinished progress; zero for a new survey. */ public data class PostHogDisplaySurvey( val id: String, @@ -20,7 +21,38 @@ public data class PostHogDisplaySurvey( val appearance: PostHogDisplaySurveyAppearance? = null, val startDate: Date? = null, val endDate: Date? = null, + val initialQuestionIndex: Int = 0, ) { + // Preserve constructor and copy signatures used by already-compiled SDK consumers. + public constructor( + id: String, + name: String, + questions: List, + appearance: PostHogDisplaySurveyAppearance? = null, + startDate: Date? = null, + endDate: Date? = null, + ) : this(id, name, questions, appearance, startDate, endDate, 0) + + public fun copy( + id: String = this.id, + name: String = this.name, + questions: List = this.questions, + appearance: PostHogDisplaySurveyAppearance? = this.appearance, + startDate: Date? = this.startDate, + endDate: Date? = this.endDate, + ): PostHogDisplaySurvey = PostHogDisplaySurvey(id, name, questions, appearance, startDate, endDate, initialQuestionIndex) + + // Kotlin's generated hashCode uses Integer.hashCode(int), unavailable on Android API 23. + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + questions.hashCode() + result = 31 * result + (appearance?.hashCode() ?: 0) + result = 31 * result + (startDate?.hashCode() ?: 0) + result = 31 * result + (endDate?.hashCode() ?: 0) + return 31 * result + initialQuestionIndex + } + public companion object { /** * Creates a PostHogDisplaySurvey from a Survey object. diff --git a/posthog/src/main/java/com/posthog/surveys/PostHogSurveysDelegate.kt b/posthog/src/main/java/com/posthog/surveys/PostHogSurveysDelegate.kt index 199044d33..fb62f0660 100644 --- a/posthog/src/main/java/com/posthog/surveys/PostHogSurveysDelegate.kt +++ b/posthog/src/main/java/com/posthog/surveys/PostHogSurveysDelegate.kt @@ -9,6 +9,8 @@ public interface PostHogSurveysDelegate { /** * Called when an activated PostHog survey needs to be rendered on the app's UI * + * Start at [PostHogDisplaySurvey.initialQuestionIndex] to resume an unfinished survey. + * * @param survey The survey to be displayed to the user * @param onSurveyShown To be called when the survey is successfully displayed to the user * @param onSurveyResponse To be called when the user submits a response to a question diff --git a/posthog/src/test/java/com/posthog/surveys/SurveyBinaryCompatibilityTest.kt b/posthog/src/test/java/com/posthog/surveys/SurveyBinaryCompatibilityTest.kt index 0370b80c8..253d60adf 100644 --- a/posthog/src/test/java/com/posthog/surveys/SurveyBinaryCompatibilityTest.kt +++ b/posthog/src/test/java/com/posthog/surveys/SurveyBinaryCompatibilityTest.kt @@ -21,6 +21,34 @@ internal class SurveyBinaryCompatibilityTest { null, null, null, null, null, null, null, null, null, null, null, null, null, ) + @Test + fun `display survey legacy constructor and copy preserve resume index`() { + val types = + arrayOf( + String::class.java, + String::class.java, + List::class.java, + PostHogDisplaySurveyAppearance::class.java, + Date::class.java, + Date::class.java, + ) + val constructor = PostHogDisplaySurvey::class.java.getDeclaredConstructor(*types) + val original = constructor.newInstance("id", "name", emptyList(), null, null, null) + assertEquals(0, original.initialQuestionIndex) + val resumed = original.copy(initialQuestionIndex = 2) + assertEquals(2, resumed.copy(name = "Updated").initialQuestionIndex) + val defaultCopy = + PostHogDisplaySurvey::class.java.getDeclaredMethod( + "copy\$default", + PostHogDisplaySurvey::class.java, + *types, + Int::class.javaPrimitiveType, + Any::class.java, + ) + val copied = defaultCopy.invoke(null, resumed, *arrayOfNulls(6), 63, null) as PostHogDisplaySurvey + assertEquals(2, copied.initialQuestionIndex) + } + @Test fun `legacy constructor and its Kotlin defaults remain callable`() { val constructor = Survey::class.java.getDeclaredConstructor(*legacyParameterTypes) From 575a3fc5053377e2279632af74657d5c29970cc7 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Wed, 9 Sep 2026 11:29:11 -0300 Subject: [PATCH 4/6] fix(surveys): protect resume state across reset and Activity teardown Invalidate delayed callbacks on reset, serialize preference mutations, and reject stale snapshots after reentrant preference reads. Defer reconciliation while credential-protected storage is unavailable. Prepare event snapshots before dispatch and keep client callbacks outside state locks; stale shown callbacks cannot close a newer renderer. Keep Compose survey state across host Activity destruction and restore on the next foreground Activity. Explicit close still dismisses exactly once. Add mounted lifecycle coverage and an explicit CI debug-host test target. Validation: deterministic reset/store/callback regressions, mounted lifecycle tests, full CI=true make compile, format/API checks, and CodeScene pre-commit review. --- .changeset/smooth-birds-cheat.md | 2 + .github/workflows/build.yml | 4 + Makefile | 6 +- .../build.gradle.kts | 8 + .../gradle.lockfile | 65 ++++- .../compose/internal/ActivityProvider.kt | 2 +- .../compose/internal/PostHogSurveyHost.kt | 47 ++- .../compose/internal/PostHogSurveyHostTest.kt | 92 ++++++ .../surveys/PostHogSurveysIntegration.kt | 276 ++++++++++-------- .../android/surveys/SurveyProgressStore.kt | 30 +- .../surveys/PostHogSurveysEventPayloadTest.kt | 134 ++++++++- .../surveys/SurveyProgressStoreTest.kt | 114 ++++++++ posthog/api/posthog.api | 1 + posthog/src/main/java/com/posthog/PostHog.kt | 10 +- .../posthog/surveys/PostHogSurveysConfig.kt | 14 + .../src/test/java/com/posthog/PostHogTest.kt | 18 ++ 16 files changed, 662 insertions(+), 161 deletions(-) create mode 100644 posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostTest.kt diff --git a/.changeset/smooth-birds-cheat.md b/.changeset/smooth-birds-cheat.md index a6b7268af..7dfc8dbd7 100644 --- a/.changeset/smooth-birds-cheat.md +++ b/.changeset/smooth-birds-cheat.md @@ -7,3 +7,5 @@ Support survey partial response collection. When enabled, submit cumulative answers after each question with a stable submission ID and completion status, matching posthog-js. Persist unfinished survey progress across app restarts and restore the submission ID, collected answers, and next question. Clear progress on completion, dismissal, SDK reset, and incompatible survey updates. The Compose renderer starts at the restored question. + +Keep unfinished surveys across Activity teardown, preserve unreadable progress during Direct Boot, and invalidate delayed responses on reset without mixing user identities. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 75dd5cdfa..98f3a2f7b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -53,6 +53,10 @@ jobs: if: needs.detect-markdown-only.outputs.markdown_only != 'true' run: make compile + - name: Test survey UI + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + run: make testSurveyUI + - name: Check release tasks and dependency locks if: needs.detect-markdown-only.outputs.markdown_only != 'true' run: make checkRelease diff --git a/Makefile b/Makefile index e6e8cead8..36b8233d3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: clean compile stop checkFormat format api dryRelease release testReport test testJava generateLintBaseLine checkRelease updateLocks +.PHONY: clean compile stop checkFormat format api dryRelease release testReport test testJava testSurveyUI generateLintBaseLine checkRelease updateLocks clean: ./gradlew clean @@ -67,6 +67,10 @@ test: testJava: ./gradlew :posthog:test +# Mounted Compose tests use the debug-only test activity manifest, including on CI. +testSurveyUI: + CI=false ./gradlew :posthog-android-surveys-compose:testDebugUnitTest + generateLintBaseLine: rm -f posthog-android/lint-baseline.xml ./gradlew lintDebug -Dlint.baselines.continue=true diff --git a/posthog-android-surveys-compose/build.gradle.kts b/posthog-android-surveys-compose/build.gradle.kts index 328b0266f..391bd5f1d 100644 --- a/posthog-android-surveys-compose/build.gradle.kts +++ b/posthog-android-surveys-compose/build.gradle.kts @@ -29,6 +29,10 @@ android { } } + testOptions { + unitTests.isIncludeAndroidResources = true + } + buildFeatures { compose = true } @@ -89,6 +93,10 @@ dependencies { debugImplementation("androidx.compose.ui:ui-tooling") // tests + testImplementation("androidx.test.ext:junit:${PosthogBuildConfig.Dependencies.ANDROIDX_JUNIT}") + testImplementation("org.robolectric:robolectric:${PosthogBuildConfig.Dependencies.ROBOLECTRIC}") + testImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-test-manifest") testImplementation("junit:junit:${PosthogBuildConfig.Dependencies.ANDROIDX_JUNIT}") testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${PosthogBuildConfig.Kotlin.KOTLIN}") } diff --git a/posthog-android-surveys-compose/gradle.lockfile b/posthog-android-surveys-compose/gradle.lockfile index d7e5faaf7..24da799b1 100644 --- a/posthog-android-surveys-compose/gradle.lockfile +++ b/posthog-android-surveys-compose/gradle.lockfile @@ -39,6 +39,11 @@ androidx.compose.ui:ui-geometry-android:1.7.0=debugAndroidTestCompileClasspath,d androidx.compose.ui:ui-geometry:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.ui:ui-graphics-android:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.ui:ui-graphics:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-test-android:1.7.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-test-junit4-android:1.7.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-test-junit4:1.7.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-test-manifest:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-test:1.7.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.ui:ui-text-android:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.ui:ui-text:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.ui:ui-tooling-android:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath @@ -53,7 +58,8 @@ androidx.compose.ui:ui-util-android:1.7.0=debugAndroidTestCompileClasspath,debug androidx.compose.ui:ui-util:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.ui:ui:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose:compose-bom:2024.09.00=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.concurrent:concurrent-futures:1.1.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.concurrent:concurrent-futures-ktx:1.1.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.concurrent:concurrent-futures:1.1.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.core:core-ktx:1.13.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.core:core:1.13.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.customview:customview-poolingcontainer:1.0.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath @@ -79,8 +85,18 @@ androidx.profileinstaller:profileinstaller:1.3.1=debugAndroidTestRuntimeClasspat androidx.savedstate:savedstate-ktx:1.2.1=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.savedstate:savedstate:1.2.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.startup:startup-runtime:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.tracing:tracing:1.0.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.test.espresso:espresso-core:3.5.0=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.test.espresso:espresso-idling-resource:3.6.1=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.test.ext:junit:1.2.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.test.services:storage:1.5.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.test:annotation:1.0.1=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.test:core:1.6.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.test:monitor:1.7.2=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.test:runner:1.5.0=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.tracing:tracing:1.0.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,releaseRuntimeClasspath +androidx.tracing:tracing:1.1.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.almworks.sqlite4java:sqlite4java:1.0.392=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.android.tools.ddms:ddmlib:31.9.1=_internal-unified-test-platform-android-device-provider-ddmlib com.android.tools.emulator:proto:31.9.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention com.android.tools.utp:android-device-provider-ddmlib-proto:31.9.1=_internal-unified-test-platform-android-device-provider-ddmlib @@ -120,18 +136,24 @@ com.google.android:annotations:4.1.1.4=_internal-unified-test-platform-android-t com.google.api.grpc:proto-google-common-protos:2.17.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core com.google.auto.service:auto-service-annotations:1.1.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle com.google.auto.service:auto-service:1.1.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +com.google.auto.value:auto-value-annotations:1.11.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.google.auto:auto-common:1.2.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle -com.google.code.findbugs:jsr305:3.0.2=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher +com.google.code.findbugs:jsr305:3.0.2=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.google.code.gson:gson:2.10.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath com.google.code.gson:gson:2.8.9=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-launcher com.google.crypto.tink:tink:1.7.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control com.google.dagger:dagger:2.48=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core +com.google.errorprone:error_prone_annotation:2.34.0=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath com.google.errorprone:error_prone_annotations:2.23.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher +com.google.errorprone:error_prone_annotations:2.28.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.google.guava:failureaccess:1.0.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher +com.google.guava:failureaccess:1.0.2=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.google.guava:guava:32.0.1-jre=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher -com.google.guava:listenablefuture:1.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher +com.google.guava:guava:33.3.1-jre=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.guava:listenablefuture:1.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,releaseRuntimeClasspath +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.google.j2objc:j2objc-annotations:2.8=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher +com.google.j2objc:j2objc-annotations:3.0.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.google.protobuf:protobuf-java-util:3.22.3=_internal-unified-test-platform-core com.google.protobuf:protobuf-java-util:3.24.4=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-launcher com.google.protobuf:protobuf-java:3.24.4=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher @@ -142,11 +164,14 @@ com.google.testing.platform:android-test-plugin:0.0.9-alpha03=_internal-unified- com.google.testing.platform:core-proto:0.0.9-alpha03=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-result-listener-gradle com.google.testing.platform:core:0.0.9-alpha03=_internal-unified-test-platform-core com.google.testing.platform:launcher:0.0.9-alpha03=_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-launcher +com.google.testparameterinjector:test-parameter-injector:1.18=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +com.ibm.icu:icu4j:75.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.squareup.curtains:curtains:1.2.5=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath com.squareup.okhttp3:okhttp-bom:4.12.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath com.squareup.okhttp3:okhttp:4.12.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath com.squareup.okio:okio-jvm:3.6.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath com.squareup.okio:okio:3.6.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +com.squareup:javawriter:2.1.1=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath commons-io:commons-io:2.16.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention io.grpc:grpc-api:1.57.2=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core io.grpc:grpc-context:1.57.2=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core @@ -173,17 +198,22 @@ io.perfmark:perfmark-api:0.26.0=_internal-unified-test-platform-android-test-plu it.unimi.dsi:fastutil-core:8.5.12=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin jakarta.activation:jakarta.activation-api:1.2.1=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime -javax.annotation:javax.annotation-api:1.3.2=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle -javax.inject:javax.inject:1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core +javax.annotation:javax.annotation-api:1.3.2=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +javax.inject:javax.inject:1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath junit:junit:4.13.2=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata net.java.dev.jna:jna-platform:5.6.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle net.java.dev.jna:jna:5.6.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle net.sf.kxml:kxml2:2.3.0=_internal-unified-test-platform-android-device-provider-ddmlib +org.bouncycastle:bcprov-jdk18on:1.78.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.checkerframework:checker-qual:3.33.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher +org.checkerframework:checker-qual:3.43.0=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.codehaus.mojo:animal-sniffer-annotations:1.23=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core org.codehaus.woodstox:stax2-api:4.2.1=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime +org.conscrypt:conscrypt-openjdk-uber:2.5.2=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath org.freemarker:freemarker:2.3.32=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin org.hamcrest:hamcrest-core:1.3=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata +org.hamcrest:hamcrest-integration:1.3=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.hamcrest:hamcrest-library:1.3=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.dokka:analysis-kotlin-descriptors:1.9.20=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin org.jetbrains.dokka:analysis-markdown:1.9.20=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin org.jetbrains.dokka:dokka-base:1.9.20=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin @@ -233,12 +263,33 @@ org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3=_internal-unified-test-platfo org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.7.3=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.kotlinx:kotlinx-html-jvm:0.9.1=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin org.jetbrains:annotations:13.0=bcv-rt-jvm-cp-resolver,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath org.jetbrains:annotations:23.0.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jetbrains:markdown-jvm:0.5.2=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin org.jetbrains:markdown:0.5.2=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin org.jsoup:jsoup:1.16.1=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin +org.ow2.asm:asm-analysis:9.7.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.ow2.asm:asm-commons:9.7.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.ow2.asm:asm-tree:9.6=bcv-rt-jvm-cp-resolver +org.ow2.asm:asm-tree:9.7.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.ow2.asm:asm-util:9.7.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.ow2.asm:asm:9.6=bcv-rt-jvm-cp-resolver +org.ow2.asm:asm:9.7.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:annotations:4.14.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:junit:4.14.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:nativeruntime-dist-compat:1.0.16=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:nativeruntime:4.14.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:pluginapi:4.14.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:plugins-maven-dependency-resolver:4.14.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:resources:4.14.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:robolectric:4.14.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:sandbox:4.14.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:shadowapi:4.14.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:shadows-framework:4.14.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:utils-reflector:4.14.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:utils:4.14.1=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.yaml:snakeyaml:2.3=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath empty=androidApis,androidJdkImage,androidTestApiDependenciesMetadata,androidTestCompileOnlyDependenciesMetadata,androidTestDebugApiDependenciesMetadata,androidTestDebugCompileOnlyDependenciesMetadata,androidTestDebugImplementationDependenciesMetadata,androidTestDebugIntransitiveDependenciesMetadata,androidTestImplementationDependenciesMetadata,androidTestIntransitiveDependenciesMetadata,androidTestReleaseApiDependenciesMetadata,androidTestReleaseCompileOnlyDependenciesMetadata,androidTestReleaseImplementationDependenciesMetadata,androidTestReleaseIntransitiveDependenciesMetadata,androidTestUtil,compileOnlyDependenciesMetadata,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestApiDependenciesMetadata,debugAndroidTestCompileOnlyDependenciesMetadata,debugAndroidTestImplementationDependenciesMetadata,debugAndroidTestIntransitiveDependenciesMetadata,debugAnnotationProcessorClasspath,debugApiDependenciesMetadata,debugCompileOnlyDependenciesMetadata,debugIntransitiveDependenciesMetadata,debugUnitTestAnnotationProcessorClasspath,debugUnitTestApiDependenciesMetadata,debugUnitTestCompileOnlyDependenciesMetadata,debugUnitTestImplementationDependenciesMetadata,debugUnitTestIntransitiveDependenciesMetadata,dokkaPlugin,dokkaRuntime,intransitiveDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseApiDependenciesMetadata,releaseCompileOnlyDependenciesMetadata,releaseImplementationDependenciesMetadata,releaseIntransitiveDependenciesMetadata,releaseUnitTestAnnotationProcessorClasspath,releaseUnitTestApiDependenciesMetadata,releaseUnitTestCompileOnlyDependenciesMetadata,releaseUnitTestImplementationDependenciesMetadata,releaseUnitTestIntransitiveDependenciesMetadata,testApiDependenciesMetadata,testCompileOnlyDependenciesMetadata,testDebugApiDependenciesMetadata,testDebugCompileOnlyDependenciesMetadata,testDebugImplementationDependenciesMetadata,testDebugIntransitiveDependenciesMetadata,testFixturesApiDependenciesMetadata,testFixturesCompileOnlyDependenciesMetadata,testFixturesDebugApiDependenciesMetadata,testFixturesDebugCompileOnlyDependenciesMetadata,testFixturesDebugImplementationDependenciesMetadata,testFixturesDebugIntransitiveDependenciesMetadata,testFixturesImplementationDependenciesMetadata,testFixturesIntransitiveDependenciesMetadata,testFixturesReleaseApiDependenciesMetadata,testFixturesReleaseCompileOnlyDependenciesMetadata,testFixturesReleaseImplementationDependenciesMetadata,testFixturesReleaseIntransitiveDependenciesMetadata,testIntransitiveDependenciesMetadata,testReleaseApiDependenciesMetadata,testReleaseCompileOnlyDependenciesMetadata,testReleaseImplementationDependenciesMetadata,testReleaseIntransitiveDependenciesMetadata diff --git a/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/internal/ActivityProvider.kt b/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/internal/ActivityProvider.kt index dbe23631a..350be6f1f 100644 --- a/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/internal/ActivityProvider.kt +++ b/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/internal/ActivityProvider.kt @@ -25,7 +25,7 @@ internal class ActivityProvider : Application.ActivityLifecycleCallbacks { /** * Invoked on the main thread when an activity resumes, so a survey dropped - * for a configuration change can be re-presented on the recreated activity. + * during host teardown can be re-presented on the next foreground activity. */ var onActivityResumedListener: ((Activity) -> Unit)? = null diff --git a/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHost.kt b/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHost.kt index 83d8ac630..cbaa7f4db 100644 --- a/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHost.kt +++ b/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHost.kt @@ -41,17 +41,14 @@ import com.posthog.surveys.PostHogDisplaySurvey * Only the explicit close button dismisses (touch-outside and back are * disabled). * - * ## Surviving configuration changes + * ## Surviving host activity changes * * A dialog window is bound to its host activity's token, so it must come down - * when that activity is destroyed. To avoid losing the survey (and emitting a - * spurious `survey dismissed`) on a rotation / dark-mode / font-size change, we - * distinguish a configuration change from a genuine finish: - * - **Configuration change** ([Activity.isChangingConfigurations]): snapshot the - * sheet's `rememberSaveable` state into a host-owned [SaveableStateRegistry], - * drop the window, and re-present on the recreated activity with that state - * restored verbatim. The survey stays active; no close event is fired. - * - **Genuine finish**: dismiss and notify the SDK as usual. + * when that activity is destroyed. Snapshot the sheet's `rememberSaveable` state + * into a host-owned [SaveableStateRegistry], drop the window, and re-present on + * the next foreground activity. This applies both to configuration changes and + * genuine Activity finishes: only an explicit survey close is a dismissal. + * The survey stays active and no close event is fired during host teardown. * * All UI mutation happens on the main thread; the public API is safe to call * from the SDK's survey thread. @@ -67,7 +64,7 @@ internal class PostHogSurveyHost(private val activityProvider: ActivityProvider) private var hostActivity: Activity? = null // Callbacks + survey for the active survey, retained so we can re-present it - // on the recreated activity after a configuration change. + // on the recreated activity after a host activity change. private var currentSurvey: PostHogDisplaySurvey? = null private var onShownCallback: OnPostHogSurveyShown? = null private var onResponseCallback: OnPostHogSurveyResponse? = null @@ -77,7 +74,7 @@ internal class PostHogSurveyHost(private val activityProvider: ActivityProvider) private var pendingShow: Runnable? = null // Whether `survey shown` has already been reported for the current survey, - // so a re-present after a configuration change doesn't double-fire it. + // so a re-present after a host activity change doesn't double-fire it. private var shownReported = false // Set when a show fired with no foreground activity to host the sheet (e.g. the @@ -87,8 +84,8 @@ internal class PostHogSurveyHost(private val activityProvider: ActivityProvider) private var awaitingForeground = false // The live registry backing the sheet's `rememberSaveable` state, plus the - // snapshot taken across a configuration change. A non-null snapshot means a - // re-present is armed: the window was dropped for a config change and should be + // snapshot taken across a host activity change. A non-null snapshot means a + // re-present is armed: the window was dropped for a host activity change and should be // rebuilt on the next foreground activity. private var saveableRegistry: SaveableStateRegistry? = null private var savedSurveyState: Map>? = null @@ -96,18 +93,14 @@ internal class PostHogSurveyHost(private val activityProvider: ActivityProvider) init { activityProvider.onActivityDestroyedListener = { destroyed -> if (destroyed === hostActivity) { - if (destroyed.isChangingConfigurations) { - // Rotation / dark-mode / font-size / locale / fold: keep the - // survey alive and rebuild it on the recreated activity. - preserveForConfigChange() - } else { - // Genuine finish: tear down and notify the SDK. - dismissInternal(notifyClosed = true) - } + // Rotation / dark-mode / font-size / locale / fold or a genuine finish: + // keep the survey alive and rebuild it on the next foreground activity. + preserveForHostChange() + activityProvider.foregroundActivity?.takeIf { it !== destroyed }?.let(::present) } } activityProvider.onActivityResumedListener = { resumed -> - // Re-present on the next foreground activity when either a config-change + // Re-present on the next foreground activity when either a host-change // snapshot is armed (window dropped for rotation/etc.) or a show was // deferred because no activity was available when it fired. if (currentSurvey != null && (savedSurveyState != null || awaitingForeground)) { @@ -174,7 +167,7 @@ internal class PostHogSurveyHost(private val activityProvider: ActivityProvider) hostActivity = activity // Host-owned registry so the sheet's `rememberSaveable` state survives the - // ComposeView being recreated across a configuration change. Seeded with + // ComposeView being recreated across a host activity change. Seeded with // any snapshot taken before the previous window was dropped. val registry = SaveableStateRegistry( @@ -222,7 +215,7 @@ internal class PostHogSurveyHost(private val activityProvider: ActivityProvider) /** * Forwards `survey shown` to the SDK at most once per survey, so re-presenting - * after a configuration change doesn't emit a duplicate event. + * after a host activity change doesn't emit a duplicate event. */ private fun reportShownOnce() { if (shownReported) return @@ -248,14 +241,14 @@ internal class PostHogSurveyHost(private val activityProvider: ActivityProvider) } /** - * Drops the dialog window for a configuration change while keeping the survey + * Drops the dialog window for a host activity change while keeping the survey * active: snapshots the sheet's saveable state and arms a re-present on the * next foreground activity. No close event is fired. */ - private fun preserveForConfigChange() { + private fun preserveForHostChange() { cancelPendingShow() - guard("preserving the survey across a configuration change") { + guard("preserving the survey across a host activity change") { // Snapshot before disposing — providers unregister on disposal. savedSurveyState = saveableRegistry?.performSave() saveableRegistry = null diff --git a/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostTest.kt b/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostTest.kt new file mode 100644 index 000000000..da1c8521c --- /dev/null +++ b/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostTest.kt @@ -0,0 +1,92 @@ +package com.posthog.android.surveys.compose.internal + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import com.posthog.surveys.PostHogDisplayOpenQuestion +import com.posthog.surveys.PostHogDisplaySurvey +import com.posthog.surveys.PostHogDisplaySurveyTextContentType +import com.posthog.surveys.PostHogNextSurveyQuestion +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +internal class PostHogSurveyHostTest { + @get:Rule + val compose = createAndroidComposeRule() + + @Test + fun `finishing host preserves question and closes only on explicit dismiss`() { + assertHostTransition(replacementAlreadyResumed = false) + } + + @Test + fun `finishing host resumes on an already resumed replacement activity`() { + assertHostTransition(replacementAlreadyResumed = true) + } + + private fun assertHostTransition(replacementAlreadyResumed: Boolean) { + val application = ApplicationProvider.getApplicationContext() + val provider = ActivityProvider() + val host = PostHogSurveyHost(provider) + var shown = 0 + var closed = 0 + val submitted = mutableListOf() + val survey = + PostHogDisplaySurvey( + id = "resume", + name = "Resume", + questions = + listOf("First?", "Second?").mapIndexed { index, text -> + PostHogDisplayOpenQuestion(index.toString(), text, null, PostHogDisplaySurveyTextContentType.TEXT, false, "Next") + }, + ) + application.registerActivityLifecycleCallbacks(provider) + var replacement: ActivityScenario? = null + try { + compose.runOnIdle { + provider.onActivityResumed(compose.activity) + host.show(survey, { shown++ }, { _, index, _ -> + submitted.add(index) + PostHogNextSurveyQuestion(index + 1, false) + }, { closed++ }) + } + compose.onNodeWithText("First?").assertIsDisplayed() + compose.onNode(hasSetTextAction()).performTextInput("Saved") + compose.onNodeWithText("Next").assertIsEnabled().performSemanticsAction(SemanticsActions.OnClick) { it() } + assertEquals(listOf(0), submitted) + compose.onNodeWithText("Second?").assertExists() + + if (replacementAlreadyResumed) replacement = ActivityScenario.launch(ComponentActivity::class.java) + compose.activityRule.scenario.close() + assertEquals(0, closed) + if (replacement == null) replacement = ActivityScenario.launch(ComponentActivity::class.java) + + compose.onNodeWithText("Second?").assertExists() + assertEquals(listOf(0), submitted) + assertEquals(1, shown) + compose.onNodeWithContentDescription("Close survey").performSemanticsAction(SemanticsActions.OnClick) { it() } + compose.waitForIdle() + assertEquals(1, closed) + } finally { + compose.runOnUiThread { host.cleanup() } + replacement?.close() + application.unregisterActivityLifecycleCallbacks(provider) + } + } +} 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 9f62ae55a..a9c9a9fb6 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 @@ -67,10 +67,11 @@ public class PostHogSurveysIntegration( private val surveysLock = Any() private val seenSurveysLock = Any() private val eventActivationLock = Any() - private val activeSurveyLock = Any() + private val activeSurveyLock = config.surveysConfig private val progressStore = SurveyProgressStore(config) private var activeSubmissionId: String? = null private var activeProgressWasPersisted = false + private var resetGeneration = config.surveysConfig.resetGeneration private val lifecycleLock = Any() private var postHog: PostHogInterface? = null @@ -212,6 +213,7 @@ public class PostHogSurveysIntegration( * @return List of filtered surveys */ internal fun getActiveMatchingSurveys(): List { + synchronizeReset() // Check if surveys are enabled in config if (!config.surveys) { return emptyList() @@ -316,9 +318,11 @@ public class PostHogSurveysIntegration( return } + val resetGeneration = synchronized(activeSurveyLock) { config.surveysConfig.resetGeneration } val displayLanguage = resolveDisplayLanguage() val translations = resolveSurveyTranslations(survey, displayLanguage) - val progress = progressStore.getOrCreate(survey) + val savedProgress = progressStore.load(survey) + val progress = savedProgress ?: progressStore.getOrCreate(survey) val responseContext = SurveyResponseContext( survey, @@ -327,6 +331,8 @@ public class PostHogSurveysIntegration( progress.submissionId, progress.questionText.toMutableMap(), progress.language, + resetGeneration, + savedProgress != null, ) val displaySurvey = @@ -340,85 +346,41 @@ public class PostHogSurveysIntegration( val originalSurvey = survey // Setup callbacks for delegate call - val onSurveyShown: OnPostHogSurveyShown = { shownSurvey -> - // Check if shownSurvey is originalSurvey - if (shownSurvey.id == originalSurvey.id) { - // If no survey is active, set this originalSurvey as active - activateSurvey(originalSurvey, progress) + val onSurveyShown = surveyShownCallback(responseContext, progress) - // Send survey shown event - sendSurveyShownEvent(originalSurvey, responseContext.language) - - // Clear up event-activated surveys if this survey has events - synchronized(eventActivationLock) { - eventActivatedSurveys.remove(originalSurvey.id) - } - } else { - config.logger.log("Received a show event for a non-matching survey: ${shownSurvey.id} vs ${originalSurvey.id}") - } - } - - val onSurveyResponse: OnPostHogSurveyResponse = onSurveyResponse@{ responseSurvey, questionIndex, response -> - // Calculate next question based on current response - val nextQuestion = getNextQuestion(originalSurvey, questionIndex, response) - var responsesToSend: Map? = null - - synchronized(activeSurveyLock) { - // Validate that this survey matches the currently active survey - if (!isActiveAttempt(responseSurvey.id, responseContext.submissionId)) { - config.logger.log("Received a response event for a non-active survey") - return@onSurveyResponse null - } - - if (!canRecordResponse(responseContext)) { - return@onSurveyResponse null - } - - recordResponse(responseContext, questionIndex, response, nextQuestion) - - // Send completion event if survey is finished - if (shouldSendResponse(originalSurvey, activeSurveyCompleted)) { - responsesToSend = currentSurveyResponses.toMap() - } - } - - responsesToSend?.let { sendSurveySentEvent(responseContext, it, nextQuestion.isSurveyCompleted) } - - nextQuestion - } + val onSurveyResponse = surveyResponseCallback(responseContext) val onSurveyClosed: OnPostHogSurveyClosed = onSurveyClosed@{ _ -> - var surveyResponses: Map = emptyMap() - var wasSurveyCompleted = false - - synchronized(activeSurveyLock) { - // Validate that this survey matches the currently active survey - if (!isActiveAttempt(originalSurvey.id, responseContext.submissionId)) { - config.logger.log("[Surveys] Received a close event for a non-active survey") - return@onSurveyClosed + val distinctId = postHog?.distinctId() + val event = + synchronized(activeSurveyLock) { + // Validate that this survey matches the currently active survey + if (!isActiveAttempt(originalSurvey.id, responseContext.submissionId)) { + config.logger.log("[Surveys] Received a close event for a non-active survey") + return@onSurveyClosed + } + + if (!canCloseAttempt(responseContext)) { + return@onSurveyClosed + } + progressStore.remove(originalSurvey) + if (responseContext.resetGeneration != config.surveysConfig.resetGeneration) return@onSurveyClosed + + // Get current active survey and completion state + val surveyResponses = currentSurveyResponses.toMap() + val wasSurveyCompleted = activeSurveyCompleted + + activeSurvey = null + activeSurveyCompleted = false + currentSurveyResponses.clear() + + // Mark survey as seen + setSurveySeen(originalSurvey) + + // Send survey dismissed event if survey was not completed + if (!wasSurveyCompleted) surveyDismissedEvent(responseContext, surveyResponses, distinctId) else null } - - if (!canCloseAttempt(responseContext)) { - return@onSurveyClosed - } - progressStore.remove(originalSurvey) - - // Get current active survey and completion state - surveyResponses = currentSurveyResponses.toMap() - wasSurveyCompleted = activeSurveyCompleted - - activeSurvey = null - activeSurveyCompleted = false - currentSurveyResponses.clear() - } - - // Send survey dismissed event if survey was not completed - if (!wasSurveyCompleted) { - sendSurveyDismissedEvent(responseContext, surveyResponses) - } - - // Mark survey as seen - setSurveySeen(originalSurvey) + event?.let(::captureSurveyEvent) // Show next survey in queue after a short delay Thread { @@ -431,9 +393,70 @@ public class PostHogSurveysIntegration( getSurveysDelegate().renderSurvey(displaySurvey, onSurveyShown, onSurveyResponse, onSurveyClosed) } + private fun surveyResponseCallback(responseContext: SurveyResponseContext): OnPostHogSurveyResponse = + onSurveyResponse@{ responseSurvey, questionIndex, response -> + val originalSurvey = responseContext.survey + // Calculate next question based on current response + val nextQuestion = getNextQuestion(originalSurvey, questionIndex, response) + val distinctId = postHog?.distinctId() + val event = + synchronized(activeSurveyLock) { + // Validate that this survey matches the currently active survey + if (!isActiveAttempt(responseSurvey.id, responseContext.submissionId)) { + config.logger.log("Received a response event for a non-active survey") + return@onSurveyResponse null + } + + if (!canRecordResponse(responseContext)) { + return@onSurveyResponse null + } + + recordResponse(responseContext, questionIndex, response, nextQuestion) + if (responseContext.resetGeneration != config.surveysConfig.resetGeneration) return@onSurveyResponse null + + // Send completion event if survey is finished + if (shouldSendResponse(originalSurvey, activeSurveyCompleted)) { + surveySentEvent(responseContext, currentSurveyResponses.toMap(), nextQuestion.isSurveyCompleted, distinctId) + } else { + null + } + } + event?.let(::captureSurveyEvent) + + nextQuestion + } + + private fun surveyShownCallback( + responseContext: SurveyResponseContext, + progress: SurveyProgress, + ): OnPostHogSurveyShown = + { shownSurvey -> + val originalSurvey = responseContext.survey + // Check if shownSurvey is originalSurvey + if (shownSurvey.id == originalSurvey.id) { + // If no survey is active, set this originalSurvey as active + val distinctId = postHog?.distinctId() + val event = + synchronized(activeSurveyLock) { + if (!isCurrentContext(responseContext)) return@synchronized null + activateSurvey(originalSurvey, progress, responseContext.wasRestored) + + // Clear up event-activated surveys if this survey has events + synchronized(eventActivationLock) { + eventActivatedSurveys.remove(originalSurvey.id) + } + surveyShownEvent(originalSurvey, responseContext.language, distinctId) + } + event?.let(::captureSurveyEvent) + } else { + config.logger.log("Received a show event for a non-matching survey: ${shownSurvey.id} vs ${originalSurvey.id}") + } + } + private fun activateSurvey( survey: Survey, progress: SurveyProgress, + persisted: Boolean, ) { synchronized(activeSurveyLock) { if (activeSurvey == null) { @@ -442,7 +465,7 @@ public class PostHogSurveysIntegration( currentSurveyResponses.clear() currentSurveyResponses.putAll(progress.responses.mapValues { checkNotNull(it.value.toResponse()) }) activeSubmissionId = progress.submissionId - activeProgressWasPersisted = hasProgress(survey) + activeProgressWasPersisted = persisted } } } @@ -452,12 +475,23 @@ public class PostHogSurveysIntegration( submissionId: String, ): Boolean = activeSurvey?.id == surveyId && activeSubmissionId == submissionId - private fun canCloseAttempt(context: SurveyResponseContext): Boolean = activeSurveyCompleted || canRecordResponse(context) + private fun canCloseAttempt(context: SurveyResponseContext): Boolean = + (context.resetGeneration == config.surveysConfig.resetGeneration && activeSurveyCompleted) || canRecordResponse(context) + + private fun isCurrentContext(context: SurveyResponseContext): Boolean = + hasExpectedProgress(context, context.wasRestored) && context.resetGeneration == config.surveysConfig.resetGeneration + + private fun hasExpectedProgress( + context: SurveyResponseContext, + persisted: Boolean, + ): Boolean = !persisted || progressStore.load(context.survey)?.submissionId == context.submissionId private fun canRecordResponse(context: SurveyResponseContext): Boolean { - if (!activeProgressWasPersisted) return true - if (progressStore.load(context.survey)?.submissionId == context.submissionId) return true - clearActiveSurvey() + if (hasExpectedProgress(context, activeProgressWasPersisted) && context.resetGeneration == config.surveysConfig.resetGeneration + ) { + return true + } + if (isActiveAttempt(context.survey.id, context.submissionId)) clearActiveSurvey() return false } @@ -498,7 +532,8 @@ public class PostHogSurveysIntegration( language = context.language, ), ) - activeProgressWasPersisted = hasProgress(context.survey) + val persisted = hasProgress(context.survey) + if (context.resetGeneration == config.surveysConfig.resetGeneration) activeProgressWasPersisted = persisted } } @@ -747,6 +782,7 @@ public class PostHogSurveysIntegration( * Returns true if there's no active survey currently being displayed. */ internal fun canShowNextSurvey(): Boolean { + synchronizeReset() return synchronized(activeSurveyLock) { config.cachePreferences?.isAvailable() != false && activeSurvey == null } @@ -804,6 +840,17 @@ public class PostHogSurveysIntegration( } } + private fun synchronizeReset() { + synchronized(activeSurveyLock) { + val generation = config.surveysConfig.resetGeneration + if (resetGeneration == generation) return + resetGeneration = generation + clearActiveSurvey() + synchronized(seenSurveysLock) { seenSurveyKeys = null } + synchronized(eventActivationLock) { eventActivatedSurveys.clear() } + } + } + private data class SurveyResponseContext( val survey: Survey, val language: String?, @@ -811,6 +858,8 @@ public class PostHogSurveysIntegration( val submissionId: String = UUID.randomUUID().toString(), val questionText: MutableMap = mutableMapOf(), var responseLanguage: String? = language, + val resetGeneration: Long, + val wasRestored: Boolean, ) private fun shouldSendResponse( @@ -823,16 +872,11 @@ public class PostHogSurveysIntegration( /** * Sends a "survey shown" event to PostHog instance */ - private fun sendSurveyShownEvent( + private fun surveyShownEvent( survey: Survey, language: String?, - ) { - sendSurveyEvent( - event = "survey shown", - survey = survey, - language = language, - ) - } + distinctId: String?, + ): SurveyEvent = SurveyEvent("survey shown", surveyEventProperties(survey, language = language), distinctId) /** * Sends a "survey sent" event to PostHog instance @@ -840,11 +884,12 @@ public class PostHogSurveysIntegration( * @param context The survey submission and display language * @param responses Map of collected responses for each question */ - private fun sendSurveySentEvent( + private fun surveySentEvent( context: SurveyResponseContext, responses: Map, isCompleted: Boolean, - ) { + distinctId: String?, + ): SurveyEvent { val additionalProperties = buildSurveyResponseProperties(context.survey, responses, context.questionTranslations, context.questionText) + mapOf( @@ -857,21 +902,17 @@ public class PostHogSurveysIntegration( ) setSurveySeen(context.survey) - sendSurveyEvent( - event = "survey sent", - survey = context.survey, - additionalProperties = additionalProperties, - language = context.language, - ) + return SurveyEvent("survey sent", surveyEventProperties(context.survey, additionalProperties, context.language), distinctId) } /** * Sends a "survey dismissed" event to PostHog instance */ - private fun sendSurveyDismissedEvent( + private fun surveyDismissedEvent( context: SurveyResponseContext, responses: Map, - ) { + distinctId: String?, + ): SurveyEvent { val additionalProperties = buildSurveyResponseProperties(context.survey, responses, context.questionTranslations, context.questionText) + mapOf( @@ -883,11 +924,14 @@ public class PostHogSurveysIntegration( ), ) - sendSurveyEvent( - event = "survey dismissed", - survey = context.survey, - additionalProperties = additionalProperties, - language = if (responses.isEmpty()) context.language else context.responseLanguage, + return SurveyEvent( + "survey dismissed", + surveyEventProperties( + context.survey, + additionalProperties, + if (responses.isEmpty()) context.language else context.responseLanguage, + ), + distinctId, ) } @@ -928,26 +972,26 @@ public class PostHogSurveysIntegration( } /** - * Helper method to send survey events with consistent properties + * Event snapshot prepared before releasing survey state, then dispatched without holding its lock. */ - private fun sendSurveyEvent( - event: String, + private data class SurveyEvent(val name: String, val properties: Map, val distinctId: String?) + + private fun captureSurveyEvent(event: SurveyEvent) { + postHog?.capture(event.name, distinctId = event.distinctId, properties = event.properties) + } + + private fun surveyEventProperties( survey: Survey, additionalProperties: Map = emptyMap(), language: String? = null, - ) { - val postHog = - postHog ?: run { - return - } - + ): Map { val properties = getBaseSurveyEventProperties(survey).toMutableMap() properties.putAll(additionalProperties) if (!language.isNullOrEmpty()) { properties["\$survey_language"] = language } - postHog.capture(event, properties = properties) + return properties.toMap() } /** diff --git a/posthog-android/src/main/java/com/posthog/android/surveys/SurveyProgressStore.kt b/posthog-android/src/main/java/com/posthog/android/surveys/SurveyProgressStore.kt index 08313124c..3c534cc12 100644 --- a/posthog-android/src/main/java/com/posthog/android/surveys/SurveyProgressStore.kt +++ b/posthog-android/src/main/java/com/posthog/android/surveys/SurveyProgressStore.kt @@ -49,7 +49,7 @@ internal data class StoredSurveyResponse( internal class SurveyProgressStore(private val config: PostHogConfig) { private val serializer = PostHogSerializer(config) - private val lock = Any() + private val lock = config.surveysConfig private fun key(survey: Survey): String = "${survey.id}/${survey.currentIteration ?: 0}" @@ -66,20 +66,23 @@ internal class SurveyProgressStore(private val config: PostHogConfig) { fun load(survey: Survey): SurveyProgress? = synchronized(lock) { + val generation = lock.resetGeneration val json = records()[key(survey)] as? String ?: return@synchronized null + if (generation != lock.resetGeneration) return@synchronized null try { val progress = serializer.deserialize(StringReader(json)) if (progress.version == 1 && progress.submissionId.isNotEmpty() && progress.questionIndex in survey.questions.indices && progress.questionOrder == questionOrder(survey) && - progress.responses.values.all { it.toResponse() != null } + progress.responses.values.all { it.toResponse() != null } && + progress.questionText.keys.all { it in survey.questions.indices } ) { - return@synchronized progress + return@synchronized progress.takeIf { generation == lock.resetGeneration } } } catch (_: Exception) { config.logger.log("Discarding invalid saved survey progress") } - remove(survey) + if (generation == lock.resetGeneration) remove(survey) null } @@ -87,25 +90,38 @@ internal class SurveyProgressStore(private val config: PostHogConfig) { survey: Survey, progress: SurveyProgress, ) = synchronized(lock) { + val generation = lock.resetGeneration if (config.cachePreferences?.isAvailable() == false) return@synchronized val records = records() records[key(survey)] = serializer.serializeObject(progress) ?: return@synchronized - config.cachePreferences?.setValue(PostHogPreferences.SURVEY_PROGRESS, records) + writeRecords(records, generation) Unit } fun reconcile(surveys: List) = synchronized(lock) { + val generation = lock.resetGeneration + if (config.cachePreferences?.isAvailable() == false) return@synchronized val keys = surveys.filter { it.startDate != null && it.endDate == null }.map(::key).toSet() - config.cachePreferences?.setValue(PostHogPreferences.SURVEY_PROGRESS, records().filterKeys { it in keys }) + writeRecords(records().filterKeys { it in keys }, generation) Unit } fun remove(survey: Survey) = synchronized(lock) { + val generation = lock.resetGeneration val records = records() records.remove(key(survey)) - config.cachePreferences?.setValue(PostHogPreferences.SURVEY_PROGRESS, records) + writeRecords(records, generation) Unit } + + private fun writeRecords( + records: Map, + generation: Long, + ) { + if (generation == lock.resetGeneration) { + config.cachePreferences?.setValue(PostHogPreferences.SURVEY_PROGRESS, records) + } + } } diff --git a/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysEventPayloadTest.kt b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysEventPayloadTest.kt index 871045d36..bfc7775e0 100644 --- a/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysEventPayloadTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysEventPayloadTest.kt @@ -2,11 +2,15 @@ package com.posthog.android.surveys import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.posthog.PostHog +import com.posthog.PostHogBeforeSend import com.posthog.PostHogConfig import com.posthog.PostHogFake +import com.posthog.PostHogInterface import com.posthog.android.PostHogAndroidConfig import com.posthog.android.internal.PostHogSharedPreferences import com.posthog.internal.PostHogMemoryPreferences +import com.posthog.internal.PostHogNetworkStatus import com.posthog.internal.PostHogPreferences import com.posthog.internal.PostHogSerializer import com.posthog.surveys.OnPostHogSurveyClosed @@ -18,9 +22,13 @@ import com.posthog.surveys.PostHogSurveysDelegate import com.posthog.surveys.Survey import com.posthog.surveys.SurveyQuestion import com.posthog.surveys.SurveyType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer import org.junit.runner.RunWith import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -34,6 +42,7 @@ internal class PostHogSurveysEventPayloadTest { var onSurveyShown: OnPostHogSurveyShown? = null var onSurveyResponse: OnPostHogSurveyResponse? = null var onSurveyClosed: OnPostHogSurveyClosed? = null + var cleanupCalls = 0 override fun renderSurvey( survey: PostHogDisplaySurvey, @@ -47,7 +56,9 @@ internal class PostHogSurveysEventPayloadTest { this.onSurveyClosed = onSurveyClosed } - override fun cleanupSurveys() {} + override fun cleanupSurveys() { + cleanupCalls++ + } } private fun createIntegration( @@ -186,6 +197,127 @@ internal class PostHogSurveysEventPayloadTest { } } + @Test + fun `reset before a restored survey is shown rejects captured answers`() { + val preferences = PostHogMemoryPreferences() + val delegate = RecordingDelegate() + val survey = partialResponseSurvey(true) + val (first, _) = createIntegration(delegate, preferences) + first.showSurvey(survey) + val firstDisplay = assertNotNull(delegate.shownSurvey) + assertNotNull(delegate.onSurveyShown).invoke(firstDisplay) + assertNotNull(delegate.onSurveyResponse).invoke(firstDisplay, 0, PostHogSurveyResponse.Text("Previous user")) + first.uninstall() + + val (resumed, postHog) = createIntegration(delegate, preferences) + try { + resumed.showSurvey(survey) + val restored = assertNotNull(delegate.shownSurvey) + assertEquals(1, restored.initialQuestionIndex) + preferences.clear() + val count = postHog.captures + assertNotNull(delegate.onSurveyShown).invoke(restored) + assertNull(assertNotNull(delegate.onSurveyResponse).invoke(restored, 1, PostHogSurveyResponse.Text("Next user"))) + assertEquals(count, postHog.captures) + assertNull(preferences.getValue(PostHogPreferences.SURVEY_PROGRESS)) + } finally { + resumed.uninstall() + } + } + + @Test + fun `stale shown callback does not clean up a newer survey`() { + val preferences = PostHogMemoryPreferences() + val delegate = RecordingDelegate() + val survey = partialResponseSurvey(true) + val (first, _) = createIntegration(delegate, preferences) + first.showSurvey(survey) + val firstDisplay = assertNotNull(delegate.shownSurvey) + assertNotNull(delegate.onSurveyShown).invoke(firstDisplay) + assertNotNull(delegate.onSurveyResponse).invoke(firstDisplay, 0, PostHogSurveyResponse.Text("Saved")) + first.uninstall() + val (resumed, postHog) = createIntegration(delegate, preferences) + try { + resumed.showSurvey(survey) + val staleDisplay = assertNotNull(delegate.shownSurvey) + val staleShown = assertNotNull(delegate.onSurveyShown) + preferences.clear() + resumed.showSurvey(survey.copy(id = "new-survey")) + val currentDisplay = assertNotNull(delegate.shownSurvey) + assertNotNull(delegate.onSurveyShown).invoke(currentDisplay) + val captures = postHog.captures + val cleanupCalls = delegate.cleanupCalls + + staleShown(staleDisplay) + + assertEquals(cleanupCalls, delegate.cleanupCalls) + assertEquals(captures, postHog.captures) + assertNotNull(assertNotNull(delegate.onSurveyResponse).invoke(currentDisplay, 0, PostHogSurveyResponse.Text("Current"))) + assertEquals("new-survey", postHog.properties?.get("\$survey_id")) + } finally { + resumed.uninstall() + } + } + + @Test + fun `capture callback can reset without retaining previous progress`() { + val delegate = RecordingDelegate() + val preferences = PostHogMemoryPreferences() + val directory = java.io.File(context.cacheDir, java.util.UUID.randomUUID().toString()).apply { mkdirs() } + lateinit var sdk: PostHogInterface + var heldSurveyLock = true + var sentIdentity: String? = null + val http = + MockWebServer().apply { + enqueue(MockResponse().setBody("{}")) + enqueue(MockResponse().setBody("{}")) + } + val config = + PostHogConfig("reset-callback-test", http.url("/").toString()).apply { + cachePreferences = preferences + storagePrefix = java.io.File(directory, "events").absolutePath + replayStoragePrefix = java.io.File(directory, "replay").absolutePath + preloadFeatureFlags = false + networkStatus = + object : PostHogNetworkStatus { + override fun isConnected(): Boolean = false + } + surveys = true + surveysConfig.surveysDelegate = delegate + addBeforeSend( + PostHogBeforeSend { event -> + if (event.event == "survey sent") { + heldSurveyLock = Thread.holdsLock(surveysConfig) + sentIdentity = event.distinctId + sdk.reset() + } + null + }, + ) + } + val integration = PostHogSurveysIntegration(context, config) + config.addIntegration(integration) + sdk = PostHog.with(config) + try { + val previousIdentity = sdk.distinctId() + integration.showSurvey(partialResponseSurvey(true)) + val display = assertNotNull(delegate.shownSurvey) + assertNotNull(delegate.onSurveyShown).invoke(display) + assertNotNull(delegate.onSurveyResponse).invoke(display, 0, PostHogSurveyResponse.Text("Previous user")) + + assertFalse(heldSurveyLock) + assertEquals(previousIdentity, sentIdentity) + assertNotEquals(previousIdentity, sdk.distinctId()) + assertNull(preferences.getValue(PostHogPreferences.SURVEY_PROGRESS)) + assertNull(preferences.getValue(PostHogPreferences.SURVEY_SEEN)) + assertNull(assertNotNull(delegate.onSurveyResponse).invoke(display, 1, PostHogSurveyResponse.Text("Stale"))) + } finally { + sdk.close() + http.shutdown() + directory.deleteRecursively() + } + } + @Test fun `dismissal and reset clear saved progress without stale callbacks restoring it`() { for (reset in listOf(false, true)) { diff --git a/posthog-android/src/test/java/com/posthog/android/surveys/SurveyProgressStoreTest.kt b/posthog-android/src/test/java/com/posthog/android/surveys/SurveyProgressStoreTest.kt index dd58d0d4d..f2fe4120c 100644 --- a/posthog-android/src/test/java/com/posthog/android/surveys/SurveyProgressStoreTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/surveys/SurveyProgressStoreTest.kt @@ -1,12 +1,24 @@ package com.posthog.android.surveys +import android.content.Context +import com.posthog.PostHog import com.posthog.PostHogConfig +import com.posthog.PostHogInterface +import com.posthog.android.FakeSharedPreferences +import com.posthog.android.PostHogAndroidConfig +import com.posthog.android.internal.PostHogSharedPreferences import com.posthog.internal.PostHogMemoryPreferences +import com.posthog.internal.PostHogNetworkStatus import com.posthog.internal.PostHogPreferences import com.posthog.internal.PostHogSerializer import com.posthog.surveys.PostHogSurveyResponse import com.posthog.surveys.Survey +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -68,6 +80,7 @@ internal class SurveyProgressStoreTest { valid.replace("\"questionIndex\":0", "\"questionIndex\":-1"), valid.replace("\"questionIndex\":0", "\"questionIndex\":10"), valid.replace("first", "removed"), + valid.replace("\"questionText\":{}", "\"questionText\":null"), )) { preferences.setValue(PostHogPreferences.SURVEY_PROGRESS, mapOf("survey/1" to invalid)) assertNull(store.load(survey)) @@ -75,6 +88,107 @@ internal class SurveyProgressStoreTest { } } + @Test + fun `reconciliation while locked preserves durable progress after unlock`() { + val disk = FakeSharedPreferences() + var locked = false + val context = + mock { + on { getSharedPreferences(any(), any()) } doAnswer { + if (locked) throw IllegalStateException("User is locked") + disk + } + } + val androidConfig = PostHogAndroidConfig("progress-test") + androidConfig.cachePreferences = PostHogSharedPreferences(context, androidConfig) + val beforeRestart = SurveyProgressStore(androidConfig) + beforeRestart.save(survey, SurveyProgress("saved", beforeRestart.questionOrder(survey))) + locked = true + androidConfig.cachePreferences = PostHogSharedPreferences(context, androidConfig) + val afterRestart = SurveyProgressStore(androidConfig) + + afterRestart.reconcile(listOf(survey)) + locked = false + + assertEquals("saved", assertNotNull(afterRestart.load(survey)).submissionId) + } + + @Test + fun `reset during preference reads cannot restore old progress or erase a new attempt`() { + for (operation in listOf("load", "save", "reconcile", "remove")) { + for (createNewAttempt in listOf(false, true)) { + assertResetDuringRead(operation, createNewAttempt) + } + } + } + + private fun assertResetDuringRead( + operation: String, + createNewAttempt: Boolean, + ) { + val backing = PostHogMemoryPreferences() + var resetOnRead = false + lateinit var sdk: PostHogInterface + lateinit var progressStore: SurveyProgressStore + val preferences = + object : PostHogPreferences by backing { + override fun getValue( + key: String, + defaultValue: Any?, + ): Any? { + val snapshot = backing.getValue(key, defaultValue) + if (key == PostHogPreferences.SURVEY_PROGRESS && resetOnRead) { + resetOnRead = false + sdk.reset() + if (createNewAttempt) { + progressStore.save(survey, SurveyProgress("new-user", progressStore.questionOrder(survey))) + } + } + return snapshot + } + } + val http = + MockWebServer().apply { + repeat(3) { enqueue(MockResponse().setBody("{}")) } + } + val directory = java.nio.file.Files.createTempDirectory("survey-reset").toFile() + val config = + PostHogConfig("store-reset-$operation", http.url("/").toString()).apply { + cachePreferences = preferences + preloadFeatureFlags = false + storagePrefix = java.io.File(directory, "events").absolutePath + replayStoragePrefix = java.io.File(directory, "replay").absolutePath + networkStatus = + object : PostHogNetworkStatus { + override fun isConnected(): Boolean = false + } + } + sdk = PostHog.with(config) + progressStore = SurveyProgressStore(config) + try { + val oldProgress = SurveyProgress("old-user", progressStore.questionOrder(survey)) + progressStore.save(survey, oldProgress) + resetOnRead = true + + when (operation) { + "load" -> assertNull(progressStore.load(survey)) + "save" -> progressStore.save(survey, oldProgress) + "reconcile" -> progressStore.reconcile(listOf(survey)) + "remove" -> progressStore.remove(survey) + } + + if (createNewAttempt) { + assertEquals("new-user", assertNotNull(progressStore.load(survey)).submissionId) + } else { + assertNull(progressStore.load(survey)) + } + } finally { + sdk.close() + http.shutdown() + directory.deleteRecursively() + } + } + @Test fun `new iterations and ended or removed surveys clear old progress`() { val progress = SurveyProgress("submission", store.questionOrder(survey)) diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 5dc03d434..d2debb40a 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -1876,6 +1876,7 @@ public final class com/posthog/surveys/PostHogSurveyResponse$Text : com/posthog/ public final class com/posthog/surveys/PostHogSurveysConfig { public fun ()V public final fun getOverrideDisplayLanguage ()Ljava/lang/String; + public final fun getResetGeneration ()J public final fun getSurveysDelegate ()Lcom/posthog/surveys/PostHogSurveysDelegate; public final fun setOverrideDisplayLanguage (Ljava/lang/String;)V public final fun setSurveysDelegate (Lcom/posthog/surveys/PostHogSurveysDelegate;)V diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index d325ec780..fe9864a57 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -1933,7 +1933,15 @@ public class PostHog private constructor( if (config?.reuseAnonymousId == true) { except.add(ANONYMOUS_ID) } - getPreferences().clear(except = except.toList()) + val surveysConfig = config?.surveysConfig + if (surveysConfig != null) { + synchronized(surveysConfig) { + surveysConfig.reset() + getPreferences().clear(except = except.toList()) + } + } else { + getPreferences().clear(except = except.toList()) + } remoteConfig?.clear() featureFlagsCalled.clear() lastScreenName = null diff --git a/posthog/src/main/java/com/posthog/surveys/PostHogSurveysConfig.kt b/posthog/src/main/java/com/posthog/surveys/PostHogSurveysConfig.kt index 1cecad84d..38c752c14 100644 --- a/posthog/src/main/java/com/posthog/surveys/PostHogSurveysConfig.kt +++ b/posthog/src/main/java/com/posthog/surveys/PostHogSurveysConfig.kt @@ -1,9 +1,23 @@ package com.posthog.surveys +import com.posthog.PostHogInternal + /** * Configuration for PostHog Surveys feature. */ public class PostHogSurveysConfig { + /** + * Invalidates pending survey callbacks across SDK reset. Access and survey state mutations + * are synchronized on this configuration so reset cannot interleave with response snapshots. + */ + @PostHogInternal + public var resetGeneration: Long = 0 + private set + + internal fun reset() { + resetGeneration++ + } + /** * Delegate responsible for managing survey presentation in your app. * Handles survey rendering, response collection, and lifecycle events. diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 5f8ad4f8e..d758102e7 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -2477,6 +2477,24 @@ internal class PostHogTest { sut.close() } + @Test + fun `reset invalidates survey callbacks and clears their progress`() { + val http = mockHttp() + val sut = getSut(http.url("/").toString(), preloadFeatureFlags = false, reloadFeatureFlags = false) + try { + val generation = config.surveysConfig.resetGeneration + config.cachePreferences?.setValue(PostHogPreferences.SURVEY_PROGRESS, mapOf("survey" to "saved")) + + sut.reset() + + assertNotEquals(generation, config.surveysConfig.resetGeneration) + assertNull(config.cachePreferences?.getValue(PostHogPreferences.SURVEY_PROGRESS)) + } finally { + sut.close() + http.shutdown() + } + } + @Test fun `reset reloads flags as anon user`() { val http = mockHttp() From 82655938de8181d842e03154784e52a6b1f588a8 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Wed, 9 Sep 2026 12:21:39 -0300 Subject: [PATCH 5/6] fix(surveys): discard retained presentation state on reset Notify the actual Compose renderer after SDK reset, including the renderer discovered automatically. Bind UI state to an installed session, configuration and reset generation so stale callbacks, pending shows, retained Activity input and late cleanup cannot reach a new presentation. Keep custom delegate callbacks outside SDK monitors. Preserve the existing delegate interface and add an internal optional session/presentation capability. Atomically bind the owner and reset generation to avoid missing a concurrent reset, and preserve delegate reuse with a new configuration. Verification: full CI=true make compile; focused ABI/Java/reset, Android survey and mounted Compose tests; final bind/reset regression; format, API snapshot, checkFormat and CodeScene safeguard. Existing size penalties remain unchanged. Mounted UI uses Robolectric. The broad build precedes the final focused bind/reset adjustment. --- .changeset/smooth-birds-cheat.md | 2 + .../compose/PostHogSurveysComposeDelegate.kt | 31 ++- .../compose/internal/PostHogSurveyHost.kt | 131 +++++++++++- .../PostHogSurveyHostResetRaceTest.kt | 111 ++++++++++ .../compose/internal/PostHogSurveyHostTest.kt | 198 ++++++++++++++++++ .../surveys/PostHogSurveysIntegration.kt | 51 ++++- .../PostHogSurveysDelegateLifecycleTest.kt | 103 +++++++++ posthog/api/posthog.api | 21 ++ posthog/src/main/java/com/posthog/PostHog.kt | 14 +- .../PostHogSurveysResetAwareDelegate.kt | 47 +++++ .../src/test/java/com/posthog/PostHogTest.kt | 34 +++ 11 files changed, 724 insertions(+), 19 deletions(-) create mode 100644 posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostResetRaceTest.kt create mode 100644 posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysDelegateLifecycleTest.kt create mode 100644 posthog/src/main/java/com/posthog/surveys/PostHogSurveysResetAwareDelegate.kt diff --git a/.changeset/smooth-birds-cheat.md b/.changeset/smooth-birds-cheat.md index 7dfc8dbd7..e469ce7b3 100644 --- a/.changeset/smooth-birds-cheat.md +++ b/.changeset/smooth-birds-cheat.md @@ -9,3 +9,5 @@ Support survey partial response collection. When enabled, submit cumulative answ Persist unfinished survey progress across app restarts and restore the submission ID, collected answers, and next question. Clear progress on completion, dismissal, SDK reset, and incompatible survey updates. The Compose renderer starts at the restored question. Keep unfinished surveys across Activity teardown, preserve unreadable progress during Direct Boot, and invalidate delayed responses on reset without mixing user identities. + +Discard visible, delayed, and retained Compose survey input on reset, while preserving fresh presentations and delegate reuse across SDK configurations. diff --git a/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/PostHogSurveysComposeDelegate.kt b/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/PostHogSurveysComposeDelegate.kt index 1e6e7daa4..a3b1e2f67 100644 --- a/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/PostHogSurveysComposeDelegate.kt +++ b/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/PostHogSurveysComposeDelegate.kt @@ -8,7 +8,10 @@ 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.PostHogSurveyPresentation +import com.posthog.surveys.PostHogSurveyPresentationSession +import com.posthog.surveys.PostHogSurveysConfig +import com.posthog.surveys.PostHogSurveysResetAwareDelegate /** * Default Compose-based UI for PostHog surveys on Android. @@ -63,7 +66,7 @@ import com.posthog.surveys.PostHogSurveysDelegate * The constructor accepts any [Context] and resolves the [Application] from * it, so passing an activity context is safe. */ -public class PostHogSurveysComposeDelegate(context: Context) : PostHogSurveysDelegate { +public class PostHogSurveysComposeDelegate(context: Context) : PostHogSurveysResetAwareDelegate { private val application: Application = context.applicationContext as Application private val activityProvider: ActivityProvider = ActivityProvider() private val host: PostHogSurveyHost = PostHogSurveyHost(activityProvider) @@ -86,6 +89,30 @@ public class PostHogSurveysComposeDelegate(context: Context) : PostHogSurveysDel ) } + override fun renderSurvey( + presentation: PostHogSurveyPresentation, + onSurveyShown: OnPostHogSurveyShown, + onSurveyResponse: OnPostHogSurveyResponse, + onSurveyClosed: OnPostHogSurveyClosed, + ) { + host.show(presentation, onSurveyShown, onSurveyResponse, onSurveyClosed) + } + + override fun bindSurveySession(session: PostHogSurveyPresentationSession) { + host.bindSession(session) + } + + override fun onSurveyReset( + resetGeneration: Long, + config: PostHogSurveysConfig, + ) { + host.onReset(resetGeneration, config) + } + + override fun cleanupSurveys(session: PostHogSurveyPresentationSession) { + host.cleanup(session) + } + override fun cleanupSurveys() { host.cleanup() } diff --git a/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHost.kt b/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHost.kt index cbaa7f4db..29050cf18 100644 --- a/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHost.kt +++ b/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHost.kt @@ -20,6 +20,11 @@ import com.posthog.surveys.OnPostHogSurveyClosed import com.posthog.surveys.OnPostHogSurveyResponse import com.posthog.surveys.OnPostHogSurveyShown import com.posthog.surveys.PostHogDisplaySurvey +import com.posthog.surveys.PostHogNextSurveyQuestion +import com.posthog.surveys.PostHogSurveyPresentation +import com.posthog.surveys.PostHogSurveyPresentationSession +import com.posthog.surveys.PostHogSurveyResponse +import com.posthog.surveys.PostHogSurveysConfig /** * Coordinator that presents the survey sheet in its **own window**, on top of @@ -56,6 +61,17 @@ import com.posthog.surveys.PostHogDisplaySurvey internal class PostHogSurveyHost(private val activityProvider: ActivityProvider) { private val mainHandler = Handler(Looper.getMainLooper()) + private val resetLock = Any() + + @Volatile private var minimumGeneration = 0L + + @Volatile private var activeOwner = PostHogSurveyPresentationSession(PostHogSurveysConfig()) + + @Volatile private var session = Any() + private var currentSession: Any? = null + private var currentGeneration = 0L + private var currentPresentation: Any? = null + private var dialog: ComponentDialog? = null private var composeView: ComposeView? = null @@ -114,14 +130,30 @@ internal class PostHogSurveyHost(private val activityProvider: ActivityProvider) onSurveyShown: OnPostHogSurveyShown, onSurveyResponse: OnPostHogSurveyResponse, onSurveyClosed: OnPostHogSurveyClosed, + ) = show(PostHogSurveyPresentation(survey, minimumGeneration, activeOwner), onSurveyShown, onSurveyResponse, onSurveyClosed) + + fun show( + presentation: PostHogSurveyPresentation, + onSurveyShown: OnPostHogSurveyShown, + onSurveyResponse: OnPostHogSurveyResponse, + onSurveyClosed: OnPostHogSurveyClosed, ) { + bindSession(presentation.session) + val survey = presentation.survey + val resetGeneration = presentation.resetGeneration + val presentationSession = advanceGeneration(resetGeneration, presentation.session) ?: return val delayMillis = ((survey.appearance?.surveyPopupDelaySeconds ?: 0.0).coerceAtLeast(0.0) * 1000).toLong() runOnMain { + if (!canPresent(presentationSession, presentation)) return@runOnMain // Replace any in-flight survey first (notify the SDK it was closed). dismissInternal(notifyClosed = true) + if (!canPresent(presentationSession, presentation)) return@runOnMain + currentSession = presentationSession + currentGeneration = resetGeneration + currentPresentation = Any() currentSurvey = survey onShownCallback = onSurveyShown onResponseCallback = onSurveyResponse @@ -142,12 +174,74 @@ internal class PostHogSurveyHost(private val activityProvider: ActivityProvider) } } - fun cleanup() { - runOnMain { dismissInternal(notifyClosed = false) } + private fun canPresent( + presentationSession: Any, + presentation: PostHogSurveyPresentation, + ): Boolean = presentationSession === session && presentation.session.isActive && presentation.resetGeneration >= minimumGeneration + + fun bindSession(owner: PostHogSurveyPresentationSession) { + val previousSession = + synchronized(owner.config) { + synchronized(resetLock) { + if (!owner.isActive) return + if (activeOwner === owner) { + minimumGeneration = maxOf(minimumGeneration, owner.config.resetGeneration) + return + } + val previous = session + activeOwner = owner + minimumGeneration = owner.config.resetGeneration + session = Any() + previous + } + } + dismissSession(previousSession) + } + + fun onReset( + resetGeneration: Long, + config: PostHogSurveysConfig, + ) { + val owner = activeOwner + if (owner.config !== config) return + val resetSession = advanceGeneration(resetGeneration, owner) ?: return + runOnMain { + if (currentSession === resetSession && currentGeneration < minimumGeneration) dismissInternal(notifyClosed = false) + } + } + + private fun advanceGeneration( + resetGeneration: Long, + owner: PostHogSurveyPresentationSession, + ): Any? = + synchronized(resetLock) { + if (activeOwner !== owner || !owner.isActive) return@synchronized null + minimumGeneration = maxOf(minimumGeneration, resetGeneration) + session + } + + fun cleanup(owner: PostHogSurveyPresentationSession = activeOwner) { + val previousSession = + synchronized(resetLock) { + if (activeOwner !== owner) return + val previous = session + session = Any() + previous + } + dismissSession(previousSession) + } + + private fun dismissSession(previousSession: Any) { + runOnMain { if (currentSession === previousSession) dismissInternal(notifyClosed = false) } } private fun present(activity: Activity?) { val survey = currentSurvey ?: return + val presentation = currentPresentation ?: return + if (!isCurrentPresentation(presentation)) { + dismissInternal(notifyClosed = false) + return + } if (activity == null || activity.isFinishing) { // No foreground activity to host the sheet — e.g. the app was backgrounded @@ -184,11 +278,18 @@ internal class PostHogSurveyHost(private val activityProvider: ActivityProvider) CompositionLocalProvider(LocalSaveableStateRegistry provides registry) { SurveySheet( survey = survey, - onSurveyShown = { reportShownOnce() }, + onSurveyShown = { reportShownOnce(presentation) }, onSubmit = { questionIndex, response -> - onResponseCallback?.invoke(survey, questionIndex, response) + if (isCurrentPresentation( + presentation, + ) + ) { + onResponseCallback?.invoke(survey, questionIndex, response) + } else { + null + } }, - onClose = { dismissInternal(notifyClosed = true) }, + onClose = { closePresentation(presentation) }, ) } } @@ -217,8 +318,22 @@ internal class PostHogSurveyHost(private val activityProvider: ActivityProvider) * Forwards `survey shown` to the SDK at most once per survey, so re-presenting * after a host activity change doesn't emit a duplicate event. */ - private fun reportShownOnce() { - if (shownReported) return + private fun isCurrentPresentation(presentation: Any): Boolean = + currentPresentation === presentation && currentSession === session && activeOwner.isActive && currentGeneration >= minimumGeneration + + private fun closePresentation(presentation: Any) { + if (isCurrentPresentation(presentation)) dismissInternal(notifyClosed = true) + } + + private fun submitResponse( + presentation: Any, + survey: PostHogDisplaySurvey, + index: Int, + response: PostHogSurveyResponse, + ): PostHogNextSurveyQuestion? = if (isCurrentPresentation(presentation)) onResponseCallback?.invoke(survey, index, response) else null + + private fun reportShownOnce(presentation: Any) { + if (!isCurrentPresentation(presentation) || shownReported) return val survey = currentSurvey ?: return shownReported = true onShownCallback?.invoke(survey) @@ -278,6 +393,8 @@ internal class PostHogSurveyHost(private val activityProvider: ActivityProvider) composeView = null hostActivity = null currentSurvey = null + currentPresentation = null + currentSession = null onShownCallback = null onResponseCallback = null onClosedCallback = null diff --git a/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostResetRaceTest.kt b/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostResetRaceTest.kt new file mode 100644 index 000000000..08e2e752a --- /dev/null +++ b/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostResetRaceTest.kt @@ -0,0 +1,111 @@ +package com.posthog.android.surveys.compose.internal + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.core.app.ApplicationProvider +import com.posthog.PostHog +import com.posthog.PostHogConfig +import com.posthog.android.surveys.compose.PostHogSurveysComposeDelegate +import com.posthog.internal.PostHogMemoryPreferences +import com.posthog.surveys.PostHogDisplayOpenQuestion +import com.posthog.surveys.PostHogDisplaySurvey +import com.posthog.surveys.PostHogDisplaySurveyTextContentType +import com.posthog.surveys.PostHogSurveyPresentation +import com.posthog.surveys.PostHogSurveyPresentationSession +import com.posthog.surveys.PostHogSurveysConfig +import com.posthog.surveys.PostHogSurveysResetAwareDelegate +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +internal class PostHogSurveyHostResetRaceTest { + @get:Rule val compose = createAndroidComposeRule() + + @Test + @Suppress("DEPRECATION") + fun `reset racing a new owner bind cannot lose invalidation`() { + val delegate = PostHogSurveysComposeDelegate(ApplicationProvider.getApplicationContext()) + val notification = CountDownLatch(1) + val config = + PostHogConfig("bind-reset", "http://127.0.0.1:1").apply { + cachePreferences = PostHogMemoryPreferences() + preloadFeatureFlags = false + remoteConfig = false + surveysConfig.surveysDelegate = + object : PostHogSurveysResetAwareDelegate by delegate { + override fun onSurveyReset( + resetGeneration: Long, + config: PostHogSurveysConfig, + ) { + delegate.onSurveyReset(resetGeneration, config) + notification.countDown() + } + } + } + val sdk = PostHog.with(config) + val owner = PostHogSurveyPresentationSession(config.surveysConfig) + val host = PostHogSurveysComposeDelegate::class.java.getDeclaredField("host").apply { isAccessible = true }.get(delegate) + val gate = checkNotNull(PostHogSurveyHost::class.java.getDeclaredField("resetLock").apply { isAccessible = true }.get(host)) + val binder = Thread { delegate.bindSurveySession(owner) } + val resetter = Thread { sdk.reset() } + val oldGeneration = config.surveysConfig.resetGeneration + try { + compose.activityRule.scenario.recreate() + synchronized(gate) { + binder.start() + awaitBlocked(binder) + resetter.start() + // The original race delivers reset before bind installs the new owner. With + // atomic binding reset waits for that installation, then invalidates it. + notification.await(2, TimeUnit.SECONDS) + } + binder.join(2000) + resetter.join(2000) + assertFalse(binder.isAlive) + assertFalse(resetter.isAlive) + val survey = + PostHogDisplaySurvey( + "old", + "Old", + listOf( + PostHogDisplayOpenQuestion( + "q", + "Previous user question", + null, + PostHogDisplaySurveyTextContentType.TEXT, + false, + "Send", + ), + ), + ) + compose.runOnIdle { + delegate.renderSurvey( + PostHogSurveyPresentation(survey, oldGeneration, owner), + {}, + { _, _, _ -> null }, + {}, + ) + } + compose.onNodeWithText("Previous user question").assertDoesNotExist() + } finally { + compose.runOnUiThread { delegate.cleanupSurveys() } + sdk.close() + } + } + + private fun awaitBlocked(thread: Thread) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2) + while (thread.state != Thread.State.BLOCKED && System.nanoTime() < deadline) Thread.yield() + assertTrue(thread.state == Thread.State.BLOCKED, "Binder must be waiting on the held host gate") + } +} diff --git a/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostTest.kt b/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostTest.kt index da1c8521c..1b0246e38 100644 --- a/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostTest.kt +++ b/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostTest.kt @@ -1,6 +1,7 @@ package com.posthog.android.surveys.compose.internal import android.app.Application +import android.os.Looper import androidx.activity.ComponentActivity import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.test.assertIsDisplayed @@ -13,15 +14,26 @@ import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performTextInput import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider +import com.posthog.PostHog +import com.posthog.PostHogConfig +import com.posthog.android.surveys.PostHogSurveysIntegration +import com.posthog.android.surveys.compose.PostHogSurveysComposeDelegate +import com.posthog.internal.PostHogMemoryPreferences import com.posthog.surveys.PostHogDisplayOpenQuestion import com.posthog.surveys.PostHogDisplaySurvey +import com.posthog.surveys.PostHogDisplaySurveyAppearance import com.posthog.surveys.PostHogDisplaySurveyTextContentType import com.posthog.surveys.PostHogNextSurveyQuestion +import com.posthog.surveys.PostHogSurveyPresentation +import com.posthog.surveys.PostHogSurveyPresentationSession +import com.posthog.surveys.PostHogSurveysConfig import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config +import java.time.Duration import kotlin.test.assertEquals @RunWith(RobolectricTestRunner::class) @@ -40,6 +52,192 @@ internal class PostHogSurveyHostTest { assertHostTransition(replacementAlreadyResumed = true) } + @Test + fun `reset removes unsent text before a new host resumes`() { + assertResetBeforeHostTransition(replacementAlreadyResumed = false) + } + + @Test + fun `reset removes unsent text when replacement host already resumed`() { + assertResetBeforeHostTransition(replacementAlreadyResumed = true) + } + + @Test + fun `reset discards retained unsent text after host has already finished`() { + assertResetBeforeHostTransition(replacementAlreadyResumed = false, resetAfterFinish = true) + } + + @Suppress("DEPRECATION") + private fun assertResetBeforeHostTransition( + replacementAlreadyResumed: Boolean, + resetAfterFinish: Boolean = false, + ) { + val application = ApplicationProvider.getApplicationContext() + val config = + PostHogConfig("host-reset", "http://127.0.0.1:1").apply { + cachePreferences = PostHogMemoryPreferences() + preloadFeatureFlags = false + remoteConfig = false + reuseAnonymousId = true + } + val sdk = PostHog.with(config) + val integration = PostHogSurveysIntegration(application, config) + integration.install(sdk) + val delegate = config.surveysConfig.surveysDelegate + val survey = + PostHogDisplaySurvey( + "reset", + "Reset", + listOf(PostHogDisplayOpenQuestion("q", "Private question?", null, PostHogDisplaySurveyTextContentType.TEXT, false, "Send")), + ) + var closed = 0 + var replacement: ActivityScenario? = null + try { + compose.activityRule.scenario.recreate() + compose.runOnIdle { delegate.renderSurvey(survey, {}, { _, _, _ -> null }, { closed++ }) } + compose.onNode(hasSetTextAction()).performTextInput("Previous user secret") + val oldClose = + compose.onNodeWithContentDescription( + "Close survey", + ).fetchSemanticsNode().config[SemanticsActions.OnClick].action!! + val oldSubmit = compose.onNodeWithText("Send").fetchSemanticsNode().config[SemanticsActions.OnClick].action!! + val distinctId = sdk.distinctId() + if (!resetAfterFinish) { + compose.runOnIdle { sdk.reset() } + compose.onNodeWithText("Previous user secret").assertDoesNotExist() + compose.onNodeWithText("Private question?").assertDoesNotExist() + } + if (replacementAlreadyResumed) replacement = ActivityScenario.launch(ComponentActivity::class.java) + compose.activityRule.scenario.close() + if (resetAfterFinish) compose.runOnUiThread { sdk.reset() } + if (replacement == null) replacement = ActivityScenario.launch(ComponentActivity::class.java) + assertEquals(distinctId, sdk.distinctId()) + compose.onNodeWithText("Previous user secret").assertDoesNotExist() + compose.onNodeWithText("Private question?").assertDoesNotExist() + assertEquals(0, closed) + compose.runOnIdle { delegate.renderSurvey(survey, {}, { _, _, _ -> null }, { closed++ }) } + compose.onNodeWithText("Private question?").assertIsDisplayed() + compose.runOnIdle { + oldClose() + oldSubmit() + } + compose.onNodeWithText("Private question?").assertIsDisplayed() + compose.onNodeWithText("Previous user secret").assertDoesNotExist() + compose.onNodeWithContentDescription("Close survey").performSemanticsAction(SemanticsActions.OnClick) { it() } + compose.waitForIdle() + assertEquals(1, closed) + } finally { + compose.runOnUiThread { delegate.cleanupSurveys() } + replacement?.close() + integration.uninstall() + sdk.close() + } + } + + @Test + fun `reset cancels queued and delayed shows while an older notification preserves fresh UI`() { + val application = ApplicationProvider.getApplicationContext() + val delegate = PostHogSurveysComposeDelegate(application) + val owner = PostHogSurveyPresentationSession(PostHogSurveysConfig()) + delegate.bindSurveySession(owner) + val survey = + PostHogDisplaySurvey( + "pending", + "Pending", + listOf(PostHogDisplayOpenQuestion("q", "Fresh question?", null, PostHogDisplaySurveyTextContentType.TEXT, false, "Send")), + ) + var shown = 0 + var closed = 0 + try { + compose.activityRule.scenario.recreate() + compose.runOnIdle { + // Enqueue a show from the SDK thread, then invalidate it before main executes it. + Thread { + delegate.renderSurvey( + PostHogSurveyPresentation(survey, 0, owner), + { shown++ }, + { _, _, _ -> null }, + { closed++ }, + ) + }.apply { + start() + join() + } + delegate.onSurveyReset(1, owner.config) + } + compose.onNodeWithText("Fresh question?").assertDoesNotExist() + assertEquals(0, shown) + compose.runOnIdle { + delegate.renderSurvey( + PostHogSurveyPresentation( + survey.copy(appearance = PostHogDisplaySurveyAppearance(surveyPopupDelaySeconds = 2.0)), + 1, + owner, + ), + { + shown++ + }, + { _, _, _ -> null }, + { closed++ }, + ) + delegate.onSurveyReset(2, owner.config) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(3)) + } + compose.onNodeWithText("Fresh question?").assertDoesNotExist() + assertEquals(0, shown) + assertFreshPresentationSurvivesStaleWork(delegate, survey, owner) + } finally { + compose.runOnUiThread { delegate.cleanupSurveys() } + } + } + + private fun assertFreshPresentationSurvivesStaleWork( + delegate: PostHogSurveysComposeDelegate, + survey: PostHogDisplaySurvey, + owner: PostHogSurveyPresentationSession, + ) { + var shown = 0 + var closed = 0 + compose.runOnIdle { + // Cleanup is queued, but a fresh presentation reaches main first. + Thread { delegate.onSurveyReset(3, owner.config) }.apply { + start() + join() + } + delegate.renderSurvey(PostHogSurveyPresentation(survey, 4, owner), { shown++ }, { _, _, _ -> null }, { closed++ }) + delegate.onSurveyReset(2, owner.config) + delegate.renderSurvey( + PostHogSurveyPresentation(survey.copy(questions = emptyList()), 3, owner), + {}, + { _, _, _ -> null }, + {}, + ) + } + compose.onNodeWithText("Fresh question?").assertIsDisplayed() + val newOwner = PostHogSurveyPresentationSession(PostHogSurveysConfig()) + compose.runOnIdle { + owner.invalidate() + delegate.cleanupSurveys() + delegate.bindSurveySession(newOwner) + delegate.renderSurvey(PostHogSurveyPresentation(survey, 0, newOwner), { shown++ }, { _, _, _ -> null }, { closed++ }) + delegate.bindSurveySession(owner) + delegate.cleanupSurveys(owner) + delegate.onSurveyReset(10, owner.config) + delegate.renderSurvey( + PostHogSurveyPresentation(survey.copy(questions = emptyList()), 10, owner), + {}, + { _, _, _ -> null }, + {}, + ) + } + compose.onNodeWithText("Fresh question?").assertIsDisplayed() + assertEquals(2, shown) + assertEquals(0, closed) + compose.onNodeWithContentDescription("Close survey").performSemanticsAction(SemanticsActions.OnClick) { it() } + compose.waitForIdle() + assertEquals(1, closed) + } + private fun assertHostTransition(replacementAlreadyResumed: Boolean) { val application = ApplicationProvider.getApplicationContext() val provider = ActivityProvider() 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 a9c9a9fb6..f9898cb59 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 @@ -20,9 +20,12 @@ import com.posthog.surveys.OnPostHogSurveyResponse import com.posthog.surveys.OnPostHogSurveyShown import com.posthog.surveys.PostHogDisplaySurvey import com.posthog.surveys.PostHogNextSurveyQuestion +import com.posthog.surveys.PostHogSurveyPresentation +import com.posthog.surveys.PostHogSurveyPresentationSession import com.posthog.surveys.PostHogSurveyResponse import com.posthog.surveys.PostHogSurveysDefaultDelegate import com.posthog.surveys.PostHogSurveysDelegate +import com.posthog.surveys.PostHogSurveysResetAwareDelegate import com.posthog.surveys.RatingSurveyQuestion import com.posthog.surveys.SingleSurveyQuestion import com.posthog.surveys.Survey @@ -97,6 +100,8 @@ public class PostHogSurveysIntegration( // Start the survey integration lifecycle synchronized(lifecycleLock) { isStarted = true + presentationSession?.invalidate() + presentationSession = PostHogSurveyPresentationSession(config.surveysConfig) } // Resolve the delegate now, at app start — do NOT defer this to first @@ -107,7 +112,10 @@ public class PostHogSurveysIntegration( // (on the first survey) registers it too late — the resume has already // fired and is not replayed, leaving no foreground activity to host the // survey, which then closes immediately as a "non-active survey". - getSurveysDelegate() + val delegate = getSurveysDelegate() + presentationSession?.let { session -> + (delegate as? PostHogSurveysResetAwareDelegate)?.bindSurveySession(session) + } showNextSurvey() } @@ -116,6 +124,7 @@ public class PostHogSurveysIntegration( // Stop the survey integration lifecycle synchronized(lifecycleLock) { isStarted = false + presentationSession?.invalidate() } // Tear down any survey UI still on screen so its dialog window doesn't outlive the @@ -158,10 +167,13 @@ public class PostHogSurveysIntegration( */ private fun getSurveysDelegate(): PostHogSurveysDelegate { val configured = config.surveysConfig.surveysDelegate - if (configured !is PostHogSurveysDefaultDelegate) { - return configured - } - return autoDiscoveredComposeDelegate ?: configured + val delegate = + if (configured !is PostHogSurveysDefaultDelegate) { + configured + } else { + autoDiscoveredComposeDelegate?.also { config.surveysConfig.surveysDelegate = it } ?: configured + } + return delegate } /** @@ -312,6 +324,7 @@ public class PostHogSurveysIntegration( * @param survey The survey to show */ internal fun showSurvey(survey: Survey) { + val session = synchronized(lifecycleLock) { presentationSession?.takeIf { isStarted } } ?: return // Check if we can show a survey (no active survey) if (!canShowNextSurvey()) { config.logger.log("Cannot show survey - another survey is already active") @@ -390,7 +403,23 @@ public class PostHogSurveysIntegration( } // Call the delegate to render the survey - getSurveysDelegate().renderSurvey(displaySurvey, onSurveyShown, onSurveyResponse, onSurveyClosed) + renderSurvey(PostHogSurveyPresentation(displaySurvey, resetGeneration, session), onSurveyShown, onSurveyResponse, onSurveyClosed) + } + + private fun renderSurvey( + presentation: PostHogSurveyPresentation, + onSurveyShown: OnPostHogSurveyShown, + onSurveyResponse: OnPostHogSurveyResponse, + onSurveyClosed: OnPostHogSurveyClosed, + ) { + if (!presentation.session.isActive) return + val delegate = getSurveysDelegate() + if (delegate is PostHogSurveysResetAwareDelegate) { + delegate.bindSurveySession(presentation.session) + delegate.renderSurvey(presentation, onSurveyShown, onSurveyResponse, onSurveyClosed) + } else { + delegate.renderSurvey(presentation.survey, onSurveyShown, onSurveyResponse, onSurveyClosed) + } } private fun surveyResponseCallback(responseContext: SurveyResponseContext): OnPostHogSurveyResponse = @@ -541,7 +570,13 @@ public class PostHogSurveysIntegration( * Cleans up any active surveys by calling the delegate's cleanupSurveys method. */ internal fun cleanupSurveys() { - getSurveysDelegate().cleanupSurveys() + val delegate = getSurveysDelegate() + val session = presentationSession + if (delegate is PostHogSurveysResetAwareDelegate && session != null) { + delegate.cleanupSurveys(session) + } else { + delegate.cleanupSurveys() + } } /** @@ -770,6 +805,8 @@ public class PostHogSurveysIntegration( // Lifecycle management private var isStarted: Boolean = false + @Volatile private var presentationSession: PostHogSurveyPresentationSession? = null + private fun resolveDisplayLanguage(): String? { val override = config.surveysConfig.overrideDisplayLanguage val personProperties = config.remoteConfigHolder?.getPersonPropertiesForFlags() diff --git a/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysDelegateLifecycleTest.kt b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysDelegateLifecycleTest.kt new file mode 100644 index 000000000..eb194cf46 --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysDelegateLifecycleTest.kt @@ -0,0 +1,103 @@ +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.PostHogFake +import com.posthog.internal.PostHogMemoryPreferences +import com.posthog.internal.PostHogSerializer +import com.posthog.surveys.OnPostHogSurveyClosed +import com.posthog.surveys.OnPostHogSurveyResponse +import com.posthog.surveys.OnPostHogSurveyShown +import com.posthog.surveys.PostHogDisplaySurvey +import com.posthog.surveys.PostHogSurveyPresentation +import com.posthog.surveys.PostHogSurveyPresentationSession +import com.posthog.surveys.PostHogSurveysConfig +import com.posthog.surveys.PostHogSurveysDefaultDelegate +import com.posthog.surveys.PostHogSurveysDelegate +import com.posthog.surveys.PostHogSurveysResetAwareDelegate +import com.posthog.surveys.Survey +import org.junit.runner.RunWith +import java.io.StringReader +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@RunWith(AndroidJUnit4::class) +internal class PostHogSurveysDelegateLifecycleTest { + private val config = PostHogConfig("delegate-lifecycle").apply { cachePreferences = PostHogMemoryPreferences() } + private val integration = PostHogSurveysIntegration(ApplicationProvider.getApplicationContext(), config) + private val survey = + checkNotNull( + PostHogSerializer( + config, + ).deserialize(StringReader("""{"id":"delegate-test","name":"Test","type":"api","questions":[]}""")), + ) + + @Test + fun `replacement delegate is bound and retired integration cannot render again`() { + var generation: Long? = null + var boundConfig: PostHogSurveysConfig? = null + val delegate = + object : PostHogSurveysResetAwareDelegate, PostHogSurveysDelegate by PostHogSurveysDefaultDelegate() { + override fun renderSurvey( + presentation: PostHogSurveyPresentation, + onSurveyShown: OnPostHogSurveyShown, + onSurveyResponse: OnPostHogSurveyResponse, + onSurveyClosed: OnPostHogSurveyClosed, + ) { + generation = presentation.resetGeneration + } + + override fun bindSurveySession(session: PostHogSurveyPresentationSession) { + boundConfig = session.config + } + + override fun cleanupSurveys(session: PostHogSurveyPresentationSession) = Unit + + override fun onSurveyReset( + resetGeneration: Long, + config: PostHogSurveysConfig, + ) = Unit + } + integration.install(PostHogFake()) + config.surveysConfig.surveysDelegate = delegate + try { + integration.showSurvey(survey) + assertEquals(0L, generation) + assertEquals(config.surveysConfig, boundConfig) + integration.uninstall() + generation = null + boundConfig = null + integration.showSurvey(survey) + assertNull(generation) + assertNull(boundConfig) + } finally { + integration.uninstall() + } + } + + @Test + fun `custom render runs without the integration lifecycle monitor`() { + var heldLock: Boolean? = null + config.surveysConfig.surveysDelegate = + object : PostHogSurveysDelegate by PostHogSurveysDefaultDelegate() { + override fun renderSurvey( + survey: PostHogDisplaySurvey, + onSurveyShown: OnPostHogSurveyShown, + onSurveyResponse: OnPostHogSurveyResponse, + onSurveyClosed: OnPostHogSurveyClosed, + ) { + val field = PostHogSurveysIntegration::class.java.getDeclaredField("lifecycleLock").apply { isAccessible = true } + heldLock = Thread.holdsLock(checkNotNull(field.get(integration))) + } + } + integration.install(PostHogFake()) + try { + integration.showSurvey(survey) + assertEquals(false, heldLock) + } finally { + integration.uninstall() + } + } +} diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index d2debb40a..c48a8d842 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -1814,6 +1814,20 @@ public final class com/posthog/surveys/PostHogNextSurveyQuestion { public final fun isSurveyCompleted ()Z } +public final class com/posthog/surveys/PostHogSurveyPresentation { + public fun (Lcom/posthog/surveys/PostHogDisplaySurvey;JLcom/posthog/surveys/PostHogSurveyPresentationSession;)V + public final fun getResetGeneration ()J + public final fun getSession ()Lcom/posthog/surveys/PostHogSurveyPresentationSession; + public final fun getSurvey ()Lcom/posthog/surveys/PostHogDisplaySurvey; +} + +public final class com/posthog/surveys/PostHogSurveyPresentationSession { + public fun (Lcom/posthog/surveys/PostHogSurveysConfig;)V + public final fun getConfig ()Lcom/posthog/surveys/PostHogSurveysConfig; + public final fun invalidate ()V + public final fun isActive ()Z +} + public abstract class com/posthog/surveys/PostHogSurveyResponse { public final fun toResponseValue ()Ljava/lang/Object; } @@ -1895,6 +1909,13 @@ public abstract interface class com/posthog/surveys/PostHogSurveysDelegate { public abstract fun renderSurvey (Lcom/posthog/surveys/PostHogDisplaySurvey;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;)V } +public abstract interface class com/posthog/surveys/PostHogSurveysResetAwareDelegate : com/posthog/surveys/PostHogSurveysDelegate { + public abstract fun bindSurveySession (Lcom/posthog/surveys/PostHogSurveyPresentationSession;)V + public abstract fun cleanupSurveys (Lcom/posthog/surveys/PostHogSurveyPresentationSession;)V + public abstract fun onSurveyReset (JLcom/posthog/surveys/PostHogSurveysConfig;)V + public abstract fun renderSurvey (Lcom/posthog/surveys/PostHogSurveyPresentation;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;)V +} + public final class com/posthog/surveys/RatingSurveyQuestion : com/posthog/surveys/SurveyQuestion { public fun (Lcom/posthog/surveys/SurveyRatingDisplayType;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;)V public final fun getDisplay ()Lcom/posthog/surveys/SurveyRatingDisplayType; diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index fe9864a57..ae6c79cac 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -39,6 +39,7 @@ import com.posthog.internal.surveys.PostHogSurveysHandler import com.posthog.logs.PostHogLogRecord import com.posthog.logs.PostHogLogSeverity import com.posthog.logs.PostHogLogger +import com.posthog.surveys.PostHogSurveysResetAwareDelegate import com.posthog.vendor.uuid.TimeBasedEpochGenerator import java.util.Date import java.util.UUID @@ -1935,9 +1936,16 @@ public class PostHog private constructor( } val surveysConfig = config?.surveysConfig if (surveysConfig != null) { - synchronized(surveysConfig) { - surveysConfig.reset() - getPreferences().clear(except = except.toList()) + val resetNotification = + synchronized(surveysConfig) { + surveysConfig.reset() + getPreferences().clear(except = except.toList()) + (surveysConfig.surveysDelegate as? PostHogSurveysResetAwareDelegate) to surveysConfig.resetGeneration + } + try { + resetNotification.first?.onSurveyReset(resetNotification.second, surveysConfig) + } catch (error: Throwable) { + config?.logger?.log("Resetting survey presentation failed: $error") } } else { getPreferences().clear(except = except.toList()) diff --git a/posthog/src/main/java/com/posthog/surveys/PostHogSurveysResetAwareDelegate.kt b/posthog/src/main/java/com/posthog/surveys/PostHogSurveysResetAwareDelegate.kt new file mode 100644 index 000000000..55927df33 --- /dev/null +++ b/posthog/src/main/java/com/posthog/surveys/PostHogSurveysResetAwareDelegate.kt @@ -0,0 +1,47 @@ +package com.posthog.surveys + +import com.posthog.PostHogInternal + +/** Optional presentation lifecycle used by SDK renderers to discard UI across reset. */ +@PostHogInternal +public interface PostHogSurveysResetAwareDelegate : PostHogSurveysDelegate { + /** Bind presentation ownership before rendering with a configuration. */ + public fun bindSurveySession(session: PostHogSurveyPresentationSession) + + /** Render only while this reset generation is current. */ + public fun renderSurvey( + presentation: PostHogSurveyPresentation, + onSurveyShown: OnPostHogSurveyShown, + onSurveyResponse: OnPostHogSurveyResponse, + onSurveyClosed: OnPostHogSurveyClosed, + ) + + /** Discard only presentations belonging to this installed integration. */ + public fun cleanupSurveys(session: PostHogSurveyPresentationSession) + + /** Discard presentations older than this generation, including pending and retained UI. */ + public fun onSurveyReset( + resetGeneration: Long, + config: PostHogSurveysConfig, + ) +} + +/** Immutable ownership snapshot for a survey presentation. */ +@PostHogInternal +public class PostHogSurveyPresentation( + public val survey: PostHogDisplaySurvey, + public val resetGeneration: Long, + public val session: PostHogSurveyPresentationSession, +) + +/** Identifies one installed integration; retired integrations cannot reclaim a renderer. */ +@PostHogInternal +public class PostHogSurveyPresentationSession(public val config: PostHogSurveysConfig) { + @Volatile + public var isActive: Boolean = true + private set + + public fun invalidate() { + isActive = false + } +} diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index d758102e7..c70d01c4a 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -24,6 +24,15 @@ import com.posthog.internal.PostHogSerializer import com.posthog.internal.PostHogSessionManager import com.posthog.internal.PostHogThreadFactory import com.posthog.internal.errortracking.PostHogThrowable +import com.posthog.surveys.OnPostHogSurveyClosed +import com.posthog.surveys.OnPostHogSurveyResponse +import com.posthog.surveys.OnPostHogSurveyShown +import com.posthog.surveys.PostHogSurveyPresentation +import com.posthog.surveys.PostHogSurveyPresentationSession +import com.posthog.surveys.PostHogSurveysConfig +import com.posthog.surveys.PostHogSurveysDefaultDelegate +import com.posthog.surveys.PostHogSurveysDelegate +import com.posthog.surveys.PostHogSurveysResetAwareDelegate import com.posthog.vendor.uuid.TimeBasedEpochGenerator import okhttp3.mockwebserver.MockResponse import org.junit.Rule @@ -2483,11 +2492,36 @@ internal class PostHogTest { val sut = getSut(http.url("/").toString(), preloadFeatureFlags = false, reloadFeatureFlags = false) try { val generation = config.surveysConfig.resetGeneration + var notifiedGeneration: Long? = null + var callbackHeldLock: Boolean? = null + config.surveysConfig.surveysDelegate = + object : PostHogSurveysResetAwareDelegate, PostHogSurveysDelegate by PostHogSurveysDefaultDelegate() { + override fun renderSurvey( + presentation: PostHogSurveyPresentation, + onSurveyShown: OnPostHogSurveyShown, + onSurveyResponse: OnPostHogSurveyResponse, + onSurveyClosed: OnPostHogSurveyClosed, + ) = Unit + + override fun cleanupSurveys(session: PostHogSurveyPresentationSession) = Unit + + override fun bindSurveySession(session: PostHogSurveyPresentationSession) = Unit + + override fun onSurveyReset( + resetGeneration: Long, + config: PostHogSurveysConfig, + ) { + notifiedGeneration = resetGeneration + callbackHeldLock = Thread.holdsLock(this@PostHogTest.config.surveysConfig) + } + } config.cachePreferences?.setValue(PostHogPreferences.SURVEY_PROGRESS, mapOf("survey" to "saved")) sut.reset() assertNotEquals(generation, config.surveysConfig.resetGeneration) + assertEquals(config.surveysConfig.resetGeneration, notifiedGeneration) + assertEquals(false, callbackHeldLock) assertNull(config.cachePreferences?.getValue(PostHogPreferences.SURVEY_PROGRESS)) } finally { sut.close() From 1cacfefe9e5ff52a96e9cd810b013ab69a1eb536 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Wed, 9 Sep 2026 15:32:12 -0300 Subject: [PATCH 6/6] fix(surveys): preserve and verify resume across SDK restarts Only deliver cached survey configuration during setup when it is available. An unknown cache must not erase unfinished answers through reconciliation; successful empty configuration remains authoritative. Exercise fresh SDK setup with production preferences, cumulative saved and new answers, submission IDs, and completion/dismissal language attribution. Strengthen mounted draft restoration, zero stale-response forwarding, session retirement and cleanup ownership, and legacy argument forwarding. Consolidate overlapping cases and move the restart journey into a focused test class. Replace private-monitor assertions with bounded behavior. Validation: 41 focused tests passed. CI=true make compile testSurveyUI passed: core 948, Android release 374 (3 skipped), Compose release 9, server 540, Compose debug 15; zero failures. Format, API, checkFormat, and CodeScene passed; no API snapshot changes. Removing session invalidation and the actual inline submit guard each failed the intended assertion; both mutations were restored before final validation. Fresh SDK recreation runs within Robolectric and captures via beforeSend; physical-device process death and network ingestion are outside this test scope. Native null-answer omission remains unchanged. --- .changeset/smooth-birds-cheat.md | 2 + .../PostHogSurveyHostResetRaceTest.kt | 61 ++++-- .../compose/internal/PostHogSurveyHostTest.kt | 81 +++++--- .../PostHogSurveysDelegateLifecycleTest.kt | 53 ++++-- .../surveys/PostHogSurveysEventPayloadTest.kt | 178 ++++++++---------- .../surveys/PostHogSurveysRestartTest.kt | 171 +++++++++++++++++ posthog/src/main/java/com/posthog/PostHog.kt | 3 +- .../surveys/SurveyBinaryCompatibilityTest.kt | 58 ++++-- 8 files changed, 429 insertions(+), 178 deletions(-) create mode 100644 posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysRestartTest.kt diff --git a/.changeset/smooth-birds-cheat.md b/.changeset/smooth-birds-cheat.md index e469ce7b3..2bf991fa6 100644 --- a/.changeset/smooth-birds-cheat.md +++ b/.changeset/smooth-birds-cheat.md @@ -11,3 +11,5 @@ Persist unfinished survey progress across app restarts and restore the submissio Keep unfinished surveys across Activity teardown, preserve unreadable progress during Direct Boot, and invalidate delayed responses on reset without mixing user identities. Discard visible, delayed, and retained Compose survey input on reset, while preserving fresh presentations and delegate reuse across SDK configurations. + +Preserve unfinished progress when startup has no cached survey configuration; confirmed empty survey lists still clear removed surveys. diff --git a/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostResetRaceTest.kt b/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostResetRaceTest.kt index 08e2e752a..7206b49b9 100644 --- a/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostResetRaceTest.kt +++ b/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostResetRaceTest.kt @@ -2,6 +2,7 @@ package com.posthog.android.surveys.compose.internal import android.app.Application import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.test.core.app.ApplicationProvider @@ -25,6 +26,7 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlin.test.fail @RunWith(RobolectricTestRunner::class) @Config(sdk = [35]) @@ -65,29 +67,13 @@ internal class PostHogSurveyHostResetRaceTest { binder.start() awaitBlocked(binder) resetter.start() - // The original race delivers reset before bind installs the new owner. With - // atomic binding reset waits for that installation, then invalidates it. - notification.await(2, TimeUnit.SECONDS) + awaitResetDeliveryOrBlocking(resetter, notification) } binder.join(2000) resetter.join(2000) assertFalse(binder.isAlive) assertFalse(resetter.isAlive) - val survey = - PostHogDisplaySurvey( - "old", - "Old", - listOf( - PostHogDisplayOpenQuestion( - "q", - "Previous user question", - null, - PostHogDisplaySurveyTextContentType.TEXT, - false, - "Send", - ), - ), - ) + val survey = oldSurvey() compose.runOnIdle { delegate.renderSurvey( PostHogSurveyPresentation(survey, oldGeneration, owner), @@ -97,6 +83,15 @@ internal class PostHogSurveyHostResetRaceTest { ) } compose.onNodeWithText("Previous user question").assertDoesNotExist() + compose.runOnIdle { + delegate.renderSurvey( + PostHogSurveyPresentation(survey, config.surveysConfig.resetGeneration, owner), + {}, + { _, _, _ -> null }, + {}, + ) + } + compose.onNodeWithText("Previous user question").assertIsDisplayed() } finally { compose.runOnUiThread { delegate.cleanupSurveys() } sdk.close() @@ -106,6 +101,34 @@ internal class PostHogSurveyHostResetRaceTest { private fun awaitBlocked(thread: Thread) { val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2) while (thread.state != Thread.State.BLOCKED && System.nanoTime() < deadline) Thread.yield() - assertTrue(thread.state == Thread.State.BLOCKED, "Binder must be waiting on the held host gate") + assertTrue(thread.state == Thread.State.BLOCKED, "Contested operation must reach its monitor before releasing the host gate") + } + + private fun awaitResetDeliveryOrBlocking( + resetter: Thread, + notification: CountDownLatch, + ) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2) + while (System.nanoTime() < deadline) { + if (notification.count == 0L || resetter.state == Thread.State.BLOCKED) return + Thread.yield() + } + fail("Reset must deliver or reach the contested monitor") } + + private fun oldSurvey(): PostHogDisplaySurvey = + PostHogDisplaySurvey( + "old", + "Old", + listOf( + PostHogDisplayOpenQuestion( + "q", + "Previous user question", + null, + PostHogDisplaySurveyTextContentType.TEXT, + false, + "Send", + ), + ), + ) } diff --git a/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostTest.kt b/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostTest.kt index 1b0246e38..79dec1ae4 100644 --- a/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostTest.kt +++ b/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostTest.kt @@ -26,6 +26,7 @@ import com.posthog.surveys.PostHogDisplaySurveyTextContentType import com.posthog.surveys.PostHogNextSurveyQuestion import com.posthog.surveys.PostHogSurveyPresentation import com.posthog.surveys.PostHogSurveyPresentationSession +import com.posthog.surveys.PostHogSurveyResponse import com.posthog.surveys.PostHogSurveysConfig import org.junit.Rule import org.junit.Test @@ -35,6 +36,7 @@ import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import java.time.Duration import kotlin.test.assertEquals +import kotlin.test.assertFalse @RunWith(RobolectricTestRunner::class) @Config(sdk = [35]) @@ -54,32 +56,17 @@ internal class PostHogSurveyHostTest { @Test fun `reset removes unsent text before a new host resumes`() { - assertResetBeforeHostTransition(replacementAlreadyResumed = false) - } - - @Test - fun `reset removes unsent text when replacement host already resumed`() { - assertResetBeforeHostTransition(replacementAlreadyResumed = true) + assertResetBeforeHostTransition(resetAfterFinish = false) } @Test fun `reset discards retained unsent text after host has already finished`() { - assertResetBeforeHostTransition(replacementAlreadyResumed = false, resetAfterFinish = true) + assertResetBeforeHostTransition(resetAfterFinish = true) } - @Suppress("DEPRECATION") - private fun assertResetBeforeHostTransition( - replacementAlreadyResumed: Boolean, - resetAfterFinish: Boolean = false, - ) { + private fun assertResetBeforeHostTransition(resetAfterFinish: Boolean) { val application = ApplicationProvider.getApplicationContext() - val config = - PostHogConfig("host-reset", "http://127.0.0.1:1").apply { - cachePreferences = PostHogMemoryPreferences() - preloadFeatureFlags = false - remoteConfig = false - reuseAnonymousId = true - } + val config = resetConfig() val sdk = PostHog.with(config) val integration = PostHogSurveysIntegration(application, config) integration.install(sdk) @@ -91,10 +78,17 @@ internal class PostHogSurveyHostTest { listOf(PostHogDisplayOpenQuestion("q", "Private question?", null, PostHogDisplaySurveyTextContentType.TEXT, false, "Send")), ) var closed = 0 + val oldAnswers = mutableListOf() + val freshAnswers = mutableListOf() var replacement: ActivityScenario? = null try { compose.activityRule.scenario.recreate() - compose.runOnIdle { delegate.renderSurvey(survey, {}, { _, _, _ -> null }, { closed++ }) } + compose.runOnIdle { + delegate.renderSurvey(survey, {}, { _, _, answer -> + oldAnswers.add(answer) + null + }, { closed++ }) + } compose.onNode(hasSetTextAction()).performTextInput("Previous user secret") val oldClose = compose.onNodeWithContentDescription( @@ -107,7 +101,6 @@ internal class PostHogSurveyHostTest { compose.onNodeWithText("Previous user secret").assertDoesNotExist() compose.onNodeWithText("Private question?").assertDoesNotExist() } - if (replacementAlreadyResumed) replacement = ActivityScenario.launch(ComponentActivity::class.java) compose.activityRule.scenario.close() if (resetAfterFinish) compose.runOnUiThread { sdk.reset() } if (replacement == null) replacement = ActivityScenario.launch(ComponentActivity::class.java) @@ -115,12 +108,19 @@ internal class PostHogSurveyHostTest { compose.onNodeWithText("Previous user secret").assertDoesNotExist() compose.onNodeWithText("Private question?").assertDoesNotExist() assertEquals(0, closed) - compose.runOnIdle { delegate.renderSurvey(survey, {}, { _, _, _ -> null }, { closed++ }) } + compose.runOnIdle { + delegate.renderSurvey(survey, {}, { _, _, answer -> + freshAnswers.add(answer) + null + }, { closed++ }) + } compose.onNodeWithText("Private question?").assertIsDisplayed() compose.runOnIdle { oldClose() oldSubmit() } + assertEquals(emptyList(), oldAnswers) + assertEquals(emptyList(), freshAnswers) compose.onNodeWithText("Private question?").assertIsDisplayed() compose.onNodeWithText("Previous user secret").assertDoesNotExist() compose.onNodeWithContentDescription("Close survey").performSemanticsAction(SemanticsActions.OnClick) { it() } @@ -161,7 +161,8 @@ internal class PostHogSurveyHostTest { ) }.apply { start() - join() + join(2_000) + assertFalse(isAlive, "Queued render must finish without waiting for main") } delegate.onSurveyReset(1, owner.config) } @@ -202,7 +203,8 @@ internal class PostHogSurveyHostTest { // Cleanup is queued, but a fresh presentation reaches main first. Thread { delegate.onSurveyReset(3, owner.config) }.apply { start() - join() + join(2_000) + assertFalse(isAlive, "Reset notification must finish without waiting for main") } delegate.renderSurvey(PostHogSurveyPresentation(survey, 4, owner), { shown++ }, { _, _, _ -> null }, { closed++ }) delegate.onSurveyReset(2, owner.config) @@ -244,7 +246,7 @@ internal class PostHogSurveyHostTest { val host = PostHogSurveyHost(provider) var shown = 0 var closed = 0 - val submitted = mutableListOf() + val submitted = mutableListOf>() val survey = PostHogDisplaySurvey( id = "resume", @@ -259,16 +261,17 @@ internal class PostHogSurveyHostTest { try { compose.runOnIdle { provider.onActivityResumed(compose.activity) - host.show(survey, { shown++ }, { _, index, _ -> - submitted.add(index) - PostHogNextSurveyQuestion(index + 1, false) + host.show(survey, { shown++ }, { _, index, answer -> + submitted.add(index to answer) + PostHogNextSurveyQuestion(1, false) }, { closed++ }) } compose.onNodeWithText("First?").assertIsDisplayed() compose.onNode(hasSetTextAction()).performTextInput("Saved") compose.onNodeWithText("Next").assertIsEnabled().performSemanticsAction(SemanticsActions.OnClick) { it() } - assertEquals(listOf(0), submitted) + assertEquals(listOf>(0 to PostHogSurveyResponse.Text("Saved")), submitted) compose.onNodeWithText("Second?").assertExists() + compose.onNode(hasSetTextAction()).performTextInput("Unsent draft") if (replacementAlreadyResumed) replacement = ActivityScenario.launch(ComponentActivity::class.java) compose.activityRule.scenario.close() @@ -276,8 +279,17 @@ internal class PostHogSurveyHostTest { if (replacement == null) replacement = ActivityScenario.launch(ComponentActivity::class.java) compose.onNodeWithText("Second?").assertExists() - assertEquals(listOf(0), submitted) + assertEquals(listOf>(0 to PostHogSurveyResponse.Text("Saved")), submitted) assertEquals(1, shown) + compose.onNodeWithText("Unsent draft").assertIsDisplayed() + compose.onNodeWithText("Next").performSemanticsAction(SemanticsActions.OnClick) { it() } + assertEquals( + listOf>( + 0 to PostHogSurveyResponse.Text("Saved"), + 1 to PostHogSurveyResponse.Text("Unsent draft"), + ), + submitted, + ) compose.onNodeWithContentDescription("Close survey").performSemanticsAction(SemanticsActions.OnClick) { it() } compose.waitForIdle() assertEquals(1, closed) @@ -287,4 +299,13 @@ internal class PostHogSurveyHostTest { application.unregisterActivityLifecycleCallbacks(provider) } } + + @Suppress("DEPRECATION") + private fun resetConfig(): PostHogConfig = + PostHogConfig("host-reset", "http://127.0.0.1:1").apply { + cachePreferences = PostHogMemoryPreferences() + preloadFeatureFlags = false + remoteConfig = false + reuseAnonymousId = true + } } diff --git a/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysDelegateLifecycleTest.kt b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysDelegateLifecycleTest.kt index eb194cf46..8f0f51fef 100644 --- a/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysDelegateLifecycleTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysDelegateLifecycleTest.kt @@ -19,9 +19,16 @@ import com.posthog.surveys.PostHogSurveysResetAwareDelegate import com.posthog.surveys.Survey import org.junit.runner.RunWith import java.io.StringReader +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) internal class PostHogSurveysDelegateLifecycleTest { @@ -37,7 +44,9 @@ internal class PostHogSurveysDelegateLifecycleTest { @Test fun `replacement delegate is bound and retired integration cannot render again`() { var generation: Long? = null - var boundConfig: PostHogSurveysConfig? = null + var boundSession: PostHogSurveyPresentationSession? = null + var renderedSession: PostHogSurveyPresentationSession? = null + var cleanedSession: PostHogSurveyPresentationSession? = null val delegate = object : PostHogSurveysResetAwareDelegate, PostHogSurveysDelegate by PostHogSurveysDefaultDelegate() { override fun renderSurvey( @@ -47,13 +56,16 @@ internal class PostHogSurveysDelegateLifecycleTest { onSurveyClosed: OnPostHogSurveyClosed, ) { generation = presentation.resetGeneration + renderedSession = presentation.session } override fun bindSurveySession(session: PostHogSurveyPresentationSession) { - boundConfig = session.config + boundSession = session } - override fun cleanupSurveys(session: PostHogSurveyPresentationSession) = Unit + override fun cleanupSurveys(session: PostHogSurveyPresentationSession) { + cleanedSession = session + } override fun onSurveyReset( resetGeneration: Long, @@ -65,21 +77,33 @@ internal class PostHogSurveysDelegateLifecycleTest { try { integration.showSurvey(survey) assertEquals(0L, generation) - assertEquals(config.surveysConfig, boundConfig) + val firstSession = assertNotNull(boundSession) + assertSame(config.surveysConfig, firstSession.config) + assertSame(firstSession, renderedSession) + assertTrue(firstSession.isActive) integration.uninstall() + assertFalse(firstSession.isActive) + assertSame(firstSession, cleanedSession) generation = null - boundConfig = null + boundSession = null integration.showSurvey(survey) assertNull(generation) - assertNull(boundConfig) + assertNull(boundSession) + integration.install(PostHogFake()) + integration.showSurvey(survey) + val nextSession = assertNotNull(boundSession) + assertNotSame(firstSession, nextSession) + assertTrue(nextSession.isActive) + assertSame(nextSession, renderedSession) } finally { integration.uninstall() } } @Test - fun `custom render runs without the integration lifecycle monitor`() { - var heldLock: Boolean? = null + fun `custom render can wait for another thread to uninstall the integration`() { + var completed: Boolean? = null + var handoff: Thread? = null config.surveysConfig.surveysDelegate = object : PostHogSurveysDelegate by PostHogSurveysDefaultDelegate() { override fun renderSurvey( @@ -88,15 +112,22 @@ internal class PostHogSurveysDelegateLifecycleTest { onSurveyResponse: OnPostHogSurveyResponse, onSurveyClosed: OnPostHogSurveyClosed, ) { - val field = PostHogSurveysIntegration::class.java.getDeclaredField("lifecycleLock").apply { isAccessible = true } - heldLock = Thread.holdsLock(checkNotNull(field.get(integration))) + val uninstalled = CountDownLatch(1) + handoff = + Thread { + integration.uninstall() + uninstalled.countDown() + }.apply { start() } + completed = uninstalled.await(2, TimeUnit.SECONDS) } } integration.install(PostHogFake()) try { integration.showSurvey(survey) - assertEquals(false, heldLock) + assertEquals(true, completed, "Custom rendering must not deadlock with an uninstall handoff") } finally { + handoff?.join(2_000) + assertFalse(handoff?.isAlive == true) integration.uninstall() } } diff --git a/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysEventPayloadTest.kt b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysEventPayloadTest.kt index bfc7775e0..2086d25b9 100644 --- a/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysEventPayloadTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysEventPayloadTest.kt @@ -7,8 +7,6 @@ import com.posthog.PostHogBeforeSend import com.posthog.PostHogConfig import com.posthog.PostHogFake import com.posthog.PostHogInterface -import com.posthog.android.PostHogAndroidConfig -import com.posthog.android.internal.PostHogSharedPreferences import com.posthog.internal.PostHogMemoryPreferences import com.posthog.internal.PostHogNetworkStatus import com.posthog.internal.PostHogPreferences @@ -37,32 +35,8 @@ internal class PostHogSurveysEventPayloadTest { private val context = ApplicationProvider.getApplicationContext() private val serializer = PostHogSerializer(PostHogConfig("test-api-key")) - private class RecordingDelegate : PostHogSurveysDelegate { - var shownSurvey: PostHogDisplaySurvey? = null - var onSurveyShown: OnPostHogSurveyShown? = null - var onSurveyResponse: OnPostHogSurveyResponse? = null - var onSurveyClosed: OnPostHogSurveyClosed? = null - var cleanupCalls = 0 - - override fun renderSurvey( - survey: PostHogDisplaySurvey, - onSurveyShown: OnPostHogSurveyShown, - onSurveyResponse: OnPostHogSurveyResponse, - onSurveyClosed: OnPostHogSurveyClosed, - ) { - shownSurvey = survey - this.onSurveyShown = onSurveyShown - this.onSurveyResponse = onSurveyResponse - this.onSurveyClosed = onSurveyClosed - } - - override fun cleanupSurveys() { - cleanupCalls++ - } - } - private fun createIntegration( - delegate: RecordingDelegate, + delegate: RecordingSurveyDelegate, preferences: PostHogPreferences = PostHogMemoryPreferences(), ): Pair { val config = @@ -161,46 +135,9 @@ internal class PostHogSurveysEventPayloadTest { } @Test - fun `unfinished responses survive integration restart`() { - for (enabled in listOf(true, false, null)) { - val preferences = PostHogSharedPreferences(context, PostHogAndroidConfig("survey-resume-test")) - preferences.clear() - val delegate = RecordingDelegate() - val survey = partialResponseSurvey(enabled) - val (first, firstPostHog) = createIntegration(delegate, preferences) - first.showSurvey(survey) - val display = assertNotNull(delegate.shownSurvey) - assertNotNull(delegate.onSurveyShown).invoke(display) - assertNotNull(delegate.onSurveyResponse).invoke(display, 0, PostHogSurveyResponse.Text("Saved")) - val submissionId = firstPostHog.properties?.get("\$survey_submission_id") - first.uninstall() - - val reloadedPreferences = PostHogSharedPreferences(context, PostHogAndroidConfig("survey-resume-test")) - val (resumed, resumedPostHog) = createIntegration(delegate, reloadedPreferences) - try { - resumed.showSurvey(survey) - val restored = assertNotNull(delegate.shownSurvey) - assertEquals(1, restored.initialQuestionIndex) - assertNotNull(delegate.onSurveyShown).invoke(restored) - assertNotNull(delegate.onSurveyResponse).invoke(restored, 1, PostHogSurveyResponse.Text("Final")) - val properties = assertNotNull(resumedPostHog.properties) - assertEquals("Saved", properties["\$survey_response_first"]) - assertEquals(true, properties["\$survey_completed"]) - if (enabled == true) assertEquals(submissionId, properties["\$survey_submission_id"]) - assertNotNull(delegate.onSurveyClosed).invoke(restored) - resumed.showSurvey(survey) - assertEquals(0, assertNotNull(delegate.shownSurvey).initialQuestionIndex) - } finally { - resumed.uninstall() - preferences.clear() - } - } - } - - @Test - fun `reset before a restored survey is shown rejects captured answers`() { + fun `clearing storage before a restored survey is shown rejects captured answers`() { val preferences = PostHogMemoryPreferences() - val delegate = RecordingDelegate() + val delegate = RecordingSurveyDelegate() val survey = partialResponseSurvey(true) val (first, _) = createIntegration(delegate, preferences) first.showSurvey(survey) @@ -228,7 +165,7 @@ internal class PostHogSurveysEventPayloadTest { @Test fun `stale shown callback does not clean up a newer survey`() { val preferences = PostHogMemoryPreferences() - val delegate = RecordingDelegate() + val delegate = RecordingSurveyDelegate() val survey = partialResponseSurvey(true) val (first, _) = createIntegration(delegate, preferences) first.showSurvey(survey) @@ -261,7 +198,7 @@ internal class PostHogSurveysEventPayloadTest { @Test fun `capture callback can reset without retaining previous progress`() { - val delegate = RecordingDelegate() + val delegate = RecordingSurveyDelegate() val preferences = PostHogMemoryPreferences() val directory = java.io.File(context.cacheDir, java.util.UUID.randomUUID().toString()).apply { mkdirs() } lateinit var sdk: PostHogInterface @@ -319,37 +256,31 @@ internal class PostHogSurveysEventPayloadTest { } @Test - fun `dismissal and reset clear saved progress without stale callbacks restoring it`() { - for (reset in listOf(false, true)) { - val preferences = PostHogMemoryPreferences() - val delegate = RecordingDelegate() - val (integration, postHog) = createIntegration(delegate, preferences) - try { - integration.showSurvey(partialResponseSurvey(true)) - val display = assertNotNull(delegate.shownSurvey) - assertNotNull(delegate.onSurveyShown).invoke(display) - assertNotNull(delegate.onSurveyResponse).invoke(display, 0, PostHogSurveyResponse.Text("Saved")) - if (reset) { - preferences.clear() - val count = postHog.captures - assertNull(assertNotNull(delegate.onSurveyResponse).invoke(display, 1, PostHogSurveyResponse.Text("Stale"))) - assertEquals(count, postHog.captures) - } else { - assertNotNull(delegate.onSurveyClosed).invoke(display) - } - integration.showSurvey(partialResponseSurvey(true)) - assertEquals(0, assertNotNull(delegate.shownSurvey).initialQuestionIndex) - } finally { - integration.uninstall() - preferences.clear() - } + fun `clearing storage rejects stale responses and starts a fresh attempt`() { + val preferences = PostHogMemoryPreferences() + val delegate = RecordingSurveyDelegate() + val (integration, postHog) = createIntegration(delegate, preferences) + try { + integration.showSurvey(partialResponseSurvey(true)) + val display = assertNotNull(delegate.shownSurvey) + assertNotNull(delegate.onSurveyShown).invoke(display) + assertNotNull(delegate.onSurveyResponse).invoke(display, 0, PostHogSurveyResponse.Text("Saved")) + preferences.clear() + val count = postHog.captures + assertNull(assertNotNull(delegate.onSurveyResponse).invoke(display, 1, PostHogSurveyResponse.Text("Stale"))) + assertEquals(count, postHog.captures) + integration.showSurvey(partialResponseSurvey(true)) + assertEquals(0, assertNotNull(delegate.shownSurvey).initialQuestionIndex) + } finally { + integration.uninstall() + preferences.clear() } } @Test fun `unfinished surveys bypass seen and internal targeting but honor linked flags`() { val preferences = PostHogMemoryPreferences() - val delegate = RecordingDelegate() + val delegate = RecordingSurveyDelegate() val (integration, _) = createIntegration(delegate, preferences) val survey = partialResponseSurvey(true).copy(startDate = java.util.Date()) integration.showSurvey(survey) @@ -374,7 +305,7 @@ internal class PostHogSurveysEventPayloadTest { @Test fun `restart restores the branching destination and omits skipped answers`() { val preferences = PostHogMemoryPreferences() - val delegate = RecordingDelegate() + val delegate = RecordingSurveyDelegate() val questions = assertNotNull( serializer.deserializeList( @@ -391,11 +322,12 @@ internal class PostHogSurveysEventPayloadTest { ), ) val survey = partialResponseSurvey(true).copy(questions = questions) - val (first, _) = createIntegration(delegate, preferences) + val (first, firstPostHog) = createIntegration(delegate, preferences) first.showSurvey(survey) val display = assertNotNull(delegate.shownSurvey) assertNotNull(delegate.onSurveyShown).invoke(display) assertNotNull(delegate.onSurveyResponse).invoke(display, 0, PostHogSurveyResponse.Text("Saved")) + val submissionId = assertNotNull(firstPostHog.properties?.get("\$survey_submission_id")) first.uninstall() val (resumed, postHog) = createIntegration(delegate, preferences) try { @@ -405,7 +337,13 @@ internal class PostHogSurveysEventPayloadTest { assertNotNull(delegate.onSurveyShown).invoke(restored) assertNotNull(delegate.onSurveyResponse).invoke(restored, 2, PostHogSurveyResponse.Text("Final")) assertEquals("Saved", postHog.properties?.get("\$survey_response_first")) + assertEquals("Saved", postHog.properties?.get("\$survey_response")) + assertEquals("Final", postHog.properties?.get("\$survey_response_last")) + assertEquals("Final", postHog.properties?.get("\$survey_response_2")) + assertEquals(true, postHog.properties?.get("\$survey_completed")) + assertEquals(submissionId, postHog.properties?.get("\$survey_submission_id")) assertNull(postHog.properties?.get("\$survey_response_skipped")) + assertNull(postHog.properties?.get("\$survey_response_1")) } finally { resumed.uninstall() preferences.clear() @@ -415,7 +353,7 @@ internal class PostHogSurveysEventPayloadTest { @Test fun `showing a survey alone does not create resumable progress`() { val preferences = PostHogMemoryPreferences() - val delegate = RecordingDelegate() + val delegate = RecordingSurveyDelegate() val (integration, _) = createIntegration(delegate, preferences) try { integration.showSurvey(partialResponseSurvey(true)) @@ -430,7 +368,7 @@ internal class PostHogSurveysEventPayloadTest { @Test fun `partial responses emit cumulative answers with one submission id`() { for (enabled in listOf(true, false, null)) { - val delegate = RecordingDelegate() + val delegate = RecordingSurveyDelegate() val (integration, postHog) = createIntegration(delegate) try { integration.showSurvey(partialResponseSurvey(enabled)) @@ -467,7 +405,7 @@ internal class PostHogSurveysEventPayloadTest { @Test fun `dismissal keeps submission id and a new attempt gets a new id`() { - val delegate = RecordingDelegate() + val delegate = RecordingSurveyDelegate() val (integration, postHog) = createIntegration(delegate) try { val original = partialResponseSurvey(true) @@ -483,6 +421,7 @@ internal class PostHogSurveysEventPayloadTest { assertEquals(true, postHog.properties?.get("\$survey_partially_completed")) assertEquals("Saved", postHog.properties?.get("\$survey_response_first")) integration.showSurvey(original) + assertEquals(0, assertNotNull(delegate.shownSurvey).initialQuestionIndex) assertNotNull(delegate.onSurveyShown).invoke(assertNotNull(delegate.shownSurvey)) assertNotNull(delegate.onSurveyResponse).invoke(assertNotNull(delegate.shownSurvey), 0, PostHogSurveyResponse.Text("New")) val nextId = assertNotNull(postHog.properties?.get("\$survey_submission_id")) @@ -494,7 +433,7 @@ internal class PostHogSurveysEventPayloadTest { @Test fun `branching to end completes a partial-enabled survey even with a skipped optional answer`() { - val delegate = RecordingDelegate() + val delegate = RecordingSurveyDelegate() val (integration, postHog) = createIntegration(delegate) try { integration.showSurvey(partialResponseSurvey(true, endAfterFirst = true)) @@ -504,7 +443,14 @@ internal class PostHogSurveysEventPayloadTest { assertEquals(true, next.isSurveyCompleted) assertEquals(2, postHog.captures) assertEquals(true, postHog.properties?.get("\$survey_completed")) - assertNull(postHog.properties?.get("\$survey_response_second")) + val properties = assertNotNull(postHog.properties) + for (key in listOf("\$survey_response", "\$survey_response_first", "\$survey_response_1", "\$survey_response_second")) { + assertFalse(properties.containsKey(key), "Null and unvisited answers must omit $key") + } + assertEquals( + listOf(mapOf("id" to "first", "question" to "First?"), mapOf("id" to "second", "question" to "Second?")), + properties["\$survey_questions"], + ) } finally { integration.uninstall() } @@ -512,7 +458,7 @@ internal class PostHogSurveysEventPayloadTest { @Test fun `survey sent includes legacy and question id response keys`() { - val delegate = RecordingDelegate() + val delegate = RecordingSurveyDelegate() val (integration, postHog) = createIntegration(delegate) val survey = createSurvey(id = "sent-survey", name = "Sent Survey") @@ -554,7 +500,7 @@ internal class PostHogSurveysEventPayloadTest { @Test fun `survey dismissed includes responses and marks partial completion when there are answers`() { - val delegate = RecordingDelegate() + val delegate = RecordingSurveyDelegate() val (integration, postHog) = createIntegration(delegate) val survey = createSurvey() @@ -594,7 +540,7 @@ internal class PostHogSurveysEventPayloadTest { @Test fun `survey dismissed marks partial completion false when there are no answers`() { - val delegate = RecordingDelegate() + val delegate = RecordingSurveyDelegate() val (integration, postHog) = createIntegration(delegate) val survey = createSurvey(id = "empty-dismissed-survey", name = "Empty Dismissed Survey") @@ -631,7 +577,7 @@ internal class PostHogSurveysEventPayloadTest { @Test fun `survey dismissed ignores null rating response`() { - val delegate = RecordingDelegate() + val delegate = RecordingSurveyDelegate() val (integration, postHog) = createIntegration(delegate) val survey = createSurvey(id = "null-rating-survey", name = "Null Rating Survey") @@ -663,3 +609,27 @@ internal class PostHogSurveysEventPayloadTest { ) } } + +internal class RecordingSurveyDelegate : PostHogSurveysDelegate { + var shownSurvey: PostHogDisplaySurvey? = null + var onSurveyShown: OnPostHogSurveyShown? = null + var onSurveyResponse: OnPostHogSurveyResponse? = null + var onSurveyClosed: OnPostHogSurveyClosed? = null + var cleanupCalls = 0 + + override fun renderSurvey( + survey: PostHogDisplaySurvey, + onSurveyShown: OnPostHogSurveyShown, + onSurveyResponse: OnPostHogSurveyResponse, + onSurveyClosed: OnPostHogSurveyClosed, + ) { + shownSurvey = survey + this.onSurveyShown = onSurveyShown + this.onSurveyResponse = onSurveyResponse + this.onSurveyClosed = onSurveyClosed + } + + override fun cleanupSurveys() { + cleanupCalls++ + } +} diff --git a/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysRestartTest.kt b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysRestartTest.kt new file mode 100644 index 000000000..da865570b --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysRestartTest.kt @@ -0,0 +1,171 @@ +package com.posthog.android.surveys + +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.posthog.PostHog +import com.posthog.PostHogBeforeSend +import com.posthog.PostHogConfig +import com.posthog.PostHogInterface +import com.posthog.android.PostHogAndroidConfig +import com.posthog.android.internal.PostHogSharedPreferences +import com.posthog.internal.PostHogSerializer +import com.posthog.surveys.PostHogSurveyResponse +import com.posthog.surveys.Survey +import org.junit.runner.RunWith +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@RunWith(AndroidJUnit4::class) +internal class PostHogSurveysRestartTest { + private val context = ApplicationProvider.getApplicationContext() + private val serializer = PostHogSerializer(PostHogConfig("test-api-key")) + private val delegate = RecordingSurveyDelegate() + private val sent = mutableListOf>() + private var dismissed: Map? = null + + @Test + fun `unfinished responses survive SDK close and fresh setup with translated wording`() { + for ((enabled, dismiss) in listOf(true to false, false to false, null to false, true to true)) { + sent.clear() + dismissed = null + val preferences = PostHogSharedPreferences(context, PostHogAndroidConfig("survey-resume-test")) + preferences.clear() + val survey = survey(enabled) + val (firstSdk, first) = setup("fr") + val identity = firstSdk.distinctId() + try { + first.showSurvey(survey) + val display = assertNotNull(delegate.shownSurvey) + assertNotNull(delegate.onSurveyShown).invoke(display) + assertNotNull(delegate.onSurveyResponse).invoke(display, display.initialQuestionIndex, PostHogSurveyResponse.Text("Saved")) + assertEquals(if (enabled == true) 1 else 0, sent.size) + } finally { + firstSdk.close() + } + val submissionId = sent.firstOrNull()?.get("\$survey_submission_id") + val (nextSdk, resumed) = setup("es") + try { + assertEquals(identity, nextSdk.distinctId()) + resumed.showSurvey(survey(enabled, revised = true)) + val restored = assertNotNull(delegate.shownSurvey) + assertEquals(1, restored.initialQuestionIndex) + assertNotNull(delegate.onSurveyShown).invoke(restored) + if (dismiss) { + assertNotNull(delegate.onSurveyClosed).invoke(restored) + assertDismissed(submissionId) + continue + } + assertNotNull( + delegate.onSurveyResponse, + ).invoke(restored, restored.initialQuestionIndex, PostHogSurveyResponse.Text("Final")) + assertCompleted(enabled, submissionId) + assertNotNull(delegate.onSurveyClosed).invoke(restored) + resumed.showSurvey(survey) + assertEquals(0, assertNotNull(delegate.shownSurvey).initialQuestionIndex) + } finally { + nextSdk.close() + preferences.clear() + } + } + } + + @Suppress("DEPRECATION") + private fun setup(language: String): Pair { + val config = + PostHogConfig("survey-resume-test", "http://127.0.0.1:1").apply { + cachePreferences = + PostHogSharedPreferences( + this@PostHogSurveysRestartTest.context, + PostHogAndroidConfig(apiKey), + ) + preloadFeatureFlags = false + remoteConfig = false + surveys = true + surveysConfig.surveysDelegate = delegate + surveysConfig.overrideDisplayLanguage = language + addBeforeSend( + PostHogBeforeSend { event -> + if (event.event == "survey sent") sent.add(assertNotNull(event.properties).toMap()) + if (event.event == "survey dismissed") dismissed = assertNotNull(event.properties).toMap() + null + }, + ) + } + val integration = PostHogSurveysIntegration(context, config) + config.addIntegration(integration) + return PostHog.with(config) to integration + } + + private val questionData = + listOf( + mapOf( + "id" to "first", + "type" to "open", + "question" to "First?", + "translations" to mapOf("fr" to mapOf("question" to "Ancienne question?")), + ), + mapOf( + "id" to "second", + "type" to "open", + "question" to "Second?", + "translations" to mapOf("es" to mapOf("question" to "Nueva pregunta?")), + ), + ) + + private fun survey( + enabled: Boolean?, + revised: Boolean = false, + ): Survey = + assertNotNull( + serializer.deserializeList( + listOf( + mapOf( + "id" to "partial-survey", + "name" to "Partial survey", + "type" to "popover", + "questions" to questionData.map { if (revised) it + ("question" to "Revised wording?") else it }, + "enable_partial_responses" to enabled, + ), + ), + )?.firstOrNull(), + ) + + private fun assertDismissed(submissionId: Any?) { + val properties = assertNotNull(dismissed) + assertEquals("fr", properties["\$survey_language"]) + assertEquals(submissionId, properties["\$survey_submission_id"]) + assertEquals("Saved", properties["\$survey_response_first"]) + assertEquals( + listOf( + mapOf("id" to "first", "question" to "Ancienne question?", "response" to "Saved"), + mapOf("id" to "second", "question" to "Nueva pregunta?"), + ), + properties["\$survey_questions"], + ) + assertEquals(1, sent.size) + } + + private fun assertCompleted( + enabled: Boolean?, + submissionId: Any?, + ) { + assertEquals(if (enabled == true) 2 else 1, sent.size) + val properties = sent.last() + assertEquals("Saved", properties["\$survey_response"]) + assertEquals("Saved", properties["\$survey_response_first"]) + assertEquals("Final", properties["\$survey_response_1"]) + assertEquals("Final", properties["\$survey_response_second"]) + assertEquals(true, properties["\$survey_completed"]) + assertEquals("es", properties["\$survey_language"]) + assertEquals( + listOf( + mapOf("id" to "first", "question" to "Ancienne question?", "response" to "Saved"), + mapOf("id" to "second", "question" to "Nueva pregunta?", "response" to "Final"), + ), + properties["\$survey_questions"], + ) + assertNotNull(properties["\$survey_submission_id"]) + if (enabled == true) assertEquals(submissionId, properties["\$survey_submission_id"]) + } +} diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index ae6c79cac..02178ea73 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -340,8 +340,7 @@ public class PostHog private constructor( surveysHandler = it // Immediately push any cached surveys from remote config try { - val surveys = remoteConfig?.getSurveys() ?: emptyList() - it.onSurveysLoaded(surveys) + remoteConfig?.getSurveys()?.let(it::onSurveysLoaded) } catch (e: Throwable) { config.logger.log("Pushing cached surveys to integration failed: $e.") } diff --git a/posthog/src/test/java/com/posthog/surveys/SurveyBinaryCompatibilityTest.kt b/posthog/src/test/java/com/posthog/surveys/SurveyBinaryCompatibilityTest.kt index 253d60adf..2192723bc 100644 --- a/posthog/src/test/java/com/posthog/surveys/SurveyBinaryCompatibilityTest.kt +++ b/posthog/src/test/java/com/posthog/surveys/SurveyBinaryCompatibilityTest.kt @@ -18,7 +18,11 @@ internal class SurveyBinaryCompatibilityTest { private fun legacyArguments(): Array = arrayOf( "survey", "Survey", SurveyType.POPOVER, emptyList(), - null, null, null, null, null, null, null, null, null, null, null, null, null, + "Description", null, "linked", "targeting", "internal", null, null, 7, + Date( + 101, + ), + Date(202), Date(303), null, emptyMap(), ) @Test @@ -33,7 +37,17 @@ internal class SurveyBinaryCompatibilityTest { Date::class.java, ) val constructor = PostHogDisplaySurvey::class.java.getDeclaredConstructor(*types) - val original = constructor.newInstance("id", "name", emptyList(), null, null, null) + val original = + constructor.newInstance( + "id", + "name", + emptyList(), + PostHogDisplaySurveyAppearance(), + Date(404), + Date(505), + ) + assertEquals(Date(404), original.startDate) + assertEquals(Date(505), original.endDate) assertEquals(0, original.initialQuestionIndex) val resumed = original.copy(initialQuestionIndex = 2) assertEquals(2, resumed.copy(name = "Updated").initialQuestionIndex) @@ -45,8 +59,15 @@ internal class SurveyBinaryCompatibilityTest { Int::class.javaPrimitiveType, Any::class.java, ) - val copied = defaultCopy.invoke(null, resumed, *arrayOfNulls(6), 63, null) as PostHogDisplaySurvey - assertEquals(2, copied.initialQuestionIndex) + val copied = + defaultCopy.invoke( + null, resumed, null, "Mixed name", null, null, + Date( + 606, + ), + null, 63 xor (1 shl 1) xor (1 shl 4), null, + ) as PostHogDisplaySurvey + assertEquals(resumed.copy(name = "Mixed name", startDate = Date(606)), copied) } @Test @@ -54,6 +75,12 @@ internal class SurveyBinaryCompatibilityTest { val constructor = Survey::class.java.getDeclaredConstructor(*legacyParameterTypes) val survey = constructor.newInstance(*legacyArguments()) assertEquals("survey", survey.id) + assertEquals( + listOf("Description", "linked", "targeting", "internal"), + listOf(survey.description, survey.linkedFlagKey, survey.targetingFlagKey, survey.internalTargetingFlagKey), + ) + assertEquals(7, survey.currentIteration) + assertEquals(listOf(Date(101), Date(202), Date(303)), listOf(survey.currentIterationStartDate, survey.startDate, survey.endDate)) assertEquals(null, survey.enablePartialResponses) val defaultConstructor = @@ -63,17 +90,15 @@ internal class SurveyBinaryCompatibilityTest { Class.forName("kotlin.jvm.internal.DefaultConstructorMarker"), ) val withDefaults = defaultConstructor.newInstance(*legacyArguments(), 1 shl 16, null) - assertEquals(survey, withDefaults) + assertEquals(survey.copy(translations = null), withDefaults) } @Test fun `legacy copy and Kotlin default copy preserve partial responses`() { val survey = - Survey( - "survey", "Survey", SurveyType.POPOVER, emptyList(), - null, null, null, null, null, null, null, null, null, null, null, null, - enablePartialResponses = true, - ) + Survey::class.java.getDeclaredConstructor( + *legacyParameterTypes, + ).newInstance(*legacyArguments()).copy(enablePartialResponses = true) assertEquals(true, survey.copy(name = "Renamed").enablePartialResponses) assertEquals(false, survey.copy(enablePartialResponses = false).enablePartialResponses) val copy = Survey::class.java.getDeclaredMethod("copy", *legacyParameterTypes) @@ -90,7 +115,16 @@ internal class SurveyBinaryCompatibilityTest { Any::class.java, ) val copiedWithDefaults = - defaultCopy.invoke(null, survey, *arrayOfNulls(17), (1 shl 17) - 1, null) as Survey - assertEquals(survey, copiedWithDefaults) + defaultCopy.invoke( + null, + survey, + *legacyArguments().apply { + this[1] = "Mixed name" + this[4] = "New description" + }, + ((1 shl 17) - 1) xor (1 shl 1) xor (1 shl 4), + null, + ) as Survey + assertEquals(survey.copy(name = "Mixed name", description = "New description"), copiedWithDefaults) } }