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 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() } }