diff --git a/android/src/androidTest/AndroidManifest.xml b/android/src/androidTest/AndroidManifest.xml
new file mode 100644
index 0000000..42331a3
--- /dev/null
+++ b/android/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/android/src/androidTest/java/com/formbricks/android/webview/SurveyPassthroughInstrumentedTest.kt b/android/src/androidTest/java/com/formbricks/android/webview/SurveyPassthroughInstrumentedTest.kt
new file mode 100644
index 0000000..1a6ca5f
--- /dev/null
+++ b/android/src/androidTest/java/com/formbricks/android/webview/SurveyPassthroughInstrumentedTest.kt
@@ -0,0 +1,184 @@
+package com.formbricks.android.webview
+
+import android.content.Context
+import android.os.SystemClock
+import android.view.MotionEvent
+import android.view.View
+import android.view.View.MeasureSpec
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import androidx.activity.ComponentActivity
+import androidx.core.graphics.Insets
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.fragment.app.FragmentActivity
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.formbricks.android.Formbricks
+import com.formbricks.android.model.workspace.SurveyOverlay
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * The no-overlay path: a survey in the host's own content view that hands touches outside the
+ * card to whatever is underneath.
+ */
+@RunWith(AndroidJUnit4::class)
+class SurveyPassthroughInstrumentedTest {
+
+ /** Counts touch-downs that reach it, and takes every one it gets. */
+ private class CountingView(context: Context) : View(context) {
+ var downs = 0
+ override fun onTouchEvent(event: MotionEvent): Boolean {
+ if (event.actionMasked == MotionEvent.ACTION_DOWN) downs++
+ return true
+ }
+ }
+
+ /**
+ * A host screen with the survey layered on top of it in the same content FrameLayout — the
+ * arrangement FormbricksFragment builds for `overlay: none`. The card is the bottom half.
+ */
+ private fun withHostAndSurvey(
+ region: (SurveyPassthroughLayout) -> Unit,
+ block: (content: ViewGroup, host: CountingView, survey: CountingView) -> Unit,
+ ) {
+ ActivityScenario.launch(ComponentActivity::class.java).use { scenario ->
+ scenario.onActivity { activity ->
+ val host = CountingView(activity)
+ activity.setContentView(host)
+ val content = activity.findViewById(android.R.id.content)
+
+ val survey = CountingView(activity)
+ val layout = SurveyPassthroughLayout(activity)
+ layout.addView(survey, MATCH_PARENT, MATCH_PARENT)
+ content.addView(layout, MATCH_PARENT, MATCH_PARENT)
+ content.measure(exactly(400), exactly(800))
+ content.layout(0, 0, 400, 800)
+ region(layout)
+
+ block(content, host, survey)
+ }
+ }
+ }
+
+ private fun exactly(size: Int) = MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY)
+
+ private fun tap(parent: ViewGroup, x: Float, y: Float) {
+ val time = SystemClock.uptimeMillis()
+ listOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP).forEach { action ->
+ val event = MotionEvent.obtain(time, time, action, x, y, 0)
+ parent.dispatchTouchEvent(event)
+ event.recycle()
+ }
+ }
+
+ // Touch routing
+
+ @Test
+ fun touchesOutsideTheCardReachTheHostAndTouchesOnItReachTheSurvey() {
+ withHostAndSurvey({ it.touchRegion = SurveyTouchRegion.Card(0f, 400f, 400f, 800f) }) { content, host, survey ->
+ tap(content, 200f, 100f)
+ assertEquals("above the card belongs to the host app", 1, host.downs)
+ assertEquals(0, survey.downs)
+
+ tap(content, 200f, 600f)
+ assertEquals("the card belongs to the survey", 1, survey.downs)
+ assertEquals(1, host.downs)
+ }
+ }
+
+ @Test
+ fun beforeAnyRectArrivesTheSurveyBlocksTheHost() {
+ // The layout's own default: an older server never reports a rect.
+ withHostAndSurvey({ }) { content, host, survey ->
+ tap(content, 200f, 100f)
+
+ assertEquals(1, survey.downs)
+ assertEquals(0, host.downs)
+ }
+ }
+
+ @Test
+ fun onceTheCardHasGoneEveryTouchReachesTheHost() {
+ withHostAndSurvey({ it.touchRegion = SurveyTouchRegion.Nothing }) { content, host, survey ->
+ tap(content, 200f, 600f)
+
+ assertEquals(1, host.downs)
+ assertEquals(0, survey.downs)
+ }
+ }
+
+ // Keyboard
+
+ private fun keyboard(height: Int): WindowInsetsCompat = WindowInsetsCompat.Builder()
+ .setInsets(WindowInsetsCompat.Type.ime(), Insets.of(0, 0, 0, height))
+ .build()
+
+ @Test
+ fun theLayoutMakesRoomForTheKeyboardAndGivesItBack() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val layout = SurveyPassthroughLayout(context)
+ layout.measure(exactly(400), exactly(800))
+ layout.layout(0, 0, 400, 800)
+
+ // Edge to edge: the layout reaches the bottom of the window, so it pads by the whole keyboard.
+ ViewCompat.dispatchApplyWindowInsets(layout, keyboard(300))
+ assertEquals(300, layout.paddingBottom)
+
+ ViewCompat.dispatchApplyWindowInsets(layout, keyboard(0))
+ assertEquals("no gap left once the keyboard closes", 0, layout.paddingBottom)
+ }
+
+ @Test
+ fun keyboardOverlapCoversOnlyTheCoveredPartAndIsNeverNegative() {
+ val window = 2400
+ assertEquals(0, SurveyPassthroughLayout.keyboardOverlap(window, window, keyboardHeight = 0))
+ assertEquals(900, SurveyPassthroughLayout.keyboardOverlap(window, window, keyboardHeight = 900))
+ // A host that already resized: padding on top would push the card twice as far up.
+ assertEquals(0, SurveyPassthroughLayout.keyboardOverlap(1500, window, keyboardHeight = 900))
+ assertEquals(0, SurveyPassthroughLayout.keyboardOverlap(1200, window, keyboardHeight = 900))
+ assertEquals(100, SurveyPassthroughLayout.keyboardOverlap(1600, window, keyboardHeight = 900))
+ }
+
+ // Showing
+
+ @Test
+ fun aSecondShowInTheSameTurnDoesNotStackASecondSurvey() {
+ // Two zero-delay surveys post their `show` back to back, so the second runs before the
+ // first's transaction would have if it were only scheduled.
+ ActivityScenario.launch(FragmentActivity::class.java).use { scenario ->
+ scenario.onActivity { activity ->
+ // `show` reads the workspace from prefs; with none stored it takes the no-overlay path.
+ // The fragment's view model reads the app URL and workspace id.
+ Formbricks.applicationContext = activity.applicationContext
+ Formbricks.appUrl = "https://test.formbricks.com"
+ Formbricks.workspaceId = "test-workspace"
+ val manager = activity.supportFragmentManager
+
+ FormbricksFragment.show(manager, "survey-one")
+ FormbricksFragment.show(manager, "survey-two")
+ manager.executePendingTransactions()
+
+ assertEquals(1, manager.fragments.count { it is FormbricksFragment })
+ }
+ }
+ }
+
+ // Which path a survey takes
+
+ @Test
+ fun aSurveyOverrideBeatsTheWorkspaceOverlay() {
+ assertEquals(SurveyOverlay.DARK, SurveyOverlay.resolve(SurveyOverlay.DARK, SurveyOverlay.NONE))
+ assertEquals(SurveyOverlay.NONE, SurveyOverlay.resolve(SurveyOverlay.NONE, SurveyOverlay.LIGHT))
+ }
+
+ @Test
+ fun withoutAnOverrideTheWorkspaceOverlayAppliesAndNoSettingMeansNone() {
+ assertEquals(SurveyOverlay.LIGHT, SurveyOverlay.resolve(null, SurveyOverlay.LIGHT))
+ // `none` is the default, so most workspaces take the pass-through path.
+ assertEquals(SurveyOverlay.NONE, SurveyOverlay.resolve(null, null))
+ }
+}
diff --git a/android/src/androidTest/java/com/formbricks/android/webview/SurveyTouchRegionInstrumentedTest.kt b/android/src/androidTest/java/com/formbricks/android/webview/SurveyTouchRegionInstrumentedTest.kt
new file mode 100644
index 0000000..7022159
--- /dev/null
+++ b/android/src/androidTest/java/com/formbricks/android/webview/SurveyTouchRegionInstrumentedTest.kt
@@ -0,0 +1,107 @@
+package com.formbricks.android.webview
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.formbricks.android.model.javascript.CardRect
+import com.formbricks.android.model.javascript.CardRectData
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * A transparent full-screen WebView still swallows every touch — `pointer-events: none` is a web
+ * hit test Android never sees. These pin which touches the survey claims in each state, because
+ * getting it wrong is invisible in review and obvious to a user: either the host app freezes, or
+ * the survey itself stops responding.
+ */
+@RunWith(AndroidJUnit4::class)
+class SurveyTouchRegionInstrumentedTest {
+
+ private val density = 3f
+ private val reported = CardRect(x = 0f, y = 200f, width = 130f, height = 80f)
+
+ // Physical pixels, i.e. CSS pixels * density.
+ private val insideCard = Pair(195f, 700f)
+ private val outsideCard = Pair(195f, 200f)
+
+ @Test
+ fun anOverlaidSurveyClaimsEveryTouch() {
+ // A visible backdrop is meant to block the host app.
+ assertTrue(SurveyTouchRegion.Everything.accepts(insideCard.first, insideCard.second))
+ assertTrue(SurveyTouchRegion.Everything.accepts(outsideCard.first, outsideCard.second))
+ assertTrue(SurveyTouchRegion.Everything.accepts(0f, 0f))
+ }
+
+ @Test
+ fun aReportedRectClaimsOnlyTheCard() {
+ val region = SurveyTouchRegion.forReported(reported, density)
+
+ assertTrue("the survey must stay usable", region.accepts(insideCard.first, insideCard.second))
+ assertFalse("the host app must stay usable", region.accepts(outsideCard.first, outsideCard.second))
+ }
+
+ @Test
+ fun theReportedRectIsScaledByDisplayDensity() {
+ // The renderer measures in CSS pixels; Android reports touches in physical pixels. Getting
+ // this wrong offsets the whole mask, which looks like the survey ignoring nearby taps.
+ val region = SurveyTouchRegion.forReported(reported, density) as SurveyTouchRegion.Card
+
+ assertEquals(0f, region.left, 0.01f)
+ assertEquals(600f, region.top, 0.01f)
+ assertEquals(390f, region.right, 0.01f)
+ assertEquals(840f, region.bottom, 0.01f)
+ }
+
+ @Test
+ fun noCardOnScreenClaimsNothing() {
+ val region = SurveyTouchRegion.forReported(null, density)
+
+ assertEquals(SurveyTouchRegion.Nothing, region)
+ assertFalse(region.accepts(insideCard.first, insideCard.second))
+ assertFalse(region.accepts(outsideCard.first, outsideCard.second))
+ }
+
+ @Test
+ fun absenceOfACardAndAbsenceOfTheFeatureAreDifferent() {
+ // An older self-hosted server serves a renderer that never calls `onCardRectChange`, so no
+ // rect ever arrives and the region stays Everything — the SDK keeps behaving as it always
+ // did. Treating that as "claim nothing" is what made the Flutter card untappable.
+ assertTrue(SurveyTouchRegion.Everything.accepts(outsideCard.first, outsideCard.second))
+ assertFalse(SurveyTouchRegion.forReported(null, density) == SurveyTouchRegion.Everything)
+ }
+
+ @Test
+ fun cardEdgesAreHalfOpenMatchingRectFContains() {
+ // Right and bottom are exclusive, so the bottom-right corner belongs to the host app.
+ // Pinned because a later inset or rounding change would move it silently.
+ val region = SurveyTouchRegion.forReported(reported, density)
+
+ assertTrue(region.accepts(0f, 600f))
+ assertFalse(region.accepts(390f, 840f))
+ assertFalse(region.accepts(-1f, 600f))
+ }
+
+ @Test
+ fun aZeroAreaCardClaimsNothing() {
+ val region = SurveyTouchRegion.forReported(CardRect(x = 10f, y = 10f, width = 0f, height = 0f), density)
+
+ assertFalse(region.accepts(30f, 30f))
+ }
+
+ @Test
+ fun theBridgePayloadDecodesNullRectIncluded() {
+ val withRect = CardRectData.from(
+ """{"event":"onCardRectChange","rect":{"x":12.5,"y":600,"width":390,"height":240.25}}"""
+ )
+ assertEquals(12.5f, withRect.rect!!.x, 0.01f)
+ assertEquals(240.25f, withRect.rect!!.height, 0.01f)
+
+ // `rect: null` is how the renderer says the card has gone. It must decode, not throw — a
+ // throw would leave the SDK masking touches to a card that is no longer there.
+ val withoutRect = CardRectData.from("""{"event":"onCardRectChange","rect":null}""")
+ assertNull(withoutRect.rect)
+ assertEquals(SurveyTouchRegion.Nothing, SurveyTouchRegion.forReported(withoutRect.rect, density))
+ }
+}
diff --git a/android/src/androidTest/java/com/formbricks/android/webview/WebAppInterfaceInstrumentedTest.kt b/android/src/androidTest/java/com/formbricks/android/webview/WebAppInterfaceInstrumentedTest.kt
index 7687669..74ef243 100644
--- a/android/src/androidTest/java/com/formbricks/android/webview/WebAppInterfaceInstrumentedTest.kt
+++ b/android/src/androidTest/java/com/formbricks/android/webview/WebAppInterfaceInstrumentedTest.kt
@@ -1,6 +1,7 @@
package com.formbricks.android.webview
import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.formbricks.android.model.javascript.CardRect
import com.formbricks.android.model.javascript.EventType
import com.formbricks.android.model.javascript.FileUploadData
import org.junit.Assert.*
@@ -20,6 +21,9 @@ class WebAppInterfaceInstrumentedTest {
var finished = false
var filePick: FileUploadData? = null
var surveyLibraryLoadError = false
+ var cardRectCalls = 0
+ var cardRect: CardRect? = null
+ override fun onCardRectChange(rect: CardRect?) { cardRectCalls++; cardRect = rect }
override fun onClose() { closed = true }
override fun onDisplayCreated() { displayCreated = true }
override fun onResponseCreated() { responseCreated = true }
@@ -34,6 +38,17 @@ class WebAppInterfaceInstrumentedTest {
webAppInterface = WebAppInterface(callback)
}
+ @Test
+ fun testMessage_onCardRectChange() {
+ webAppInterface.message("""{"event":"onCardRectChange","rect":{"x":0,"y":498,"width":411,"height":416}}""")
+ assertEquals(498f, callback.cardRect!!.y, 0.01f)
+
+ // `rect: null` means the card has gone, and still has to reach the callback.
+ webAppInterface.message("""{"event":"onCardRectChange","rect":null}""")
+ assertEquals(2, callback.cardRectCalls)
+ assertNull(callback.cardRect)
+ }
+
@Test
fun testMessage_onClose() {
val json = "{\"event\":\"onClose\"}"
diff --git a/android/src/main/java/com/formbricks/android/model/javascript/CardRectData.kt b/android/src/main/java/com/formbricks/android/model/javascript/CardRectData.kt
new file mode 100644
index 0000000..502f45f
--- /dev/null
+++ b/android/src/main/java/com/formbricks/android/model/javascript/CardRectData.kt
@@ -0,0 +1,40 @@
+package com.formbricks.android.model.javascript
+
+import com.google.gson.Gson
+import com.google.gson.annotations.SerializedName
+
+/**
+ * Where the survey card is, as the shared renderer measures it.
+ *
+ * A WebView hit-tests its whole rectangle and ignores the `pointer-events: none` the renderer
+ * puts outside the card, so a full-screen WebView swallows every touch even when nothing is
+ * painted. To let touches through, the native side has to mask them itself — and only the web
+ * layer knows where the card is, because CSS decides that.
+ *
+ * Values are CSS pixels relative to the viewport, so they must be scaled by the WebView's display
+ * density before being compared against Android touch coordinates, which are in physical pixels.
+ */
+data class CardRect(
+ @SerializedName("x") val x: Float,
+ @SerializedName("y") val y: Float,
+ @SerializedName("width") val width: Float,
+ @SerializedName("height") val height: Float,
+)
+
+/**
+ * `onCardRectChange` payload. [rect] is absent or null when no card is on screen — while it
+ * animates out, or before the first paint.
+ */
+data class CardRectData(
+ @SerializedName("rect") val rect: CardRect?,
+) {
+ companion object {
+ fun from(string: String): CardRectData {
+ return try {
+ Gson().fromJson(string, CardRectData::class.java)
+ } catch (e: Exception) {
+ throw IllegalArgumentException("Invalid JSON format: ${e.message}", e)
+ }
+ }
+ }
+}
diff --git a/android/src/main/java/com/formbricks/android/model/javascript/EventType.kt b/android/src/main/java/com/formbricks/android/model/javascript/EventType.kt
index a9bb3d4..99751f8 100644
--- a/android/src/main/java/com/formbricks/android/model/javascript/EventType.kt
+++ b/android/src/main/java/com/formbricks/android/model/javascript/EventType.kt
@@ -8,5 +8,7 @@ enum class EventType {
@SerializedName("onResponseCreated") ON_RESPONSE_CREATED,
@SerializedName("onFinished") ON_FINISHED,
@SerializedName("onFilePick") ON_FILE_PICK,
- @SerializedName("onSurveyLibraryLoadError") ON_SURVEY_LIBRARY_LOAD_ERROR
+ @SerializedName("onSurveyLibraryLoadError") ON_SURVEY_LIBRARY_LOAD_ERROR,
+ /** The survey card moved or resized; carries its rect. See [com.formbricks.android.webview.SurveyTouchRegion]. */
+ @SerializedName("onCardRectChange") ON_CARD_RECT_CHANGE
}
\ No newline at end of file
diff --git a/android/src/main/java/com/formbricks/android/model/workspace/Survey.kt b/android/src/main/java/com/formbricks/android/model/workspace/Survey.kt
index afeb7fe..5ab5874 100644
--- a/android/src/main/java/com/formbricks/android/model/workspace/Survey.kt
+++ b/android/src/main/java/com/formbricks/android/model/workspace/Survey.kt
@@ -49,6 +49,20 @@ enum class SurveyOverlay(val value: String) {
@SerializedName("none") @SerialName("none") NONE("none"),
@SerializedName("light") @SerialName("light") LIGHT("light"),
@SerializedName("dark") @SerialName("dark") DARK("dark");
+
+ companion object {
+ /**
+ * The overlay a survey actually renders with: its own override, then the workspace
+ * setting, then [NONE].
+ *
+ * One function for both callers on purpose. The WebView payload decides what the renderer
+ * paints, the presentation decides whether the native side blocks touches, and if they
+ * disagree the user gets either a backdrop they can tap through or a corner card that
+ * freezes the app.
+ */
+ fun resolve(surveyOverride: SurveyOverlay?, workspaceSetting: SurveyOverlay?): SurveyOverlay =
+ surveyOverride ?: workspaceSetting ?: NONE
+ }
}
@Serializable
diff --git a/android/src/main/java/com/formbricks/android/webview/FormbricksFragment.kt b/android/src/main/java/com/formbricks/android/webview/FormbricksFragment.kt
index 9d4ce56..b898fe7 100644
--- a/android/src/main/java/com/formbricks/android/webview/FormbricksFragment.kt
+++ b/android/src/main/java/com/formbricks/android/webview/FormbricksFragment.kt
@@ -22,6 +22,7 @@ import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
+import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
@@ -30,20 +31,36 @@ import com.formbricks.android.databinding.FragmentFormbricksBinding
import com.formbricks.android.logger.Logger
import com.formbricks.android.manager.SurveyManager
import com.formbricks.android.model.error.SDKError
+import com.formbricks.android.model.javascript.CardRect
import com.formbricks.android.model.javascript.FileUploadData
import com.formbricks.android.model.workspace.InteractionSource
+import com.formbricks.android.model.workspace.SurveyOverlay
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.gson.JsonObject
import java.io.ByteArrayOutputStream
import java.io.InputStream
+/**
+ * Shows one survey.
+ *
+ * A `light` or `dark` overlay is a bottom-sheet dialog: its backdrop is meant to block the host
+ * app, and a dialog window blocks everything. `overlay: none` cannot work that way — a dialog is a
+ * window of its own and takes every touch inside its bounds, however transparent — so that case is
+ * the same fragment without a dialog, with its view placed into the host Activity's content and
+ * wrapped in [SurveyPassthroughLayout].
+ */
class FormbricksFragment : BottomSheetDialogFragment() {
private lateinit var binding: FragmentFormbricksBinding
private lateinit var surveyId: String
private val viewModel: FormbricksViewModel by viewModels()
private var isDismissing = false
+ private val isPassthrough: Boolean by lazy { arguments?.getBoolean(ARG_PASSTHROUGH) ?: false }
+
+ /** Only set on the no-overlay path. */
+ private var passthroughLayout: SurveyPassthroughLayout? = null
+
/** Scoped to this showing, so each interaction refreshes segments at most once. */
private val interactionForwarder = SurveyInteractionForwarder()
@@ -101,6 +118,15 @@ class FormbricksFragment : BottomSheetDialogFragment() {
Logger.e(error)
safeDismiss()
}
+
+ override fun onCardRectChange(rect: CardRect?) {
+ // JavaScript interface calls arrive on a WebView background thread.
+ Handler(Looper.getMainLooper()).post {
+ val layout = passthroughLayout ?: return@post
+ // CSS pixels to physical pixels: the viewport is pinned at initial-scale=1.0.
+ layout.touchRegion = SurveyTouchRegion.forReported(rect, layout.resources.displayMetrics.density)
+ }
+ }
})
var resultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
@@ -143,6 +169,9 @@ class FormbricksFragment : BottomSheetDialogFragment() {
arguments?.let {
surveyId = it.getString(ARG_SURVEY_ID) ?: throw IllegalArgumentException("Survey ID is required")
}
+ // Has to happen here: DialogFragment decides whether to build a dialog right after
+ // onCreate, and a fragment added without a container would otherwise get one.
+ if (isPassthrough) showsDialog = false
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@@ -151,7 +180,11 @@ class FormbricksFragment : BottomSheetDialogFragment() {
}
binding.viewModel = viewModel
- return binding.root
+ if (!isPassthrough) return binding.root
+ return SurveyPassthroughLayout(requireContext()).also {
+ it.addView(binding.root, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
+ passthroughLayout = it
+ }
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
@@ -162,6 +195,8 @@ class FormbricksFragment : BottomSheetDialogFragment() {
@Suppress("DEPRECATION")
override fun onStart() {
super.onStart()
+ // The bottom-sheet setup below needs the dialog, which the no-overlay path does not have.
+ if (isPassthrough) return
val view: FrameLayout = dialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet)!!
view.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
val behavior = BottomSheetBehavior.from(view)
@@ -179,6 +214,7 @@ class FormbricksFragment : BottomSheetDialogFragment() {
@SuppressLint("SetJavaScriptEnabled")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
+ if (isPassthrough) attachToHostContent(view)
dialog?.window?.setDimAmount(0.0f)
binding.formbricksWebview.setBackgroundColor(Color.TRANSPARENT)
binding.formbricksWebview.let {
@@ -223,6 +259,31 @@ class FormbricksFragment : BottomSheetDialogFragment() {
}
}
+ /**
+ * Puts the survey on top of the host Activity's content.
+ *
+ * The fragment is added without a container, so the FragmentManager creates the view but
+ * places it nowhere. Placing it by hand, rather than adding the fragment into
+ * `android.R.id.content`, keeps this working when the host handed us a child FragmentManager,
+ * whose container lookup would not find that id and would crash the commit.
+ */
+ private fun attachToHostContent(view: View) {
+ val content = requireActivity().findViewById(android.R.id.content)
+ content.addView(view, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
+
+ // The dialog swallowed back (it is not cancelable). Here back would otherwise reach the
+ // host, which may navigate away and leave the survey floating over another screen, so it
+ // closes the survey instead.
+ requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { safeDismiss() }
+ }
+
+ override fun onDestroyView() {
+ // The FragmentManager only removes views it placed itself; this one it did not.
+ (view?.parent as? ViewGroup)?.removeView(view)
+ passthroughLayout = null
+ super.onDestroyView()
+ }
+
private fun getFileName(uri: Uri): String? {
var fileName: String? = null
activity?.contentResolver?.query(uri, null, null, null, null)?.use { cursor ->
@@ -281,14 +342,40 @@ class FormbricksFragment : BottomSheetDialogFragment() {
companion object {
private val TAG: String by lazy { FormbricksFragment::class.java.simpleName }
private const val ARG_SURVEY_ID = "survey_id"
+ private const val ARG_PASSTHROUGH = "passthrough"
fun show(childFragmentManager: FragmentManager, surveyId: String) {
+ // The host app stays usable while a no-overlay survey is open, so it can track again
+ // mid-survey. Without this a second survey would stack on top of the first.
+ //
+ // Both paths below commit synchronously, which is what lets this guard see a survey shown
+ // earlier in the same main-thread turn: `findFragmentByTag` does not search pending
+ // transactions, and two zero-delay surveys can post their `show` back to back.
+ val showing = childFragmentManager.findFragmentByTag(TAG)
+ if (showing != null && !showing.isRemoving) {
+ Logger.d("Skipping survey $surveyId: a survey is already showing.")
+ return
+ }
+
+ val workspace = SurveyManager.workspaceDataHolder?.data?.data
+ val overlay = SurveyOverlay.resolve(
+ workspace?.surveys?.firstOrNull { it.id == surveyId }?.projectOverwrites?.overlay,
+ workspace?.settings?.overlay,
+ )
+ val passthrough = overlay == SurveyOverlay.NONE
+
val fragment = FormbricksFragment().apply {
arguments = Bundle().apply {
putString(ARG_SURVEY_ID, surveyId)
+ putBoolean(ARG_PASSTHROUGH, passthrough)
}
}
- fragment.show(childFragmentManager, TAG)
+ if (passthrough) {
+ // No container: the fragment places its own view (see attachToHostContent).
+ childFragmentManager.beginTransaction().add(fragment, TAG).commitNow()
+ } else {
+ fragment.showNow(childFragmentManager, TAG)
+ }
}
}
}
\ No newline at end of file
diff --git a/android/src/main/java/com/formbricks/android/webview/FormbricksViewModel.kt b/android/src/main/java/com/formbricks/android/webview/FormbricksViewModel.kt
index 3b0a491..bbcf6bd 100644
--- a/android/src/main/java/com/formbricks/android/webview/FormbricksViewModel.kt
+++ b/android/src/main/java/com/formbricks/android/webview/FormbricksViewModel.kt
@@ -65,6 +65,17 @@ class FormbricksViewModel : ViewModel() {
FormbricksJavascript.message(JSON.stringify({ event: "onFinished" }));
};
+ // Where the survey card is. The native side cannot work this out for itself —
+ // CSS decides it inside the page — and it needs it to pass touches outside the
+ // card through to the host app. `rect` is null when no card is on screen.
+ //
+ // Only the renderer shipped with Formbricks 6.0+ calls this. Against an older
+ // self-hosted server it never fires, and the native side keeps its previous
+ // behaviour of taking every touch.
+ function onCardRectChange(rect) {
+ FormbricksJavascript.message(JSON.stringify({ event: "onCardRectChange", rect: rect }));
+ };
+
let setResponseFinished = null;
function getSetIsResponseSendingFinished(callback) {
setResponseFinished = callback;
@@ -79,6 +90,7 @@ class FormbricksViewModel : ViewModel() {
onResponseCreated,
onFinished,
onClose,
+ onCardRectChange,
};
window.formbricksSurveys.renderSurvey(surveyProps);
@@ -178,7 +190,7 @@ class FormbricksViewModel : ViewModel() {
val clickOutside = matchedSurvey?.projectOverwrites?.clickOutsideClose ?: settings?.clickOutsideClose ?: false
jsonObject.addProperty("clickOutside", clickOutside)
- val overlay = (matchedSurvey?.projectOverwrites?.overlay ?: settings?.overlay ?: SurveyOverlay.NONE).value
+ val overlay = SurveyOverlay.resolve(matchedSurvey?.projectOverwrites?.overlay, settings?.overlay).value
jsonObject.addProperty("overlay", overlay)
val enabled = settings?.styling?.allowStyleOverwrite ?: false
diff --git a/android/src/main/java/com/formbricks/android/webview/SurveyPassthroughLayout.kt b/android/src/main/java/com/formbricks/android/webview/SurveyPassthroughLayout.kt
new file mode 100644
index 0000000..55e43c4
--- /dev/null
+++ b/android/src/main/java/com/formbricks/android/webview/SurveyPassthroughLayout.kt
@@ -0,0 +1,86 @@
+package com.formbricks.android.webview
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.view.MotionEvent
+import android.view.ViewTreeObserver
+import android.widget.FrameLayout
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import kotlin.math.max
+
+/**
+ * Hosts a no-overlay survey inside the host Activity's own content view.
+ *
+ * Declining a touch-down here is what lets the host app have it: the parent `FrameLayout` then
+ * offers the same event to the next child down, which is the host's content. That only works
+ * within one window — a `Dialog` is a window of its own, and Android picks the window by bounds
+ * before any view sees the touch — which is why this path is not a dialog.
+ */
+@SuppressLint("ViewConstructor")
+internal class SurveyPassthroughLayout(context: Context) : FrameLayout(context) {
+
+ /** Starts at [SurveyTouchRegion.Everything]: blocks like the dialog did until a rect arrives. */
+ var touchRegion: SurveyTouchRegion = SurveyTouchRegion.Everything
+
+ private val layoutListener = ViewTreeObserver.OnGlobalLayoutListener { updateKeyboardPadding(null) }
+
+ init {
+ // Edge-to-edge windows (the default from Android 15) do not shrink for the keyboard, so
+ // the survey has to make room itself. Insets changes cover that case; the layout listener
+ // covers hosts that still resize, where this view simply comes out shorter.
+ ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets ->
+ updateKeyboardPadding(insets)
+ insets
+ }
+ }
+
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean {
+ // The rest of a gesture follows its down event, so deciding on the down is enough.
+ if (event.actionMasked == MotionEvent.ACTION_DOWN && !touchRegion.accepts(event.x, event.y)) {
+ return false
+ }
+ return super.dispatchTouchEvent(event)
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ viewTreeObserver.addOnGlobalLayoutListener(layoutListener)
+ ViewCompat.requestApplyInsets(this)
+ }
+
+ override fun onDetachedFromWindow() {
+ viewTreeObserver.removeOnGlobalLayoutListener(layoutListener)
+ super.onDetachedFromWindow()
+ }
+
+ /**
+ * Pads the bottom by however much of this view the keyboard covers. The WebView shrinks with
+ * the padding, so the renderer lays the card out above the keyboard and reports the new rect.
+ */
+ private fun updateKeyboardPadding(dispatched: WindowInsetsCompat?) {
+ // The root insets when attached: before API 30, a host view earlier in the content
+ // FrameLayout can consume the keyboard inset before it is ever dispatched to this one.
+ val insets = ViewCompat.getRootWindowInsets(this) ?: dispatched ?: return
+ val location = IntArray(2)
+ getLocationInWindow(location)
+ val padding = keyboardOverlap(
+ viewBottom = location[1] + height,
+ windowHeight = rootView.height,
+ keyboardHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom,
+ )
+ if (padding != paddingBottom) setPadding(0, 0, 0, padding)
+ }
+
+ companion object {
+ /**
+ * How far the keyboard reaches up into a view whose bottom edge sits at [viewBottom], all
+ * in window pixels. Zero when the host already resized the window, because the view then
+ * ends above the keyboard anyway.
+ */
+ fun keyboardOverlap(viewBottom: Int, windowHeight: Int, keyboardHeight: Int): Int {
+ if (keyboardHeight <= 0) return 0
+ return max(0, viewBottom - (windowHeight - keyboardHeight))
+ }
+ }
+}
diff --git a/android/src/main/java/com/formbricks/android/webview/SurveyTouchRegion.kt b/android/src/main/java/com/formbricks/android/webview/SurveyTouchRegion.kt
new file mode 100644
index 0000000..3d96672
--- /dev/null
+++ b/android/src/main/java/com/formbricks/android/webview/SurveyTouchRegion.kt
@@ -0,0 +1,86 @@
+package com.formbricks.android.webview
+
+import com.formbricks.android.model.javascript.CardRect
+
+/**
+ * Which touches over the survey's full-screen WebView belong to the survey, and which should
+ * reach the host app underneath.
+ *
+ * A WebView hit-tests its entire rectangle. The shared renderer already sets
+ * `pointer-events: none` outside the card, but that is a *web* hit test — Android never sees it,
+ * so a transparent full-screen WebView still swallows every touch and the host app appears frozen.
+ *
+ * Three states rather than two, because "no rect has arrived" and "the card is not on screen"
+ * need opposite answers. Conflating them is what made the Flutter SDK's card untappable when its
+ * DOM probe stopped matching: a missing rect was read as "claim nothing", so the survey itself
+ * stopped responding.
+ */
+sealed interface SurveyTouchRegion {
+
+ /** Whether a touch at ([x], [y]) in physical pixels belongs to the survey. */
+ fun accepts(x: Float, y: Float): Boolean
+
+ /**
+ * Every touch belongs to the survey.
+ *
+ * Correct for a `light` or `dark` overlay, whose visible backdrop is meant to block the host
+ * app. Also the starting state for a no-overlay survey, and it stays that way if the renderer
+ * never reports a rect — an older self-hosted server serves a bundle without
+ * `onCardRectChange`, and behaving exactly as the SDK always did is the safe answer there.
+ */
+ data object Everything : SurveyTouchRegion {
+ override fun accepts(x: Float, y: Float): Boolean = true
+ }
+
+ /**
+ * Nothing belongs to the survey, because no card is on screen.
+ *
+ * The renderer reports this while the card animates out, and the card is hidden for a full
+ * second before the close arrives. Without this state the SDK leaves a dead patch over a host
+ * app that looks perfectly usable.
+ */
+ data object Nothing : SurveyTouchRegion {
+ override fun accepts(x: Float, y: Float): Boolean = false
+ }
+
+ /**
+ * Only touches inside these bounds (physical pixels) belong to the survey.
+ *
+ * Plain floats rather than `RectF` on purpose: `android.graphics` is stubbed to throw in JVM
+ * unit tests, so a framework type here would force this logic onto a device or Robolectric for
+ * no benefit. The fragment converts at the edge.
+ *
+ * Right and bottom are exclusive, matching `RectF.contains`.
+ */
+ data class Card(
+ val left: Float,
+ val top: Float,
+ val right: Float,
+ val bottom: Float,
+ ) : SurveyTouchRegion {
+ override fun accepts(x: Float, y: Float): Boolean =
+ x >= left && x < right && y >= top && y < bottom
+ }
+
+ companion object {
+ /**
+ * Maps a rect reported by the renderer onto a region.
+ *
+ * [density] converts the renderer's CSS pixels into the physical pixels Android reports
+ * touches in; passing the wrong one silently offsets the whole mask, which looks like the
+ * survey ignoring taps near its edges.
+ *
+ * A missing rect means the card is not on screen — deliberately *not* [Everything], which
+ * means the renderer never reported at all.
+ */
+ fun forReported(rect: CardRect?, density: Float): SurveyTouchRegion {
+ if (rect == null) return Nothing
+ return Card(
+ left = rect.x * density,
+ top = rect.y * density,
+ right = (rect.x + rect.width) * density,
+ bottom = (rect.y + rect.height) * density,
+ )
+ }
+ }
+}
diff --git a/android/src/main/java/com/formbricks/android/webview/WebAppInterface.kt b/android/src/main/java/com/formbricks/android/webview/WebAppInterface.kt
index 8f6d196..cc934cf 100644
--- a/android/src/main/java/com/formbricks/android/webview/WebAppInterface.kt
+++ b/android/src/main/java/com/formbricks/android/webview/WebAppInterface.kt
@@ -4,6 +4,8 @@ import android.webkit.JavascriptInterface
import com.formbricks.android.logger.Logger
import com.formbricks.android.model.javascript.JsMessageData
import com.formbricks.android.model.javascript.EventType
+import com.formbricks.android.model.javascript.CardRect
+import com.formbricks.android.model.javascript.CardRectData
import com.formbricks.android.model.javascript.FileUploadData
import com.google.gson.JsonParseException
import java.lang.RuntimeException
@@ -26,6 +28,16 @@ class WebAppInterface(private val callback: WebAppCallback?) {
}
fun onFilePick(data: FileUploadData)
fun onSurveyLibraryLoadError()
+
+ /**
+ * The survey card moved, resized, or left the screen ([rect] null).
+ *
+ * Defaulted for the same reason as [onFinished]: [WebAppCallback] is public, so an
+ * abstract member would stop existing implementors compiling.
+ */
+ fun onCardRectChange(rect: CardRect?) {
+ // Intentionally empty; FormbricksFragment overrides it.
+ }
}
/**
@@ -44,6 +56,7 @@ class WebAppInterface(private val callback: WebAppCallback?) {
EventType.ON_FINISHED -> callback?.onFinished()
EventType.ON_FILE_PICK -> { callback?.onFilePick(FileUploadData.from(data)) }
EventType.ON_SURVEY_LIBRARY_LOAD_ERROR -> { callback?.onSurveyLibraryLoadError() }
+ EventType.ON_CARD_RECT_CHANGE -> { callback?.onCardRectChange(CardRectData.from(data).rect) }
}
} catch (e: Exception) {
Logger.e(RuntimeException(e.message))