Skip to content

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

Draft
leonhardprinz wants to merge 14 commits into
mainfrom
feat/android-surveys-compose-ui
Draft

feat(surveys): add posthog-android-surveys-compose default UI module#541
leonhardprinz wants to merge 14 commits into
mainfrom
feat/android-surveys-compose-ui

Conversation

@leonhardprinz
Copy link
Copy Markdown

@leonhardprinz leonhardprinz commented May 29, 2026

Context

Closes the long-standing gap in #102 (open since Feb 2024): the Android SDK's default surveys delegate is a stub that only logs. Customers using surveys on Android have to implement their own UI, which is a significant lift.

Driven by large customer (20+ global apps), who are blocked rolling out post-print NPS surveys on Android. iOS shipped a complete SwiftUI implementation in PostHog/posthog-ios#320 — this PR ports the same pattern to Kotlin/Compose as an opt-in module so non-Compose apps don't pay for it.

Disclosure: I'm a TAM, not an engineer. This is vibe-coded with PostHog Code using iOS PR #320 as the port-from blueprint. Please push back hard on any architectural calls that conflict with SDK conventions.

What's in this PR

  • New module :posthog-android-surveys-compose — Material 3 ModalBottomSheet + Jetpack Compose
  • All seven question/screen types ported from iOS PR fix(server): Evaluate properly when flag dependency should be false #320: open text, single choice, multiple choice, number rating (NPS), emoji rating, link, and the thank-you / confirmation screen — each in its own component file with default + themed @Preview composables
  • Multi-question surveys — sheet advances one question at a time; the thank-you screen appears when the customer has enabled displayThankYouMessage, otherwise the sheet dismisses immediately
  • Theming sourced from PostHogDisplaySurveyAppearance — background, submit button, text, border, rating-button, placeholder, and thank-you copy colors all pull through, plus a faithful port of the iOS hex / CSS-color name parser (140-entry table)
  • Lifecycle callbacks wiredonSurveyShown / onSurveyResponse / onSurveyClosed fire at the right moments so the SDK's survey shown / survey sent / survey dismissed events fire correctly. The delegate never calls PostHog.capture directly.
  • ComposeView injected into the foreground Activity's android.R.id.content via an ActivityLifecycleCallbacks-backed ActivityProvider — works on any Activity type (no FragmentActivity requirement)
  • EmojiRating shapes are 1:1 ports of iOS Resources.swift SVG paths — same normalised coordinate space, same fill mode, same y-translation. Unicode emoji was deliberately rejected because OEM rendering varies dramatically across Android devices.
  • README.md + ARCHITECTURE.md — one-line integration, supported question types, known gaps; full rationale for the separate module, ComposeView injection, state ownership, appearance resolution, and the EmojiRating path choice
  • Sample app wiredTrigger test survey button capturing show_test_survey, delegate set in MyApp.kt

What's NOT in this PR (planned follow-ups)

  • Server-driven branching logic across multi-question surveys (sheet currently advances naïvely currentQuestionIndex + 1)
  • Event dispatch directly from the delegate — today the host SDK fires events via the lifecycle callbacks our delegate invokes
  • HTML descriptions (question + thank-you) — rendered as plain text only, matching iOS
  • Dark-mode polish
  • Compose UI tests + accessibility audit

Architecture decisions

  • Separate Gradle module (sibling to :posthog-android, no changes to :posthog or :posthog-android) — non-Compose apps don't pay APK-size cost. Mirrors the AndroidX optional-Compose-module pattern.
  • Independent versioning starting at 1.0.0-alpha01 — lets this module iterate through alphas without dragging core SDK versions. Module added to binary-compatibility-validator ignoredProjects during alpha.
  • Compose BOM 2024.12.01 (matches the sample app's existing pin), Material 3 1.3.1, kotlin.plugin.compose for K2-era Compose.
  • rememberModalBottomSheetState(confirmValueChange = { it != SheetValue.Hidden }) for the "X-button-only dismissal" — matches iOS interactiveDismissDisabled() semantics with one line instead of a custom SheetState.
  • Empty consumer-rules.pro + -keep PostHogSurveysComposeDelegate — minimal consumer-side ProGuard surface; populate if downstream minified builds ever surface issues.
  • @file-level ktlint_function_naming_ignore_when_annotated_with = Composable in module .editorconfig so PascalCase @Composable functions don't trip the standard:function-naming rule.

For Ioannis (next steps)

  • 7 question types ported from iOS PR fix(server): Evaluate properly when flag dependency should be false #320 matching iOS visual styling, asset approach (5-emoji rating SVG paths transcribed directly from Resources.swift), and SurveyAppearance property names so customer appearance config in the PostHog UI works on Android without changes
  • Each question type is a separate commit so feel free to split this into smaller PRs if review is too large to handle in one pass (commit graph: refactor: align SurveyAppearancefeat: OpenTextfeat: SingleChoice + ChoiceOptionsfeat: MultipleChoicefeat: EmojiRatingfeat: LinkQuestionfeat: ConfirmationScreenfeat: wire dispatch in SurveySheetdocs: README + ARCHITECTURE)
  • No Compose UI tests added — leaving alongside emulator testing per your call
  • Still needed (happy to do as follow-up PRs once you confirm architecture):
    • Event dispatch in delegate (survey shown / survey sent / survey dismissed)
    • Branching logic
    • Response submission to PostHog API

Local verification

This is what I ran in my dev env (no Android emulator available there, so the visual checks are deferred to a real device run):

  • ./gradlew spotlessCheck — passes
  • ./gradlew detekt — passes
  • ./gradlew :posthog:build — passes (confirming no regression in core)
  • Android-specific tasks (:posthog-android-surveys-compose:assembleDebug, :posthog-android-surveys-compose:lint, full ./gradlew build) deferred to CI here because my dev env doesn't have the Android SDK

Manual test plan (for me to run on an emulator before un-drafting)

  1. Build & install the sample app on a Pixel emulator (API 34)
  2. In a PostHog dev project, create a Popover survey with an NPS question, target it on show_test_survey, set custom background/submit colors so theming is visible
  3. Tap "Trigger test survey" in the sample app
  4. Verify: sheet slides up, scale renders 0–10, tap-to-select highlights with submit color, tap same number deselects, submit fires survey sent with the right rating, X-button fires survey dismissed, swipe-down is ignored
  5. Repeat with a multi-question survey covering each new type (open text, single choice with "other", multi choice with "other", emoji rating, link) and the thank-you screen enabled — verify advancement, validation, and the confirmation screen render

Reviewers

cc @marandaneto @ioannisj @lucasheriques

Will also link the Feb 2026 internal Slack thread on Android surveys in a comment.

Refs #102

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
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.

1 participant