feat(surveys): add posthog-android-surveys-compose default UI module#541
Conversation
Adds a new optional Gradle module `posthog-android-surveys-compose` that ports the iOS SwiftUI survey UI (PostHog/posthog-ios#320) to Jetpack Compose. Closes the long-standing gap in #102: the SDK's default `PostHogSurveysDelegate` only logs, so customers have had to ship their own UI. Driven by Fujifilm (20+ INSTAX apps) who are blocked rolling out NPS surveys on Android. Disclosure: vibe-coded by a TAM using PostHog Code with the iOS implementation as the port-from blueprint. MVP scope (this commit): - NPS / Number Rating question type only (0–10, 1–5, 1–7 scales) - Material 3 ModalBottomSheet container with `confirmValueChange` intercept so only the X button dismisses (matches iOS `interactiveDismissDisabled` semantics) - Theming sourced from PostHogDisplaySurveyAppearance — faithful port of the iOS hex/CSS-name color parser (140-entry color table) - onSurveyShown / onSurveyResponse / onSurveyClosed callbacks wired so the core SDK fires `survey shown` / `survey sent` / `survey dismissed` events correctly - ComposeView injected into the foreground Activity's android.R.id.content via an ActivityLifecycleCallbacks-backed ActivityProvider — works on any Activity type - @Preview composables (default + themed) - Sample app wired with `Trigger test survey` button capturing `show_test_survey` Out of MVP (planned follow-ups): open-text/choice/link questions, emoji rating, thank-you screen, multi-question branching coverage, dark-mode polish, Compose UI tests, accessibility audit. Architecture: - Separate Gradle module — non-Compose customers don't pay APK-size cost. Mirrors how AndroidX ships optional Compose modules. - Independent versioning starting at 1.0.0-alpha01 — lets the module iterate through alphas without dragging core SDK versions. - Compose BOM 2024.12.01 (same as the sample app). - No changes to `:posthog` or `:posthog-android` modules; no public API changes; module excluded from binary-compatibility-validator during alpha. Refs: #102 Generated-By: PostHog Code Task-Id: b89cbdd1-9f16-4fa4-b3d0-3f9b268b0c2b
Android Lint's AutoboxingStateCreation rule (warningsAsErrors enabled) flagged mutableStateOf(0) for the question index. Switching to mutableIntStateOf avoids the boxing and clears the lint failure. Generated-By: PostHog Code Task-Id: b89cbdd1-9f16-4fa4-b3d0-3f9b268b0c2b
The previous commit removed the mutableStateOf import while switching the question index to mutableIntStateOf, but the nullable rating state at SurveySheet.kt:189 (mutableStateOf<Int?>(null)) still needs the boxed variant since mutableIntStateOf does not support nullable Int. Generated-By: PostHog Code Task-Id: b89cbdd1-9f16-4fa4-b3d0-3f9b268b0c2b
AGP aborts the build when lint is configured with a baseline file that doesn't exist on disk (it generates one and exits to force a check-in). Matching the empty placeholder convention used by posthog-android/. Generated-By: PostHog Code Task-Id: b89cbdd1-9f16-4fa4-b3d0-3f9b268b0c2b
Adds the appearance fields needed by the remaining question types and the thank-you screen: placeholder text + color, derived questionTextColor and placeholderTextColor, and the displayThankYouMessage / thankYouMessage* fields. Defaults track iOS's getAppearanceWithDefaults — empty fallbacks match "Thank you for your feedback!", "Close", "Start typing...", etc. QuestionHeader now reads questionTextColor (derived from background's contrasting color) rather than textColor, mirroring iOS's backgroundColor.getContrastingTextColor() behavior. Generated-By: PostHog Code Task-Id: b89cbdd1-9f16-4fa4-b3d0-3f9b268b0c2b
Multi-line text input for open-ended questions. Visual port of iOS OpenTextQuestionView: 150 dp bordered card with a placeholder rendered behind the BasicTextField while empty. Pulls placeholder text and color from the resolved survey appearance. Component is stateless — caller hoists the text value and onValueChange callback. Two @previews show default and themed appearances side-by-side. Generated-By: PostHog Code Task-Id: b89cbdd1-9f16-4fa4-b3d0-3f9b268b0c2b
Single-selection choice list. Visual port of iOS SingleChoiceQuestionView backed by a shared ChoiceOptions composable that also serves the multiple choice port — each option is a rounded bordered button that turns bold and gets a checkmark decoration when selected. Open-choice handling: when question.hasOpenChoice is true the last entry becomes an inline-editable "other" option that opens a BasicTextField once selected. State (selectedChoice and openChoiceInput) is hoisted so the dispatcher can drive submit validation. Two @previews demonstrate default and themed appearances. Generated-By: PostHog Code Task-Id: b89cbdd1-9f16-4fa4-b3d0-3f9b268b0c2b
Multi-selection choice list. Delegates to the shared ChoiceOptions composable with multi-selection semantics, mirroring iOS MultipleChoiceQuestionView. State is hoisted as Set<String> plus an openChoiceInput String for the "other" option. Two @previews cover default and themed appearances. Generated-By: PostHog Code Task-Id: b89cbdd1-9f16-4fa4-b3d0-3f9b268b0c2b
Emoji rating control rendered with 3 (Dissatisfied/Neutral/Satisfied) or 5 face shapes (VeryDissatisfied → VerySatisfied). All five SVG paths are 1:1 ports of the Shape implementations in iOS Resources.swift — same normalised coordinate space, same y-translation, same fill-mode for the ring outline. We deliberately don't fall back to Unicode emoji: OEM emoji rendering varies dramatically across Android devices and would break visual parity with iOS. The trade-off is ~250 lines of path-construction code; a shared addFaceCircle helper plus dot-eye helpers cuts the per-emoji body to its unique mouth/eye sub-paths. Two @previews cover the default (5-emoji NPS-style) and themed (3-emoji pastel) appearances. Generated-By: PostHog Code Task-Id: b89cbdd1-9f16-4fa4-b3d0-3f9b268b0c2b
Body content for link questions plus an openLink helper that fires an
ACTION_VIEW intent on submit. Visual port of iOS LinkQuestionView, which
renders no body content between the header and the submit button. We
additionally surface the destination URL as description-coloured text so
users have context for the action — the iOS-mirrored response payload
("link clicked") is unaffected.
ACTION_VIEW failures (no browser installed) are swallowed; the survey
flow continues either way. Two @previews cover default and themed
appearances.
Generated-By: PostHog Code
Task-Id: b89cbdd1-9f16-4fa4-b3d0-3f9b268b0c2b
Thank-you screen displayed after the last question of a survey when the customer has displayThankYouMessage enabled in their PostHog appearance config. Visual port of iOS ConfirmationMessage: bold header, optional plain-text description (HTML deferred to a follow-up — iOS skips it too), and a close button. Two @previews show default and themed appearances (the themed one includes a description string). Generated-By: PostHog Code Task-Id: b89cbdd1-9f16-4fa4-b3d0-3f9b268b0c2b
Replaces the unsupported-type placeholder with full dispatch over every PostHogDisplaySurveyQuestion subtype. Each dispatcher owns its per- question state (text, rating, choice selection, open-choice input) keyed by question.id so navigating to a new question resets it. When the host SDK reports completion the sheet either shows the ConfirmationScreen (if displayThankYouMessage is set) or dismisses. Otherwise it naïvely advances to currentQuestionIndex + 1 — server- driven branching stays a tracked follow-up. LinkQuestion submission opens question.link via the openLink helper before firing the "link clicked" response. Generated-By: PostHog Code Task-Id: b89cbdd1-9f16-4fa4-b3d0-3f9b268b0c2b
README documents the one-line integration, the seven supported question types, and the known gaps (branching, event dispatch, HTML descriptions, Compose UI tests, dark mode). ARCHITECTURE captures the rationale behind the separate module, the ActivityProvider + ComposeView injection approach, state ownership in SurveySheet, appearance resolution, the EmojiRating SVG-path choice, and the deliberately tight responsibility boundary. CHANGELOG moves the new question types from "out of scope" into the shipped feature list. PostHogSurveysComposeDelegate KDoc updates the "MVP scope" blurb to reflect what's now covered. Generated-By: PostHog Code Task-Id: b89cbdd1-9f16-4fa4-b3d0-3f9b268b0c2b
…ierTo Compose UI's `Path.quadraticBezierTo` was deprecated in favor of `quadraticTo` (for consistency with `cubicTo`). The new name compiles cleanly under `-Werror`. Generated-By: PostHog Code Task-Id: b89cbdd1-9f16-4fa4-b3d0-3f9b268b0c2b
posthog-android Compliance ReportDate: 2026-06-11 23:31:11 UTC
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 383ms |
| Format Validation.Event Has Uuid | ✅ | 28ms |
| Format Validation.Event Has Lib Properties | ✅ | 28ms |
| Format Validation.Distinct Id Is String | ✅ | 27ms |
| Format Validation.Token Is Present | ✅ | 28ms |
| Format Validation.Custom Properties Preserved | ✅ | 21ms |
| Format Validation.Event Has Timestamp | ✅ | 27ms |
| Retry Behavior.Retries On 503 | ✅ | 7026ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 4023ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 4024ms |
| Retry Behavior.Respects Retry After Header | ✅ | 7026ms |
| Retry Behavior.Implements Backoff | ✅ | 17020ms |
| Retry Behavior.Retries On 500 | ✅ | 7019ms |
| Retry Behavior.Retries On 502 | ✅ | 7019ms |
| Retry Behavior.Retries On 504 | ✅ | 7016ms |
| Retry Behavior.Max Retries Respected | ✅ | 17021ms |
| Deduplication.Generates Unique Uuids | ✅ | 40ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 7016ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 12030ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 7016ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 36ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 26ms |
| Compression.Sends Gzip When Enabled | ✅ | 21ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 19ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 13ms |
| Batch Format.Multiple Events Batched Together | ✅ | 32ms |
| Error Handling.Does Not Retry On 403 | ✅ | 4021ms |
| Error Handling.Does Not Retry On 413 | ❌ | 4014ms |
| Error Handling.Retries On 408 | ✅ | 5032ms |
Failures
error_handling.does_not_retry_on_413
Expected 1 requests, got 2
Feature_Flags Tests
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ❌ | 34ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 18ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 24ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 26ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 21ms |
| Request Payload.Groups Round Trip | ✅ | 25ms |
| Request Payload.Groups Default To Empty Object | ❌ | 26ms |
| Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It | ❌ | 23ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ❌ | 35ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ❌ | 27ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ❌ | 33ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 11ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 23ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 2027ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 26ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 33ms |
Failures
request_payload.request_with_person_properties_device_id
Expected distinct_id='test_user_123', got '019eb906-7521-7926-817c-dbb7ac9eea16'
request_payload.groups_default_to_empty_object
Field 'groups' not found in /flags request body at path 'groups'. Available keys: ['$anon_distinct_id', '$device_id', 'api_key', 'distinct_id', 'timezone', 'person_properties']
request_payload.person_properties_distinct_id_auto_populated_when_caller_omits_it
Field 'distinct_id' not found in /flags request body at path 'person_properties.distinct_id'. Available keys: ['$lib', '$lib_version', 'email']
request_payload.disable_geoip_false_propagates_as_geoip_disable_false
Field 'geoip_disable' not found in /flags request body at path 'geoip_disable'. Available keys: ['$anon_distinct_id', '$device_id', 'api_key', 'distinct_id', 'timezone', 'person_properties']
request_payload.disable_geoip_omitted_defaults_to_false
Field 'geoip_disable' not found in /flags request body at path 'geoip_disable'. Available keys: ['$anon_distinct_id', '$device_id', 'api_key', 'distinct_id', 'timezone', 'person_properties']
request_payload.flag_keys_to_evaluate_contains_only_requested_key
Field 'flag_keys_to_evaluate' not found in /flags request body at path 'flag_keys_to_evaluate'. Available keys: ['$anon_distinct_id', '$device_id', 'api_key', 'distinct_id', 'timezone', 'person_properties']
|
It might also be good to consider: #502 |
I think this is a substantial feature for both this implementation and iOS, since they are both implemented as bottom sheets, and would be a big scope creep. To be honest, I don't think anchoring makes a lot of sense on mobile either? I see mainly two display methods that would make sense to me: center modal and bottom-sheet. I don't feel top and side anchoring are ideal or make sense for mobile screens? Happy for a push back but I would keep the scope of this as iOS parity for first version, then maybe work on positioning for both iOS and Android together |
turnipdabeets
left a comment
There was a problem hiding this comment.
Left some nits before merging
|
Will get this merged first thing tomorrow so I can monitor the release. Thanx for the review all |
Summary
Closes #102, #448
Adds
:posthog-android-surveys-compose, an optional Jetpack Compose UI that renders PostHog surveys on Android. Until now the default surveys delegate only logged, so Android customers had to build their own survey UI. With this module on the classpath the core SDK auto-discovers it and renders surveys with no extra wiring — customers just add the dependency and setsurveys = true. It's a separate module so apps that don't use surveys don't pay the APK-size cost. Ported from the iOS SwiftUI implementation.How it works
PostHogSurveysComposeDelegatefrom the classpath. Explicit wiring (config.surveysConfig.surveysDelegate = …) is still supported.ComponentDialog(its own window) hosting a Material 3ModalBottomSheet, shown above the foreground activity (found via anActivityLifecycleCallbacks-backedActivityProvider). Works over both XML and Compose hosts, on anyActivitytype, without interfering with host navigation.onSurveyShown/onSurveyResponse/onSurveyClosed; the host SDK emitssurvey shown/survey sent/survey dismissed. The delegate never callsPostHog.capturedirectly.Supported
nullreturn ends the survey).surveyPopupDelaySeconds) before the sheet appears.PostHogDisplaySurveyAppearance— background, submit button, text, border, rating-button, placeholder, input, and thank-you colors/copy, with sensible defaults and a hex / CSS-color-name parser ported from iOS.Not yet supported
@Previewcomposables for now).Notes
0.x), versioned independently from the core SDK and excluded from binary-compatibility validation during the alpha.USAGE.md/ARCHITECTURE.mdcover integration and the rationale for the separate module, theComponentDialogpresentation, appearance resolution, and the EmojiRating SVG-path approach.Companion documentation PRs