From ff029c177b52896850e95762c16f91803f5041f8 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Wed, 30 Sep 2026 17:39:44 +0530 Subject: [PATCH 1/3] fix: let host-app touches through when the survey has no overlay [ENG-3157] --- android/build.gradle.kts | 2 + .../android/model/javascript/CardRectData.kt | 40 ++++++ .../android/model/javascript/EventType.kt | 4 +- .../android/model/workspace/Survey.kt | 14 +++ .../android/webview/FormbricksFragment.kt | 87 ++++++++++++- .../android/webview/FormbricksViewModel.kt | 14 ++- .../webview/SurveyPassthroughLayout.kt | 84 +++++++++++++ .../android/webview/SurveyTouchRegion.kt | 86 +++++++++++++ .../android/webview/WebAppInterface.kt | 13 ++ .../android/webview/SurveyPassthroughTest.kt | 55 +++++++++ .../android/webview/SurveyTouchRegionTest.kt | 115 ++++++++++++++++++ 11 files changed, 510 insertions(+), 4 deletions(-) create mode 100644 android/src/main/java/com/formbricks/android/model/javascript/CardRectData.kt create mode 100644 android/src/main/java/com/formbricks/android/webview/SurveyPassthroughLayout.kt create mode 100644 android/src/main/java/com/formbricks/android/webview/SurveyTouchRegion.kt create mode 100644 android/src/test/java/com/formbricks/android/webview/SurveyPassthroughTest.kt create mode 100644 android/src/test/java/com/formbricks/android/webview/SurveyTouchRegionTest.kt diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 3f6a805..20f949a 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -162,6 +162,8 @@ dependencies { implementation(libs.androidx.fragment.ktx) implementation(libs.androidx.databinding.common) + // Plain JVM tests, for logic that needs no device (see SurveyTouchRegionTest). + testImplementation("junit:junit:4.13.2") androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) } 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..ac3026c 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,36 @@ 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. + 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).commit() + } else { + fragment.show(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..a674b7a --- /dev/null +++ b/android/src/main/java/com/formbricks/android/webview/SurveyPassthroughLayout.kt @@ -0,0 +1,84 @@ +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() } + + 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 + } + } + + 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() { + val insets = ViewCompat.getRootWindowInsets(this) ?: 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)) diff --git a/android/src/test/java/com/formbricks/android/webview/SurveyPassthroughTest.kt b/android/src/test/java/com/formbricks/android/webview/SurveyPassthroughTest.kt new file mode 100644 index 0000000..47d011e --- /dev/null +++ b/android/src/test/java/com/formbricks/android/webview/SurveyPassthroughTest.kt @@ -0,0 +1,55 @@ +package com.formbricks.android.webview + +import com.formbricks.android.model.workspace.SurveyOverlay +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The no-overlay path's two decisions that need no device: which surveys take it, and how much + * room the keyboard needs. + */ +class SurveyPassthroughTest { + + // Which path a survey takes + + @Test + fun `a survey override beats the workspace overlay`() { + assertEquals(SurveyOverlay.DARK, SurveyOverlay.resolve(SurveyOverlay.DARK, SurveyOverlay.NONE)) + assertEquals(SurveyOverlay.NONE, SurveyOverlay.resolve(SurveyOverlay.NONE, SurveyOverlay.LIGHT)) + } + + @Test + fun `without an override the workspace overlay applies, and no setting means none`() { + 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)) + } + + // Keyboard + + private val windowHeight = 2400 + + @Test + fun `no keyboard needs no room`() { + assertEquals(0, SurveyPassthroughLayout.keyboardOverlap(windowHeight, windowHeight, keyboardHeight = 0)) + } + + @Test + fun `an edge-to-edge window makes room for the whole keyboard`() { + // The window does not shrink, so the survey still reaches the bottom of the screen. + assertEquals(900, SurveyPassthroughLayout.keyboardOverlap(windowHeight, windowHeight, keyboardHeight = 900)) + } + + @Test + fun `a host that already resized needs nothing more`() { + // Adding padding on top of the resize would push the card twice as far up. + assertEquals(0, SurveyPassthroughLayout.keyboardOverlap(1500, windowHeight, keyboardHeight = 900)) + // Ending clear of the keyboard is still zero, never negative padding. + assertEquals(0, SurveyPassthroughLayout.keyboardOverlap(1200, windowHeight, keyboardHeight = 900)) + } + + @Test + fun `a view ending above the bottom only makes up the part the keyboard covers`() { + assertEquals(100, SurveyPassthroughLayout.keyboardOverlap(1600, windowHeight, keyboardHeight = 900)) + } +} diff --git a/android/src/test/java/com/formbricks/android/webview/SurveyTouchRegionTest.kt b/android/src/test/java/com/formbricks/android/webview/SurveyTouchRegionTest.kt new file mode 100644 index 0000000..2884046 --- /dev/null +++ b/android/src/test/java/com/formbricks/android/webview/SurveyTouchRegionTest.kt @@ -0,0 +1,115 @@ +package com.formbricks.android.webview + +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 + +/** + * 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 overlay mode, + * because getting it wrong is invisible in review and obvious to a user: either the host app + * freezes, or the survey itself stops responding. + * + * Plain JVM tests: the logic is arithmetic on a rect, so it needs no device. + */ +class SurveyTouchRegionTest { + + 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 `an overlaid survey claims every touch`() { + // 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 `a reported rect claims only the card`() { + 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 `the reported rect is scaled by display density`() { + // 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 `no card on screen claims nothing`() { + 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 `absence of a card and absence of the feature are different`() { + // 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 `card edges are half-open, matching RectF contains`() { + // Right and bottom are exclusive, matching RectF.contains, 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 `a zero-area card claims nothing`() { + val region = SurveyTouchRegion.forReported( + CardRect(x = 10f, y = 10f, width = 0f, height = 0f), density + ) + + assertFalse(region.accepts(30f, 30f)) + } + + @Test + fun `the bridge payload decodes, null rect included`() { + 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)) + } +} From 93927e6229bf4d579011bfaa9e42254302ee3170 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Wed, 30 Sep 2026 18:36:51 +0530 Subject: [PATCH 2/3] test: run the pass-through tests on the emulator so coverage sees them [ENG-3157] --- android/build.gradle.kts | 2 - android/src/androidTest/AndroidManifest.xml | 10 ++ .../SurveyPassthroughInstrumentedTest.kt | 158 ++++++++++++++++++ .../SurveyTouchRegionInstrumentedTest.kt} | 48 +++--- .../WebAppInterfaceInstrumentedTest.kt | 15 ++ .../webview/SurveyPassthroughLayout.kt | 10 +- .../android/webview/SurveyPassthroughTest.kt | 55 ------ 7 files changed, 209 insertions(+), 89 deletions(-) create mode 100644 android/src/androidTest/AndroidManifest.xml create mode 100644 android/src/androidTest/java/com/formbricks/android/webview/SurveyPassthroughInstrumentedTest.kt rename android/src/{test/java/com/formbricks/android/webview/SurveyTouchRegionTest.kt => androidTest/java/com/formbricks/android/webview/SurveyTouchRegionInstrumentedTest.kt} (71%) delete mode 100644 android/src/test/java/com/formbricks/android/webview/SurveyPassthroughTest.kt diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 20f949a..3f6a805 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -162,8 +162,6 @@ dependencies { implementation(libs.androidx.fragment.ktx) implementation(libs.androidx.databinding.common) - // Plain JVM tests, for logic that needs no device (see SurveyTouchRegionTest). - testImplementation("junit:junit:4.13.2") androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) } diff --git a/android/src/androidTest/AndroidManifest.xml b/android/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..94d0a2c --- /dev/null +++ b/android/src/androidTest/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + + 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..1a29132 --- /dev/null +++ b/android/src/androidTest/java/com/formbricks/android/webview/SurveyPassthroughInstrumentedTest.kt @@ -0,0 +1,158 @@ +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.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +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)) + } + + // 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/test/java/com/formbricks/android/webview/SurveyTouchRegionTest.kt b/android/src/androidTest/java/com/formbricks/android/webview/SurveyTouchRegionInstrumentedTest.kt similarity index 71% rename from android/src/test/java/com/formbricks/android/webview/SurveyTouchRegionTest.kt rename to android/src/androidTest/java/com/formbricks/android/webview/SurveyTouchRegionInstrumentedTest.kt index 2884046..7022159 100644 --- a/android/src/test/java/com/formbricks/android/webview/SurveyTouchRegionTest.kt +++ b/android/src/androidTest/java/com/formbricks/android/webview/SurveyTouchRegionInstrumentedTest.kt @@ -1,5 +1,6 @@ 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 @@ -7,16 +8,16 @@ 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 overlay mode, - * because getting it wrong is invisible in review and obvious to a user: either the host app - * freezes, or the survey itself stops responding. - * - * Plain JVM tests: the logic is arithmetic on a rect, so it needs no device. + * 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. */ -class SurveyTouchRegionTest { +@RunWith(AndroidJUnit4::class) +class SurveyTouchRegionInstrumentedTest { private val density = 3f private val reported = CardRect(x = 0f, y = 200f, width = 130f, height = 80f) @@ -26,7 +27,7 @@ class SurveyTouchRegionTest { private val outsideCard = Pair(195f, 200f) @Test - fun `an overlaid survey claims every touch`() { + 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)) @@ -34,21 +35,15 @@ class SurveyTouchRegionTest { } @Test - fun `a reported rect claims only the card`() { + 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) - ) + 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 `the reported rect is scaled by display density`() { + 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 @@ -60,7 +55,7 @@ class SurveyTouchRegionTest { } @Test - fun `no card on screen claims nothing`() { + fun noCardOnScreenClaimsNothing() { val region = SurveyTouchRegion.forReported(null, density) assertEquals(SurveyTouchRegion.Nothing, region) @@ -69,7 +64,7 @@ class SurveyTouchRegionTest { } @Test - fun `absence of a card and absence of the feature are different`() { + 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. @@ -78,10 +73,9 @@ class SurveyTouchRegionTest { } @Test - fun `card edges are half-open, matching RectF contains`() { - // Right and bottom are exclusive, matching RectF.contains, so the bottom-right corner - // belongs to the host app. Pinned because a later inset or rounding change would move it - // silently. + 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)) @@ -90,16 +84,14 @@ class SurveyTouchRegionTest { } @Test - fun `a zero-area card claims nothing`() { - val region = SurveyTouchRegion.forReported( - CardRect(x = 10f, y = 10f, width = 0f, height = 0f), density - ) + fun aZeroAreaCardClaimsNothing() { + val region = SurveyTouchRegion.forReported(CardRect(x = 10f, y = 10f, width = 0f, height = 0f), density) assertFalse(region.accepts(30f, 30f)) } @Test - fun `the bridge payload decodes, null rect included`() { + fun theBridgePayloadDecodesNullRectIncluded() { val withRect = CardRectData.from( """{"event":"onCardRectChange","rect":{"x":12.5,"y":600,"width":390,"height":240.25}}""" ) 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/webview/SurveyPassthroughLayout.kt b/android/src/main/java/com/formbricks/android/webview/SurveyPassthroughLayout.kt index a674b7a..55e43c4 100644 --- a/android/src/main/java/com/formbricks/android/webview/SurveyPassthroughLayout.kt +++ b/android/src/main/java/com/formbricks/android/webview/SurveyPassthroughLayout.kt @@ -23,14 +23,14 @@ 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() } + 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() + updateKeyboardPadding(insets) insets } } @@ -58,8 +58,10 @@ internal class SurveyPassthroughLayout(context: Context) : FrameLayout(context) * 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() { - val insets = ViewCompat.getRootWindowInsets(this) ?: return + 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( diff --git a/android/src/test/java/com/formbricks/android/webview/SurveyPassthroughTest.kt b/android/src/test/java/com/formbricks/android/webview/SurveyPassthroughTest.kt deleted file mode 100644 index 47d011e..0000000 --- a/android/src/test/java/com/formbricks/android/webview/SurveyPassthroughTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.formbricks.android.webview - -import com.formbricks.android.model.workspace.SurveyOverlay -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * The no-overlay path's two decisions that need no device: which surveys take it, and how much - * room the keyboard needs. - */ -class SurveyPassthroughTest { - - // Which path a survey takes - - @Test - fun `a survey override beats the workspace overlay`() { - assertEquals(SurveyOverlay.DARK, SurveyOverlay.resolve(SurveyOverlay.DARK, SurveyOverlay.NONE)) - assertEquals(SurveyOverlay.NONE, SurveyOverlay.resolve(SurveyOverlay.NONE, SurveyOverlay.LIGHT)) - } - - @Test - fun `without an override the workspace overlay applies, and no setting means none`() { - 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)) - } - - // Keyboard - - private val windowHeight = 2400 - - @Test - fun `no keyboard needs no room`() { - assertEquals(0, SurveyPassthroughLayout.keyboardOverlap(windowHeight, windowHeight, keyboardHeight = 0)) - } - - @Test - fun `an edge-to-edge window makes room for the whole keyboard`() { - // The window does not shrink, so the survey still reaches the bottom of the screen. - assertEquals(900, SurveyPassthroughLayout.keyboardOverlap(windowHeight, windowHeight, keyboardHeight = 900)) - } - - @Test - fun `a host that already resized needs nothing more`() { - // Adding padding on top of the resize would push the card twice as far up. - assertEquals(0, SurveyPassthroughLayout.keyboardOverlap(1500, windowHeight, keyboardHeight = 900)) - // Ending clear of the keyboard is still zero, never negative padding. - assertEquals(0, SurveyPassthroughLayout.keyboardOverlap(1200, windowHeight, keyboardHeight = 900)) - } - - @Test - fun `a view ending above the bottom only makes up the part the keyboard covers`() { - assertEquals(100, SurveyPassthroughLayout.keyboardOverlap(1600, windowHeight, keyboardHeight = 900)) - } -} From ddf8656f2cd088ac31ccc256bd222ac56ed6c9fb Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Thu, 1 Oct 2026 11:26:58 +0530 Subject: [PATCH 3/3] fix: show surveys synchronously so the duplicate guard sees them [ENG-3157] --- android/src/androidTest/AndroidManifest.xml | 4 +++ .../SurveyPassthroughInstrumentedTest.kt | 26 +++++++++++++++++++ .../android/webview/FormbricksFragment.kt | 8 ++++-- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/android/src/androidTest/AndroidManifest.xml b/android/src/androidTest/AndroidManifest.xml index 94d0a2c..42331a3 100644 --- a/android/src/androidTest/AndroidManifest.xml +++ b/android/src/androidTest/AndroidManifest.xml @@ -6,5 +6,9 @@ + + diff --git a/android/src/androidTest/java/com/formbricks/android/webview/SurveyPassthroughInstrumentedTest.kt b/android/src/androidTest/java/com/formbricks/android/webview/SurveyPassthroughInstrumentedTest.kt index 1a29132..1a6ca5f 100644 --- a/android/src/androidTest/java/com/formbricks/android/webview/SurveyPassthroughInstrumentedTest.kt +++ b/android/src/androidTest/java/com/formbricks/android/webview/SurveyPassthroughInstrumentedTest.kt @@ -11,9 +11,11 @@ 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 @@ -141,6 +143,30 @@ class SurveyPassthroughInstrumentedTest { 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 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 ac3026c..b898fe7 100644 --- a/android/src/main/java/com/formbricks/android/webview/FormbricksFragment.kt +++ b/android/src/main/java/com/formbricks/android/webview/FormbricksFragment.kt @@ -347,6 +347,10 @@ class FormbricksFragment : BottomSheetDialogFragment() { 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.") @@ -368,9 +372,9 @@ class FormbricksFragment : BottomSheetDialogFragment() { } if (passthrough) { // No container: the fragment places its own view (see attachToHostContent). - childFragmentManager.beginTransaction().add(fragment, TAG).commit() + childFragmentManager.beginTransaction().add(fragment, TAG).commitNow() } else { - fragment.show(childFragmentManager, TAG) + fragment.showNow(childFragmentManager, TAG) } } }