Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prevent crashes in form filling when restoring process #5602

Merged
merged 8 commits into from
Jun 16, 2023
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.odk.collect.androidshared.system

import android.content.Context
import android.os.Bundle
import org.odk.collect.androidshared.data.getState
import org.odk.collect.shared.strings.UUIDGenerator

object ProcessRestoreDetector {

@JvmStatic
fun registerOnSaveInstanceState(context: Context, outState: Bundle) {
val uuid = UUIDGenerator().generateUUID()
context.getState().set("${getKey()}:$uuid", Any())
outState.putString(getKey(), uuid)
}

@JvmStatic
fun isProcessRestoring(context: Context, savedInstanceState: Bundle?): Boolean {
return if (savedInstanceState != null) {
val bundleUuid = savedInstanceState.getString(getKey())
context.getState().get<Any>("${getKey()}:$bundleUuid") == null
} else {
false
}
}

private fun getKey() = this::class.qualifiedName
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.odk.collect.androidshared.system

import android.os.Bundle

interface SavedInstanceStateProvider {
fun getState(savedInstanceState: Bundle?): Bundle?
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package org.odk.collect.androidtest

import android.app.Activity
import android.os.Bundle
import android.os.PersistableBundle
import androidx.test.core.app.ActivityScenario

object ActivityScenarioExtensions {
Expand All @@ -20,4 +22,10 @@ object ActivityScenarioExtensions {

return isFinishing
}

fun <T : Activity> ActivityScenario<T>.saveInstanceState(): Bundle {
val bundle = Bundle()
onActivity { it.onSaveInstanceState(bundle, PersistableBundle()) }
return bundle
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package org.odk.collect.android.feature.formentry

import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.runner.RunWith
import org.odk.collect.android.R
import org.odk.collect.android.support.CollectHelpers
import org.odk.collect.android.support.pages.FormEntryPage
import org.odk.collect.android.support.pages.FormHierarchyPage
import org.odk.collect.android.support.pages.Page
import org.odk.collect.android.support.rules.FormEntryActivityTestRule
import org.odk.collect.android.support.rules.TestRuleChain

@RunWith(AndroidJUnit4::class)
class ProcessRestoreTest {

private val rule = FormEntryActivityTestRule()

@get:Rule
val ruleChain: RuleChain = TestRuleChain.chain().around(rule)

@Test
fun whenProcessIsKilledAndRestoredDuringFormEntry_returnsToHierarchy() {
rule.setUpProjectAndCopyForm("one-question.xml")
.fillNewForm("one-question.xml", "One Question")
.answerQuestion("what is your age", "123")
.let { simulateProcessRestore(FormHierarchyPage("One Question")) }

.assertText("123")
.pressBack(FormEntryPage("One Question"))
.assertQuestion("what is your age")
}

@Test
fun whenProcessIsKilledAndRestoredDuringFormEntry_andThereADialogFragmentOpen_returnsToHierarchy() {
rule.setUpProjectAndCopyForm("all-widgets.xml")
.fillNewForm("all-widgets.xml", "All widgets")
.clickGoToArrow()
.clickOnGroup("Select one widgets")
.clickOnQuestion("Select one from map widget")
.clickOnString(R.string.select_place)
.let { simulateProcessRestore(FormHierarchyPage("All widgets")) }

.pressBack(FormEntryPage("All widgets"))
.assertQuestion("Welcome to ODK Collect! This form showcases the different available question types (widgets).")
}

/**
* Simulate a "process restore" case where an app in the background is killed by Android
* to reclaim memory, change permissions etc and then the process is recreated (backstack etc)
* when navigated back to
*/
private fun <T : Page<T>> simulateProcessRestore(destination: Page<T>): Page<T> {
rule.navigateAwayFromActivity()
rule.destroyActivity()

CollectHelpers.simulateProcessRestart()
rule.restoreActivity()

return destination.assertOnPage()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ class SavePointTest {
rule.setUpProjectAndCopyForm("two-question-audit.xml")
.fillNewForm("two-question-audit.xml", "Two Question")
.answerQuestion("What is your name?", "Alexei")
.let { simulateProcessRestore() }
.let { simulateProcessDeath() }

// Start blank form and check save point is loaded
rule.fillNewForm("two-question-audit.xml", FormHierarchyPage("Two Question"))
Expand Down Expand Up @@ -148,7 +148,7 @@ class SavePointTest {
rule.editForm("two-question-audit.xml", "Two Question")
.clickGoToStart()
.answerQuestion("What is your name?", "Alexei")
.let { simulateProcessRestore() }
.let { simulateProcessDeath() }

// Edit instance and check save point is loaded
rule.editForm("two-question-audit.xml", "Two Question")
Expand Down Expand Up @@ -188,7 +188,7 @@ class SavePointTest {
// Create save point for blank form
rule.fillNewForm("two-question-audit.xml", "Two Question")
.answerQuestion("What is your name?", "Alexei")
.let { simulateProcessRestore() }
.let { simulateProcessDeath() }

// Check editing instance doesn't load save point
rule.editForm("two-question-audit.xml", "Two Question")
Expand All @@ -212,7 +212,7 @@ class SavePointTest {
rule.editForm("two-question-audit.xml", "Two Question")
.clickGoToStart()
.answerQuestion("What is your name?", "Alexei")
.let { simulateProcessRestore() }
.let { simulateProcessDeath() }

// Check starting blank form does not load save point
rule.fillNewForm("two-question-audit.xml", "Two Question")
Expand All @@ -228,11 +228,10 @@ class SavePointTest {
}

/**
* Simulate a "process restore" case where an app in the background is killed by Android
* to reclaim memory, change permissions etc
* Simulate a "process death" case where an app in the background is killed
*/
private fun simulateProcessRestore(): FormEntryActivityTestRule {
rule.saveInstanceStateForActivity()
private fun simulateProcessDeath(): FormEntryActivityTestRule {
rule.navigateAwayFromActivity()
.destroyActivity()

CollectHelpers.simulateProcessRestart()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
package org.odk.collect.android.support;

import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static org.hamcrest.core.AllOf.allOf;

import android.app.Activity;
import android.view.ContextThemeWrapper;
import android.view.View;

import androidx.test.espresso.Espresso;
import androidx.test.espresso.UiController;
import androidx.test.espresso.ViewAction;
import androidx.test.espresso.matcher.ViewMatchers;

import org.hamcrest.Matcher;
import org.hamcrest.core.AllOf;

import static androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;

public final class ActivityHelpers {

Expand All @@ -21,7 +22,7 @@ private ActivityHelpers() {

public static Activity getActivity() {
final Activity[] currentActivity = new Activity[1];
Espresso.onView(AllOf.allOf(ViewMatchers.withId(android.R.id.content), isDisplayed())).perform(new ViewAction() {
ViewAction getActivityViewAction = new ViewAction() {
@Override
public Matcher<View> getConstraints() {
return isAssignableFrom(View.class);
Expand All @@ -35,11 +36,16 @@ public String getDescription() {
@Override
public void perform(UiController uiController, View view) {
if (view.getContext() instanceof Activity) {
Activity activity1 = (Activity) view.getContext();
currentActivity[0] = activity1;
Activity activity = (Activity) view.getContext();
currentActivity[0] = activity;
} else if (view.getContext() instanceof ContextThemeWrapper) {
Activity activity = (Activity) ((ContextThemeWrapper) view.getContext()).getBaseContext();
currentActivity[0] = activity;
}
}
});
};

onView(allOf(withId(android.R.id.content), isDisplayed())).perform(getActivityViewAction);
return currentActivity[0];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ object CollectHelpers {
}

fun simulateProcessRestart(appDependencyModule: AppDependencyModule? = null) {
ApplicationProvider.getApplicationContext<Collect>().getState().clear()

val newComponent =
overrideAppDependencyModule(appDependencyModule ?: AppDependencyModule())

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import android.app.Activity
import android.app.Application
import android.content.Intent
import android.os.Bundle
import android.os.PersistableBundle
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
Expand All @@ -13,12 +12,15 @@ import org.odk.collect.android.activities.FormFillingActivity
import org.odk.collect.android.external.FormsContract
import org.odk.collect.android.formmanagement.FormFillingIntentFactory
import org.odk.collect.android.injection.DaggerUtils
import org.odk.collect.android.injection.config.AppDependencyModule
import org.odk.collect.android.storage.StorageSubdirectory
import org.odk.collect.android.support.ActivityHelpers
import org.odk.collect.android.support.CollectHelpers
import org.odk.collect.android.support.StorageUtils
import org.odk.collect.android.support.pages.FormEntryPage
import org.odk.collect.android.support.pages.FormHierarchyPage
import org.odk.collect.android.support.pages.Page
import org.odk.collect.androidshared.system.SavedInstanceStateProvider
import org.odk.collect.androidtest.ActivityScenarioExtensions.saveInstanceState
import org.odk.collect.projects.Project
import timber.log.Timber
import java.io.IOException
Expand All @@ -28,6 +30,20 @@ class FormEntryActivityTestRule : ExternalResource() {
private lateinit var intent: Intent
private lateinit var scenario: ActivityScenario<Activity>

private var outState: Bundle? = null

private val savedInstanceStateProvider = InMemSavedInstanceStateProvider()

override fun before() {
super.before()

CollectHelpers.overrideAppDependencyModule(object : AppDependencyModule() {
override fun providesSavedInstanceStateProvider(): SavedInstanceStateProvider {
return savedInstanceStateProvider
}
})
}

override fun after() {
try {
scenario.close()
Expand Down Expand Up @@ -67,28 +83,22 @@ class FormEntryActivityTestRule : ExternalResource() {
return FormHierarchyPage(instanceName).assertOnPage()
}

fun saveInstanceStateForActivity(): FormEntryActivityTestRule {
scenario.onActivity {
it.onSaveInstanceState(Bundle(), PersistableBundle())
}

fun navigateAwayFromActivity(): FormEntryActivityTestRule {
scenario.moveToState(Lifecycle.State.STARTED)
outState = scenario.saveInstanceState()
return this
}

fun destroyActivity(): FormEntryActivityTestRule {
lateinit var scenarioActivity: Activity
scenario.onActivity {
scenarioActivity = it
}

if (ActivityHelpers.getActivity() != scenarioActivity) {
throw IllegalStateException("Can't destroy backstack!")
}

scenario.moveToState(Lifecycle.State.DESTROYED)
return this
}

fun restoreActivity() {
savedInstanceStateProvider.setState(outState)
scenario = ActivityScenario.launch(intent)
}

private fun createNewFormIntent(formFilename: String): Intent {
val application = ApplicationProvider.getApplicationContext<Application>()
val formPath = DaggerUtils.getComponent(application).storagePathProvider()
Expand All @@ -98,7 +108,11 @@ class FormEntryActivityTestRule : ExternalResource() {
val projectId = DaggerUtils.getComponent(application).currentProjectProvider()
.getCurrentProject().uuid

return FormFillingIntentFactory.newInstanceIntent(application, FormsContract.getUri(projectId, form!!.dbId), FormFillingActivity::class)
return FormFillingIntentFactory.newInstanceIntent(
application,
FormsContract.getUri(projectId, form!!.dbId),
FormFillingActivity::class
)
}

private fun createEditFormIntent(formFilename: String): Intent {
Expand All @@ -120,3 +134,22 @@ class FormEntryActivityTestRule : ExternalResource() {
)
}
}

private class InMemSavedInstanceStateProvider : SavedInstanceStateProvider {

private var bundle: Bundle? = null

fun setState(savedInstanceState: Bundle?) {
bundle = savedInstanceState
}

override fun getState(savedInstanceState: Bundle?): Bundle? {
return if (bundle != null) {
bundle.also {
bundle = null
}
} else {
savedInstanceState
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ private class ResetStateStatement(

clearPrefs(oldComponent)
clearDisk(oldComponent)
clearAppState(application)
setTestState()
CollectHelpers.simulateProcessRestart(appDependencyModule)
base.evaluate()
Expand Down
Loading