Skip to content

feat(surveys): add posthog-android-surveys-compose default UI module#541

Merged
ioannisj merged 38 commits into
mainfrom
feat/android-surveys-compose-ui
Jun 12, 2026
Merged

feat(surveys): add posthog-android-surveys-compose default UI module#541
ioannisj merged 38 commits into
mainfrom
feat/android-surveys-compose-ui

Conversation

@leonhardprinz

@leonhardprinz leonhardprinz commented May 29, 2026

Copy link
Copy Markdown
Contributor

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 set surveys = 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

  • Opt-in via auto-discovery — when no delegate is set, the core SDK reflectively loads PostHogSurveysComposeDelegate from the classpath. Explicit wiring (config.surveysConfig.surveysDelegate = …) is still supported.
  • Presentation — a transparent, undimmed ComponentDialog (its own window) hosting a Material 3 ModalBottomSheet, shown above the foreground activity (found via an ActivityLifecycleCallbacks-backed ActivityProvider). Works over both XML and Compose hosts, on any Activity type, without interfering with host navigation.
  • Dismissal — X-button only; swipe-down, touch-outside, and back are ignored.
  • Events — the delegate invokes onSurveyShown / onSurveyResponse / onSurveyClosed; the host SDK emits survey shown / survey sent / survey dismissed. The delegate never calls PostHog.capture directly.

Supported

  • Question / screen types: open text, single choice, multiple choice, number rating (NPS 0–10, 1–5, 1–7), emoji rating (3- and 5-face plus thumbs up/down), link, and the thank-you / confirmation screen.
  • Multi-question surveys with server-driven branching — the host SDK returns the next-question index after each answer; the sheet navigates to it (a null return ends the survey).
  • Configured popup delay (surveyPopupDelaySeconds) before the sheet appears.
  • Theming from 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

  • HTML question / thank-you descriptions (rendered as plain text, matching iOS).
  • Dark-mode polish (defaults tuned for light backgrounds).
  • Compose UI tests + accessibility audit (verified via emulator + @Preview composables for now).

Notes

  • Pre-1.0 (0.x), versioned independently from the core SDK and excluded from binary-compatibility validation during the alpha.
  • USAGE.md / ARCHITECTURE.md cover integration and the rationale for the separate module, the ComponentDialog presentation, appearance resolution, and the EmojiRating SVG-path approach.

Companion documentation PRs

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
@leonhardprinz leonhardprinz requested a review from ioannisj May 29, 2026 20:59
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

posthog-android Compliance Report

Date: 2026-06-11 23:31:11 UTC
Duration: 119773ms

⚠️ Some Tests Failed

38/45 tests passed, 7 failed


Capture Tests

⚠️ 28/29 tests passed, 1 failed

View Details
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

⚠️ 10/16 tests passed, 6 failed

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']

@turnipdabeets turnipdabeets left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this is really solid, a few inline notes below, mostly edge cases + nit. Nothing blocking that I can see.

Also the description looks like it might be a bit out of date vs what shipped — worth updating.

Comment thread posthog-android-surveys-compose/gradle.lockfile
@turnipdabeets turnipdabeets requested a review from a team June 8, 2026 14:24
@turnipdabeets

Copy link
Copy Markdown
Contributor

It might also be good to consider: #502

@ioannisj

ioannisj commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

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

@ioannisj ioannisj requested review from a team and turnipdabeets and removed request for a team June 9, 2026 10:52
Comment thread .changeset/android-surveys-compose-ui.md
Comment thread posthog-android-surveys-compose/USAGE.md Outdated
Comment thread posthog-android-surveys-compose/build.gradle.kts Outdated

@turnipdabeets turnipdabeets left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some nits before merging

@ioannisj

ioannisj commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Will get this merged first thing tomorrow so I can monitor the release. Thanx for the review all

@ioannisj ioannisj merged commit 8632f77 into main Jun 12, 2026
15 checks passed
@ioannisj ioannisj deleted the feat/android-surveys-compose-ui branch June 12, 2026 04:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PostHog Survey on Android

4 participants