Android SDK
Complete integration guide for the Encatch native Android SDK — in-app feedback and survey collection for Android apps
The Encatch Android SDK lets you collect in-app feedback and surveys in native Android apps. Display forms as a modal WebView overlay or inline in your layout, identify users, track screens and events, and submit responses to the Encatch backend.
Overview
- Package:
com.encatch:android(Maven Central) - Version: 0.1.1
- Platforms: Android (minSdk 24+)
- Repository: github.com/get-encatch/encatch-android
Installation
// build.gradle.kts
dependencies {
implementation("com.encatch:android:0.1.1")
}// build.gradle
dependencies {
implementation 'com.encatch:android:0.1.1'
}The com.encatch:android artifact pulls in com.encatch:core (the platform-agnostic business logic — networking, storage, session management) automatically and adds the classic-Views UI: the modal form overlay and the WebView bridge wiring.
Quick Start
1. Initialization
Install the form UI once in your Application.onCreate. EncatchFormHost.install tracks the current foreground Activity so modal forms have a host to attach to, and wires foreground retry-queue flushing and completion-CTA handling.
import android.app.Application
import com.encatch.android.EncatchFormHost
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
EncatchFormHost.install(this)
}
}Then initialize the SDK. All main SDK entry points are suspend functions, so call them from a coroutine — for example lifecycleScope.launch:
import androidx.lifecycle.lifecycleScope
import com.encatch.core.Encatch
import kotlinx.coroutines.launch
lifecycleScope.launch {
Encatch.init("your-api-key")
}EncatchFormHost is required for modal forms
Without EncatchFormHost.install(application), showForm calls that resolve to the modal presentation have no Activity to attach to and nothing will be displayed. Inline forms (see below) attach through EncatchInlineFormView instead, but installing the host is still recommended as the fallback presenter.
For inline forms, add EncatchInlineFormView to your screen layout separately.
Pass an optional EncatchConfig to customize SDK behavior:
import com.encatch.core.Encatch
import com.encatch.core.EncatchConfig
import com.encatch.core.Theme
lifecycleScope.launch {
Encatch.init(
"your-api-key",
EncatchConfig(
theme = Theme.SYSTEM,
debugMode = true,
isFullScreen = false,
apiBaseUrl = "https://api.encatch.com",
appVersion = "1.2.3",
onBeforeShowForm = { payload ->
// Return false to prevent the form from showing
true
},
),
)
}Prop
Type
Calling init again with a new API key or config reconfigures the SDK in place — useful for switching environments at runtime.
2. Identify users
Identify the current user. The userName is required (can be a username, email, or unique identifier). Traits and options are optional.
Username format
userName must be an ASCII identifier: 1–50 characters, using only letters A–Z / a–z, digits 0–9, and ., _, @, -. Spaces and non-English characters (Unicode, accented letters, emoji, etc.) are not supported. Use an email address, internal user ID, or ASCII username — for example user@example.com or user_123. To store a display name in another language, pass it as a trait instead (e.g. set = mapOf("display_name" to JsonPrimitive("…"))).
lifecycleScope.launch {
Encatch.identifyUser("user@example.com")
}Trait values in set / setOnce are JsonElements — wrap primitives with JsonPrimitive:
import com.encatch.core.UserTraits
import kotlinx.serialization.json.JsonPrimitive
lifecycleScope.launch {
Encatch.identifyUser(
"user@example.com",
traits = UserTraits(
set = mapOf(
"name" to JsonPrimitive("Alice"),
"plan" to JsonPrimitive("team"),
),
),
)
}import com.encatch.core.UserTraits
import kotlinx.serialization.json.JsonPrimitive
lifecycleScope.launch {
Encatch.identifyUser(
"user@example.com",
traits = UserTraits(
set = mapOf(
"name" to JsonPrimitive("Alice"),
"plan" to JsonPrimitive("team"),
),
setOnce = mapOf(
"firstSeen" to JsonPrimitive("2026-08-05T12:00:00Z"),
),
increment = mapOf("loginCount" to 1.0),
decrement = mapOf("credits" to 5.0),
unset = listOf("trialEndDate"),
),
)
}Prop
Type
User traits support the following operations:
| Operation | Type | Description |
|---|---|---|
set | Map<String, JsonElement>? | Set user attributes (overwrites existing values) |
setOnce | Map<String, JsonElement>? | Set user attributes only if they don't already exist |
increment | Map<String, Double>? | Increment numeric user attributes |
decrement | Map<String, Double>? | Decrement numeric user attributes |
unset | List<String>? | Remove user attributes |
IdentifyOptions fields:
| Field | Type | Description |
|---|---|---|
locale | String? | Preferred language for this user (persisted) |
country | String? | ISO 3166 country code (persisted) |
secure | SecureOptions? | Server-generated HMAC signature for verified identification |
Recommended
Using the secure option with a server-generated signature is recommended to verify that identification requests come from your backend. Keep your secret key on the server only — never expose it in client-side code.
Pass a server-generated HMAC signature so Encatch can validate the request. generatedDateTimeInUtc must be milliseconds since the Unix epoch (for example the string form of System.currentTimeMillis() from your server). When your publishable key has a session timeout, use the same value in HMAC-SHA256(userName + epochMs, secretKey). It is sent as the X-User-Signature-Time header and limits the signature's lifespan.
import com.encatch.core.IdentifyOptions
import com.encatch.core.SecureOptions
lifecycleScope.launch {
Encatch.identifyUser(
"user@example.com",
options = IdentifyOptions(
secure = SecureOptions(
signature = "your-hmac-signature",
generatedDateTimeInUtc = "1741867200000", // ms since epoch (2025-03-13T12:00:00Z)
),
),
)
}3. Show a form manually
Show a specific form by slug or ID.
import com.encatch.core.ResetMode
import com.encatch.core.ShowFormOptions
lifecycleScope.launch {
Encatch.showForm("feedback-form")
Encatch.showForm("feedback-form", ShowFormOptions(
reset = ResetMode.ALWAYS,
))
}Prop
Type
Prop
Type
| ResetMode | Behavior |
|---|---|
ResetMode.ALWAYS | Reset pre-fill and response data on every form display |
ResetMode.ON_COMPLETE | Reset only after the form is completed |
ResetMode.NEVER | Never reset response data |
Pass caller context when showing a form. ContextValue is a sealed class with StringValue, NumberValue, BooleanValue, and DateValue (epoch millis) variants:
import com.encatch.core.ContextValue
lifecycleScope.launch {
Encatch.showForm("feedback-form", ShowFormOptions(
reset = ResetMode.ALWAYS,
context = mapOf(
"plan" to ContextValue.StringValue("team"),
"feature" to ContextValue.StringValue("checkout"),
"seats" to ContextValue.NumberValue(12.0),
"trial" to ContextValue.BooleanValue(false),
"signedUpAt" to ContextValue.DateValue(System.currentTimeMillis()),
),
))
}Other actions
Set the user's preferred language.
Encatch.setLocale("fr")Set the user's country.
Encatch.setCountry("FR") // ISO 3166 country codeSet the theme for forms and surveys.
import com.encatch.core.Theme
Encatch.setTheme(Theme.DARK)
Encatch.setTheme(Theme.LIGHT)
Encatch.setTheme(Theme.SYSTEM) // Follows system preferencelifecycleScope.launch {
Encatch.trackEvent("button_clicked")
}lifecycleScope.launch {
Encatch.trackScreen("HomeScreen")
}Call trackScreen from each Activity's onResume, a Fragment's onResume, or your navigation library's destination-changed listener — for example with Jetpack Navigation:
navController.addOnDestinationChangedListener { _, destination, _ ->
lifecycleScope.launch {
Encatch.trackScreen(destination.route ?: destination.label?.toString() ?: "unknown")
}
}Subscribe to form lifecycle events. Returns an unsubscribe function.
val unsubscribe = Encatch.on { eventType, payload ->
Log.d("Encatch", "Event: ${eventType.wireValue}, payload: ${payload.data}")
}
// Later, to unsubscribe:
unsubscribe()Callbacks may fire on any thread — hop to the main thread (runOnUiThread, Dispatchers.Main) before touching UI.
| Event | Description |
|---|---|
EventType.FORM_SHOW | Fired when a form is displayed |
EventType.FORM_STARTED | Fired when a user starts interacting |
EventType.FORM_SUBMIT | Fired when a form is submitted |
EventType.FORM_COMPLETE | Fired when a form is fully completed |
EventType.FORM_CLOSE | Fired when a form is closed |
EventType.FORM_DISMISSED | Fired when a form is dismissed without completion |
EventType.FORM_ERROR | Fired when an error occurs |
EventType.FORM_SECTION_CHANGE | Fired when the visible section changes |
EventType.FORM_ANSWERED | Fired when a question is answered |
EventType.FORM_REMIND_ME_LATER | Fired when the user taps "Remind me later" |
EventType.FORM_CTA_TRIGGERED | Fired when a completion CTA is triggered on thank-you or exit screens |
Handle completion CTAs (in-app navigation, internal redirect, or external browser) via FORM_CTA_TRIGGERED. Configure actions in the form builder — see Call to action. The SDK closes the form overlay after emitting the event — your app handles in-app navigation:
import com.encatch.core.EventType
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.contentOrNull
Encatch.on { eventType, payload ->
if (eventType != EventType.FORM_CTA_TRIGGERED) return@on
val action = payload.data?.get("action")?.jsonPrimitive?.contentOrNull
if (action != "app_navigate") return@on
val route = payload.data?.get("route")?.jsonPrimitive?.contentOrNull
// Map route strings to your app's navigation paths
if (route == "billing" || route == "billing/upgrade") {
runOnUiThread { navController.navigate("billing") }
}
}For in-app navigation, the SDK closes the form overlay after emitting the event and expects your app to perform navigation. For URL redirect actions, the SDK opens the URL (Custom Tabs or an external browser intent) and closes the form automatically.
Pre-fill a form response before showing a form. questionId may be a question UUID or a question slug.
Encatch.addToResponse("question_id", "pre-filled value")
Encatch.addToResponse("choice_question_id", listOf("option-a", "option-b"))
lifecycleScope.launch {
Encatch.showForm("your-form-slug")
}Dismiss the currently displayed form.
lifecycleScope.launch {
Encatch.dismissForm()
// Or dismiss a specific form configuration:
Encatch.dismissForm(formConfigurationId = "config-id")
}Use onBeforeShowForm in EncatchConfig to conditionally block forms from showing. The interceptor is a suspend lambda, so you can await your own logic inside it.
import com.encatch.core.TriggerType
lifecycleScope.launch {
Encatch.init(
"your-api-key",
EncatchConfig(
onBeforeShowForm = { payload ->
// Inspect payload.formId, payload.formConfig, payload.triggerType, etc.
if (payload.triggerType == TriggerType.AUTOMATIC && someCondition) {
false // Block this form
} else {
true // Allow
}
},
),
)
}identifyUser starts a session automatically once the backend confirms the identity. You can also control the session lifecycle manually:
import com.encatch.core.StartSessionOptions
lifecycleScope.launch {
Encatch.startSession()
// Skip the immediate ping or screen re-track on start:
Encatch.startSession(
StartSessionOptions(
skipImmediatePing = true,
skipImmediateTrackScreen = true,
),
)
}// Temporarily stop the 30-second background ping (not persisted)
Encatch.pauseSession()
// Resume the ping interval after pauseSession()
Encatch.resumeSession()// Fully suspend SDK activity — stops ping and dismisses open forms.
// Persists across app restarts. Re-enable with startSession().
lifecycleScope.launch {
Encatch.stopSession()
}Reset the current user identity and clear persisted identity data. Reverts the SDK to anonymous mode. User identity is preserved across stopSession() — use resetUser() after logout.
lifecycleScope.launch {
Encatch.resetUser()
}Wipes all persisted SDK data and resets in-memory state. Stronger than resetUser() — also clears session-stopped state and device preferences. Note that on Android clearAll() de-initializes the SDK entirely — call init (and then identifyUser) again afterward.
lifecycleScope.launch {
Encatch.clearAll()
}The SDK sends a background ping every 30 seconds (configurable via server response) to maintain engagement sessions and check for triggered forms. Ping is suppressed while a form is visible. The offline retry queue is flushed automatically when the app returns to the foreground (via ProcessLifecycleOwner, wired by EncatchFormHost.install).
Inline Forms
Inline forms are a way to show Encatch in-app feedback without a modal — the survey renders directly in your layout instead of as a full-screen overlay.
EncatchInlineFormView renders a form directly inside your view hierarchy instead of as a modal overlay. Place it anywhere — in a LinearLayout, ScrollView, RecyclerView item, or a Compose AndroidView.
XML:
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<!-- ... content above ... -->
<com.encatch.android.EncatchInlineFormView
android:id="@+id/inlineForm"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<!-- ... content below ... -->
</LinearLayout>
</ScrollView>findViewById<EncatchInlineFormView>(R.id.inlineForm).formId = "your-form-slug" // exact match; leave null for wildcardOr programmatically:
val inlineForm = EncatchInlineFormView(context).apply {
formId = "your-form-slug"
}
container.addView(inlineForm)Then trigger the form from anywhere:
lifecycleScope.launch {
Encatch.showForm("your-form-slug")
}When showForm is called, the SDK resolves the presenter in this order:
- Exact match — first attached
EncatchInlineFormViewwhoseformIdmatches the payload wins. - Wildcard — first attached
EncatchInlineFormViewwithformId = nullcatches anything not exact-matched. - Modal fallback — the modal dialog (hosted by
EncatchFormHost) shows the form as the default overlay when no inline slot is registered or none match.
Slot registration is tied to the view's attach/detach lifecycle: the slot registers in onAttachedToWindow and unregisters in onDetachedFromWindow. If your UI keeps off-screen pages attached (e.g. ViewPager with page retention), a wildcard slot on a hidden page can intercept a showForm meant for the visible one — detach the view while backgrounded, or give it an exact formId no other screen uses.
In Jetpack Compose, wrap the view in AndroidView:
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import com.encatch.android.EncatchInlineFormView
@Composable
fun FeedbackSlot() {
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = { context ->
EncatchInlineFormView(context).apply {
formId = "your-form-slug"
}
},
)
}Because Compose disposes the underlying view when the composable leaves composition, slot registration follows your navigation naturally — an inline slot on a screen that is no longer composed will not intercept showForm calls.
The WebView's internal scroll is disabled. The host ScrollView (or NestedScrollView) provides scrolling. The view height grows automatically via form:resize messages from the web form; before the first resize a 300dp loading skeleton is shown, which crossfades away once the form is ready.
The host app controls keyboard avoidance — use android:windowSoftInputMode="adjustResize" (with edge-to-edge inset handling on API 30+) so content slides above the keyboard.
When an in-form overlay opens (QnA with AI, Scheduler), the view freezes its height and reports the change through onOverlayOpenChange:
inlineForm.onOverlayOpenChange = { open ->
// e.g. lock host scrolling while the overlay is open
}| Property | Type | Default | Description |
|---|---|---|---|
formId | String? | null | Exact form slug/id to match. null = wildcard. |
minHeight | Int | 0 | Minimum height floor in px applied after form:resize. |
onOverlayOpenChange | ((Boolean) -> Unit)? | null | Called when a QnA/Scheduler overlay opens or closes. |
Build Your Own Form UX & UI
If your feedback flow uses a fixed, predictable question set — the same fields and workflow every time — you can build the form with your own Android views or composables and submit responses through the SDK. That keeps typography, spacing, colors, and interaction patterns aligned with the rest of your app, so the survey feels like a native screen rather than an embedded web page.
The flow has three parts: intercept the form, render your own UI, then submit through the SDK.
1. Intercept the form. Set onBeforeShowForm in EncatchConfig and return false to suppress the SDK's WebView. The payload carries everything you need to render natively — payload.formConfig.feedbackConfigurationId (required for submission) and payload.formConfig.questionnaireFields (the question definitions):
import com.encatch.core.ShowFormResponse
var pendingNativeForm: ShowFormResponse? = null
lifecycleScope.launch {
Encatch.init(
"your-api-key",
EncatchConfig(
onBeforeShowForm = { payload ->
pendingNativeForm = payload.formConfig
// Hand off to your own UI (e.g. post to a StateFlow your screen observes)
false // Suppress the SDK's WebView form
},
),
)
}2. Emit lifecycle events (optional). The WebView normally reports lifecycle events; with a native UI you emit them yourself so dashboards and Encatch.on listeners stay accurate:
import com.encatch.core.EventPayload
import com.encatch.core.EventType
val configId = formConfig.feedbackConfigurationId
Encatch.emitEvent(EventType.FORM_SHOW, EventPayload(formId = configId, timestamp = 0))
// ... later, as the user interacts:
Encatch.emitEvent(EventType.FORM_STARTED, EventPayload(formId = configId, timestamp = 0))(emitEvent stamps the current timestamp for you.)
3. Build and submit the response. Collect answers from your UI as NativeFormResponse entries (questionId, question type wire value, and the value), then convert them with buildSubmitRequest and send with Encatch.submitForm:
import com.encatch.core.BuildSubmitRequestOptions
import com.encatch.core.NativeFormResponse
import com.encatch.core.buildSubmitRequest
lifecycleScope.launch {
val responses = listOf(
NativeFormResponse("q1", "rating", 5),
NativeFormResponse("q2", "short_answer", "Great product!"),
NativeFormResponse("q3", "multiple_choice_multiple", listOf("option-a", "option-b")),
NativeFormResponse("q4", "yes_no", true),
)
val request = buildSubmitRequest(
BuildSubmitRequestOptions(
formConfigurationId = formConfig.feedbackConfigurationId,
completionTimeInSeconds = 42,
),
responses,
)
Encatch.submitForm(request)
Encatch.emitEvent(
EventType.FORM_COMPLETE,
EventPayload(formId = formConfig.feedbackConfigurationId, timestamp = 0),
)
}buildSubmitRequest maps every supported question type (rating, NPS, CSAT, opinion scale, text types, choice types, ranking, yes/no, consent, date, matrix types, and structured types like signature, file upload, phone number, address, video/audio, scheduler, QnA with AI, and UPI payments) to the wire format the backend expects. Numeric scale values are rounded to integers; unknown types fall back to short_answer for forward-compatibility.
Value shapes
The value passed to NativeFormResponse depends on the question type: numbers for scales (rating, nps, csat, opinion_scale), strings for text types, String or List<String> for choice/ranking types, Boolean for yes_no/consent, Map for matrix types, and the matching Kotlin data class (SignatureAnswer, PhoneNumberAnswer, AddressAnswer, etc.) for structured types.
All entry points live on the com.encatch.core.Encatch singleton. Methods marked suspend must be called from a coroutine.
| Method | Description |
|---|---|
suspend init(apiKey, config) | Initialize (or reconfigure) the SDK |
suspend identifyUser(userName, traits, options) | Identify a user |
setLocale(locale) | Set locale |
setCountry(country) | Set country (ISO 3166) |
setTheme(theme) | Set form theme |
suspend trackEvent(eventName) | Track a custom event |
suspend trackScreen(screenName) | Track screen navigation |
suspend showForm(formId, options) | Show a form (inline or modal) |
suspend dismissForm(formConfigurationId) | Dismiss the current form |
addToResponse(questionId, value) | Pre-fill a question answer |
suspend startSession(options) | Start a new session |
pauseSession() | Pause background ping |
resumeSession() | Resume background ping |
suspend stopSession() | Suspend SDK activity |
suspend resetUser() | Reset user identity |
suspend clearAll() | Wipe all persisted SDK data |
on(callback) | Subscribe to lifecycle events (returns unsubscribe) |
off(callback) | Unsubscribe from events |
emitEvent(eventType, payload) | Emit a lifecycle event (custom native forms) |
suspend submitForm(params) | Submit a custom native form |
flushRetryQueue() | Flush the offline retry queue |
stop() | Stop the background ping loop |
isInitialized | Whether init has completed (read-only property) |
Add permissions to your AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />INTERNET is required for API calls and the form WebView. The other three permissions are required when forms include video/audio capture questions (video_audio).
The SDK grants WebView media permission requests automatically and, since 0.1.1, shows the runtime permission prompt itself when it's needed — for recording questions and for the camera option in file-upload questions. You can still pre-request the permissions yourself for a smoother first-run experience:
import android.Manifest
import androidx.activity.result.contract.ActivityResultContracts
private val mediaPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { grants ->
if (grants[Manifest.permission.CAMERA] != true ||
grants[Manifest.permission.RECORD_AUDIO] != true
) {
// Handle denied permissions — recording questions will not work
}
}
fun requestEncatchMediaPermissions() {
mediaPermissionLauncher.launch(
arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)
)
}Support
- Maven Central: com.encatch:android
- Issues: github.com/get-encatch/encatch-android/issues
Was this page helpful?
Overview
Mobile and native SDKs for collecting in-app feedback and surveys — native Android, iOS, macOS, Kotlin Multiplatform, Compose Multiplatform, Flutter, and React Native
iOS SDK
Complete integration guide for the Encatch native iOS SDK — in-app feedback and survey collection for iOS apps
