diff --git a/.changeset/smooth-birds-cheat.md b/.changeset/smooth-birds-cheat.md new file mode 100644 index 000000000..2bf991fa6 --- /dev/null +++ b/.changeset/smooth-birds-cheat.md @@ -0,0 +1,15 @@ +--- +"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. + +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/.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/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/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..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 @@ -41,17 +46,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. @@ -59,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 @@ -67,7 +80,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 +90,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 +100,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 +109,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)) { @@ -121,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 @@ -149,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 @@ -174,7 +261,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( @@ -191,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) }, ) } } @@ -222,10 +316,24 @@ 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 + 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) @@ -248,14 +356,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 @@ -285,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/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-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..7206b49b9 --- /dev/null +++ b/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostResetRaceTest.kt @@ -0,0 +1,134 @@ +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 +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 +import kotlin.test.fail + +@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() + awaitResetDeliveryOrBlocking(resetter, notification) + } + binder.join(2000) + resetter.join(2000) + assertFalse(binder.isAlive) + assertFalse(resetter.isAlive) + val survey = oldSurvey() + compose.runOnIdle { + delegate.renderSurvey( + PostHogSurveyPresentation(survey, oldGeneration, owner), + {}, + { _, _, _ -> null }, + {}, + ) + } + 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() + } + } + + 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, "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 new file mode 100644 index 000000000..79dec1ae4 --- /dev/null +++ b/posthog-android-surveys-compose/src/testDebug/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHostTest.kt @@ -0,0 +1,311 @@ +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 +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.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.PostHogSurveyResponse +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 +import kotlin.test.assertFalse + +@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) + } + + @Test + fun `reset removes unsent text before a new host resumes`() { + assertResetBeforeHostTransition(resetAfterFinish = false) + } + + @Test + fun `reset discards retained unsent text after host has already finished`() { + assertResetBeforeHostTransition(resetAfterFinish = true) + } + + private fun assertResetBeforeHostTransition(resetAfterFinish: Boolean) { + val application = ApplicationProvider.getApplicationContext() + val config = resetConfig() + 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 + val oldAnswers = mutableListOf() + val freshAnswers = mutableListOf() + var replacement: ActivityScenario? = null + try { + compose.activityRule.scenario.recreate() + compose.runOnIdle { + delegate.renderSurvey(survey, {}, { _, _, answer -> + oldAnswers.add(answer) + 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() + } + 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, {}, { _, _, 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() } + 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(2_000) + assertFalse(isAlive, "Queued render must finish without waiting for main") + } + 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(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) + 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() + 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, 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 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() + assertEquals(0, closed) + if (replacement == null) replacement = ActivityScenario.launch(ComponentActivity::class.java) + + compose.onNodeWithText("Second?").assertExists() + 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) + } finally { + compose.runOnUiThread { host.cleanup() } + replacement?.close() + 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/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 9a8410b7b..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 @@ -34,6 +37,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, @@ -66,7 +70,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 @@ -92,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 @@ -102,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() } @@ -111,14 +124,15 @@ 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 // integration; clearActiveSurvey() only resets our bookkeeping, not the delegate's UI. - cleanupSurveys() - clearActiveSurvey() + cleanupSurveys() + this.postHog = null } @@ -127,6 +141,7 @@ public class PostHogSurveysIntegration( synchronized(surveysLock) { cachedSurveys = surveys } + progressStore.reconcile(surveys) synchronized(eventActivationLock) { rebuildEventsToSurveysMap(surveys) } @@ -152,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 } /** @@ -207,6 +225,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() @@ -263,7 +282,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) } } @@ -287,9 +306,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. * @@ -301,132 +324,259 @@ 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") return } + val resetGeneration = synchronized(activeSurveyLock) { config.surveysConfig.resetGeneration } val displayLanguage = resolveDisplayLanguage() val translations = resolveSurveyTranslations(survey, displayLanguage) - val resolvedLanguage = translations.matchedKey - val resolvedQuestionTranslations = translations.questions + val savedProgress = progressStore.load(survey) + val progress = savedProgress ?: progressStore.getOrCreate(survey) + val responseContext = + SurveyResponseContext( + survey, + translations.matchedKey, + translations.questions, + progress.submissionId, + progress.questionText.toMutableMap(), + progress.language, + resetGeneration, + savedProgress != null, + ) val displaySurvey = PostHogDisplaySurvey.toDisplaySurvey( survey, surveyTranslation = translations.survey, - questionTranslations = resolvedQuestionTranslations, - ) + questionTranslations = responseContext.questionTranslations, + ).copy(initialQuestionIndex = progress.questionIndex) // Store the original survey for branching logic 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 + val onSurveyShown = surveyShownCallback(responseContext, progress) + + val onSurveyResponse = surveyResponseCallback(responseContext) + + val onSurveyClosed: OnPostHogSurveyClosed = onSurveyClosed@{ _ -> + val distinctId = postHog?.distinctId() + val event = synchronized(activeSurveyLock) { - if (activeSurvey == null) { - activeSurvey = originalSurvey - activeSurveyCompleted = false - currentSurveyResponses.clear() + // 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 } - } - - // Send survey shown event - sendSurveyShownEvent(originalSurvey, resolvedLanguage) - // Clear up event-activated surveys if this survey has events - if (hasEvents(originalSurvey)) { - synchronized(eventActivationLock) { - eventActivatedSurveys.remove(originalSurvey.id) + 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 } - } else { - config.logger.log("Received a show event for a non-matching survey: ${shownSurvey.id} vs ${originalSurvey.id}") - } + event?.let(::captureSurveyEvent) + + // Show next survey in queue after a short delay + Thread { + Thread.sleep(NEXT_SURVEY_TRANSITION_DELAY_MS) + showNextSurvey() + }.start() + } + + // Call the delegate to render the survey + 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) } + } - val onSurveyResponse: OnPostHogSurveyResponse = onSurveyResponse@{ responseSurvey, questionIndex, response -> + 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) - var responsesToSend: Map? = null - - synchronized(activeSurveyLock) { - // Validate that this survey matches the currently active survey - val currentActiveSurvey = activeSurvey - if (currentActiveSurvey == null || responseSurvey.id != currentActiveSurvey.id) { - config.logger.log("Received a response event for a non-active survey") - return@onSurveyResponse null - } + 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 + } - // 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) + if (responseContext.resetGeneration != config.surveysConfig.resetGeneration) return@onSurveyResponse null - // Send completion event if survey is finished - if (activeSurveyCompleted) { - responsesToSend = currentSurveyResponses.toMap() + // Send completion event if survey is finished + if (shouldSendResponse(originalSurvey, activeSurveyCompleted)) { + surveySentEvent(responseContext, currentSurveyResponses.toMap(), nextQuestion.isSurveyCompleted, distinctId) + } else { + null + } } - } - - responsesToSend?.let { sendSurveySentEvent(originalSurvey, it, resolvedLanguage, resolvedQuestionTranslations) } + event?.let(::captureSurveyEvent) nextQuestion } - val onSurveyClosed: OnPostHogSurveyClosed = onSurveyClosed@{ _ -> - var surveyResponses: Map = emptyMap() - var wasSurveyCompleted = false - - synchronized(activeSurveyLock) { - // Validate that this survey matches the currently active survey - val currentActiveSurvey = activeSurvey - if (currentActiveSurvey == null || originalSurvey.id != currentActiveSurvey.id) { - config.logger.log("[Surveys] Received a close event for a non-active survey") - return@onSurveyClosed - } - - // Get current active survey and completion state - surveyResponses = currentSurveyResponses.toMap() - wasSurveyCompleted = activeSurveyCompleted + 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}") + } + } - activeSurvey = null + private fun activateSurvey( + survey: Survey, + progress: SurveyProgress, + persisted: Boolean, + ) { + synchronized(activeSurveyLock) { + if (activeSurvey == null) { + activeSurvey = survey activeSurveyCompleted = false currentSurveyResponses.clear() + currentSurveyResponses.putAll(progress.responses.mapValues { checkNotNull(it.value.toResponse()) }) + activeSubmissionId = progress.submissionId + activeProgressWasPersisted = persisted } + } + } - // Send survey dismissed event if survey was not completed - if (!wasSurveyCompleted) { - sendSurveyDismissedEvent(originalSurvey, surveyResponses, resolvedLanguage, resolvedQuestionTranslations) - } + private fun isActiveAttempt( + surveyId: String, + submissionId: String, + ): Boolean = activeSurvey?.id == surveyId && activeSubmissionId == submissionId - // Mark survey as seen - setSurveySeen(originalSurvey) + private fun canCloseAttempt(context: SurveyResponseContext): Boolean = + (context.resetGeneration == config.surveysConfig.resetGeneration && activeSurveyCompleted) || canRecordResponse(context) - // Show next survey in queue after a short delay - Thread { - Thread.sleep(NEXT_SURVEY_TRANSITION_DELAY_MS) - showNextSurvey() - }.start() + 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 (hasExpectedProgress(context, activeProgressWasPersisted) && context.resetGeneration == config.surveysConfig.resetGeneration + ) { + return true } + if (isActiveAttempt(context.survey.id, context.submissionId)) clearActiveSurvey() + return false + } - // Call the delegate to render the survey - getSurveysDelegate().renderSurvey(displaySurvey, onSurveyShown, onSurveyResponse, onSurveyClosed) + 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, + ), + ) + val persisted = hasProgress(context.survey) + if (context.resetGeneration == config.surveysConfig.resetGeneration) activeProgressWasPersisted = persisted + } } /** * 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() + } } /** @@ -469,11 +619,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 +629,7 @@ public class PostHogSurveysIntegration( branching.responseValues, ) ?: PostHogNextSurveyQuestion( questionIndex = nextQuestionIndex, - isSurveyCompleted = nextQuestionIndex == originalSurvey.questions.size - 1, + isSurveyCompleted = currentIndex == originalSurvey.questions.size - 1, ) } } @@ -578,6 +724,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 +742,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( @@ -654,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() @@ -666,8 +819,9 @@ public class PostHogSurveysIntegration( * Returns true if there's no active survey currently being displayed. */ internal fun canShowNextSurvey(): Boolean { + synchronizeReset() return synchronized(activeSurveyLock) { - activeSurvey == null + config.cachePreferences?.isAvailable() != false && activeSurvey == null } } @@ -716,80 +870,105 @@ public class PostHogSurveysIntegration( private fun clearActiveSurvey() { synchronized(activeSurveyLock) { activeSurvey = null + activeSubmissionId = null + activeProgressWasPersisted = false activeSurveyCompleted = false currentSurveyResponses.clear() } } + 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?, + val questionTranslations: List?, + val submissionId: String = UUID.randomUUID().toString(), + val questionText: MutableMap = mutableMapOf(), + var responseLanguage: String? = language, + val resetGeneration: Long, + val wasRestored: Boolean, + ) + + private fun shouldSendResponse( + survey: Survey, + isCompleted: Boolean, + ): Boolean = survey.enablePartialResponses == true || isCompleted + // Survey Event Methods /** * 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 * 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, + private fun surveySentEvent( + context: SurveyResponseContext, responses: Map, - language: String?, - questionTranslations: List?, - ) { + isCompleted: Boolean, + distinctId: String?, + ): SurveyEvent { val additionalProperties = - buildSurveyResponseProperties(survey, responses, questionTranslations) + + buildSurveyResponseProperties(context.survey, responses, context.questionTranslations, context.questionText) + 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, ), ) - sendSurveyEvent( - event = "survey sent", - survey = survey, - additionalProperties = additionalProperties, - language = language, - ) + setSurveySeen(context.survey) + return SurveyEvent("survey sent", surveyEventProperties(context.survey, additionalProperties, context.language), distinctId) } /** * Sends a "survey dismissed" event to PostHog instance */ - private fun sendSurveyDismissedEvent( - survey: Survey, + private fun surveyDismissedEvent( + context: SurveyResponseContext, responses: Map, - language: String?, - questionTranslations: List?, - ) { + distinctId: String?, + ): SurveyEvent { val additionalProperties = - buildSurveyResponseProperties(survey, responses, questionTranslations) + + buildSurveyResponseProperties(context.survey, responses, context.questionTranslations, context.questionText) + 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, - additionalProperties = additionalProperties, - language = language, + return SurveyEvent( + "survey dismissed", + surveyEventProperties( + context.survey, + additionalProperties, + if (responses.isEmpty()) context.language else context.responseLanguage, + ), + distinctId, ) } @@ -797,6 +976,7 @@ public class PostHogSurveysIntegration( survey: Survey, responses: Map, questionTranslations: List?, + questionText: Map, ): Map { val responsesProperties = responses.mapNotNull { (key, response) -> @@ -811,7 +991,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 = @@ -829,26 +1009,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() } /** @@ -939,7 +1119,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..3c534cc12 --- /dev/null +++ b/posthog-android/src/main/java/com/posthog/android/surveys/SurveyProgressStore.kt @@ -0,0 +1,127 @@ +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 = config.surveysConfig + + 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 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.questionText.keys.all { it in survey.questions.indices } + ) { + return@synchronized progress.takeIf { generation == lock.resetGeneration } + } + } catch (_: Exception) { + config.logger.log("Discarding invalid saved survey progress") + } + if (generation == lock.resetGeneration) remove(survey) + null + } + + fun save( + 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 + 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() + 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)) + 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/PostHogSurveysDelegateLifecycleTest.kt b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysDelegateLifecycleTest.kt new file mode 100644 index 000000000..8f0f51fef --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysDelegateLifecycleTest.kt @@ -0,0 +1,134 @@ +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 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 { + 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 boundSession: PostHogSurveyPresentationSession? = null + var renderedSession: PostHogSurveyPresentationSession? = null + var cleanedSession: PostHogSurveyPresentationSession? = null + val delegate = + object : PostHogSurveysResetAwareDelegate, PostHogSurveysDelegate by PostHogSurveysDefaultDelegate() { + override fun renderSurvey( + presentation: PostHogSurveyPresentation, + onSurveyShown: OnPostHogSurveyShown, + onSurveyResponse: OnPostHogSurveyResponse, + onSurveyClosed: OnPostHogSurveyClosed, + ) { + generation = presentation.resetGeneration + renderedSession = presentation.session + } + + override fun bindSurveySession(session: PostHogSurveyPresentationSession) { + boundSession = session + } + + override fun cleanupSurveys(session: PostHogSurveyPresentationSession) { + cleanedSession = session + } + + override fun onSurveyReset( + resetGeneration: Long, + config: PostHogSurveysConfig, + ) = Unit + } + integration.install(PostHogFake()) + config.surveysConfig.surveysDelegate = delegate + try { + integration.showSurvey(survey) + assertEquals(0L, generation) + 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 + boundSession = null + integration.showSurvey(survey) + assertNull(generation) + 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 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( + survey: PostHogDisplaySurvey, + onSurveyShown: OnPostHogSurveyShown, + onSurveyResponse: OnPostHogSurveyResponse, + onSurveyClosed: OnPostHogSurveyClosed, + ) { + 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(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 af9b180a2..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 @@ -2,8 +2,14 @@ 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.internal.PostHogMemoryPreferences +import com.posthog.internal.PostHogNetworkStatus +import com.posthog.internal.PostHogPreferences import com.posthog.internal.PostHogSerializer import com.posthog.surveys.OnPostHogSurveyClosed import com.posthog.surveys.OnPostHogSurveyResponse @@ -14,9 +20,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 @@ -25,30 +35,13 @@ 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 - - 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() {} - } - - private fun createIntegration(delegate: RecordingDelegate): Pair { + private fun createIntegration( + delegate: RecordingSurveyDelegate, + preferences: PostHogPreferences = PostHogMemoryPreferences(), + ): Pair { val config = PostHogConfig("test-api-key").apply { + cachePreferences = preferences surveys = true surveysConfig.surveysDelegate = delegate } @@ -111,9 +104,361 @@ 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 `clearing storage before a restored survey is shown rejects captured answers`() { + val preferences = PostHogMemoryPreferences() + val delegate = RecordingSurveyDelegate() + 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 = RecordingSurveyDelegate() + 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 = RecordingSurveyDelegate() + 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 `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 = RecordingSurveyDelegate() + 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 = RecordingSurveyDelegate() + 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, 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 { + 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")) + 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() + } + } + + @Test + fun `showing a survey alone does not create resumable progress`() { + val preferences = PostHogMemoryPreferences() + val delegate = RecordingSurveyDelegate() + 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)) { + val delegate = RecordingSurveyDelegate() + 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 = RecordingSurveyDelegate() + 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) + 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")) + 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 = RecordingSurveyDelegate() + 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")) + 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() + } + } + @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") @@ -155,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() @@ -195,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") @@ -232,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") @@ -264,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-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..f2fe4120c --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/surveys/SurveyProgressStoreTest.kt @@ -0,0 +1,207 @@ +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 + +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"), + valid.replace("\"questionText\":{}", "\"questionText\":null"), + )) { + preferences.setValue(PostHogPreferences.SURVEY_PROGRESS, mapOf("survey/1" to invalid)) + assertNull(store.load(survey)) + assertEquals(emptyMap(), preferences.getValue(PostHogPreferences.SURVEY_PROGRESS)) + } + } + + @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)) + 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 ec32a74d9..c48a8d842 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; @@ -1806,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; } @@ -1868,6 +1890,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 @@ -1886,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; @@ -1912,6 +1942,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 +1953,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; @@ -1930,13 +1963,16 @@ public final class com/posthog/surveys/Survey { 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; 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/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index d325ec780..02178ea73 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 @@ -339,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.") } @@ -1933,7 +1933,22 @@ 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) { + 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()) + } remoteConfig?.clear() featureFlagsCalled.clear() lastScreenName = null 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/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/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/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/main/java/com/posthog/surveys/Survey.kt b/posthog/src/main/java/com/posthog/surveys/Survey.kt index 0759f7f28..43ab70229 100644 --- a/posthog/src/main/java/com/posthog/surveys/Survey.kt +++ b/posthog/src/main/java/com/posthog/surveys/Survey.kt @@ -29,4 +29,87 @@ public data class Survey( val endDate: Date?, val schedule: SurveySchedule?, 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/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 5f8ad4f8e..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 @@ -2477,6 +2486,49 @@ 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 + 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() + http.shutdown() + } + } + @Test fun `reset reloads flags as anon user`() { val http = mockHttp() 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..2192723bc --- /dev/null +++ b/posthog/src/test/java/com/posthog/surveys/SurveyBinaryCompatibilityTest.kt @@ -0,0 +1,130 @@ +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(), + "Description", null, "linked", "targeting", "internal", null, null, 7, + Date( + 101, + ), + Date(202), Date(303), null, emptyMap(), + ) + + @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(), + 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) + val defaultCopy = + PostHogDisplaySurvey::class.java.getDeclaredMethod( + "copy\$default", + PostHogDisplaySurvey::class.java, + *types, + Int::class.javaPrimitiveType, + Any::class.java, + ) + 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 + 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( + 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 = + 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.copy(translations = null), withDefaults) + } + + @Test + fun `legacy copy and Kotlin default copy preserve partial responses`() { + val survey = + 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) + 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, + *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) + } +} 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()); + } +}