diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/async/TrackableWorker.kt b/androidshared/src/main/java/org/odk/collect/androidshared/async/TrackableWorker.kt index 0df1c90871f..b6ad2e521d0 100644 --- a/androidshared/src/main/java/org/odk/collect/androidshared/async/TrackableWorker.kt +++ b/androidshared/src/main/java/org/odk/collect/androidshared/async/TrackableWorker.kt @@ -18,4 +18,13 @@ class TrackableWorker(private val scheduler: Scheduler) { foreground.accept(result) } } + + fun immediate(background: Runnable) { + immediate( + background = { + background.run() + }, + foreground = {} + ) + } } diff --git a/androidtest/src/main/java/org/odk/collect/androidtest/LiveDataTestUtils.kt b/androidtest/src/main/java/org/odk/collect/androidtest/LiveDataTestUtils.kt index 03edaeb55ca..a0f072815ce 100644 --- a/androidtest/src/main/java/org/odk/collect/androidtest/LiveDataTestUtils.kt +++ b/androidtest/src/main/java/org/odk/collect/androidtest/LiveDataTestUtils.kt @@ -22,19 +22,18 @@ fun LiveData.getOrAwaitValue( ): T { var data: T? = null val latch = CountDownLatch(1) - val observer = object : Observer { - override fun onChanged(o: T) { - data = o - latch.countDown() - this@getOrAwaitValue.removeObserver(this) - } + val observer = Observer { o -> + data = o + latch.countDown() } - this.observeForever(observer) + this.observeForever(observer) afterObserve.invoke() // Don't wait indefinitely if the LiveData is not set. - if (!latch.await(time, timeUnit)) { + if (latch.await(time, timeUnit)) { + this.removeObserver(observer) + } else { this.removeObserver(observer) throw TimeoutException("LiveData value was never set.") } diff --git a/async/src/main/java/org/odk/collect/async/CoroutineScheduler.kt b/async/src/main/java/org/odk/collect/async/CoroutineScheduler.kt index 8d4bb4f5b60..9a4387be73c 100644 --- a/async/src/main/java/org/odk/collect/async/CoroutineScheduler.kt +++ b/async/src/main/java/org/odk/collect/async/CoroutineScheduler.kt @@ -2,6 +2,8 @@ package org.odk.collect.async import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -18,9 +20,15 @@ open class CoroutineScheduler(private val foregroundContext: CoroutineContext, p } } - override fun immediate(foreground: Runnable) { - CoroutineScope(foregroundContext).launch { - foreground.run() + override fun immediate(background: Boolean, runnable: Runnable) { + val context = if (background) { + backgroundContext + } else { + foregroundContext + } + + CoroutineScope(context).launch { + runnable.run() } } @@ -37,6 +45,10 @@ open class CoroutineScheduler(private val foregroundContext: CoroutineContext, p return ScopeCancellable(repeatScope) } + override fun flowOnBackground(flow: Flow): Flow { + return flow.flowOn(backgroundContext) + } + override fun cancelAllDeferred() { throw UnsupportedOperationException() } diff --git a/async/src/main/java/org/odk/collect/async/Scheduler.kt b/async/src/main/java/org/odk/collect/async/Scheduler.kt index d5b4e0d4635..170cc7a3a8f 100644 --- a/async/src/main/java/org/odk/collect/async/Scheduler.kt +++ b/async/src/main/java/org/odk/collect/async/Scheduler.kt @@ -1,5 +1,6 @@ package org.odk.collect.async +import kotlinx.coroutines.flow.Flow import java.util.function.Consumer import java.util.function.Supplier @@ -22,9 +23,9 @@ interface Scheduler { fun immediate(background: Supplier, foreground: Consumer) /** - * Run work in the foreground. Cancelled if application closed. + * Run work in the foreground or background. Cancelled if application closed. */ - fun immediate(foreground: Runnable) + fun immediate(background: Boolean = false, runnable: Runnable) /** * Schedule a task to run in the background even if the app isn't running. The task @@ -74,4 +75,10 @@ interface Scheduler { fun repeat(foreground: Runnable, repeatPeriod: Long): Cancellable fun cancelAllDeferred() + + fun flowOnBackground(flow: Flow): Flow +} + +fun Flow.flowOnBackground(scheduler: Scheduler): Flow { + return scheduler.flowOnBackground(this) } diff --git a/async/src/main/java/org/odk/collect/async/SchedulerAsyncTaskMimic.kt b/async/src/main/java/org/odk/collect/async/SchedulerAsyncTaskMimic.kt index 8f1f2d680f3..a1983ba6066 100644 --- a/async/src/main/java/org/odk/collect/async/SchedulerAsyncTaskMimic.kt +++ b/async/src/main/java/org/odk/collect/async/SchedulerAsyncTaskMimic.kt @@ -70,7 +70,7 @@ abstract class SchedulerAsyncTaskMimic(private val sch protected fun publishProgress(vararg values: Progress) { scheduler.immediate( - foreground = { onProgressUpdate(*values) } + runnable = { onProgressUpdate(*values) } ) } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/ExternalSecondaryInstanceTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/ExternalSecondaryInstanceTest.java index f1ad96da5cf..24179a410f7 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/ExternalSecondaryInstanceTest.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/ExternalSecondaryInstanceTest.java @@ -8,7 +8,6 @@ import org.junit.runner.RunWith; import org.odk.collect.android.support.rules.CollectTestRule; import org.odk.collect.android.support.rules.TestRuleChain; -import org.odk.collect.android.support.pages.MainMenuPage; import java.util.Collections; @@ -32,15 +31,24 @@ public class ExternalSecondaryInstanceTest { .around(rule); @Test - public void displaysAllOptionsFromSecondaryInstance() { - //TestCase1 - new MainMenuPage() - .copyForm("external_select_10.xml", Collections.singletonList("external_data_10.xml")) - .startBlankForm("external select 10") - .clickOnText("a") - .swipeToNextQuestion("Second") - .assertText("aa") - .assertText("ab") - .assertText("ac"); + public void displaysAllOptionsFromXMLSecondaryInstance() { + rule.startAtMainMenu() + .copyForm("external_select.xml", Collections.singletonList("external_data.xml")) + .startBlankForm("external select") + .assertQuestion("First") + .assertText("One") + .assertText("Two") + .assertText("Three"); + } + + @Test + public void displaysAllOptionsFromCSVSecondaryInstance() { + rule.startAtMainMenu() + .copyForm("external_select_csv.xml", Collections.singletonList("external_data.csv")) + .startBlankForm("external select") + .assertQuestion("First") + .assertText("One") + .assertText("Two") + .assertText("Three"); } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/DynamicPreLoadedDataPullTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/dynamicpreload/DynamicPreLoadedDataPullTest.kt similarity index 92% rename from collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/DynamicPreLoadedDataPullTest.kt rename to collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/dynamicpreload/DynamicPreLoadedDataPullTest.kt index 8a0f5e374ca..833130ac093 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/DynamicPreLoadedDataPullTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/dynamicpreload/DynamicPreLoadedDataPullTest.kt @@ -1,4 +1,4 @@ -package org.odk.collect.android.feature.formentry +package org.odk.collect.android.feature.formentry.dynamicpreload import org.junit.Rule import org.junit.Test diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/MatchExactlyTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/MatchExactlyTest.kt index c79e465d35c..37949c426f2 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/MatchExactlyTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/MatchExactlyTest.kt @@ -180,6 +180,6 @@ class MatchExactlyTest { .enableMatchExactly() .enableManualUpdates() - assertThat(testDependencies.scheduler.deferredTasks, `is`(empty())) + assertThat(testDependencies.scheduler.getDeferredTasks(), `is`(empty())) } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/PreviouslyDownloadedOnlyTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/PreviouslyDownloadedOnlyTest.kt index 930dc9e706a..d522bb2848a 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/PreviouslyDownloadedOnlyTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/PreviouslyDownloadedOnlyTest.kt @@ -153,6 +153,6 @@ class PreviouslyDownloadedOnlyTest { .enablePreviouslyDownloadedOnlyUpdates() .enableManualUpdates() - assertThat(testDependencies.scheduler.deferredTasks, equalTo(emptyList())) + assertThat(testDependencies.scheduler.getDeferredTasks(), equalTo(emptyList())) } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/settings/FormManagementSettingsTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/settings/FormManagementSettingsTest.kt index 66d59892f95..86d32e87b80 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/settings/FormManagementSettingsTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/settings/FormManagementSettingsTest.kt @@ -8,7 +8,6 @@ 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.TestDependencies import org.odk.collect.android.support.pages.MainMenuPage import org.odk.collect.android.support.rules.CollectTestRule @@ -27,7 +26,7 @@ class FormManagementSettingsTest { @Test fun whenMatchExactlyEnabled_changingAutomaticUpdateFrequency_changesTaskFrequency() { - var deferredTasks = testDependencies.scheduler.deferredTasks + var deferredTasks = testDependencies.scheduler.getDeferredTasks() assertThat(deferredTasks, `is`(empty())) @@ -38,14 +37,14 @@ class FormManagementSettingsTest { .clickUpdateForms() .clickOption(org.odk.collect.strings.R.string.match_exactly) - deferredTasks = testDependencies.scheduler.deferredTasks + deferredTasks = testDependencies.scheduler.getDeferredTasks() assertThat(deferredTasks.size, `is`(1)) val matchExactlyTag = deferredTasks[0].tag page.clickAutomaticUpdateFrequency().clickOption(org.odk.collect.strings.R.string.every_one_hour) - deferredTasks = testDependencies.scheduler.deferredTasks + deferredTasks = testDependencies.scheduler.getDeferredTasks() assertThat(deferredTasks.size, `is`(1)) assertThat(deferredTasks[0].tag, `is`(matchExactlyTag)) @@ -54,7 +53,7 @@ class FormManagementSettingsTest { @Test fun whenPreviouslyDownloadedOnlyEnabled_changingAutomaticUpdateFrequency_changesTaskFrequency() { - var deferredTasks = testDependencies.scheduler.deferredTasks + var deferredTasks = testDependencies.scheduler.getDeferredTasks() assertThat(deferredTasks, `is`(empty())) @@ -65,14 +64,14 @@ class FormManagementSettingsTest { .clickUpdateForms() .clickOption(org.odk.collect.strings.R.string.previously_downloaded_only) - deferredTasks = testDependencies.scheduler.deferredTasks + deferredTasks = testDependencies.scheduler.getDeferredTasks() assertThat(deferredTasks.size, `is`(1)) val previouslyDownloadedTag = deferredTasks[0].tag page.clickAutomaticUpdateFrequency().clickOption(org.odk.collect.strings.R.string.every_one_hour) - deferredTasks = testDependencies.scheduler.deferredTasks + deferredTasks = testDependencies.scheduler.getDeferredTasks() assertThat(deferredTasks.size, `is`(1)) assertThat(deferredTasks[0].tag, `is`(previouslyDownloadedTag)) diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/forms/FormNavigationTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/forms/FormNavigationTest.java deleted file mode 100644 index 8e8792b607a..00000000000 --- a/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/forms/FormNavigationTest.java +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright 2018 Nafundi - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.odk.collect.android.instrumented.forms; - -import static junit.framework.Assert.assertEquals; - -import static org.mockito.Mockito.mock; - -import android.app.Application; - -import androidx.test.core.app.ApplicationProvider; - -import org.javarosa.core.model.FormDef; -import org.javarosa.form.api.FormEntryController; -import org.javarosa.form.api.FormEntryModel; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.RuleChain; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -import org.junit.runners.Parameterized.Parameters; -import org.odk.collect.android.injection.DaggerUtils; -import org.odk.collect.android.injection.config.AppDependencyComponent; -import org.odk.collect.android.javarosawrapper.FormController; -import org.odk.collect.android.listeners.FormLoaderListener; -import org.odk.collect.android.storage.StoragePathProvider; -import org.odk.collect.android.storage.StorageSubdirectory; -import org.odk.collect.android.support.StorageUtils; -import org.odk.collect.android.support.rules.RunnableRule; -import org.odk.collect.android.support.rules.TestRuleChain; -import org.odk.collect.android.tasks.FormLoaderTask; -import org.odk.collect.projects.Project; - -import java.io.File; -import java.io.IOException; -import java.util.Arrays; -import java.util.concurrent.ExecutionException; - -import timber.log.Timber; - -/** - * This test has been created in order to check indices while navigating through a form. - * It's especially important while navigating through a form that contains nested groups and if we - * use groups with field-list appearance because in that case we need to collect all indices of - * questions we want to display on one page (we need to recursively get all indices contained in - * such a group and its children). It might be also tricky when navigating backwards because then we - * need to navigate to an index of the first question of all we want to display on one page. - */ -@RunWith(Parameterized.class) -public class FormNavigationTest { - - private final FormLoaderTask.FormEntryControllerFactory formEntryControllerFactory = new FormLoaderTask.FormEntryControllerFactory() { - @Override - public FormEntryController create(FormDef formDef, File formMediaDir) { - return new FormEntryController(new FormEntryModel(formDef)); - } - }; - - @Rule - public RuleChain copyFormChain = TestRuleChain.chain() - .around(new RunnableRule(() -> { - // Set up demo project - AppDependencyComponent component = DaggerUtils.getComponent(ApplicationProvider.getApplicationContext()); - component.projectsRepository().save(Project.Companion.getDEMO_PROJECT()); - component.currentProjectProvider().setCurrentProject(Project.DEMO_PROJECT_ID); - })); - - @Parameters(name = "{0}") - public static Iterable data() { - // Expected indices when swiping forward until the end of the form and back once. - // An index of -1 indicates the start or end of a form. - return Arrays.asList( - ei("simpleFieldList.xml", - "-1, ", "0, ", "-1, ", "0, "), - ei("fieldListInFieldList.xml", - "-1, ", "0, ", "-1, ", "0, "), - ei("regularGroupWithFieldListGroupInside.xml", - "-1, ", "0, 0, ", "-1, ", "0, 0, "), - ei("twoNestedRegularGroups.xml", - "-1, ", "0, 0, 0, ", "0, 0, 1, ", "0, 0, 2, ", "-1, ", "0, 0, 2, "), - ei("regularGroupWithQuestionAndRegularGroupInside.xml", - "-1, ", "0, 0, ", "0, 1, 0, ", "0, 1, 1, ", "-1, ", "0, 1, 1, "), - ei("regularGroupWithQuestionsAndRegularGroupInside.xml", - "-1, ", "0, 0, ", "0, 1, 0, ", "0, 2, ", "-1, ", "0, 2, "), - ei("fieldListWithQuestionAndRegularGroupInside.xml", - "-1, ", "0, ", "-1, ", "0, "), - ei("fieldListWithQuestionsAndRegularGroupsInside.xml", - "-1, ", "0, ", "-1, ", "0, "), - ei("threeNestedFieldListGroups.xml", - "-1, ", "0, ", "-1, ", "0, ")); - } - - /** - * Expected indices for each form - */ - private static Object[] ei(String formName, String... expectedIndices) { - return new Object[]{formName, expectedIndices}; - } - - private final String formName; - private final String[] expectedIndices; - - public FormNavigationTest(String formName, String[] expectedIndices) { - this.formName = formName; - this.expectedIndices = expectedIndices; - } - - @Test - public void formNavigationTestCase() throws ExecutionException, InterruptedException { - testIndices(formName, expectedIndices); - } - - private void testIndices(String formName, String[] expectedIndices) throws ExecutionException, InterruptedException { - try { - copyToStorage(formName); - } catch (IOException e) { - Timber.i(e); - } - - FormLoaderTask formLoaderTask = new FormLoaderTask(formPath(formName), null, null, formEntryControllerFactory, mock()); - formLoaderTask.setFormLoaderListener(new FormLoaderListener() { - @Override - public void loadingComplete(FormLoaderTask task, FormDef fd, String warningMsg) { - try { - // For each form, simulate swiping forward through screens until the end of the - // form and then swiping back once. Verify the expected indices before and after each swipe. - for (int i = 0; i < expectedIndices.length - 1; i++) { - FormController formController = task.getFormController(); - // check the current index - assertEquals(expectedIndices[i], formController.getFormIndex().toString()); - if (i < expectedIndices.length - 2) { - formController.stepToNextScreenEvent(); - } else { - formController.stepToPreviousScreenEvent(); - } - // check the index again after navigating - assertEquals(expectedIndices[i + 1], formController.getFormIndex().toString()); - } - } catch (Exception e) { - Timber.i(e); - } - } - - @Override - public void loadingError(String errorMsg) { - } - - @Override - public void onProgressStep(String stepMessage) { - - } - }); - formLoaderTask.executeSynchronously(formPath(formName)); - } - - /** - * FormLoaderTask loads forms from SD card so we need to put each form there - */ - private void copyToStorage(String formName) throws IOException { - StorageUtils.copyFormToDemoProject(formName); - } - - private static String formPath(String formName) { - return new StoragePathProvider().getOdkDirPath(StorageSubdirectory.FORMS) - + File.separator - + formName; - } -} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/forms/FormUtilsTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/forms/FormUtilsTest.java index 0750489a75e..733a6abebb7 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/forms/FormUtilsTest.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/forms/FormUtilsTest.java @@ -2,6 +2,11 @@ import static org.mockito.Mockito.mock; import static org.odk.collect.android.support.StorageUtils.copyFormToStorage; +import static java.util.Collections.emptyList; + +import android.net.Uri; + +import androidx.test.core.app.ApplicationProvider; import org.javarosa.core.model.FormDef; import org.javarosa.core.reference.RootTranslator; @@ -11,6 +16,7 @@ import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import org.odk.collect.android.external.FormsContract; import org.odk.collect.android.storage.StoragePathProvider; import org.odk.collect.android.storage.StorageSubdirectory; import org.odk.collect.android.support.CollectHelpers; @@ -18,6 +24,8 @@ import org.odk.collect.android.tasks.FormLoaderTask; import org.odk.collect.android.utilities.FileUtils; import org.odk.collect.android.utilities.FormUtils; +import org.odk.collect.android.utilities.FormsRepositoryProvider; +import org.odk.collect.forms.Form; import java.io.File; import java.io.IOException; @@ -39,7 +47,7 @@ public FormEntryController create(FormDef formDef, File formMediaDir) { @Before public void setUp() throws IOException { CollectHelpers.addDemoProject(); - copyFormToStorage(BASIC_FORM); + copyFormToStorage(BASIC_FORM, emptyList(), true); } /* Verify that each host string matches only a single root translator, allowing for them to @@ -48,9 +56,12 @@ public void setUp() throws IOException { @Test public void sessionRootTranslatorOrderDoesNotMatter() throws Exception { final String formPath = new StoragePathProvider().getOdkDirPath(StorageSubdirectory.FORMS) + File.separator + BASIC_FORM; + final Form form = new FormsRepositoryProvider(ApplicationProvider.getApplicationContext()).get().getOneByPath(formPath); + final Uri formUri = FormsContract.getUri("DEMO", form.getDbId()); + // Load the form in order to populate the ReferenceManager - FormLoaderTask formLoaderTask = new FormLoaderTask(formPath, null, null, formEntryControllerFactory, mock()); - formLoaderTask.executeSynchronously(formPath); + FormLoaderTask formLoaderTask = new FormLoaderTask(formUri, FormsContract.CONTENT_ITEM_TYPE, null, null, formEntryControllerFactory, mock()); + formLoaderTask.executeSynchronously(); final File formXml = new File(formPath); final File formMediaDir = FileUtils.getFormMediaDir(formXml); diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/tasks/FormLoaderTaskTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/tasks/FormLoaderTaskTest.java index b1ba55945e2..d19d9954d65 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/tasks/FormLoaderTaskTest.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/tasks/FormLoaderTaskTest.java @@ -1,10 +1,9 @@ package org.odk.collect.android.instrumented.tasks; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.notNullValue; import static org.mockito.Mockito.mock; import android.app.Application; +import android.net.Uri; import androidx.test.core.app.ApplicationProvider; @@ -15,6 +14,7 @@ import org.junit.Rule; import org.junit.Test; import org.junit.rules.RuleChain; +import org.odk.collect.android.external.FormsContract; import org.odk.collect.android.injection.DaggerUtils; import org.odk.collect.android.injection.config.AppDependencyComponent; import org.odk.collect.android.storage.StoragePathProvider; @@ -24,6 +24,8 @@ import org.odk.collect.android.support.rules.TestRuleChain; import org.odk.collect.android.tasks.FormLoaderTask; import org.odk.collect.android.tasks.FormLoaderTask.FormEntryControllerFactory; +import org.odk.collect.android.utilities.FormsRepositoryProvider; +import org.odk.collect.forms.Form; import org.odk.collect.projects.Project; import java.io.File; @@ -56,51 +58,23 @@ public FormEntryController create(FormDef formDef, File formMediaDir) { component.projectsRepository().save(Project.Companion.getDEMO_PROJECT()); component.currentProjectProvider().setCurrentProject(Project.DEMO_PROJECT_ID); - StorageUtils.copyFormToDemoProject(SECONDARY_INSTANCE_EXTERNAL_CSV_FORM, Arrays.asList("external_csv_cities.csv", "external_csv_countries.csv", "external_csv_neighbourhoods.csv")); - StorageUtils.copyFormToDemoProject(SIMPLE_SEARCH_EXTERNAL_CSV_FORM, Collections.singletonList(SIMPLE_SEARCH_EXTERNAL_CSV_FILE)); + StorageUtils.copyFormToDemoProject(SECONDARY_INSTANCE_EXTERNAL_CSV_FORM, Arrays.asList("external_csv_cities.csv", "external_csv_countries.csv", "external_csv_neighbourhoods.csv"), true); + StorageUtils.copyFormToDemoProject(SIMPLE_SEARCH_EXTERNAL_CSV_FORM, Collections.singletonList(SIMPLE_SEARCH_EXTERNAL_CSV_FILE), true); } catch (IOException e) { throw new RuntimeException(e); } })); - // Validate the use of CSV files as secondary instances accessed through "jr://file-csv" - @Test - public void loadFormWithSecondaryCSV() throws Exception { - final String formPath = storagePathProvider.getOdkDirPath(StorageSubdirectory.FORMS) + File.separator + SECONDARY_INSTANCE_EXTERNAL_CSV_FORM; - FormLoaderTask formLoaderTask = new FormLoaderTask(formPath, null, null, formEntryControllerFactory, mock()); - FormLoaderTask.FECWrapper wrapper = formLoaderTask.executeSynchronously(formPath); - Assert.assertNotNull(wrapper); - } - - // Validate the use of a CSV file externally accessed through search/pulldata - @Test - public void loadSearchFromExternalCSV() throws Exception { - final String formPath = storagePathProvider.getOdkDirPath(StorageSubdirectory.FORMS) + File.separator + SIMPLE_SEARCH_EXTERNAL_CSV_FORM; - FormLoaderTask formLoaderTask = new FormLoaderTask(formPath, null, null, formEntryControllerFactory, mock()); - FormLoaderTask.FECWrapper wrapper = formLoaderTask.executeSynchronously(formPath); - assertThat(wrapper, notNullValue()); - } - - @Test - public void loadSearchFromexternalCsvLeavesFileUnchanged() throws Exception { - final String formPath = storagePathProvider.getOdkDirPath(StorageSubdirectory.FORMS) + File.separator + SIMPLE_SEARCH_EXTERNAL_CSV_FORM; - FormLoaderTask formLoaderTask = new FormLoaderTask(formPath, null, null, formEntryControllerFactory, mock()); - FormLoaderTask.FECWrapper wrapper = formLoaderTask.executeSynchronously(formPath); - Assert.assertNotNull(wrapper); - Assert.assertNotNull(wrapper.getController()); - - File mediaFolder = wrapper.getController().getMediaFolder(); - File importedCSV = new File(mediaFolder + File.separator + SIMPLE_SEARCH_EXTERNAL_CSV_FILE); - Assert.assertTrue("Expected the imported CSV file to remain unchanged", importedCSV.exists()); - } - // Validate that importing external data multiple times does not fail due to side effects from import @Test public void loadSearchFromExternalCSVmultipleTimes() throws Exception { final String formPath = storagePathProvider.getOdkDirPath(StorageSubdirectory.FORMS) + File.separator + SIMPLE_SEARCH_EXTERNAL_CSV_FORM; + final Form form = new FormsRepositoryProvider(ApplicationProvider.getApplicationContext()).get().getOneByPath(formPath); + final Uri formUri = FormsContract.getUri("DEMO", form.getDbId()); + // initial load with side effects - FormLoaderTask formLoaderTask = new FormLoaderTask(formPath, null, null, formEntryControllerFactory, mock()); - FormLoaderTask.FECWrapper wrapper = formLoaderTask.executeSynchronously(formPath); + FormLoaderTask formLoaderTask = new FormLoaderTask(formUri, FormsContract.CONTENT_ITEM_TYPE, null, null, formEntryControllerFactory, mock()); + FormLoaderTask.FECWrapper wrapper = formLoaderTask.executeSynchronously(); Assert.assertNotNull(wrapper); Assert.assertNotNull(wrapper.getController()); @@ -110,8 +84,8 @@ public void loadSearchFromExternalCSVmultipleTimes() throws Exception { long dbLastModified = dbFile.lastModified(); // subsequent load should succeed despite side effects from import - formLoaderTask = new FormLoaderTask(formPath, null, null, formEntryControllerFactory, mock()); - wrapper = formLoaderTask.executeSynchronously(formPath); + formLoaderTask = new FormLoaderTask(formUri, FormsContract.CONTENT_ITEM_TYPE, null, null, formEntryControllerFactory, mock()); + wrapper = formLoaderTask.executeSynchronously(); Assert.assertNotNull(wrapper); Assert.assertNotNull(wrapper.getController()); Assert.assertEquals("expected file modification timestamp to be unchanged", dbLastModified, dbFile.lastModified()); diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/TestScheduler.java b/collect_app/src/androidTest/java/org/odk/collect/android/support/TestScheduler.java deleted file mode 100644 index f744795f20a..00000000000 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/TestScheduler.java +++ /dev/null @@ -1,156 +0,0 @@ -package org.odk.collect.android.support; - -import android.content.Context; - -import androidx.test.core.app.ApplicationProvider; -import androidx.work.WorkManager; - -import org.jetbrains.annotations.NotNull; -import org.odk.collect.async.Cancellable; -import org.odk.collect.async.CoroutineAndWorkManagerScheduler; -import org.odk.collect.async.Scheduler; -import org.odk.collect.async.TaskSpec; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; -import java.util.function.Supplier; - -public class TestScheduler implements Scheduler { - - private final Scheduler wrappedScheduler; - - private final Object lock = new Object(); - private int tasks; - private Runnable finishedCallback; - - private final List deferredTasks = new ArrayList<>(); - - public TestScheduler() { - WorkManager workManager = WorkManager.getInstance(ApplicationProvider.getApplicationContext()); - this.wrappedScheduler = new CoroutineAndWorkManagerScheduler(workManager); - } - - @Override - public Cancellable repeat(@NotNull Runnable foreground, long repeatPeriod) { - return wrappedScheduler.repeat(foreground::run, repeatPeriod); - } - - @Override - public void immediate(@NotNull Supplier foreground, @NotNull Consumer background) { - increment(); - - wrappedScheduler.immediate(foreground, t -> { - background.accept(t); - decrement(); - }); - } - - @Override - public void immediate(@NotNull Runnable foreground) { - increment(); - - wrappedScheduler.immediate(() -> { - foreground.run(); - decrement(); - }); - } - - @Override - public void networkDeferred(@NotNull String tag, @NotNull TaskSpec spec, @NotNull Map inputData) { - deferredTasks.add(new DeferredTask(tag, spec, null, inputData)); - } - - @Override - public void networkDeferred(@NotNull String tag, @NotNull TaskSpec spec, long repeatPeriod, @NotNull Map inputData) { - cancelDeferred(tag); - deferredTasks.add(new DeferredTask(tag, spec, repeatPeriod, inputData)); - } - - @Override - public void cancelDeferred(@NotNull String tag) { - deferredTasks.removeIf(t -> t.getTag().equals(tag)); - } - - @Override - public boolean isDeferredRunning(@NotNull String tag) { - return wrappedScheduler.isDeferredRunning(tag); - } - - public void runDeferredTasks() { - Context applicationContext = ApplicationProvider.getApplicationContext(); - - for (DeferredTask deferredTask : deferredTasks) { - deferredTask.getSpec().getTask(applicationContext, deferredTask.getInputData(), true).get(); - } - - // Remove non repeating tasks - deferredTasks.removeIf(deferredTask -> deferredTask.repeatPeriod == null); - } - - public void setFinishedCallback(Runnable callback) { - this.finishedCallback = callback; - } - - private void increment() { - synchronized (lock) { - tasks++; - } - } - - private void decrement() { - synchronized (lock) { - tasks--; - - if (tasks == 0 && finishedCallback != null) { - finishedCallback.run(); - } - } - } - - public int getTaskCount() { - synchronized (lock) { - return tasks; - } - } - - public List getDeferredTasks() { - return deferredTasks; - } - - @Override - public void cancelAllDeferred() { - } - - public static class DeferredTask { - - private final String tag; - private final TaskSpec spec; - private final Long repeatPeriod; - private final Map inputData; - - public DeferredTask(String tag, TaskSpec spec, Long repeatPeriod, Map inputData) { - this.tag = tag; - this.spec = spec; - this.repeatPeriod = repeatPeriod; - this.inputData = inputData; - } - - public String getTag() { - return tag; - } - - public TaskSpec getSpec() { - return spec; - } - - public long getRepeatPeriod() { - return repeatPeriod; - } - - public Map getInputData() { - return inputData; - } - } -} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/TestScheduler.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/TestScheduler.kt new file mode 100644 index 00000000000..417aedce5a7 --- /dev/null +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/TestScheduler.kt @@ -0,0 +1,130 @@ +package org.odk.collect.android.support + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.work.WorkManager +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Runnable +import kotlinx.coroutines.flow.Flow +import org.odk.collect.async.Cancellable +import org.odk.collect.async.CoroutineAndWorkManagerScheduler +import org.odk.collect.async.Scheduler +import org.odk.collect.async.TaskSpec +import java.util.function.Consumer +import java.util.function.Supplier +import kotlin.coroutines.CoroutineContext + +class TestScheduler : Scheduler, CoroutineDispatcher() { + + private val wrappedScheduler: Scheduler + private val lock = Any() + private var tasks = 0 + private var finishedCallback: Runnable? = null + private val deferredTasks: MutableList = ArrayList() + private val backgroundDispatcher = Dispatchers.IO + + init { + val workManager = WorkManager.getInstance(ApplicationProvider.getApplicationContext()) + wrappedScheduler = CoroutineAndWorkManagerScheduler(Dispatchers.Main, this, workManager) + } + + override fun repeat(foreground: Runnable, repeatPeriod: Long): Cancellable { + return wrappedScheduler.repeat({ foreground.run() }, repeatPeriod) + } + + override fun immediate(background: Supplier, foreground: Consumer) { + increment() + wrappedScheduler.immediate(background) { t: T -> + foreground.accept(t) + decrement() + } + } + + override fun immediate(background: Boolean, runnable: Runnable) { + increment() + wrappedScheduler.immediate(background) { + runnable.run() + decrement() + } + } + + override fun networkDeferred(tag: String, spec: TaskSpec, inputData: Map) { + deferredTasks.add(DeferredTask(tag, spec, null, inputData)) + } + + override fun networkDeferred( + tag: String, + spec: TaskSpec, + repeatPeriod: Long, + inputData: Map + ) { + cancelDeferred(tag) + deferredTasks.add(DeferredTask(tag, spec, repeatPeriod, inputData)) + } + + override fun cancelDeferred(tag: String) { + deferredTasks.removeIf { t: DeferredTask -> t.tag == tag } + } + + override fun isDeferredRunning(tag: String): Boolean { + return wrappedScheduler.isDeferredRunning(tag) + } + + fun runDeferredTasks() { + val applicationContext = ApplicationProvider.getApplicationContext() + for (deferredTask in deferredTasks) { + deferredTask.spec.getTask(applicationContext, deferredTask.inputData, true).get() + } + + // Remove non repeating tasks + deferredTasks.removeIf { deferredTask: DeferredTask -> deferredTask.repeatPeriod == null } + } + + fun setFinishedCallback(callback: Runnable?) { + finishedCallback = callback + } + + private fun increment() { + synchronized(lock) { tasks++ } + } + + private fun decrement() { + synchronized(lock) { + tasks-- + if (tasks == 0 && finishedCallback != null) { + finishedCallback!!.run() + } + } + } + + val taskCount: Int + get() { + synchronized(lock) { return tasks } + } + + fun getDeferredTasks(): List { + return deferredTasks + } + + override fun cancelAllDeferred() {} + + override fun flowOnBackground(flow: Flow): Flow { + return wrappedScheduler.flowOnBackground(flow) + } + + override fun dispatch(context: CoroutineContext, block: Runnable) { + increment() + backgroundDispatcher.dispatch(context) { + block.run() + decrement() + } + } + + class DeferredTask( + val tag: String, + val spec: TaskSpec, + val repeatPeriod: Long?, + val inputData: Map + ) +} diff --git a/collect_app/src/main/java/org/odk/collect/android/activities/FormEntryViewModelFactory.kt b/collect_app/src/main/java/org/odk/collect/android/activities/FormEntryViewModelFactory.kt index ae6aab22ae7..e0f5b995a26 100644 --- a/collect_app/src/main/java/org/odk/collect/android/activities/FormEntryViewModelFactory.kt +++ b/collect_app/src/main/java/org/odk/collect/android/activities/FormEntryViewModelFactory.kt @@ -22,6 +22,7 @@ import org.odk.collect.android.formentry.saving.FormSaveViewModel import org.odk.collect.android.instancemanagement.autosend.AutoSendSettingsProvider import org.odk.collect.android.projects.ProjectsDataService import org.odk.collect.android.utilities.ApplicationConstants +import org.odk.collect.android.utilities.FormsRepositoryProvider import org.odk.collect.android.utilities.InstancesRepositoryProvider import org.odk.collect.android.utilities.MediaUtils import org.odk.collect.async.Scheduler @@ -49,6 +50,7 @@ class FormEntryViewModelFactory( private val fusedLocationClient: LocationClient, private val permissionsProvider: PermissionsProvider, private val autoSendSettingsProvider: AutoSendSettingsProvider, + private val formsRepositoryProvider: FormsRepositoryProvider, private val instancesRepositoryProvider: InstancesRepositoryProvider, private val qrCodeCreator: QRCodeCreator, private val htmlPrinter: HtmlPrinter @@ -66,7 +68,8 @@ class FormEntryViewModelFactory( System::currentTimeMillis, scheduler, formSessionRepository, - sessionId + sessionId, + formsRepositoryProvider.get(projectId) ) FormSaveViewModel::class.java -> { diff --git a/collect_app/src/main/java/org/odk/collect/android/activities/FormFillingActivity.java b/collect_app/src/main/java/org/odk/collect/android/activities/FormFillingActivity.java index 5de970aca18..c48e6715516 100644 --- a/collect_app/src/main/java/org/odk/collect/android/activities/FormFillingActivity.java +++ b/collect_app/src/main/java/org/odk/collect/android/activities/FormFillingActivity.java @@ -92,7 +92,6 @@ import org.odk.collect.android.dao.helpers.InstancesDaoHelper; import org.odk.collect.android.entities.EntitiesRepositoryProvider; import org.odk.collect.android.exception.JavaRosaException; -import org.odk.collect.android.external.FormsContract; import org.odk.collect.android.external.InstancesContract; import org.odk.collect.android.formentry.BackgroundAudioPermissionDialogFragment; import org.odk.collect.android.formentry.BackgroundAudioViewModel; @@ -150,7 +149,6 @@ import org.odk.collect.android.mainmenu.MainMenuActivity; import org.odk.collect.android.projects.ProjectsDataService; import org.odk.collect.android.storage.StoragePathProvider; -import org.odk.collect.android.storage.StorageSubdirectory; import org.odk.collect.android.tasks.FormLoaderTask; import org.odk.collect.android.tasks.SaveFormIndexTask; import org.odk.collect.android.tasks.SavePointTask; @@ -186,7 +184,6 @@ import org.odk.collect.audiorecorder.recording.AudioRecorder; import org.odk.collect.externalapp.ExternalAppUtils; import org.odk.collect.forms.Form; -import org.odk.collect.forms.FormsRepository; import org.odk.collect.forms.instances.Instance; import org.odk.collect.location.LocationClient; import org.odk.collect.material.MaterialProgressDialogFragment; @@ -242,16 +239,12 @@ public class FormFillingActivity extends LocalizedActivity implements AnimationL private static final String TAG_MEDIA_LOADING_FRAGMENT = "media_loading_fragment"; - // Identifies the gp of the form used to launch form entry - public static final String KEY_FORMPATH = "formpath"; - // Identifies whether this is a new form, or reloading a form after a screen // rotation (or similar) private static final String NEWFORM = "newform"; // these are only processed if we shut down and are restoring after an // external intent fires - public static final String KEY_INSTANCEPATH = "instancepath"; public static final String KEY_XPATH = "xpath"; public static final String KEY_XPATH_WAITING_FOR_DATA = "xpathwaiting"; @@ -265,8 +258,6 @@ public class FormFillingActivity extends LocalizedActivity implements AnimationL // Random ID private static final int DELETE_REPEAT = 654321; - - private String formPath; private String saveName; private Animation inAnimation; @@ -287,7 +278,6 @@ public class FormFillingActivity extends LocalizedActivity implements AnimationL private ODKView odkView; private final ControllableLifecyleOwner odkViewLifecycle = new ControllableLifecyleOwner(); - private String instancePath; private String startingXPath; private String waitingXPath; private boolean newForm = true; @@ -312,7 +302,6 @@ public void allowSwiping(boolean doSwipe) { @Inject FormsRepositoryProvider formsRepositoryProvider; - private FormsRepository formsRepository; @Inject PropertyManager propertyManager; @@ -434,6 +423,7 @@ public void onCreate(Bundle savedInstanceState) { fusedLocatonClient, permissionsProvider, autoSendSettingsProvider, + formsRepositoryProvider, instancesRepositoryProvider, new QRCodeCreatorImpl(), new HtmlPrinter() @@ -461,8 +451,6 @@ public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - formsRepository = formsRepositoryProvider.get(); - setContentView(R.layout.form_entry); setupViewModels(viewModelFactory); @@ -644,25 +632,14 @@ private void setupViewModels(FormEntryViewModelFactory formEntryViewModelFactory }); } - private void formControllerAvailable(@NonNull FormController formController) { - Form form = formsRepository.getOneByPath(formPath); - String instancePath = formController.getInstanceFile().getAbsolutePath(); - Instance instance = instancesRepositoryProvider.get().getOneByPath(instancePath); + private void formControllerAvailable(@NonNull FormController formController, @NonNull Form form, @Nullable Instance instance) { formSessionRepository.set(sessionId, formController, form, instance); - AnalyticsUtils.setForm(formController); - backgroundLocationViewModel.formFinishedLoading(); } private void setupFields(Bundle savedInstanceState) { if (savedInstanceState != null) { - if (savedInstanceState.containsKey(KEY_FORMPATH)) { - formPath = savedInstanceState.getString(KEY_FORMPATH); - } - if (savedInstanceState.containsKey(KEY_INSTANCEPATH)) { - instancePath = savedInstanceState.getString(KEY_INSTANCEPATH); - } if (savedInstanceState.containsKey(KEY_XPATH)) { startingXPath = savedInstanceState.getString(KEY_XPATH); Timber.i("startingXPath is: %s", startingXPath); @@ -707,14 +684,11 @@ private void loadForm() { FormController formController = getFormController(); if (formController != null) { - formControllerAvailable(formController); activityDisplayed(); formEntryViewModel.refreshSync(); } else { Timber.w("Reloading form and restoring state."); - formLoaderTask = new FormLoaderTask(instancePath, startingXPath, waitingXPath, formEntryControllerFactory, scheduler); - showIfNotShowing(FormLoadingDialogFragment.class, getSupportFragmentManager()); - formLoaderTask.execute(formPath); + loadFromIntent(getIntent()); } return; @@ -735,76 +709,10 @@ private void loadFromIntent(Intent intent) { uriMimeType = getContentResolver().getType(uri); } - if (uriMimeType != null && uriMimeType.equals(InstancesContract.CONTENT_ITEM_TYPE)) { - Instance instance = new InstancesRepositoryProvider(Collect.getInstance()).get().get(ContentUriHelper.getIdFromUri(uri)); - - instancePath = instance.getInstanceFilePath(); - - List
candidateForms = formsRepository.getAllByFormIdAndVersion(instance.getFormId(), instance.getFormVersion()); - - formPath = candidateForms.get(0).getFormFilePath(); - } else if (uriMimeType != null && uriMimeType.equals(FormsContract.CONTENT_ITEM_TYPE)) { - Form form = formsRepositoryProvider.get().get(ContentUriHelper.getIdFromUri(uri)); - formPath = form.getFormFilePath(); - - /** - * This is the fill-blank-form code path.See if there is a savepoint for this form - * that has never been explicitly saved by the user. If there is, open this savepoint(resume this filled-in form). - * Savepoints for forms that were explicitly saved will be recovered when that - * explicitly saved instance is edited via edit-saved-form. - */ - instancePath = loadSavePoint(); - } - - formLoaderTask = new FormLoaderTask(instancePath, startingXPath, waitingXPath, formEntryControllerFactory, scheduler); + formLoaderTask = new FormLoaderTask(uri, uriMimeType, startingXPath, waitingXPath, formEntryControllerFactory, scheduler); formLoaderTask.setFormLoaderListener(this); showIfNotShowing(FormLoadingDialogFragment.class, getSupportFragmentManager()); - formLoaderTask.execute(formPath); - } - - private String loadSavePoint() { - final String filePrefix = formPath.substring( - formPath.lastIndexOf('/') + 1, - formPath.lastIndexOf('.')) - + "_"; - final String fileSuffix = ".xml.save"; - File cacheDir = new File(storagePathProvider.getOdkDirPath(StorageSubdirectory.CACHE)); - File[] files = cacheDir.listFiles(pathname -> { - String name = pathname.getName(); - return name.startsWith(filePrefix) - && name.endsWith(fileSuffix); - }); - - if (files != null) { - /** - * See if any of these savepoints are for a filled-in form that has never - * been explicitly saved by the user. - */ - for (File candidate : files) { - String instanceDirName = candidate.getName() - .substring( - 0, - candidate.getName().length() - - fileSuffix.length()); - File instanceDir = new File( - storagePathProvider.getOdkDirPath(StorageSubdirectory.INSTANCES) + File.separator - + instanceDirName); - File instanceFile = new File(instanceDir, - instanceDirName + ".xml"); - if (instanceDir.exists() - && instanceDir.isDirectory() - && !instanceFile.exists()) { - // yes! -- use this savepoint file - return instanceFile - .getAbsolutePath(); - } - } - - } else { - Timber.e(new Error("Couldn't access cache directory when looking for save points!")); - } - - return null; + formLoaderTask.execute(); } @@ -848,12 +756,8 @@ protected void onSaveInstanceState(Bundle outState) { outState.putString(KEY_SESSION_ID, sessionId); - outState.putString(KEY_FORMPATH, formPath); FormController formController = getFormController(); if (formController != null) { - if (formController.getInstanceFile() != null) { - outState.putString(KEY_INSTANCEPATH, getAbsoluteInstancePath()); - } outState.putString(KEY_XPATH, formController.getXPath(formController.getFormIndex())); FormIndex waiting = formController.getIndexWaitingForData(); @@ -1756,17 +1660,10 @@ public void createLanguageDialog() { alertDialog = new MaterialAlertDialogBuilder(this) .setSingleChoiceItems(languages, selected, (dialog, whichButton) -> { - Form form = formsRepository.getOneByPath(formPath); - if (form != null) { - formsRepository.save(new Form.Builder(form) - .language(languages[whichButton]) - .build() - ); - } + formEntryViewModel.changeLanguage(languages[whichButton]); + formEntryViewModel.updateAnswersForScreen(getAnswers(), false); - getFormController().setLanguage(languages[whichButton]); dialog.dismiss(); - formEntryViewModel.updateAnswersForScreen(getAnswers(), false); onScreenRefresh(); }) .setTitle(getString(org.odk.collect.strings.R.string.change_language)) @@ -1988,6 +1885,9 @@ public void loadingComplete(FormLoaderTask task, FormDef formDef, String warning DialogFragmentUtils.dismissDialog(FormLoadingDialogFragment.class, getSupportFragmentManager()); final FormController formController = task.getFormController(); + Instance instance = task.getInstance(); + Form form = task.getForm(); + String formPath = form.getFormFilePath(); if (formController != null) { formLoaderTask.setFormLoaderListener(null); @@ -2000,8 +1900,6 @@ public void loadingComplete(FormLoaderTask task, FormDef formDef, String warning String[] languageTest = formController.getLanguages(); if (languageTest != null) { String defaultLanguage = formController.getLanguage(); - Form form = formsRepository.getOneByPath(formPath); - if (form != null) { String newLanguage = form.getLanguage(); @@ -2051,7 +1949,7 @@ && new PlayServicesChecker().isGooglePlayServicesAvailable(this)) { registerReceiver(locationProvidersReceiver, new IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION)); } - formControllerAvailable(formController); + formControllerAvailable(formController, form, instance); // onResume ran before the form was loaded. Let the viewModel know that the activity // is about to be displayed and configured. Do this before the refresh actually @@ -2085,7 +1983,7 @@ && new PlayServicesChecker().isGooglePlayServicesAvailable(this)) { FormIndex formIndex = SaveFormIndexTask.loadFormIndexFromFile(formController); if (formIndex != null) { formController.jumpToIndex(formIndex); - formControllerAvailable(formController); + formControllerAvailable(formController, form, instance); formEntryViewModel.refresh(); return; } @@ -2093,12 +1991,12 @@ && new PlayServicesChecker().isGooglePlayServicesAvailable(this)) { boolean pendingActivityResult = task.hasPendingActivityResult(); if (pendingActivityResult) { - formControllerAvailable(formController); + formControllerAvailable(formController, form, instance); formEntryViewModel.refreshSync(); onActivityResult(task.getRequestCode(), task.getResultCode(), task.getIntent()); } else { formController.getAuditEventLogger().logEvent(AuditEvent.AuditEventType.HIERARCHY, true, System.currentTimeMillis()); - formControllerAvailable(formController); + formControllerAvailable(formController, form, instance); Intent intent = new Intent(this, FormHierarchyActivity.class); intent.putExtra(FormHierarchyActivity.EXTRA_SESSION_ID, sessionId); startActivityForResult(intent, RequestCodes.HIERARCHY_ACTIVITY); @@ -2106,7 +2004,7 @@ && new PlayServicesChecker().isGooglePlayServicesAvailable(this)) { } }); } else { - formControllerAvailable(formController); + formControllerAvailable(formController, form, instance); if (ApplicationConstants.FormModes.VIEW_SENT.equalsIgnoreCase(formMode)) { Intent intent = new Intent(this, ViewOnlyFormHierarchyActivity.class); intent.putExtra(FormHierarchyActivity.EXTRA_SESSION_ID, sessionId); @@ -2170,9 +2068,8 @@ private void finishAndReturnInstance() { Uri uri = null; String path = getAbsoluteInstancePath(); if (path != null) { - Instance instance = new InstancesRepositoryProvider(this).get().getOneByPath(path); - if (instance != null) { - uri = InstancesContract.getUri(projectsDataService.getCurrentProject().getUuid(), instance.getDbId()); + if (formSaveViewModel.getInstance() != null) { + uri = InstancesContract.getUri(projectsDataService.getCurrentProject().getUuid(), formSaveViewModel.getInstance().getDbId()); } } diff --git a/collect_app/src/main/java/org/odk/collect/android/database/DatabaseConnection.kt b/collect_app/src/main/java/org/odk/collect/android/database/DatabaseConnection.kt index 49206df8a82..57129868717 100644 --- a/collect_app/src/main/java/org/odk/collect/android/database/DatabaseConnection.kt +++ b/collect_app/src/main/java/org/odk/collect/android/database/DatabaseConnection.kt @@ -4,6 +4,7 @@ import android.content.Context import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase.CursorFactory import android.database.sqlite.SQLiteOpenHelper +import android.os.StrictMode import timber.log.Timber import java.io.File @@ -23,9 +24,15 @@ open class DatabaseConnection( ) { val writeableDatabase: SQLiteDatabase - get() = dbHelper.writableDatabase + get() { + StrictMode.noteSlowCall("Accessing writable DB") + return dbHelper.writableDatabase + } + val readableDatabase: SQLiteDatabase - get() = dbHelper.readableDatabase + get() { + return dbHelper.readableDatabase + } private val dbHelper: SQLiteOpenHelper get() { diff --git a/collect_app/src/main/java/org/odk/collect/android/database/forms/DatabaseFormsRepository.java b/collect_app/src/main/java/org/odk/collect/android/database/forms/DatabaseFormsRepository.java index 6c548039552..9e009b06937 100644 --- a/collect_app/src/main/java/org/odk/collect/android/database/forms/DatabaseFormsRepository.java +++ b/collect_app/src/main/java/org/odk/collect/android/database/forms/DatabaseFormsRepository.java @@ -1,5 +1,19 @@ package org.odk.collect.android.database.forms; +import static android.provider.BaseColumns._ID; +import static org.odk.collect.android.database.DatabaseConstants.FORMS_TABLE_NAME; +import static org.odk.collect.android.database.DatabaseObjectMapper.getFormFromCurrentCursorPosition; +import static org.odk.collect.android.database.DatabaseObjectMapper.getValuesFromForm; +import static org.odk.collect.android.database.forms.DatabaseFormColumns.DATE; +import static org.odk.collect.android.database.forms.DatabaseFormColumns.DELETED_DATE; +import static org.odk.collect.android.database.forms.DatabaseFormColumns.FORM_FILE_PATH; +import static org.odk.collect.android.database.forms.DatabaseFormColumns.FORM_MEDIA_PATH; +import static org.odk.collect.android.database.forms.DatabaseFormColumns.JRCACHE_FILE_PATH; +import static org.odk.collect.android.database.forms.DatabaseFormColumns.JR_FORM_ID; +import static org.odk.collect.android.database.forms.DatabaseFormColumns.JR_VERSION; +import static org.odk.collect.android.database.forms.DatabaseFormColumns.MD5_HASH; +import static org.odk.collect.shared.PathUtils.getRelativeFilePath; + import android.content.ContentValues; import android.content.Context; import android.database.Cursor; @@ -7,6 +21,7 @@ import android.database.sqlite.SQLiteBlobTooBigException; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteQueryBuilder; +import android.os.StrictMode; import org.jetbrains.annotations.NotNull; import org.odk.collect.android.database.DatabaseConnection; @@ -27,20 +42,6 @@ import javax.annotation.Nullable; -import static android.provider.BaseColumns._ID; -import static org.odk.collect.android.database.DatabaseConstants.FORMS_TABLE_NAME; -import static org.odk.collect.android.database.DatabaseObjectMapper.getFormFromCurrentCursorPosition; -import static org.odk.collect.android.database.DatabaseObjectMapper.getValuesFromForm; -import static org.odk.collect.android.database.forms.DatabaseFormColumns.DATE; -import static org.odk.collect.android.database.forms.DatabaseFormColumns.DELETED_DATE; -import static org.odk.collect.android.database.forms.DatabaseFormColumns.FORM_FILE_PATH; -import static org.odk.collect.android.database.forms.DatabaseFormColumns.FORM_MEDIA_PATH; -import static org.odk.collect.android.database.forms.DatabaseFormColumns.JRCACHE_FILE_PATH; -import static org.odk.collect.android.database.forms.DatabaseFormColumns.JR_FORM_ID; -import static org.odk.collect.android.database.forms.DatabaseFormColumns.JR_VERSION; -import static org.odk.collect.android.database.forms.DatabaseFormColumns.MD5_HASH; -import static org.odk.collect.shared.PathUtils.getRelativeFilePath; - import timber.log.Timber; public class DatabaseFormsRepository implements FormsRepository { @@ -102,6 +103,7 @@ public Form getOneByMd5Hash(@NotNull String hash) { @Override public List getAll() { + StrictMode.noteSlowCall("Accessing readable DB"); return queryForForms(null, null); } @@ -116,17 +118,21 @@ public List getAllByFormIdAndVersion(String jrFormId, @Nullable String jrV @Override public List getAllByFormId(String formId) { + StrictMode.noteSlowCall("Accessing readable DB"); return queryForForms(JR_FORM_ID + "=?", new String[]{formId}); } @Override public List getAllNotDeletedByFormId(String jrFormId) { + StrictMode.noteSlowCall("Accessing readable DB"); return queryForForms(JR_FORM_ID + "=? AND " + DELETED_DATE + " IS NULL", new String[]{jrFormId}); } @Override public List getAllNotDeletedByFormIdAndVersion(String jrFormId, @Nullable String jrVersion) { + StrictMode.noteSlowCall("Accessing readable DB"); + if (jrVersion != null) { return queryForForms(DELETED_DATE + " IS NULL AND " + JR_FORM_ID + "=? AND " + JR_VERSION + "=?", new String[]{jrFormId, jrVersion}); } else { @@ -204,6 +210,7 @@ public Cursor rawQuery(Map projectionMap, String[] projection, S @Nullable private Form queryForForm(String selection, String[] selectionArgs) { + StrictMode.noteSlowCall("Accessing readable DB"); List forms = queryForForms(selection, selectionArgs); return !forms.isEmpty() ? forms.get(0) : null; } @@ -237,6 +244,8 @@ private void updateForm(Long id, ContentValues values) { } private void deleteForms(String selection, String[] selectionArgs) { + StrictMode.noteSlowCall("Accessing readable DB"); + List forms = queryForForms(selection, selectionArgs); for (Form form : forms) { deleteFilesForForm(form); diff --git a/collect_app/src/main/java/org/odk/collect/android/database/instances/DatabaseInstancesRepository.java b/collect_app/src/main/java/org/odk/collect/android/database/instances/DatabaseInstancesRepository.java index 36e56401db7..8dba6db3388 100644 --- a/collect_app/src/main/java/org/odk/collect/android/database/instances/DatabaseInstancesRepository.java +++ b/collect_app/src/main/java/org/odk/collect/android/database/instances/DatabaseInstancesRepository.java @@ -5,6 +5,7 @@ import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteQueryBuilder; +import android.os.StrictMode; import org.odk.collect.android.database.DatabaseConnection; import org.odk.collect.android.database.DatabaseConstants; @@ -83,6 +84,8 @@ public Instance getOneByPath(String instancePath) { @Override public List getAll() { + StrictMode.noteSlowCall("Accessing readable DB"); + try (Cursor cursor = query(null, null, null, null)) { return getInstancesFromCursor(cursor, instancesPath); } @@ -90,6 +93,8 @@ public List getAll() { @Override public List getAllNotDeleted() { + StrictMode.noteSlowCall("Accessing readable DB"); + try (Cursor cursor = query(null, DELETED_DATE + " IS NULL ", null, null)) { return getInstancesFromCursor(cursor, instancesPath); } @@ -112,6 +117,8 @@ public int getCountByStatus(String... status) { @Override public List getAllByFormId(String formId) { + StrictMode.noteSlowCall("Accessing readable DB"); + try (Cursor c = query(null, JR_FORM_ID + " = ?", new String[]{formId}, null)) { return getInstancesFromCursor(c, instancesPath); } @@ -119,6 +126,8 @@ public List getAllByFormId(String formId) { @Override public List getAllNotDeletedByFormIdAndVersion(String jrFormId, String jrVersion) { + StrictMode.noteSlowCall("Accessing readable DB"); + if (jrVersion != null) { try (Cursor cursor = query(null, JR_FORM_ID + " = ? AND " + JR_VERSION + " = ? AND " + DELETED_DATE + " IS NULL", new String[]{jrFormId, jrVersion}, null)) { return getInstancesFromCursor(cursor, instancesPath); diff --git a/collect_app/src/main/java/org/odk/collect/android/external/FormUriActivity.kt b/collect_app/src/main/java/org/odk/collect/android/external/FormUriActivity.kt index ac48ce4c035..57ea84a4d61 100644 --- a/collect_app/src/main/java/org/odk/collect/android/external/FormUriActivity.kt +++ b/collect_app/src/main/java/org/odk/collect/android/external/FormUriActivity.kt @@ -1,11 +1,21 @@ package org.odk.collect.android.external +import android.content.ContentResolver import android.content.Intent +import android.content.res.Resources +import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.odk.collect.analytics.Analytics +import org.odk.collect.android.R import org.odk.collect.android.activities.FormFillingActivity import org.odk.collect.android.analytics.AnalyticsEvents import org.odk.collect.android.injection.DaggerUtils @@ -16,9 +26,10 @@ import org.odk.collect.android.utilities.ApplicationConstants import org.odk.collect.android.utilities.ContentUriHelper import org.odk.collect.android.utilities.FormsRepositoryProvider import org.odk.collect.android.utilities.InstancesRepositoryProvider -import org.odk.collect.forms.Form +import org.odk.collect.async.Scheduler import org.odk.collect.projects.ProjectsRepository import org.odk.collect.settings.SettingsProvider +import org.odk.collect.strings.R.string import java.io.File import javax.inject.Inject @@ -43,6 +54,9 @@ class FormUriActivity : ComponentActivity() { @Inject lateinit var settingsProvider: SettingsProvider + @Inject + lateinit var scheduler: Scheduler + private var formFillingAlreadyStarted = false private val openForm = @@ -51,175 +65,210 @@ class FormUriActivity : ComponentActivity() { finish() } + private val formUriViewModel by viewModels { + object : ViewModelProvider.Factory { + override fun create(modelClass: Class, extras: CreationExtras): T { + return FormUriViewModel( + intent.data, + scheduler, + projectsRepository, + projectsDataService, + contentResolver, + formsRepositoryProvider, + instanceRepositoryProvider, + resources + ) as T + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) DaggerUtils.getComponent(this).inject(this) + setContentView(R.layout.circular_progress_indicator) - when { - !assertProjectListNotEmpty() -> Unit - !assertCurrentProjectUsed() -> Unit - !assertValidUri() -> Unit - !assertFormExists() -> Unit - !assertFormNotEncrypted() -> Unit - !assertFormFillingNotAlreadyStarted(savedInstanceState) -> Unit - else -> startForm() + formUriViewModel.error.observe(this) { + if (it != null) { + displayErrorDialog(it) + } else if (savedInstanceState?.getBoolean(FORM_FILLING_ALREADY_STARTED) != true) { + startForm() + } } } - private fun assertProjectListNotEmpty(): Boolean { + private fun startForm() { + formFillingAlreadyStarted = true + openForm.launch( + Intent(this, FormFillingActivity::class.java).apply { + action = intent.action + data = intent.data + intent.extras?.let { sourceExtras -> putExtras(sourceExtras) } + if (!canFormBeEdited()) { + putExtra( + ApplicationConstants.BundleKeys.FORM_MODE, + ApplicationConstants.FormModes.VIEW_SENT + ) + } + } + ) + } + + private fun displayErrorDialog(message: String) { + MaterialAlertDialogBuilder(this) + .setMessage(message) + .setPositiveButton(string.ok) { _, _ -> finish() } + .setOnCancelListener { finish() } + .create() + .show() + } + + private fun canFormBeEdited(): Boolean { + val uri = intent.data!! + val uriMimeType = contentResolver.getType(uri) + + val formEditingEnabled = if (uriMimeType == InstancesContract.CONTENT_ITEM_TYPE) { + val instance = instanceRepositoryProvider.get().get(ContentUriHelper.getIdFromUri(uri)) + instance!!.canBeEdited(settingsProvider) + } else { + true + } + + return formEditingEnabled + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean(FORM_FILLING_ALREADY_STARTED, formFillingAlreadyStarted) + super.onSaveInstanceState(outState) + } + + companion object { + private const val FORM_FILLING_ALREADY_STARTED = "FORM_FILLING_ALREADY_STARTED" + } +} + +private class FormUriViewModel( + private val uri: Uri?, + scheduler: Scheduler, + private val projectsRepository: ProjectsRepository, + private val projectsDataService: ProjectsDataService, + private val contentResolver: ContentResolver, + private val formsRepositoryProvider: FormsRepositoryProvider, + private val instancesRepositoryProvider: InstancesRepositoryProvider, + private val resources: Resources +) : ViewModel() { + + private val _error = MutableLiveData() + val error: LiveData = _error + + init { + scheduler.immediate( + background = { + assertProjectListNotEmpty() ?: assertCurrentProjectUsed() ?: assertValidUri() + ?: assertFormExists() ?: assertFormNotEncrypted() + }, + foreground = { + _error.value = it + } + ) + } + + private fun assertProjectListNotEmpty(): String? { val projects = projectsRepository.getAll() return if (projects.isEmpty()) { - displayErrorDialog(getString(org.odk.collect.strings.R.string.app_not_configured)) - false + resources.getString(string.app_not_configured) } else { - true + null } } - private fun assertCurrentProjectUsed(): Boolean { + private fun assertCurrentProjectUsed(): String? { val projects = projectsRepository.getAll() val firstProject = projects.first() - val uriProjectId = intent.data?.getQueryParameter("projectId") + val uriProjectId = uri?.getQueryParameter("projectId") val projectId = uriProjectId ?: firstProject.uuid return if (projectId != projectsDataService.getCurrentProject().uuid) { - displayErrorDialog(getString(org.odk.collect.strings.R.string.wrong_project_selected_for_form)) - false + resources.getString(string.wrong_project_selected_for_form) } else { - true + null } } - private fun assertValidUri(): Boolean { - val isUriValid = intent.data?.let { + private fun assertValidUri(): String? { + val isUriValid = uri?.let { val uriMimeType = contentResolver.getType(it) if (uriMimeType == null) { - return@let false + false } else { - return@let uriMimeType == FormsContract.CONTENT_ITEM_TYPE || uriMimeType == InstancesContract.CONTENT_ITEM_TYPE + uriMimeType == FormsContract.CONTENT_ITEM_TYPE || uriMimeType == InstancesContract.CONTENT_ITEM_TYPE } } ?: false return if (!isUriValid) { - displayErrorDialog(getString(org.odk.collect.strings.R.string.unrecognized_uri)) - false + resources.getString(string.unrecognized_uri) } else { - true + null } } - private fun assertFormExists(): Boolean { - val uri = intent.data!! - val uriMimeType = contentResolver.getType(uri) + private fun assertFormExists(): String? { + val uriMimeType = contentResolver.getType(uri!!) - val doesFormExist = if (uriMimeType == FormsContract.CONTENT_ITEM_TYPE) { - formsRepositoryProvider.get().get(ContentUriHelper.getIdFromUri(uri))?.let { - File(it.formFilePath).exists() - } ?: false - } else { - instanceRepositoryProvider.get().get(ContentUriHelper.getIdFromUri(uri))?.let { - if (!File(it.instanceFilePath).exists()) { - Analytics.log(AnalyticsEvents.OPEN_DELETED_INSTANCE) - InstanceDeleter(instanceRepositoryProvider.get(), formsRepositoryProvider.get()).delete(it.dbId) - displayErrorDialog(getString(org.odk.collect.strings.R.string.instance_deleted_message)) - return false - } + return if (uriMimeType == FormsContract.CONTENT_ITEM_TYPE) { + val formExists = + formsRepositoryProvider.get().get(ContentUriHelper.getIdFromUri(uri))?.let { + File(it.formFilePath).exists() + } ?: false - val candidateForms = formsRepositoryProvider.get().getAllByFormIdAndVersion(it.formId, it.formVersion) + if (formExists) { + null + } else { + resources.getString(string.bad_uri) + } + } else { + val instance = instancesRepositoryProvider.get().get(ContentUriHelper.getIdFromUri(uri)) + if (instance == null) { + resources.getString(string.bad_uri) + } else if (!File(instance.instanceFilePath).exists()) { + Analytics.log(AnalyticsEvents.OPEN_DELETED_INSTANCE) + InstanceDeleter( + instancesRepositoryProvider.get(), + formsRepositoryProvider.get() + ).delete(instance.dbId) + resources.getString(string.instance_deleted_message) + } else { + val candidateForms = formsRepositoryProvider.get() + .getAllByFormIdAndVersion(instance.formId, instance.formVersion) if (candidateForms.isEmpty()) { - val version = if (it.formVersion == null) { + val version = if (instance.formVersion == null) { "" } else { - "\n${getString(org.odk.collect.strings.R.string.version)} ${it.formVersion}" + "\n${resources.getString(string.version)} ${instance.formVersion}" } - displayErrorDialog(getString(org.odk.collect.strings.R.string.parent_form_not_present, "${it.formId}$version")) - return false - } else if (candidateForms.count { form: Form -> !form.isDeleted } > 1) { - displayErrorDialog(getString(org.odk.collect.strings.R.string.survey_multiple_forms_error)) - return false + resources.getString(string.parent_form_not_present, "${instance.formId}$version") + } else if (candidateForms.filter { !it.isDeleted }.size > 1) { + resources.getString(string.survey_multiple_forms_error) + } else { + null } - - true - } ?: false - } - - return if (!doesFormExist) { - displayErrorDialog(getString(org.odk.collect.strings.R.string.bad_uri)) - false - } else { - true + } } } - private fun assertFormNotEncrypted(): Boolean { - val uri = intent.data!! - val uriMimeType = contentResolver.getType(uri) + private fun assertFormNotEncrypted(): String? { + val uriMimeType = contentResolver.getType(uri!!) return if (uriMimeType == InstancesContract.CONTENT_ITEM_TYPE) { - val instance = instanceRepositoryProvider.get().get(ContentUriHelper.getIdFromUri(uri)) + val instance = instancesRepositoryProvider.get().get(ContentUriHelper.getIdFromUri(uri)) if (instance!!.canEditWhenComplete()) { - true + null } else { - displayErrorDialog(getString(org.odk.collect.strings.R.string.encrypted_form)) - false + resources.getString(string.encrypted_form) } } else { - true + null } } - - private fun assertFormFillingNotAlreadyStarted(savedInstanceState: Bundle?): Boolean { - if (savedInstanceState != null) { - formFillingAlreadyStarted = savedInstanceState.getBoolean(FORM_FILLING_ALREADY_STARTED) - } - return !formFillingAlreadyStarted - } - - private fun startForm() { - formFillingAlreadyStarted = true - openForm.launch( - Intent(this, FormFillingActivity::class.java).apply { - action = intent.action - data = intent.data - intent.extras?.let { sourceExtras -> putExtras(sourceExtras) } - if (!canFormBeEdited()) { - putExtra(ApplicationConstants.BundleKeys.FORM_MODE, ApplicationConstants.FormModes.VIEW_SENT) - } - } - ) - } - - private fun displayErrorDialog(message: String) { - MaterialAlertDialogBuilder(this) - .setMessage(message) - .setPositiveButton(org.odk.collect.strings.R.string.ok) { _, _ -> finish() } - .setOnCancelListener { finish() } - .create() - .show() - } - - private fun canFormBeEdited(): Boolean { - val uri = intent.data!! - val uriMimeType = contentResolver.getType(uri) - - val formEditingEnabled = if (uriMimeType == InstancesContract.CONTENT_ITEM_TYPE) { - val instance = instanceRepositoryProvider.get().get(ContentUriHelper.getIdFromUri(uri)) - instance!!.canBeEdited(settingsProvider) - } else { - true - } - - return formEditingEnabled - } - - override fun onSaveInstanceState(outState: Bundle) { - outState.putBoolean(FORM_FILLING_ALREADY_STARTED, formFillingAlreadyStarted) - super.onSaveInstanceState(outState) - } - - companion object { - private const val FORM_FILLING_ALREADY_STARTED = "FORM_FILLING_ALREADY_STARTED" - } } diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryViewModel.java b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryViewModel.java index 8e81898bb2d..1bd7fbb1576 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryViewModel.java +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryViewModel.java @@ -18,7 +18,6 @@ import org.javarosa.form.api.FormEntryController; import org.javarosa.form.api.FormEntryPrompt; import org.javarosa.xpath.parser.XPathSyntaxException; -import org.odk.collect.androidshared.async.TrackableWorker; import org.odk.collect.android.exception.ExternalDataException; import org.odk.collect.android.exception.JavaRosaException; import org.odk.collect.android.formentry.audit.AuditEvent; @@ -29,11 +28,14 @@ import org.odk.collect.android.javarosawrapper.RepeatsInFieldListException; import org.odk.collect.android.javarosawrapper.ValidationResult; import org.odk.collect.android.widgets.interfaces.SelectChoiceLoader; +import org.odk.collect.androidshared.async.TrackableWorker; import org.odk.collect.androidshared.data.Consumable; import org.odk.collect.androidshared.livedata.MutableNonNullLiveData; import org.odk.collect.androidshared.livedata.NonNullLiveData; import org.odk.collect.async.Cancellable; import org.odk.collect.async.Scheduler; +import org.odk.collect.forms.Form; +import org.odk.collect.forms.FormsRepository; import java.io.FileNotFoundException; import java.util.HashMap; @@ -53,6 +55,7 @@ public class FormEntryViewModel extends ViewModel implements SelectChoiceLoader @NonNull private final FormSessionRepository formSessionRepository; private final String sessionId; + private Form form; @Nullable private FormController formController; @@ -64,13 +67,14 @@ public class FormEntryViewModel extends ViewModel implements SelectChoiceLoader private AnswerListener answerListener; private final Cancellable formSessionObserver; + private final FormsRepository formsRepository; private final Map> choices = new HashMap<>(); private final TrackableWorker worker; @SuppressWarnings("WeakerAccess") - public FormEntryViewModel(Supplier clock, Scheduler scheduler, FormSessionRepository formSessionRepository, String sessionId) { + public FormEntryViewModel(Supplier clock, Scheduler scheduler, FormSessionRepository formSessionRepository, String sessionId, FormsRepository formsRepository) { this.clock = clock; this.formSessionRepository = formSessionRepository; worker = new TrackableWorker(scheduler); @@ -78,10 +82,12 @@ public FormEntryViewModel(Supplier clock, Scheduler scheduler, FormSession this.sessionId = sessionId; formSessionObserver = observe(formSessionRepository.get(this.sessionId), formSession -> { this.formController = formSession.getFormController(); + this.form = formSession.getForm(); boolean hasBackgroundRecording = formController.getFormDef().hasAction(RecordAudioActionHandler.ELEMENT_NAME); this.hasBackgroundRecording.setValue(hasBackgroundRecording); }); + this.formsRepository = formsRepository; } public String getSessionId() { @@ -386,6 +392,17 @@ private void preloadSelectChoices() throws RepeatsInFieldListException, FileNotF } } + public void changeLanguage(String newLanguage) { + formController.setLanguage(newLanguage); + + worker.immediate(() -> { + formsRepository.save(new Form.Builder(form) + .language(newLanguage) + .build() + ); + }); + } + public interface AnswerListener { void onAnswer(FormIndex index, IAnswerData answer); } diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/saving/FormSaveViewModel.java b/collect_app/src/main/java/org/odk/collect/android/formentry/saving/FormSaveViewModel.java index 036259da3e0..4d473e28f1d 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/saving/FormSaveViewModel.java +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/saving/FormSaveViewModel.java @@ -406,6 +406,11 @@ public Long getLastSavedTime() { return instance != null ? instance.getLastStatusChangeDate() : null; } + @Nullable + public Instance getInstance() { + return instance; + } + public static class SaveResult { private final State state; private final String message; diff --git a/collect_app/src/main/java/org/odk/collect/android/formhierarchy/FormHierarchyActivity.java b/collect_app/src/main/java/org/odk/collect/android/formhierarchy/FormHierarchyActivity.java index db396211c97..822fc242110 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formhierarchy/FormHierarchyActivity.java +++ b/collect_app/src/main/java/org/odk/collect/android/formhierarchy/FormHierarchyActivity.java @@ -60,6 +60,7 @@ import org.odk.collect.android.projects.ProjectsDataService; import org.odk.collect.android.utilities.ApplicationConstants; import org.odk.collect.android.utilities.FormEntryPromptUtils; +import org.odk.collect.android.utilities.FormsRepositoryProvider; import org.odk.collect.android.utilities.HtmlUtils; import org.odk.collect.android.utilities.InstancesRepositoryProvider; import org.odk.collect.android.utilities.MediaUtils; @@ -186,6 +187,9 @@ public class FormHierarchyActivity extends LocalizedActivity implements DeleteRe @Inject public InstancesRepositoryProvider instancesRepositoryProvider; + @Inject + public FormsRepositoryProvider formsRepositoryProvider; + protected final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { @@ -217,6 +221,7 @@ public void onCreate(Bundle savedInstanceState) { fusedLocationClient, permissionsProvider, autoSendSettingsProvider, + formsRepositoryProvider, instancesRepositoryProvider, new QRCodeCreatorImpl(), new HtmlPrinter() diff --git a/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListViewModel.kt b/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListViewModel.kt index 58e7ef63754..8243dbce292 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListViewModel.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListViewModel.kt @@ -5,12 +5,16 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.asLiveData import androidx.lifecycle.map +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import org.odk.collect.android.formmanagement.FormsDataService import org.odk.collect.android.preferences.utilities.FormUpdateMode import org.odk.collect.android.preferences.utilities.SettingsUtils -import org.odk.collect.androidshared.livedata.LiveDataUtils import org.odk.collect.async.Scheduler +import org.odk.collect.async.flowOnBackground import org.odk.collect.forms.Form import org.odk.collect.forms.FormSourceException import org.odk.collect.forms.FormSourceException.AuthRequired @@ -28,12 +32,19 @@ class BlankFormListViewModel( private val showAllVersions: Boolean = false ) : ViewModel() { - private val _filterText = MutableLiveData("") - private val _sortingOrder = MutableLiveData(generalSettings.getInt("formChooserListSortingOrder")) - private val filteredForms = LiveDataUtils.zip3(formsDataService.getForms(projectId), _filterText, _sortingOrder) - val formsToDisplay: LiveData> = filteredForms.map { (forms, filter, sort) -> - filterAndSortForms(forms, sort, filter) - } + private val _filterText = MutableStateFlow("") + private val _sortingOrder = MutableStateFlow(generalSettings.getInt("formChooserListSortingOrder")) + private val filteredForms = formsDataService.getForms(projectId) + .combine(_filterText) { forms, filter -> + Pair(forms, filter) + }.combine(_sortingOrder) { (forms, filter), sort -> + Triple(forms, filter, sort) + } + + val formsToDisplay: LiveData> = + filteredForms.map { (forms, filter, sort) -> + filterAndSortForms(forms, sort, filter) + }.flowOnBackground(scheduler).asLiveData() val syncResult: LiveData = formsDataService.getDiskError(projectId) val isLoading: LiveData = formsDataService.isSyncing(projectId) diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/FormsDataService.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/FormsDataService.kt index f7b076c50fd..25a19f65aa0 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/FormsDataService.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/FormsDataService.kt @@ -2,6 +2,8 @@ package org.odk.collect.android.formmanagement import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import org.odk.collect.android.formmanagement.matchexactly.ServerFormsSynchronizer import org.odk.collect.android.notifications.Notifier import org.odk.collect.android.projects.ProjectDependencyProvider @@ -21,8 +23,8 @@ class FormsDataService( private val clock: Supplier ) { - fun getForms(projectId: String): LiveData> { - return getFormsLiveData(projectId) + fun getForms(projectId: String): Flow> { + return getFormsFlow(projectId) } fun isSyncing(projectId: String): LiveData { @@ -172,11 +174,11 @@ class FormsDataService( private fun syncWithDb(projectId: String) { val projectDependencies = projectDependencyProviderFactory.create(projectId) - getFormsLiveData(projectId).postValue(projectDependencies.formsRepository.all) + getFormsFlow(projectId).value = projectDependencies.formsRepository.all } - private fun getFormsLiveData(projectId: String): MutableLiveData> { - return appState.get("forms:$projectId", MutableLiveData(emptyList())) + private fun getFormsFlow(projectId: String): MutableStateFlow> { + return appState.get("forms:$projectId", MutableStateFlow(emptyList())) } private fun getSyncingLiveData(projectId: String) = diff --git a/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java b/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java index 0d4fd24d99e..194f937a5db 100644 --- a/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java +++ b/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java @@ -20,6 +20,7 @@ import android.content.Intent; import android.database.Cursor; import android.database.SQLException; +import android.net.Uri; import androidx.annotation.NonNull; @@ -41,21 +42,31 @@ import org.odk.collect.android.dynamicpreload.ExternalAnswerResolver; import org.odk.collect.android.dynamicpreload.ExternalDataManager; import org.odk.collect.android.dynamicpreload.ExternalDataUseCases; +import org.odk.collect.android.external.FormsContract; +import org.odk.collect.android.external.InstancesContract; import org.odk.collect.android.fastexternalitemset.ItemsetDbAdapter; import org.odk.collect.android.javarosawrapper.FormController; import org.odk.collect.android.javarosawrapper.JavaRosaFormController; import org.odk.collect.android.listeners.FormLoaderListener; +import org.odk.collect.android.storage.StoragePathProvider; +import org.odk.collect.android.storage.StorageSubdirectory; +import org.odk.collect.android.utilities.ContentUriHelper; import org.odk.collect.android.utilities.ExternalizableFormDefCache; import org.odk.collect.android.utilities.FileUtils; +import org.odk.collect.android.utilities.FormsRepositoryProvider; +import org.odk.collect.android.utilities.InstancesRepositoryProvider; import org.odk.collect.android.utilities.ZipUtils; import org.odk.collect.async.Scheduler; import org.odk.collect.async.SchedulerAsyncTaskMimic; +import org.odk.collect.forms.Form; +import org.odk.collect.forms.instances.Instance; import org.odk.collect.shared.strings.Md5; import java.io.File; import java.io.FileFilter; import java.io.FileReader; import java.io.IOException; +import java.util.List; import java.util.Locale; import timber.log.Timber; @@ -66,13 +77,15 @@ * @author Carl Hartung (carlhartung@gmail.com) * @author Yaw Anokwa (yanokwa@gmail.com) */ -public class FormLoaderTask extends SchedulerAsyncTaskMimic { +public class FormLoaderTask extends SchedulerAsyncTaskMimic { private static final String ITEMSETS_CSV = "itemsets.csv"; private FormLoaderListener stateListener; private String errorMsg; private String warningMsg; private String instancePath; + private final Uri uri; + private final String uriMimeType; private final String xpath; private final String waitingXPath; private FormEntryControllerFactory formEntryControllerFactory; @@ -82,12 +95,26 @@ public class FormLoaderTask extends SchedulerAsyncTaskMimic candidateForms = new FormsRepositoryProvider(Collect.getInstance()).get().getAllByFormIdAndVersion(instance.getFormId(), instance.getFormVersion()); + + form = candidateForms.get(0); + } else if (uriMimeType != null && uriMimeType.equals(FormsContract.CONTENT_ITEM_TYPE)) { + form = new FormsRepositoryProvider(Collect.getInstance()).get().get(ContentUriHelper.getIdFromUri(uri)); + + /** + * This is the fill-blank-form code path.See if there is a savepoint for this form + * that has never been explicitly saved by the user. If there is, open this savepoint(resume this filled-in form). + * Savepoints for forms that were explicitly saved will be recovered when that + * explicitly saved instance is edited via edit-saved-form. + */ + instancePath = loadSavePoint(); + } + + if (form.getFormFilePath() == null) { Timber.e(new Error("formPath is null")); errorMsg = "formPath is null, please email support@getodk.org with a description of what you were doing when this happened."; return null; } - final File formXml = new File(formPath); + final File formXml = new File(form.getFormFilePath()); final File formMediaDir = FileUtils.getFormMediaDir(formXml); unzipMediaFiles(formMediaDir); @@ -143,7 +189,7 @@ protected FECWrapper doInBackground(String... path) { FormDef formDef = null; try { - formDef = createFormDefFromCacheOrXml(formPath, formXml); + formDef = createFormDefFromCacheOrXml(form.getFormFilePath(), formXml); } catch (StackOverflowError e) { Timber.e(e); errorMsg = getLocalizedString(Collect.getInstance(), org.odk.collect.strings.R.string.too_complex_form); @@ -528,6 +574,51 @@ private void readCSV(File csv, String formHash, String pathHash) { } } + private String loadSavePoint() { + final String filePrefix = form.getFormFilePath().substring( + form.getFormFilePath().lastIndexOf('/') + 1, + form.getFormFilePath().lastIndexOf('.')) + + "_"; + final String fileSuffix = ".xml.save"; + File cacheDir = new File(new StoragePathProvider().getOdkDirPath(StorageSubdirectory.CACHE)); + File[] files = cacheDir.listFiles(pathname -> { + String name = pathname.getName(); + return name.startsWith(filePrefix) + && name.endsWith(fileSuffix); + }); + + if (files != null) { + /** + * See if any of these savepoints are for a filled-in form that has never + * been explicitly saved by the user. + */ + for (File candidate : files) { + String instanceDirName = candidate.getName() + .substring( + 0, + candidate.getName().length() + - fileSuffix.length()); + File instanceDir = new File( + new StoragePathProvider().getOdkDirPath(StorageSubdirectory.INSTANCES) + File.separator + + instanceDirName); + File instanceFile = new File(instanceDir, + instanceDirName + ".xml"); + if (instanceDir.exists() + && instanceDir.isDirectory() + && !instanceFile.exists()) { + // yes! -- use this savepoint file + return instanceFile + .getAbsolutePath(); + } + } + + } else { + Timber.e(new Error("Couldn't access cache directory when looking for save points!")); + } + + return null; + } + public FormDef getFormDef() { return formDef; } diff --git a/collect_app/src/main/res/layout/circular_progress_indicator.xml b/collect_app/src/main/res/layout/circular_progress_indicator.xml new file mode 100644 index 00000000000..ecd45cea27e --- /dev/null +++ b/collect_app/src/main/res/layout/circular_progress_indicator.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/collect_app/src/test/java/org/odk/collect/android/dynamicpreload/ExternalDataUseCasesTest.kt b/collect_app/src/test/java/org/odk/collect/android/dynamicpreload/ExternalDataUseCasesTest.kt index 672fc6ca56f..908690ddd5c 100644 --- a/collect_app/src/test/java/org/odk/collect/android/dynamicpreload/ExternalDataUseCasesTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/dynamicpreload/ExternalDataUseCasesTest.kt @@ -36,4 +36,17 @@ class ExternalDataUseCasesTest { ExternalDataUseCases.create(form, mediaDir, { false }, {}) assertThat(mediaDir.listFiles().size, equalTo(2)) } + + @Test + fun `create() leaves original CSV in place`() { + val form = FormDef().also { + it.extras.put(DynamicPreloadExtra(true)) + } + + val mediaDir = TempFiles.createTempDir() + val csv = File(mediaDir, "items.csv").also { it.writeText("name_key,name\nmango,Mango") } + + ExternalDataUseCases.create(form, mediaDir, { false }, {}) + assertThat(csv.exists(), equalTo(true)) + } } diff --git a/collect_app/src/test/java/org/odk/collect/android/external/FormUriActivityTest.kt b/collect_app/src/test/java/org/odk/collect/android/external/FormUriActivityTest.kt index a2588005c40..2e92d0d42f8 100644 --- a/collect_app/src/test/java/org/odk/collect/android/external/FormUriActivityTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/external/FormUriActivityTest.kt @@ -22,6 +22,7 @@ import androidx.test.espresso.matcher.ViewMatchers.isRoot import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.WorkManager import com.google.gson.Gson import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo @@ -45,6 +46,7 @@ import org.odk.collect.android.utilities.FormsRepositoryProvider import org.odk.collect.android.utilities.InstancesRepositoryProvider import org.odk.collect.androidtest.ActivityScenarioLauncherRule import org.odk.collect.androidtest.RecordedIntentsRule +import org.odk.collect.async.Scheduler import org.odk.collect.forms.instances.Instance import org.odk.collect.formstest.FormUtils import org.odk.collect.formstest.InMemFormsRepository @@ -57,15 +59,19 @@ import org.odk.collect.settings.SettingsProvider import org.odk.collect.settings.keys.ProtectedProjectKeys import org.odk.collect.shared.TempFiles import org.odk.collect.shared.strings.UUIDGenerator +import org.odk.collect.testshared.FakeScheduler import java.io.File @RunWith(AndroidJUnit4::class) class FormUriActivityTest { + private val context = ApplicationProvider.getApplicationContext() private val projectsRepository = InMemProjectsRepository() private val projectsDataService = mock() private val formsRepository = InMemFormsRepository() private val instancesRepository = InMemInstancesRepository() + private val fakeScheduler = FakeScheduler() + private val settingsProvider = InMemSettingsProvider().apply { getProtectedSettings().save(ProtectedProjectKeys.KEY_EDIT_SAVED, true) } @@ -115,12 +121,17 @@ class FormUriActivityTest { override fun providesSettingsProvider(context: Context): SettingsProvider { return settingsProvider } + + override fun providesScheduler(workManager: WorkManager?): Scheduler { + return fakeScheduler + } }) } @Test fun `When there are no projects then display alert dialog`() { val scenario = launcherRule.launchForResult(FormUriActivity::class.java) + fakeScheduler.flush() assertErrorDialogAndClickCancelButton( scenario, @@ -150,6 +161,7 @@ class FormUriActivityTest { form.dbId ) ) + fakeScheduler.flush() assertErrorDialogAndClickCancelButton( scenario, @@ -175,6 +187,7 @@ class FormUriActivityTest { val scenario = launcherRule.launchForResult(getBlankFormIntent(null, form.dbId)) + fakeScheduler.flush() assertErrorDialogAndClickCancelButton( scenario, @@ -201,6 +214,7 @@ class FormUriActivityTest { data = null } ) + fakeScheduler.flush() assertErrorDialogAndClickCancelButton( scenario, @@ -227,6 +241,7 @@ class FormUriActivityTest { data = Uri.parse("blah") } ) + fakeScheduler.flush() assertErrorDialogAndClickCancelButton( scenario, @@ -242,6 +257,7 @@ class FormUriActivityTest { val scenario = launcherRule.launchForResult(getBlankFormIntent(project.uuid, 1)) + fakeScheduler.flush() assertErrorDialogAndClickCancelButton(scenario, context.getString(org.odk.collect.strings.R.string.bad_uri)) } @@ -267,6 +283,7 @@ class FormUriActivityTest { form.dbId ) ) + fakeScheduler.flush() assertErrorDialogAndClickCancelButton(scenario, context.getString(org.odk.collect.strings.R.string.bad_uri)) } @@ -279,6 +296,7 @@ class FormUriActivityTest { val scenario = launcherRule.launchForResult(getSavedIntent(project.uuid, 1)) + fakeScheduler.flush() assertErrorDialogAndClickCancelButton(scenario, context.getString(org.odk.collect.strings.R.string.bad_uri)) } @@ -309,6 +327,8 @@ class FormUriActivityTest { instance.dbId ) ) + fakeScheduler.flush() + fakeScheduler.flush() assertThat(instancesRepository.get(instance.dbId), equalTo(null)) assertErrorDialogAndClickCancelButton( @@ -337,6 +357,7 @@ class FormUriActivityTest { instance.dbId ) ) + fakeScheduler.flush() val expectedMessage = context.getString( org.odk.collect.strings.R.string.parent_form_not_present, @@ -367,6 +388,7 @@ class FormUriActivityTest { instance.dbId ) ) + fakeScheduler.flush() val expectedMessage = context.getString( org.odk.collect.strings.R.string.parent_form_not_present, @@ -379,7 +401,7 @@ class FormUriActivityTest { } @Test - fun `When attempting to edit a form with multiple non-deleted form definitions then display alert dialog`() { + fun `When attempting to edit a form with multiple form definitions then display alert dialog`() { val project = Project.Saved("123", "First project", "A", "#cccccc") projectsRepository.save(project) whenever(projectsDataService.getCurrentProject()).thenReturn(project) @@ -416,6 +438,7 @@ class FormUriActivityTest { instance.dbId ) ) + fakeScheduler.flush() assertErrorDialogAndClickCancelButton( scenario, @@ -423,6 +446,51 @@ class FormUriActivityTest { ) } + @Test + fun `When attempting to edit a form with only one non-deleted form definitions then start form`() { + val project = Project.Saved("123", "First project", "A", "#cccccc") + projectsRepository.save(project) + whenever(projectsDataService.getCurrentProject()).thenReturn(project) + + val form1 = formsRepository.save( + FormUtils.buildForm( + "1", + "1", + TempFiles.createTempDir().absolutePath, + FormUtils.createXFormBody("1", "1", "Form 1") + ).build() + ) + val form2 = formsRepository.save( + FormUtils.buildForm( + "1", + "1", + TempFiles.createTempDir().absolutePath, + FormUtils.createXFormBody("1", "1", "Form 2") + ).build() + ) + + formsRepository.softDelete(form1.dbId) + + val instance = instancesRepository.save( + Instance.Builder() + .formId("1") + .formVersion("1") + .instanceFilePath(TempFiles.createTempFile(TempFiles.createTempDir()).absolutePath) + .status(Instance.STATUS_INCOMPLETE) + .build() + ) + + launcherRule.launchForResult( + getSavedIntent( + project.uuid, + instance.dbId + ) + ) + fakeScheduler.flush() + + assertStartSavedFormIntent(project.uuid, instance.dbId, true) + } + @Test fun `When attempting to edit an encrypted form then display alert dialog`() { val project = Project.Saved("123", "First project", "A", "#cccccc") @@ -454,6 +522,7 @@ class FormUriActivityTest { instance.dbId ) ) + fakeScheduler.flush() assertErrorDialogAndClickCancelButton( scenario, @@ -483,6 +552,7 @@ class FormUriActivityTest { ) launcherRule.launchForResult(getSavedIntent(project.uuid, instance.dbId)) + fakeScheduler.flush() assertStartSavedFormIntent(project.uuid, instance.dbId, false) } @@ -504,6 +574,7 @@ class FormUriActivityTest { ) launcherRule.launchForResult(getBlankFormIntent(project.uuid, form.dbId)) + fakeScheduler.flush() assertStartBlankFormIntent(project.uuid, form.dbId) } @@ -528,6 +599,7 @@ class FormUriActivityTest { ) launcherRule.launchForResult(getSavedIntent(project.uuid, instance.dbId)) + fakeScheduler.flush() assertStartSavedFormIntent(project.uuid, instance.dbId, false) } @@ -552,6 +624,7 @@ class FormUriActivityTest { ) launcherRule.launchForResult(getSavedIntent(project.uuid, instance.dbId)) + fakeScheduler.flush() assertStartSavedFormIntent(project.uuid, instance.dbId, false) } @@ -576,12 +649,36 @@ class FormUriActivityTest { ) launcherRule.launchForResult(getSavedIntent(project.uuid, instance.dbId)) + fakeScheduler.flush() assertStartSavedFormIntent(project.uuid, instance.dbId, false) } @Test - fun `Form filling should not be started again after recreating the activity or getting back to it`() { + fun `Form filling should not be started again after recreating the activity`() { + val project = Project.Saved("123", "First project", "A", "#cccccc") + projectsRepository.save(project) + whenever(projectsDataService.getCurrentProject()).thenReturn(project) + + val form = formsRepository.save( + FormUtils.buildForm( + "1", + "1", + TempFiles.createTempDir().absolutePath + ).build() + ) + + val scenario = + launcherRule.launch(getBlankFormIntent(project.uuid, form.dbId)) + fakeScheduler.flush() + scenario.recreate() + fakeScheduler.flush() + + Intents.intended(hasComponent(FormFillingActivity::class.java.name), Intents.times(1)) + } + + @Test + fun `Form filling should not be started again after recreating the activity before starting`() { val project = Project.Saved("123", "First project", "A", "#cccccc") projectsRepository.save(project) whenever(projectsDataService.getCurrentProject()).thenReturn(project) @@ -597,6 +694,7 @@ class FormUriActivityTest { val scenario = launcherRule.launch(getBlankFormIntent(project.uuid, form.dbId)) scenario.recreate() + fakeScheduler.flush() Intents.intended(hasComponent(FormFillingActivity::class.java.name), Intents.times(1)) } @@ -616,6 +714,7 @@ class FormUriActivityTest { ) launcherRule.launch(getBlankFormIntent(project.uuid, form.dbId)) + fakeScheduler.flush() assertStartBlankFormIntent(project.uuid, form.dbId) } @@ -640,6 +739,7 @@ class FormUriActivityTest { ) launcherRule.launch(getSavedIntent(project.uuid, instance.dbId)) + fakeScheduler.flush() assertStartSavedFormIntent(project.uuid, instance.dbId, true) } @@ -664,6 +764,7 @@ class FormUriActivityTest { ) launcherRule.launch(getSavedIntent(project.uuid, instance.dbId)) + fakeScheduler.flush() assertStartSavedFormIntent(project.uuid, instance.dbId, true) } @@ -683,6 +784,7 @@ class FormUriActivityTest { ) launcherRule.launch(getBlankFormIntent(null, form.dbId)) + fakeScheduler.flush() assertStartBlankFormIntent(null, form.dbId) } @@ -707,6 +809,7 @@ class FormUriActivityTest { ) launcherRule.launch(getSavedIntent(null, instance.dbId)) + fakeScheduler.flush() assertStartSavedFormIntent(null, instance.dbId, true) } @@ -714,6 +817,7 @@ class FormUriActivityTest { @Test fun `The activity should be finished after clicking the back button when an error dialog is displayed`() { val scenario = launcherRule.launchForResult(FormUriActivity::class.java) + fakeScheduler.flush() assertErrorDialogAndClickBackButton( scenario, diff --git a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryViewModelTest.java b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryViewModelTest.java index f442d1f27b6..86d6313015c 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryViewModelTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryViewModelTest.java @@ -33,6 +33,8 @@ import org.odk.collect.android.javarosawrapper.FakeFormController; import org.odk.collect.android.support.MockFormEntryPromptBuilder; import org.odk.collect.androidshared.data.Consumable; +import org.odk.collect.forms.FormsRepository; +import org.odk.collect.formstest.InMemFormsRepository; import org.odk.collect.testshared.FakeScheduler; import java.io.FileNotFoundException; @@ -49,6 +51,7 @@ public class FormEntryViewModelTest { private AuditEventLogger auditEventLogger; private FakeScheduler scheduler; private final FormSessionRepository formSessionRepository = new InMemFormSessionRepository(); + private final FormsRepository formsRepository = new InMemFormsRepository(); @Rule public InstantTaskExecutorRule instantTaskExecutorRule = new InstantTaskExecutorRule(); @@ -62,7 +65,7 @@ public void setup() { scheduler = new FakeScheduler(); formSessionRepository.set("blah", formController, mock()); - viewModel = new FormEntryViewModel(() -> 0L, scheduler, formSessionRepository, "blah"); + viewModel = new FormEntryViewModel(() -> 0L, scheduler, formSessionRepository, "blah", formsRepository); } @Test diff --git a/collect_app/src/test/java/org/odk/collect/android/formlists/blankformlist/BlankFormListViewModelTest.kt b/collect_app/src/test/java/org/odk/collect/android/formlists/blankformlist/BlankFormListViewModelTest.kt index 8000ca6a7b4..cf88bdd5125 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formlists/blankformlist/BlankFormListViewModelTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/formlists/blankformlist/BlankFormListViewModelTest.kt @@ -5,6 +5,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.MutableLiveData import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.flow.MutableStateFlow import org.hamcrest.CoreMatchers.`is` import org.hamcrest.MatcherAssert.assertThat import org.junit.Rule @@ -18,7 +19,6 @@ import org.mockito.kotlin.whenever import org.odk.collect.android.formmanagement.FormsDataService import org.odk.collect.android.preferences.utilities.FormUpdateMode import org.odk.collect.android.utilities.ChangeLockProvider -import org.odk.collect.androidtest.getOrAwaitValue import org.odk.collect.forms.Form import org.odk.collect.forms.FormSourceException import org.odk.collect.forms.instances.Instance @@ -28,6 +28,7 @@ import org.odk.collect.settings.keys.ProjectKeys import org.odk.collect.shared.settings.InMemSettings import org.odk.collect.testshared.BooleanChangeLock import org.odk.collect.testshared.FakeScheduler +import org.odk.collect.testshared.getOrAwaitValue @RunWith(AndroidJUnit4::class) class BlankFormListViewModelTest { @@ -38,7 +39,7 @@ class BlankFormListViewModelTest { private val instancesRepository = InMemInstancesRepository() private val context = ApplicationProvider.getApplicationContext() private val formsDataService: FormsDataService = mock { - on { getForms(any()) } doReturn MutableLiveData() + on { getForms(any()) } doReturn MutableStateFlow(emptyList()) } private val scheduler = FakeScheduler() @@ -97,9 +98,9 @@ class BlankFormListViewModelTest { whenever(formsDataService.getServerError(projectId)).thenReturn(liveData) val outOfSync = viewModel.isOutOfSyncWithServer() - assertThat(outOfSync.getOrAwaitValue(), `is`(true)) + assertThat(outOfSync.getOrAwaitValue(scheduler), `is`(true)) liveData.value = null - assertThat(outOfSync.getOrAwaitValue(), `is`(false)) + assertThat(outOfSync.getOrAwaitValue(scheduler), `is`(false)) } @Test @@ -110,11 +111,11 @@ class BlankFormListViewModelTest { whenever(formsDataService.getServerError(projectId)).thenReturn(liveData) val authenticationRequired = viewModel.isAuthenticationRequired() - assertThat(authenticationRequired.getOrAwaitValue(), `is`(false)) + assertThat(authenticationRequired.getOrAwaitValue(scheduler), `is`(false)) liveData.value = FormSourceException.AuthRequired() - assertThat(authenticationRequired.getOrAwaitValue(), `is`(true)) + assertThat(authenticationRequired.getOrAwaitValue(scheduler), `is`(true)) liveData.value = null - assertThat(authenticationRequired.getOrAwaitValue(), `is`(false)) + assertThat(authenticationRequired.getOrAwaitValue(scheduler), `is`(false)) } @Test @@ -126,8 +127,8 @@ class BlankFormListViewModelTest { createViewModel() - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[0], form(dbId = 1, formId = "1")) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[1], form(dbId = 2, formId = "2")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 1, formId = "1")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[1], form(dbId = 2, formId = "2")) } @Test @@ -139,8 +140,9 @@ class BlankFormListViewModelTest { createViewModel() - assertThat(viewModel.formsToDisplay.getOrAwaitValue().size, `is`(1)) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[0], form(dbId = 1, formId = "1")) + val formsToDisplay = viewModel.formsToDisplay.getOrAwaitValue(scheduler) + assertThat(formsToDisplay.size, `is`(1)) + assertFormItem(formsToDisplay[0], form(dbId = 1, formId = "1")) } @Test @@ -152,8 +154,8 @@ class BlankFormListViewModelTest { createViewModel(showAllVersions = false) - assertThat(viewModel.formsToDisplay.getOrAwaitValue().size, `is`(1)) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[0], form(dbId = 2, formId = "1", version = "1")) + assertThat(viewModel.formsToDisplay.getOrAwaitValue(scheduler).size, `is`(1)) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 2, formId = "1", version = "1")) } @Test @@ -165,9 +167,9 @@ class BlankFormListViewModelTest { createViewModel(showAllVersions = true) - assertThat(viewModel.formsToDisplay.getOrAwaitValue().size, `is`(2)) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[0], form(dbId = 1, formId = "1", version = "2")) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[1], form(dbId = 2, formId = "1", version = "1")) + assertThat(viewModel.formsToDisplay.getOrAwaitValue(scheduler).size, `is`(2)) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 1, formId = "1", version = "2")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[1], form(dbId = 2, formId = "1", version = "1")) } @Test @@ -184,11 +186,11 @@ class BlankFormListViewModelTest { viewModel.sortingOrder = 0 - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[0], form(dbId = 1, formId = "1", formName = "1Form")) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[1], form(dbId = 5, formId = "5", formName = "2Form")) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[2], form(dbId = 3, formId = "3", formName = "aForm")) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[3], form(dbId = 4, formId = "4", formName = "AForm")) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[4], form(dbId = 2, formId = "2", formName = "BForm")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 1, formId = "1", formName = "1Form")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[1], form(dbId = 5, formId = "5", formName = "2Form")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[2], form(dbId = 3, formId = "3", formName = "aForm")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[3], form(dbId = 4, formId = "4", formName = "AForm")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[4], form(dbId = 2, formId = "2", formName = "BForm")) } @Test @@ -205,11 +207,11 @@ class BlankFormListViewModelTest { viewModel.sortingOrder = 1 - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[0], form(dbId = 2, formId = "2", formName = "BForm")) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[1], form(dbId = 3, formId = "3", formName = "aForm")) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[2], form(dbId = 4, formId = "4", formName = "AForm")) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[3], form(dbId = 5, formId = "5", formName = "2Form")) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[4], form(dbId = 1, formId = "1", formName = "1Form")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 2, formId = "2", formName = "BForm")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[1], form(dbId = 3, formId = "3", formName = "aForm")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[2], form(dbId = 4, formId = "4", formName = "AForm")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[3], form(dbId = 5, formId = "5", formName = "2Form")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[4], form(dbId = 1, formId = "1", formName = "1Form")) } @Test @@ -226,11 +228,11 @@ class BlankFormListViewModelTest { viewModel.sortingOrder = 2 - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[0], form(dbId = 4, formId = "4", formName = "AForm", lastDetectedAttachmentsUpdateDate = 7)) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[1], form(dbId = 2, formId = "2", formName = "BForm", lastDetectedAttachmentsUpdateDate = 6)) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[2], form(dbId = 5, formId = "5", formName = "2Form")) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[3], form(dbId = 3, formId = "3", formName = "aForm")) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[4], form(dbId = 1, formId = "1", formName = "1Form")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 4, formId = "4", formName = "AForm", lastDetectedAttachmentsUpdateDate = 7)) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[1], form(dbId = 2, formId = "2", formName = "BForm", lastDetectedAttachmentsUpdateDate = 6)) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[2], form(dbId = 5, formId = "5", formName = "2Form")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[3], form(dbId = 3, formId = "3", formName = "aForm")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[4], form(dbId = 1, formId = "1", formName = "1Form")) } @Test @@ -247,11 +249,11 @@ class BlankFormListViewModelTest { viewModel.sortingOrder = 3 - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[0], form(dbId = 1, formId = "1", formName = "1Form")) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[1], form(dbId = 3, formId = "3", formName = "aForm")) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[2], form(dbId = 5, formId = "5", formName = "2Form")) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[3], form(dbId = 2, formId = "2", formName = "BForm", lastDetectedAttachmentsUpdateDate = 6)) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[4], form(dbId = 4, formId = "4", formName = "AForm", lastDetectedAttachmentsUpdateDate = 7)) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 1, formId = "1", formName = "1Form")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[1], form(dbId = 3, formId = "3", formName = "aForm")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[2], form(dbId = 5, formId = "5", formName = "2Form")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[3], form(dbId = 2, formId = "2", formName = "BForm", lastDetectedAttachmentsUpdateDate = 6)) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[4], form(dbId = 4, formId = "4", formName = "AForm", lastDetectedAttachmentsUpdateDate = 7)) } @Test @@ -276,11 +278,11 @@ class BlankFormListViewModelTest { viewModel.sortingOrder = 4 - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[0], form(dbId = 2, formId = "2", formName = "BForm"), 5L) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[1], form(dbId = 4, formId = "4", formName = "AForm"), 4L) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[2], form(dbId = 5, formId = "5", formName = "2Form"), 3L) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[3], form(dbId = 3, formId = "3", formName = "aForm"), 2L) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[4], form(dbId = 1, formId = "1", formName = "1Form"), 1L) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 2, formId = "2", formName = "BForm"), 5L) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[1], form(dbId = 4, formId = "4", formName = "AForm"), 4L) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[2], form(dbId = 5, formId = "5", formName = "2Form"), 3L) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[3], form(dbId = 3, formId = "3", formName = "aForm"), 2L) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[4], form(dbId = 1, formId = "1", formName = "1Form"), 1L) } @Test @@ -297,11 +299,11 @@ class BlankFormListViewModelTest { viewModel.sortingOrder = 4 - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[0], form(dbId = 1, formId = "1", formName = "1Form")) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[1], form(dbId = 2, formId = "2", formName = "BForm")) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[2], form(dbId = 3, formId = "3", formName = "aForm")) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[3], form(dbId = 4, formId = "4", formName = "AForm")) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[4], form(dbId = 5, formId = "5", formName = "2Form")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 1, formId = "1", formName = "1Form")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[1], form(dbId = 2, formId = "2", formName = "BForm")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[2], form(dbId = 3, formId = "3", formName = "aForm")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[3], form(dbId = 4, formId = "4", formName = "AForm")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[4], form(dbId = 5, formId = "5", formName = "2Form")) } @Test @@ -323,11 +325,11 @@ class BlankFormListViewModelTest { viewModel.sortingOrder = 4 - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[0], form(dbId = 3, formId = "3", formName = "aForm"), 2L) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[1], form(dbId = 1, formId = "1", formName = "1Form"), 1L) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[2], form(dbId = 2, formId = "2", formName = "BForm")) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[3], form(dbId = 4, formId = "4", formName = "AForm")) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[4], form(dbId = 5, formId = "5", formName = "2Form")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 3, formId = "3", formName = "aForm"), 2L) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[1], form(dbId = 1, formId = "1", formName = "1Form"), 1L) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[2], form(dbId = 2, formId = "2", formName = "BForm")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[3], form(dbId = 4, formId = "4", formName = "AForm")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[4], form(dbId = 5, formId = "5", formName = "2Form")) } @Test @@ -348,9 +350,9 @@ class BlankFormListViewModelTest { viewModel.sortingOrder = 4 - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[0], form(dbId = 2, formId = "1", formName = "AForm v2", version = "2"), 3L) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[1], form(dbId = 3, formId = "2", formName = "BForm"), 2L) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[2], form(dbId = 1, formId = "1", formName = "AForm v1", version = "1"), 1L) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 2, formId = "1", formName = "AForm v2", version = "2"), 3L) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[1], form(dbId = 3, formId = "2", formName = "BForm"), 2L) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[2], form(dbId = 1, formId = "1", formName = "AForm v1", version = "1"), 1L) } @Test @@ -365,28 +367,28 @@ class BlankFormListViewModelTest { viewModel.filterText = "2" - assertThat(viewModel.formsToDisplay.getOrAwaitValue().size, `is`(2)) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[0], form(dbId = 2, formId = "2")) + assertThat(viewModel.formsToDisplay.getOrAwaitValue(scheduler).size, `is`(2)) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 2, formId = "2")) assertFormItem( - viewModel.formsToDisplay.getOrAwaitValue()[1], + viewModel.formsToDisplay.getOrAwaitValue(scheduler)[1], form(dbId = 3, formId = "3", formName = "Form 2x") ) viewModel.filterText = "2x" - assertThat(viewModel.formsToDisplay.getOrAwaitValue().size, `is`(1)) + assertThat(viewModel.formsToDisplay.getOrAwaitValue(scheduler).size, `is`(1)) assertFormItem( - viewModel.formsToDisplay.getOrAwaitValue()[0], + viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 3, formId = "3", formName = "Form 2x") ) viewModel.filterText = "" - assertThat(viewModel.formsToDisplay.getOrAwaitValue().size, `is`(3)) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[0], form(dbId = 1, formId = "1")) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[1], form(dbId = 2, formId = "2")) + assertThat(viewModel.formsToDisplay.getOrAwaitValue(scheduler).size, `is`(3)) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 1, formId = "1")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[1], form(dbId = 2, formId = "2")) assertFormItem( - viewModel.formsToDisplay.getOrAwaitValue()[2], + viewModel.formsToDisplay.getOrAwaitValue(scheduler)[2], form(dbId = 3, formId = "3", formName = "Form 2x") ) } @@ -402,25 +404,25 @@ class BlankFormListViewModelTest { viewModel.filterText = "2" - assertThat(viewModel.formsToDisplay.getOrAwaitValue().size, `is`(2)) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[0], form(dbId = 2, formId = "2")) + assertThat(viewModel.formsToDisplay.getOrAwaitValue(scheduler).size, `is`(2)) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 2, formId = "2")) assertFormItem( - viewModel.formsToDisplay.getOrAwaitValue()[1], + viewModel.formsToDisplay.getOrAwaitValue(scheduler)[1], form(dbId = 3, formId = "3", formName = "Form 2x") ) viewModel.sortingOrder = 1 - assertThat(viewModel.formsToDisplay.getOrAwaitValue().size, `is`(2)) + assertThat(viewModel.formsToDisplay.getOrAwaitValue(scheduler).size, `is`(2)) assertFormItem( - viewModel.formsToDisplay.getOrAwaitValue()[0], + viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 3, formId = "3", formName = "Form 2x") ) - assertFormItem(viewModel.formsToDisplay.getOrAwaitValue()[1], form(dbId = 2, formId = "2")) + assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[1], form(dbId = 2, formId = "2")) } private fun saveForms(vararg forms: Form) { - whenever(formsDataService.getForms(any())).thenReturn(MutableLiveData(forms.toList())) + whenever(formsDataService.getForms(any())).thenReturn(MutableStateFlow(forms.toList())) } private fun saveInstances(vararg instances: Instance) { diff --git a/forms/src/main/java/org/odk/collect/forms/Form.java b/forms/src/main/java/org/odk/collect/forms/Form.java index b42adb3e62b..62578b725d6 100644 --- a/forms/src/main/java/org/odk/collect/forms/Form.java +++ b/forms/src/main/java/org/odk/collect/forms/Form.java @@ -19,6 +19,8 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.Objects; + /** * A form definition stored on the device. *

@@ -281,13 +283,22 @@ public Long getLastDetectedAttachmentsUpdateDate() { } @Override - public boolean equals(Object other) { - return other == this || other instanceof Form && this.md5Hash.equals(((Form) other).md5Hash); + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + Form form = (Form) o; + return deleted == form.deleted && Objects.equals(dbId, form.dbId) && Objects.equals(displayName, form.displayName) && Objects.equals(description, form.description) && Objects.equals(formId, form.formId) && Objects.equals(version, form.version) && Objects.equals(formFilePath, form.formFilePath) && Objects.equals(submissionUri, form.submissionUri) && Objects.equals(base64RSAPublicKey, form.base64RSAPublicKey) && Objects.equals(md5Hash, form.md5Hash) && Objects.equals(date, form.date) && Objects.equals(jrCacheFilePath, form.jrCacheFilePath) && Objects.equals(formMediaPath, form.formMediaPath) && Objects.equals(language, form.language) && Objects.equals(autoSend, form.autoSend) && Objects.equals(autoDelete, form.autoDelete) && Objects.equals(geometryXPath, form.geometryXPath) && Objects.equals(lastDetectedAttachmentsUpdateDate, form.lastDetectedAttachmentsUpdateDate); } @Override public int hashCode() { - return md5Hash.hashCode(); + return Objects.hash(dbId, displayName, description, formId, version, formFilePath, submissionUri, base64RSAPublicKey, md5Hash, date, jrCacheFilePath, formMediaPath, language, autoSend, autoDelete, geometryXPath, deleted, lastDetectedAttachmentsUpdateDate); } @Override diff --git a/test-forms/src/main/resources/forms/external_select.xml b/test-forms/src/main/resources/forms/external_select.xml new file mode 100644 index 00000000000..4bab54f62a7 --- /dev/null +++ b/test-forms/src/main/resources/forms/external_select.xml @@ -0,0 +1,28 @@ + + + + external select + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test-forms/src/main/resources/forms/external_select_csv.xml b/test-forms/src/main/resources/forms/external_select_csv.xml new file mode 100644 index 00000000000..9599874ca88 --- /dev/null +++ b/test-forms/src/main/resources/forms/external_select_csv.xml @@ -0,0 +1,28 @@ + + + + external select + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test-forms/src/main/resources/media/external_data.csv b/test-forms/src/main/resources/media/external_data.csv new file mode 100644 index 00000000000..5a9e734afc7 --- /dev/null +++ b/test-forms/src/main/resources/media/external_data.csv @@ -0,0 +1,4 @@ +name,label +one,One +two,Two +three,Three diff --git a/test-forms/src/main/resources/media/external_data.xml b/test-forms/src/main/resources/media/external_data.xml new file mode 100644 index 00000000000..f825590b6ff --- /dev/null +++ b/test-forms/src/main/resources/media/external_data.xml @@ -0,0 +1,14 @@ + + + one + + + + two + + + + three + + + diff --git a/test-shared/src/main/java/org/odk/collect/testshared/FakeScheduler.kt b/test-shared/src/main/java/org/odk/collect/testshared/FakeScheduler.kt index 18ff5ca8e7c..53a9ed19a13 100644 --- a/test-shared/src/main/java/org/odk/collect/testshared/FakeScheduler.kt +++ b/test-shared/src/main/java/org/odk/collect/testshared/FakeScheduler.kt @@ -1,14 +1,27 @@ package org.odk.collect.testshared +import androidx.lifecycle.LiveData +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Runnable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import org.odk.collect.androidtest.getOrAwaitValue import org.odk.collect.async.Cancellable import org.odk.collect.async.Scheduler import org.odk.collect.async.TaskSpec import java.util.LinkedList import java.util.function.Consumer import java.util.function.Supplier +import kotlin.coroutines.CoroutineContext class FakeScheduler : Scheduler { + private val backgroundDispatcher = object : CoroutineDispatcher() { + override fun dispatch(context: CoroutineContext, block: Runnable) { + backgroundTasks.add(block) + } + } + private var foregroundTasks = LinkedList() private var backgroundTasks = LinkedList() private var repeatTasks = ArrayList() @@ -22,8 +35,12 @@ class FakeScheduler : Scheduler { ) } - override fun immediate(foreground: Runnable) { - foregroundTasks.push(foreground) + override fun immediate(background: Boolean, runnable: Runnable) { + if (background) { + backgroundTasks.push(runnable) + } else { + foregroundTasks.push(runnable) + } } override fun networkDeferred(tag: String, spec: TaskSpec, inputData: Map) {} @@ -50,6 +67,10 @@ class FakeScheduler : Scheduler { override fun cancelAllDeferred() {} + override fun flowOnBackground(flow: Flow): Flow { + return flow.flowOn(backgroundDispatcher) + } + fun runForeground() { while (foregroundTasks.isNotEmpty()) { foregroundTasks.remove().run() @@ -100,4 +121,10 @@ class FakeScheduler : Scheduler { override fun cancelDeferred(tag: String) {} } +fun LiveData.getOrAwaitValue( + scheduler: FakeScheduler +): T { + return this.getOrAwaitValue { scheduler.flush() } +} + private data class RepeatTask(val interval: Long, val runnable: Runnable, var lastRun: Long?)