From 8efaf2b8789f1b8ccbac92006cffe7d5c4c208a9 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Mon, 9 Nov 2026 12:38:45 +0530 Subject: [PATCH 1/2] fix: keep the host Activity alive when a backgrounded survey closes [ENG-3845] Once the host has saved its state, dismiss() throws, so safeDismiss() finished the host Activity instead and the app restarted on return. Dismiss with state loss and never touch the host. The survey library load-error path runs through the same function. --- android/src/androidTest/AndroidManifest.xml | 6 +- ...rmbricksFragmentDismissInstrumentedTest.kt | 168 ++++++++++++++++++ .../android/webview/FormbricksFragment.kt | 17 +- 3 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 android/src/androidTest/java/com/formbricks/android/webview/FormbricksFragmentDismissInstrumentedTest.kt diff --git a/android/src/androidTest/AndroidManifest.xml b/android/src/androidTest/AndroidManifest.xml index 42331a3..532c448 100644 --- a/android/src/androidTest/AndroidManifest.xml +++ b/android/src/androidTest/AndroidManifest.xml @@ -6,9 +6,11 @@ - + + android:exported="false" + android:theme="@style/Theme.MaterialComponents.DayNight.NoActionBar" /> diff --git a/android/src/androidTest/java/com/formbricks/android/webview/FormbricksFragmentDismissInstrumentedTest.kt b/android/src/androidTest/java/com/formbricks/android/webview/FormbricksFragmentDismissInstrumentedTest.kt new file mode 100644 index 0000000..088a5e9 --- /dev/null +++ b/android/src/androidTest/java/com/formbricks/android/webview/FormbricksFragmentDismissInstrumentedTest.kt @@ -0,0 +1,168 @@ +package com.formbricks.android.webview + +import android.os.Bundle +import android.view.ViewGroup +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +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.manager.SurveyManager +import com.formbricks.android.model.workspace.Settings +import com.formbricks.android.model.workspace.SurveyOverlay +import com.formbricks.android.model.workspace.WorkspaceData +import com.formbricks.android.model.workspace.WorkspaceDataHolder +import com.formbricks.android.model.workspace.WorkspaceResponseData +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Closing a survey must only ever dismiss the survey. Once the host has saved its state, which it + * does when it goes to the background, a plain `dismiss()` throws. The SDK used to finish the host + * Activity instead, and the user came back to an app that had restarted. + * + * Tests that store a workspace run in one main-thread turn on purpose. A stored workspace makes the + * WebView load the survey library, which has no network here and reports a failed load a moment + * later. That report closes the survey through the very code under test. + */ +@RunWith(AndroidJUnit4::class) +class FormbricksFragmentDismissInstrumentedTest { + + private var workspaceBefore: WorkspaceDataHolder? = null + + @Before + fun setUp() { + Formbricks.applicationContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext + Formbricks.appUrl = "https://test.formbricks.com" + Formbricks.workspaceId = "test-workspace" + workspaceBefore = SurveyManager.workspaceDataHolder + } + + @After + fun tearDown() { + SurveyManager.workspaceDataHolder = workspaceBefore + } + + /** The workspace overlay picks the path: `none` is the pass-through view, `light` the dialog. */ + private fun workspaceWith(overlay: SurveyOverlay) = WorkspaceDataHolder( + data = WorkspaceResponseData( + data = WorkspaceData( + surveys = emptyList(), + actionClasses = null, + settings = Settings( + id = "test-workspace", + recontactDays = null, + clickOutsideClose = null, + overlay = overlay, + placement = null, + inAppSurveyBranding = null, + styling = null, + ), + ), + expiresAt = null, + ), + originalResponseMap = mapOf(), + ) + + private fun inOneTurn(overlay: SurveyOverlay, block: (FragmentActivity, FormbricksFragment) -> Unit) { + SurveyManager.workspaceDataHolder = workspaceWith(overlay) + ActivityScenario.launch(FragmentActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + FormbricksFragment.show(activity.supportFragmentManager, "survey-one") + block(activity, surveysIn(activity).single()) + } + } + } + + private fun surveysIn(activity: FragmentActivity) = + activity.supportFragmentManager.fragments.filterIsInstance() + + /** Pass-through surveys put their own layer into the host's content; it must not outlive them. */ + private fun surveyLayersOn(activity: FragmentActivity): Int { + val content = activity.findViewById(android.R.id.content) + return (0 until content.childCount).count { content.getChildAt(it) is SurveyPassthroughLayout } + } + + /** What the framework does to a host on its way to the background, without waiting for the stop. */ + private fun saveHostState(activity: FragmentActivity) { + InstrumentationRegistry.getInstrumentation().callActivityOnSaveInstanceState(activity, Bundle()) + } + + private fun closeAfterTheHostSavedItsState(overlay: SurveyOverlay) = inOneTurn(overlay) { activity, survey -> + saveHostState(activity) + assertTrue("the host must be past onSaveInstanceState", survey.isStateSaved) + + survey.safeDismiss() + + assertFalse("closing the survey must not close the host", activity.isFinishing) + activity.supportFragmentManager.executePendingTransactions() + assertTrue("the survey is dismissed", surveysIn(activity).isEmpty()) + assertEquals("no survey layer is left over the host", 0, surveyLayersOn(activity)) + } + + private fun closeInTheForeground(overlay: SurveyOverlay) = inOneTurn(overlay) { activity, survey -> + assertFalse(survey.isStateSaved) + + survey.safeDismiss() + activity.supportFragmentManager.executePendingTransactions() + + assertTrue("the survey is dismissed", surveysIn(activity).isEmpty()) + assertEquals("no survey layer is left over the host", 0, surveyLayersOn(activity)) + assertFalse(activity.isFinishing) + } + + @Test + fun aCloseAfterTheHostSavedItsStateKeepsTheHostAliveWithoutAnOverlay() = + closeAfterTheHostSavedItsState(SurveyOverlay.NONE) + + @Test + fun aCloseAfterTheHostSavedItsStateKeepsTheHostAliveBehindADialog() = + closeAfterTheHostSavedItsState(SurveyOverlay.LIGHT) + + @Test + fun aForegroundCloseDismissesASurveyWithoutAnOverlay() = closeInTheForeground(SurveyOverlay.NONE) + + @Test + fun aForegroundCloseDismissesASurveyBehindADialog() = closeInTheForeground(SurveyOverlay.LIGHT) + + /** + * The real thing: the host is stopped, the survey closes itself, the user comes back. + * + * No workspace is stored, so the view model loads nothing and the WebView has no failed load + * to report halfway through the lifecycle changes. + */ + @Test + fun aSurveyClosedInTheBackgroundIsGoneWhenTheHostReturns() { + SurveyManager.workspaceDataHolder = null + ActivityScenario.launch(FragmentActivity::class.java).use { scenario -> + scenario.onActivity { FormbricksFragment.show(it.supportFragmentManager, "survey-one") } + + scenario.moveToState(Lifecycle.State.CREATED) + scenario.onActivity { activity -> + val survey = surveysIn(activity).single() + assertTrue("the host must be past onSaveInstanceState", survey.isStateSaved) + + survey.safeDismiss() + + assertFalse("closing the survey must not close the host", activity.isFinishing) + } + + scenario.moveToState(Lifecycle.State.RESUMED) + scenario.onActivity { activity -> + activity.supportFragmentManager.executePendingTransactions() + assertTrue("the survey is gone once the host is back", surveysIn(activity).isEmpty()) + assertEquals("no survey layer is left over the host", 0, surveyLayersOn(activity)) + + FormbricksFragment.show(activity.supportFragmentManager, "survey-two") + activity.supportFragmentManager.executePendingTransactions() + assertEquals("the host can show a survey again, and only one", 1, surveysIn(activity).size) + } + } + } +} 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 3667ee1..c257e01 100644 --- a/android/src/main/java/com/formbricks/android/webview/FormbricksFragment.kt +++ b/android/src/main/java/com/formbricks/android/webview/FormbricksFragment.kt @@ -366,21 +366,22 @@ class FormbricksFragment : BottomSheetDialogFragment() { } } - private fun safeDismiss() { + /** + * Dismisses only the survey, whatever state the host is in. + * + * Once the host has saved its state (it is in the background) a plain `dismiss()` throws, so + * this allows state loss: the survey is over, there is nothing to restore. Finishing the host to + * get around it would restart the user's app, so the host Activity is never touched here. + */ + internal fun safeDismiss() { if (isDismissing) return isDismissing = true try { - if (isAdded && !isStateSaved) { - dismiss() - } else { - // If we can't dismiss safely, just finish the activity - activity?.finish() - } + if (isAdded) dismissAllowingStateLoss() } catch (e: Exception) { val error = SDKError.somethingWentWrongError Logger.e(error) - activity?.finish() } } From 9da8b2e7344195b3512aa9a839419facfbbeae9f Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Mon, 9 Nov 2026 14:52:03 +0530 Subject: [PATCH 2/2] ci: pin the SonarCloud job to ubuntu-24.04 [ENG-3845] ubuntu-latest now serves 26.04, where qemu-kvm has no install candidate, so the Setup KVM step fails before any analysis runs. 24.04 is the image the last green runs used. --- .github/workflows/sonarcloud.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 71fc84d..862360d 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -14,7 +14,9 @@ permissions: jobs: sonarcloud: name: SonarCloud - runs-on: ubuntu-latest + # Pinned: ubuntu-latest is now 26.04, where `qemu-kvm` has no install candidate and the + # "Setup KVM" step fails. Move to a newer image only after reworking that step. + runs-on: ubuntu-24.04 steps: - name: Harden Runner