
Event-driven form and referral SDK with UI host, user identification, event tracking, referral link creation/redeeming, install-referrer matching, configurable API key and debug logging, themeable rendering.
This is the Compose Multiplatform SDK for WandKit.
This README covers:
wandkit-core: SDK configuration, identity, and event trackingwandkit-ui-compose: Compose host and UI for rendering formsWandKitConfig currently supports:
apiKey: your WandKit API keyisDebugLoggingEnabled: enables SDK debug loggingapiBaseUrl: overrides the API host (events, forms, referrals, feedback sessions); null uses productionfeedbackWebUrl: origin the feedback web app is served from, for pointing a build at a staging deployment (see Feedback)feedbackTheme: styling for the feedback web app (see Theming)screenshotReporting: turns a screenshot into a "Report a problem?" prompt (see Screenshot reporting)debugAttachmentsProvider: supplies extra files (logs, JSON dumps) uploaded alongside a screenshot report, for your team's eyes only (see Debug attachments)sessionReplay: records the last minute of frames, touches and events and attaches it to a screenshot report (see Session replay)Example:
WandKitConfig(
apiKey = "your_api_key",
isDebugLoggingEnabled = true,
)Add the SDK modules to your app:
implementation("com.flabbergast.wandkit:core:<version>")
implementation("com.flabbergast.wandkit:ui-compose:<version>")Configure WandKit must be called before calling other WandKit methods.
WandKit.configure(
config = WandKitConfig(
apiKey = "your_api_key",
isDebugLoggingEnabled = true,
),
context = applicationContext,
)WandKit.configure(
config = WandKitConfig(
apiKey = "your_api_key",
isDebugLoggingEnabled = true,
),
)If you have a known user id, identify the user before sending events:
WandKit.identify(userId = "user_123")You can also suggest a display name for the user:
WandKit.identify(userId = "user_123", displayName = "Jane")displayName is only a suggestion: it is shown on the user's feedback posts until they set their own name in the feedback UI, and re-identifying with a new name updates the suggestion but never overwrites a name the user has already set themselves. Passing null (the default) leaves any previously suggested name untouched.
Clear the identified user when needed:
WandKit.clearUser()Send events with a name and optional string properties:
WandKit.event(
name = "checkout_started",
properties = mapOf(
"plan" to "pro",
"entry_point" to "pricing_screen",
),
)You can also provide a custom event timestamp:
WandKit.event(
name = "signup_completed",
occurredAt = occurredAt,
)The SDK also supports creating and redeeming referral links and codes.
Identify the current user first, then create a referral for a campaign:
WandKit.identify(userId = "user_123")
val referral = WandKit.invite(
userId = "user_123",
campaign = "samplecampaign",
)
val referralUrl = referral?.urlWandKit.invite(...) returns ReferralInfo?. Use ReferralInfo.url as the shareable referral link.
You can also pass optional string properties:
val referral = WandKit.invite(
userId = "user_123",
campaign = "samplecampaign",
properties = mapOf(
"source" to "profile_screen",
),
)If you have a referral short path, you can fetch its metadata:
val referral = WandKit.getReferral(path = "abc123")WandKit.getReferral(...) returns GetReferralResponse?.
How far an inviter is toward their reward - what drives an in-app "3 of 5 friends joined" meter:
val progress = WandKit.getReferralProgress(
userId = "user_123",
campaign = "samplecampaign",
)
val joined = progress?.convertedCount
val goal = progress?.reward?.thresholdWandKit.getReferralProgress(...) returns ReferralProgress?, and null when the
campaign does not exist or this inviter has no referral yet - call
WandKit.invite(...) first.
convertedCount is what counts toward the reward: claims that went on to sign up.
claimedCount is the larger number of installs that merely entered the code.
Ask the backend which referral this install probably came from. Nothing is bound
by this - the returned code is meant to be offered back to the user to confirm or
replace, and redeemCode is what actually claims it:
val detection = WandKit.detectReferral()
val prefill = detection?.codeFingerprint accuracy decays quickly and the server-side match window is short, so
detection has to run early - long before the user has agreed to anything. Call this
right after configure:
WandKit.detectReferralOnFirstLaunchIfNeeded()It runs once per install, in the background, and persists the result. Read it back whenever your UI is ready:
val detection = WandKit.detectedReferralA transient failure does not count as an attempt, so the next launch retries - a dropped attempt costs an inviter a referral they earned. Retries are capped, and a permanent failure (a rejected key, an unreadable response) gives up at once, so an install that can never get an answer stops fingerprinting rather than re-sending on every launch.
redeemCode clears the detection on success, since the question it exists to
answer has been answered. If the user dismisses the prefilled code instead, clear
it yourself:
WandKit.clearDetectedReferral()Redeem a code, whether the user typed it or confirmed a detected one:
val match = WandKit.redeemCode(code = "INVITE_CODE")WandKit.redeemCode(...) returns ReferralMatch?. This is the only call that
creates a claim.
WandKit.installId is this device's install ID, the same one redeemCode claims
with, and it is stable across launches. Forward it to your own backend so it can
report referral conversions server-to-server:
myBackend.reportReferralAttribution(wandkitInstallId = WandKit.installId)You can also ask the SDK to read the install referral code from the platform provider and redeem it:
val match = WandKit.matchReferral()WandKit.matchReferral() returns ReferralMatch?.
Note that this predates detectReferral() and claims the referral immediately,
without asking the user. Prefer detection unless you specifically want the old
auto-claim behaviour.
Important: if you want to use the install referral code provider, you must pass context when calling WandKit.configure(...) on Android.
Without context, the SDK cannot create the Android install referrer client, so WandKit.getInstallReferralCode() and WandKit.matchReferral() will not be able to read the install referral code.
The SDK exposes the raw install referral code lookup as well:
val installReferralCode = WandKit.getInstallReferralCode()Required Android setup:
WandKit.configure(
config = WandKitConfig(
apiKey = "your_api_key",
isDebugLoggingEnabled = true,
),
context = applicationContext,
)On Android, this uses the Play Install Referrer API and extracts the referral_code query parameter from the install referrer payload.
WandKit.matchReferral() uses this provider internally. If a referral code is available, it redeems that code automatically.
On iOS, the install referral code provider is currently not implemented and always returns null. Because of that:
WandKit.getInstallReferralCode() returns null on iOSWandKit.matchReferral() also returns null on iOS unless install referral support is implemented there laterWandKit.presentFeedback() opens the feedback screen - the feed, the composer, and the roadmap - on top of whatever is currently visible:
WandKit.presentFeedback()It uses whichever user identify(...) last named. Without one the session is anonymous, which the backend makes read-only: the user can read the feed and the roadmap but not post, comment, or vote. Nothing errors and nothing is hidden - the web app simply renders without the write actions.
When a display name was suggested via identify(...), it appears on that user's posts and is editable by the user right in the feedback UI - editing it there does not change what your app passed to identify(...), and future calls to identify(...) will not overwrite a name the user has set for themselves.
Open straight on the new-post composer, optionally seeded with something the user already typed elsewhere in your app:
WandKit.presentFeedback(
startAt = WandKitFeedbackScreen.Composer(
WandKitComposerPrefill(description = "It crashes when…"),
),
)WandKitComposerPrefill can also pre-select the type and attach an image - say, a screenshot your own "report a bug" button captured:
WandKit.presentFeedback(
startAt = WandKitFeedbackScreen.Composer(
WandKitComposerPrefill(
type = WandKitPostType.BUG,
attachments = listOfNotNull(WandKitComposerAttachment.image(bitmap)),
),
),
)WandKitComposerAttachment.image(bitmap) is an Android helper: it JPEG-encodes the bitmap, downscaled to 2000 px on the long edge, and returns null only if the image can't be made to fit the SDK's payload cap. A type the project has disabled is ignored by the composer.
The screen itself is a WandKit-hosted web app rendered in a WebView, inside an Activity the SDK declares in its own manifest - there is nothing to add to yours. It changes when WandKit ships, not when your app does.
Android only. The iOS targets of this library log a warning and do nothing; use the native WandKit iOS SDK there.
For custom launching - a notification tap, a deep link handler, anywhere else that already holds a Context - build the Intent yourself instead of going through presentFeedback:
val intent = WandKit.feedbackIntent(context, startAt)
context.startActivity(intent)Style the feedback web app with feedbackTheme at configure time. It is serialized into the webview and applied as CSS custom properties.
WandKit.configure(
config = WandKitConfig(
apiKey = "your_api_key",
feedbackTheme = WandKitFeedbackTheme(
primaryColor = "#4F46E5",
backgroundColor = "#FFFFFF",
cornerRadius = 16.0,
fontFamily = "-apple-system, system-ui, sans-serif",
preferredColorScheme = WandKitColorSchemePreference.SYSTEM,
),
),
context = applicationContext,
)| Field | Type | Default | Notes |
|---|---|---|---|
primaryColor |
String? |
null |
Buttons, links, anything accented |
backgroundColor |
String? |
null |
Page background, also painted behind the webview so a slow first paint doesn't flash white |
cornerRadius |
Double? |
null |
|
fontFamily |
String? |
null |
A CSS font family the webview can resolve - a web-safe stack, or a font the hosted app bundles. A font that only exists inside your app will not resolve |
preferredColorScheme |
WandKitColorSchemePreference |
SYSTEM |
LIGHT and DARK also override the native chrome around the webview |
Colors are CSS hex strings (#RRGGBB, or #RRGGBBAA when translucent); the native chrome around the webview - window background, spinner - only honours the opaque form. Omit the theme entirely and the web app keeps its own defaults.
feedbackWebUrl overrides the origin the web app is served from, and apiBaseUrl the API host, for pointing a build at a staging deployment. Both take plain http:// origins for a local stack, which also needs android:usesCleartextTraffic="true" (or a network security config) in your manifest.
Opt in at configure time and a screenshot turns into a "Report a problem?" prompt:
WandKit.configure(
config = WandKitConfig(
apiKey = "your_api_key",
screenshotReporting = true,
),
context = applicationContext,
)When the user takes a screenshot, WandKitHost() shows a small card over your app with a thumbnail of what they just captured. The whole flow is native - there is no webview involved:
The report lands as a pending post (type=bug) in the project's triage inbox, same as anything else a user sends - the team publishes it from there. Another screenshot within two seconds of the last card is debounced rather than shown again.
Requirements:
Activity.ScreenCaptureCallback; earlier versions never see a card. The SDK's own manifest merges the android.permission.DETECT_SCREEN_CAPTURE normal permission into your app - there is no runtime prompt to wire up.identify(...)). Anonymous sessions cannot post, so without one the screenshot is skipped silently rather than shown to a user who could never submit it.WandKitHost() mounted on the screen, the same as for survey forms.configure called in Application.onCreate, so the SDK sees the first Activity and can register its capture callback as soon as it resumes.Supply a debugAttachmentsProvider and your own files - logs, JSON dumps, anything that helps triage - go up as attachments alongside the screenshot:
WandKitConfig(
apiKey = "your_api_key",
screenshotReporting = true,
debugAttachmentsProvider = WandKitDebugAttachmentsProvider {
listOf(WandKitDebugAttachment(File(logDir, "app.log").readBytes(), "app.log", "text/plain"))
},
)The provider runs when the user taps Send on the report card - and again if they tap "Try again", since a retry re-runs the whole submission. It has a 10 second budget; on timeout, or if it throws, the report still goes out, just without the files (logged, not surfaced to the user). Up to 5 files are kept, 10 MB each - extra or oversize files are dropped with a warning log rather than failing the report.
These files are dashboard-only: visible to your team in the post detail, never to the end user. Mind PII in whatever you attach. When a provider is configured, the composer shows a static disclosure line under the text field ("Diagnostic logs will be included to help us fix this.") so the user knows more than the screenshot is going up; there's no per-file toggle.
WandKitDebugAttachment.text(text, fileName) is a shortcut for a UTF-8 text file (text/plain; charset=utf-8) - handy for in-memory logs you don't want to write to disk first.
What it does not do:
Read the gallery. The image is read back from your app's own window via PixelCopy, not from Photos, so there is no permission prompt for it. SurfaceView content comes out black, and a window flagged FLAG_SECURE never triggers a callback at all.
Upload anything until Send. The image stays in memory while the card or text box is open, and only leaves the device once the user taps Send.
Compress or otherwise transform debug attachments. Whatever bytes the provider returns are what get uploaded.
Show debug attachments to the end user. They never appear in the composer beyond the static disclosure line, and never in the end-user-facing parts of the product - only in the dashboard.
Fail the report over a debug attachment problem. A slow provider, a thrown exception, or a failed upload for one file is logged and skipped; the screenshot report itself still goes out.
Prompt on Android 13 and below. There is no capture callback to hook there. If you want a screenshot-report entry point on older devices, wire your own trigger to the (webview) composer directly - this deep-links to the simplified web composer, which still accepts a type:
WandKit.presentFeedback(
startAt = WandKitFeedbackScreen.Composer(
WandKitComposerPrefill(type = WandKitPostType.BUG),
),
)Opt in alongside screenshot reporting and a report can carry a short replay of what led up to the screenshot:
WandKit.configure(
config = WandKitConfig(
apiKey = "...",
isDebugLoggingEnabled = false,
screenshotReporting = true,
sessionReplay = WandKitSessionReplayOptions(),
),
context = applicationContext,
)While the app is in the foreground the SDK keeps a ring buffer of roughly the last minute (windowSeconds = 60, capped at maxBytes = 4 MB): low-resolution JPEG frames of your window (720 px long edge, captured on touch and about once a second, identical frames skipped), touches, and every WandKit.event(...) call. When a screenshot brings up the report card, the buffer is frozen before the card appears, and the composer shows an "Include a replay of the last minute" switch (on by default; includeByDefault = false flips that). Send with it on and the recording is uploaded as a dashboard-only replay attachment, played back in the post detail. The file is wandkit-replay v1 NDJSON - the same format the iOS SDK writes.
Storage. By default (persistToDisk = true) the frames - the only heavy part - are written to cacheDir/wandkit-replay/ instead of being kept on the heap; memory only holds a few bytes of metadata per frame plus the touches and events. The frozen recording is streamed to a file there too and read back only at upload time. Files follow the buffer's lifetime: deleted when evicted, when the app goes to the background, after the report is sent or the card dismissed, and anything a previous process left behind is swept on the next launch. persistToDisk = false keeps everything in memory, like iOS.
Masking is applied to the recorded frame, never to your UI:
maskTextInputs (default on): EditTexts, and Compose text fields (read from the Compose semantics tree - requires WandKitHost() from ui-compose, which screenshot reporting needs anyway).maskWebViews: WebViews. maskAllText: every TextView and Compose text.Modifier.wandKitReplayMasked() (or a testTag containing wandkit-mask) in Compose; a View tag or contentDescription containing wandkit-mask.The recorder pauses while the app isn't in the foreground and while WandKit's own UI is up (the report card, surveys, the feedback screen, the feature-preview sheet), and records the pause as a gap the player skips. WandKit.sessionReplayStatus exposes frame count, buffered bytes and pause state for a debug indicator.
What it does not do:
Window.Callback wrapper, events only from your own WandKit.event(...) calls.Forms are event-driven.
When you call WandKit.event(...), the backend may return a form for that event. If your app has the Compose host mounted, the SDK will present that form automatically.
There is no separate public API for manually opening a form.
A form can include a server-controlled display-name confirmation page: when a response is about to create a post and the responder doesn't have a display name set yet, the backend splices in a page asking them to confirm or set the name shown on that post (prefilled with any host-app suggestion). It's skippable like any other optional page, and disappears automatically if the page that would create the post was left blank.
The displayName suggested via identify(...) rides along on every event for
an identified user, not just the posts session mint, so this prefill is
available even if the user never opened the feedback UI before triggering the
form.
To render forms, add WandKitHost() to your Compose UI tree.
@Composable
fun App() {
MaterialTheme {
Box {
MainContent()
WandKitHost()
}
}
}WandKitHost() should be mounted at the root of the composable container where forms can appear. It also renders the screenshot-report card (see Screenshot reporting) when screenshotReporting is enabled.
If that container uses ModalBottomSheetLayout, place the host at that root level so the SDK can present forms correctly inside the same container.
In practice, do not place it deep inside a screen subtree that may not be present when an event returns a form.
You can provide a custom theme to WandKitHost():
WandKitHost(
theme = WandKitThemeDefaults.system(),
)Available defaults:
WandKitThemeDefaults.light()WandKitThemeDefaults.dark()WandKitThemeDefaults.system()You can also construct a custom WandKitTheme with your own colors and typography.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WandKit.configure(
config = WandKitConfig(
apiKey = "your_api_key",
isDebugLoggingEnabled = true,
),
context = applicationContext,
)
setContent {
MaterialTheme {
Box {
ScreenContent(
onAction = {
WandKit.identify("user_123")
WandKit.event(name = "screen_action_tapped")
}
)
WandKitHost()
}
}
}
}
}This is the Compose Multiplatform SDK for WandKit.
This README covers:
wandkit-core: SDK configuration, identity, and event trackingwandkit-ui-compose: Compose host and UI for rendering formsWandKitConfig currently supports:
apiKey: your WandKit API keyisDebugLoggingEnabled: enables SDK debug loggingapiBaseUrl: overrides the API host (events, forms, referrals, feedback sessions); null uses productionfeedbackWebUrl: origin the feedback web app is served from, for pointing a build at a staging deployment (see Feedback)feedbackTheme: styling for the feedback web app (see Theming)screenshotReporting: turns a screenshot into a "Report a problem?" prompt (see Screenshot reporting)debugAttachmentsProvider: supplies extra files (logs, JSON dumps) uploaded alongside a screenshot report, for your team's eyes only (see Debug attachments)sessionReplay: records the last minute of frames, touches and events and attaches it to a screenshot report (see Session replay)Example:
WandKitConfig(
apiKey = "your_api_key",
isDebugLoggingEnabled = true,
)Add the SDK modules to your app:
implementation("com.flabbergast.wandkit:core:<version>")
implementation("com.flabbergast.wandkit:ui-compose:<version>")Configure WandKit must be called before calling other WandKit methods.
WandKit.configure(
config = WandKitConfig(
apiKey = "your_api_key",
isDebugLoggingEnabled = true,
),
context = applicationContext,
)WandKit.configure(
config = WandKitConfig(
apiKey = "your_api_key",
isDebugLoggingEnabled = true,
),
)If you have a known user id, identify the user before sending events:
WandKit.identify(userId = "user_123")You can also suggest a display name for the user:
WandKit.identify(userId = "user_123", displayName = "Jane")displayName is only a suggestion: it is shown on the user's feedback posts until they set their own name in the feedback UI, and re-identifying with a new name updates the suggestion but never overwrites a name the user has already set themselves. Passing null (the default) leaves any previously suggested name untouched.
Clear the identified user when needed:
WandKit.clearUser()Send events with a name and optional string properties:
WandKit.event(
name = "checkout_started",
properties = mapOf(
"plan" to "pro",
"entry_point" to "pricing_screen",
),
)You can also provide a custom event timestamp:
WandKit.event(
name = "signup_completed",
occurredAt = occurredAt,
)The SDK also supports creating and redeeming referral links and codes.
Identify the current user first, then create a referral for a campaign:
WandKit.identify(userId = "user_123")
val referral = WandKit.invite(
userId = "user_123",
campaign = "samplecampaign",
)
val referralUrl = referral?.urlWandKit.invite(...) returns ReferralInfo?. Use ReferralInfo.url as the shareable referral link.
You can also pass optional string properties:
val referral = WandKit.invite(
userId = "user_123",
campaign = "samplecampaign",
properties = mapOf(
"source" to "profile_screen",
),
)If you have a referral short path, you can fetch its metadata:
val referral = WandKit.getReferral(path = "abc123")WandKit.getReferral(...) returns GetReferralResponse?.
How far an inviter is toward their reward - what drives an in-app "3 of 5 friends joined" meter:
val progress = WandKit.getReferralProgress(
userId = "user_123",
campaign = "samplecampaign",
)
val joined = progress?.convertedCount
val goal = progress?.reward?.thresholdWandKit.getReferralProgress(...) returns ReferralProgress?, and null when the
campaign does not exist or this inviter has no referral yet - call
WandKit.invite(...) first.
convertedCount is what counts toward the reward: claims that went on to sign up.
claimedCount is the larger number of installs that merely entered the code.
Ask the backend which referral this install probably came from. Nothing is bound
by this - the returned code is meant to be offered back to the user to confirm or
replace, and redeemCode is what actually claims it:
val detection = WandKit.detectReferral()
val prefill = detection?.codeFingerprint accuracy decays quickly and the server-side match window is short, so
detection has to run early - long before the user has agreed to anything. Call this
right after configure:
WandKit.detectReferralOnFirstLaunchIfNeeded()It runs once per install, in the background, and persists the result. Read it back whenever your UI is ready:
val detection = WandKit.detectedReferralA transient failure does not count as an attempt, so the next launch retries - a dropped attempt costs an inviter a referral they earned. Retries are capped, and a permanent failure (a rejected key, an unreadable response) gives up at once, so an install that can never get an answer stops fingerprinting rather than re-sending on every launch.
redeemCode clears the detection on success, since the question it exists to
answer has been answered. If the user dismisses the prefilled code instead, clear
it yourself:
WandKit.clearDetectedReferral()Redeem a code, whether the user typed it or confirmed a detected one:
val match = WandKit.redeemCode(code = "INVITE_CODE")WandKit.redeemCode(...) returns ReferralMatch?. This is the only call that
creates a claim.
WandKit.installId is this device's install ID, the same one redeemCode claims
with, and it is stable across launches. Forward it to your own backend so it can
report referral conversions server-to-server:
myBackend.reportReferralAttribution(wandkitInstallId = WandKit.installId)You can also ask the SDK to read the install referral code from the platform provider and redeem it:
val match = WandKit.matchReferral()WandKit.matchReferral() returns ReferralMatch?.
Note that this predates detectReferral() and claims the referral immediately,
without asking the user. Prefer detection unless you specifically want the old
auto-claim behaviour.
Important: if you want to use the install referral code provider, you must pass context when calling WandKit.configure(...) on Android.
Without context, the SDK cannot create the Android install referrer client, so WandKit.getInstallReferralCode() and WandKit.matchReferral() will not be able to read the install referral code.
The SDK exposes the raw install referral code lookup as well:
val installReferralCode = WandKit.getInstallReferralCode()Required Android setup:
WandKit.configure(
config = WandKitConfig(
apiKey = "your_api_key",
isDebugLoggingEnabled = true,
),
context = applicationContext,
)On Android, this uses the Play Install Referrer API and extracts the referral_code query parameter from the install referrer payload.
WandKit.matchReferral() uses this provider internally. If a referral code is available, it redeems that code automatically.
On iOS, the install referral code provider is currently not implemented and always returns null. Because of that:
WandKit.getInstallReferralCode() returns null on iOSWandKit.matchReferral() also returns null on iOS unless install referral support is implemented there laterWandKit.presentFeedback() opens the feedback screen - the feed, the composer, and the roadmap - on top of whatever is currently visible:
WandKit.presentFeedback()It uses whichever user identify(...) last named. Without one the session is anonymous, which the backend makes read-only: the user can read the feed and the roadmap but not post, comment, or vote. Nothing errors and nothing is hidden - the web app simply renders without the write actions.
When a display name was suggested via identify(...), it appears on that user's posts and is editable by the user right in the feedback UI - editing it there does not change what your app passed to identify(...), and future calls to identify(...) will not overwrite a name the user has set for themselves.
Open straight on the new-post composer, optionally seeded with something the user already typed elsewhere in your app:
WandKit.presentFeedback(
startAt = WandKitFeedbackScreen.Composer(
WandKitComposerPrefill(description = "It crashes when…"),
),
)WandKitComposerPrefill can also pre-select the type and attach an image - say, a screenshot your own "report a bug" button captured:
WandKit.presentFeedback(
startAt = WandKitFeedbackScreen.Composer(
WandKitComposerPrefill(
type = WandKitPostType.BUG,
attachments = listOfNotNull(WandKitComposerAttachment.image(bitmap)),
),
),
)WandKitComposerAttachment.image(bitmap) is an Android helper: it JPEG-encodes the bitmap, downscaled to 2000 px on the long edge, and returns null only if the image can't be made to fit the SDK's payload cap. A type the project has disabled is ignored by the composer.
The screen itself is a WandKit-hosted web app rendered in a WebView, inside an Activity the SDK declares in its own manifest - there is nothing to add to yours. It changes when WandKit ships, not when your app does.
Android only. The iOS targets of this library log a warning and do nothing; use the native WandKit iOS SDK there.
For custom launching - a notification tap, a deep link handler, anywhere else that already holds a Context - build the Intent yourself instead of going through presentFeedback:
val intent = WandKit.feedbackIntent(context, startAt)
context.startActivity(intent)Style the feedback web app with feedbackTheme at configure time. It is serialized into the webview and applied as CSS custom properties.
WandKit.configure(
config = WandKitConfig(
apiKey = "your_api_key",
feedbackTheme = WandKitFeedbackTheme(
primaryColor = "#4F46E5",
backgroundColor = "#FFFFFF",
cornerRadius = 16.0,
fontFamily = "-apple-system, system-ui, sans-serif",
preferredColorScheme = WandKitColorSchemePreference.SYSTEM,
),
),
context = applicationContext,
)| Field | Type | Default | Notes |
|---|---|---|---|
primaryColor |
String? |
null |
Buttons, links, anything accented |
backgroundColor |
String? |
null |
Page background, also painted behind the webview so a slow first paint doesn't flash white |
cornerRadius |
Double? |
null |
|
fontFamily |
String? |
null |
A CSS font family the webview can resolve - a web-safe stack, or a font the hosted app bundles. A font that only exists inside your app will not resolve |
preferredColorScheme |
WandKitColorSchemePreference |
SYSTEM |
LIGHT and DARK also override the native chrome around the webview |
Colors are CSS hex strings (#RRGGBB, or #RRGGBBAA when translucent); the native chrome around the webview - window background, spinner - only honours the opaque form. Omit the theme entirely and the web app keeps its own defaults.
feedbackWebUrl overrides the origin the web app is served from, and apiBaseUrl the API host, for pointing a build at a staging deployment. Both take plain http:// origins for a local stack, which also needs android:usesCleartextTraffic="true" (or a network security config) in your manifest.
Opt in at configure time and a screenshot turns into a "Report a problem?" prompt:
WandKit.configure(
config = WandKitConfig(
apiKey = "your_api_key",
screenshotReporting = true,
),
context = applicationContext,
)When the user takes a screenshot, WandKitHost() shows a small card over your app with a thumbnail of what they just captured. The whole flow is native - there is no webview involved:
The report lands as a pending post (type=bug) in the project's triage inbox, same as anything else a user sends - the team publishes it from there. Another screenshot within two seconds of the last card is debounced rather than shown again.
Requirements:
Activity.ScreenCaptureCallback; earlier versions never see a card. The SDK's own manifest merges the android.permission.DETECT_SCREEN_CAPTURE normal permission into your app - there is no runtime prompt to wire up.identify(...)). Anonymous sessions cannot post, so without one the screenshot is skipped silently rather than shown to a user who could never submit it.WandKitHost() mounted on the screen, the same as for survey forms.configure called in Application.onCreate, so the SDK sees the first Activity and can register its capture callback as soon as it resumes.Supply a debugAttachmentsProvider and your own files - logs, JSON dumps, anything that helps triage - go up as attachments alongside the screenshot:
WandKitConfig(
apiKey = "your_api_key",
screenshotReporting = true,
debugAttachmentsProvider = WandKitDebugAttachmentsProvider {
listOf(WandKitDebugAttachment(File(logDir, "app.log").readBytes(), "app.log", "text/plain"))
},
)The provider runs when the user taps Send on the report card - and again if they tap "Try again", since a retry re-runs the whole submission. It has a 10 second budget; on timeout, or if it throws, the report still goes out, just without the files (logged, not surfaced to the user). Up to 5 files are kept, 10 MB each - extra or oversize files are dropped with a warning log rather than failing the report.
These files are dashboard-only: visible to your team in the post detail, never to the end user. Mind PII in whatever you attach. When a provider is configured, the composer shows a static disclosure line under the text field ("Diagnostic logs will be included to help us fix this.") so the user knows more than the screenshot is going up; there's no per-file toggle.
WandKitDebugAttachment.text(text, fileName) is a shortcut for a UTF-8 text file (text/plain; charset=utf-8) - handy for in-memory logs you don't want to write to disk first.
What it does not do:
Read the gallery. The image is read back from your app's own window via PixelCopy, not from Photos, so there is no permission prompt for it. SurfaceView content comes out black, and a window flagged FLAG_SECURE never triggers a callback at all.
Upload anything until Send. The image stays in memory while the card or text box is open, and only leaves the device once the user taps Send.
Compress or otherwise transform debug attachments. Whatever bytes the provider returns are what get uploaded.
Show debug attachments to the end user. They never appear in the composer beyond the static disclosure line, and never in the end-user-facing parts of the product - only in the dashboard.
Fail the report over a debug attachment problem. A slow provider, a thrown exception, or a failed upload for one file is logged and skipped; the screenshot report itself still goes out.
Prompt on Android 13 and below. There is no capture callback to hook there. If you want a screenshot-report entry point on older devices, wire your own trigger to the (webview) composer directly - this deep-links to the simplified web composer, which still accepts a type:
WandKit.presentFeedback(
startAt = WandKitFeedbackScreen.Composer(
WandKitComposerPrefill(type = WandKitPostType.BUG),
),
)Opt in alongside screenshot reporting and a report can carry a short replay of what led up to the screenshot:
WandKit.configure(
config = WandKitConfig(
apiKey = "...",
isDebugLoggingEnabled = false,
screenshotReporting = true,
sessionReplay = WandKitSessionReplayOptions(),
),
context = applicationContext,
)While the app is in the foreground the SDK keeps a ring buffer of roughly the last minute (windowSeconds = 60, capped at maxBytes = 4 MB): low-resolution JPEG frames of your window (720 px long edge, captured on touch and about once a second, identical frames skipped), touches, and every WandKit.event(...) call. When a screenshot brings up the report card, the buffer is frozen before the card appears, and the composer shows an "Include a replay of the last minute" switch (on by default; includeByDefault = false flips that). Send with it on and the recording is uploaded as a dashboard-only replay attachment, played back in the post detail. The file is wandkit-replay v1 NDJSON - the same format the iOS SDK writes.
Storage. By default (persistToDisk = true) the frames - the only heavy part - are written to cacheDir/wandkit-replay/ instead of being kept on the heap; memory only holds a few bytes of metadata per frame plus the touches and events. The frozen recording is streamed to a file there too and read back only at upload time. Files follow the buffer's lifetime: deleted when evicted, when the app goes to the background, after the report is sent or the card dismissed, and anything a previous process left behind is swept on the next launch. persistToDisk = false keeps everything in memory, like iOS.
Masking is applied to the recorded frame, never to your UI:
maskTextInputs (default on): EditTexts, and Compose text fields (read from the Compose semantics tree - requires WandKitHost() from ui-compose, which screenshot reporting needs anyway).maskWebViews: WebViews. maskAllText: every TextView and Compose text.Modifier.wandKitReplayMasked() (or a testTag containing wandkit-mask) in Compose; a View tag or contentDescription containing wandkit-mask.The recorder pauses while the app isn't in the foreground and while WandKit's own UI is up (the report card, surveys, the feedback screen, the feature-preview sheet), and records the pause as a gap the player skips. WandKit.sessionReplayStatus exposes frame count, buffered bytes and pause state for a debug indicator.
What it does not do:
Window.Callback wrapper, events only from your own WandKit.event(...) calls.Forms are event-driven.
When you call WandKit.event(...), the backend may return a form for that event. If your app has the Compose host mounted, the SDK will present that form automatically.
There is no separate public API for manually opening a form.
A form can include a server-controlled display-name confirmation page: when a response is about to create a post and the responder doesn't have a display name set yet, the backend splices in a page asking them to confirm or set the name shown on that post (prefilled with any host-app suggestion). It's skippable like any other optional page, and disappears automatically if the page that would create the post was left blank.
The displayName suggested via identify(...) rides along on every event for
an identified user, not just the posts session mint, so this prefill is
available even if the user never opened the feedback UI before triggering the
form.
To render forms, add WandKitHost() to your Compose UI tree.
@Composable
fun App() {
MaterialTheme {
Box {
MainContent()
WandKitHost()
}
}
}WandKitHost() should be mounted at the root of the composable container where forms can appear. It also renders the screenshot-report card (see Screenshot reporting) when screenshotReporting is enabled.
If that container uses ModalBottomSheetLayout, place the host at that root level so the SDK can present forms correctly inside the same container.
In practice, do not place it deep inside a screen subtree that may not be present when an event returns a form.
You can provide a custom theme to WandKitHost():
WandKitHost(
theme = WandKitThemeDefaults.system(),
)Available defaults:
WandKitThemeDefaults.light()WandKitThemeDefaults.dark()WandKitThemeDefaults.system()You can also construct a custom WandKitTheme with your own colors and typography.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WandKit.configure(
config = WandKitConfig(
apiKey = "your_api_key",
isDebugLoggingEnabled = true,
),
context = applicationContext,
)
setContent {
MaterialTheme {
Box {
ScreenContent(
onAction = {
WandKit.identify("user_123")
WandKit.event(name = "screen_action_tapped")
}
)
WandKitHost()
}
}
}
}
}