Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions android/src/androidTest/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application>
<!-- A bare host Activity for view tests (SurveyPassthroughInstrumentedTest): the library has none. -->
<activity
android:name="androidx.activity.ComponentActivity"
android:exported="false" />
<!-- A host with a FragmentManager, for FormbricksFragment.show. -->
<activity
android:name="androidx.fragment.app.FragmentActivity"
android:exported="false" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -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<ViewGroup>(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))
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
Original file line number Diff line number Diff line change
@@ -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.*
Expand All @@ -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 }
Expand All @@ -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\"}"
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading
Loading