From 932362fa3823d1b383596aaf5b4ea7d47242c6b3 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Tue, 5 Sep 2023 13:52:32 +0100 Subject: [PATCH 01/46] Spike out use cases for form entry loading and init --- .../android/formentry/FormEntryUseCases.kt | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt new file mode 100644 index 00000000000..c390b8cb018 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt @@ -0,0 +1,98 @@ +package org.odk.collect.android.formentry + +import org.apache.commons.io.FileUtils.readFileToByteArray +import org.javarosa.core.model.FormDef +import org.javarosa.core.model.instance.InstanceInitializationFactory +import org.javarosa.core.model.instance.TreeReference +import org.javarosa.core.model.instance.utils.DefaultAnswerResolver +import org.javarosa.core.reference.ReferenceManager +import org.javarosa.form.api.FormEntryController +import org.javarosa.xform.parse.XFormParser +import org.javarosa.xform.util.XFormUtils +import org.odk.collect.android.externaldata.ExternalAnswerResolver +import org.odk.collect.android.javarosawrapper.FormController +import org.odk.collect.android.javarosawrapper.JavaRosaFormController +import org.odk.collect.android.tasks.FormLoaderTask.FormEntryControllerFactory +import org.odk.collect.android.utilities.FileUtils +import org.odk.collect.android.utilities.FormDefCache +import org.odk.collect.android.utilities.FormUtils +import java.io.File + +object FormEntryUseCases { + + @JvmStatic + fun loadXForm(xForm: File, formMediaDir: File): FormDef? { + FormUtils.setupReferenceManagerForForm(ReferenceManager.instance(), formMediaDir) + return createFormDefFromCacheOrXml(xForm) + } + + @JvmStatic + fun initializeInstance(formDef: FormDef, formMediaDir: File, formEntryControllerFactory: FormEntryControllerFactory, instance: File): FormController { + val formEntryController = formEntryControllerFactory.create(formDef) + val instanceInit = InstanceInitializationFactory() + + importInstance(instance, formEntryController) + formDef.initialize(false, instanceInit) + + return JavaRosaFormController( + formMediaDir, + formEntryController, + instance + ) + } + + private fun createFormDefFromCacheOrXml(xForm: File): FormDef? { + val formDefFromCache = FormDefCache.readCache(xForm) + if (formDefFromCache != null) { + return formDefFromCache + } + + val lastSavedSrc = FileUtils.getOrCreateLastSavedSrc(xForm) + return XFormUtils.getFormFromFormXml(xForm.absolutePath, lastSavedSrc)?.also { + FormDefCache.writeCache(it, xForm.path) + } + } + + private fun importInstance(instanceFile: File, formEntryController: FormEntryController) { + // convert files into a byte array + val fileBytes = readFileToByteArray(instanceFile) + + // get the root of the saved and template instances + val savedRoot = XFormParser.restoreDataModel(fileBytes, null).root + val templateRoot = formEntryController.model.form.instance.root.deepCopy(true) + + // weak check for matching forms + if (savedRoot.name != templateRoot.name || savedRoot.mult != 0) { + return + } + + // populate the data model + val tr = TreeReference.rootRef() + tr.add(templateRoot.name, TreeReference.INDEX_UNBOUND) + + // Here we set the Collect's implementation of the IAnswerResolver. + // We set it back to the default after select choices have been populated. + XFormParser.setAnswerResolver(ExternalAnswerResolver()) + templateRoot.populate(savedRoot, formEntryController.model.form) + XFormParser.setAnswerResolver(DefaultAnswerResolver()) + + // FormInstanceParser.parseInstance is responsible for initial creation of instances. It explicitly sets the + // main instance name to null so we force this again on deserialization because some code paths rely on the main + // instance not having a name. Must be before the call on setRoot because setRoot also sets the root's name. + formEntryController.model.form.instance.name = null + + // populated model to current form + formEntryController.model.form.instance.root = templateRoot + + // fix any language issues + // : + // http://bitbucket.org/javarosa/main/issue/5/itext-n-appearing-in-restored-instances + if (formEntryController.model.languages != null) { + formEntryController.model.form + .localeChanged( + formEntryController.model.language, + formEntryController.model.form.localizer + ) + } + } +} From 1f7917ad7e00f758c8c4fd8b884c6aa95f7da69b Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Tue, 5 Sep 2023 19:04:50 +0100 Subject: [PATCH 02/46] Spike out use case for finalizing a draft --- .../android/formentry/FormEntryUseCases.kt | 61 ++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt index c390b8cb018..6b60d874e21 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt @@ -10,24 +10,33 @@ import org.javarosa.form.api.FormEntryController import org.javarosa.xform.parse.XFormParser import org.javarosa.xform.util.XFormUtils import org.odk.collect.android.externaldata.ExternalAnswerResolver +import org.odk.collect.android.javarosawrapper.FailedValidationResult import org.odk.collect.android.javarosawrapper.FormController import org.odk.collect.android.javarosawrapper.JavaRosaFormController import org.odk.collect.android.tasks.FormLoaderTask.FormEntryControllerFactory import org.odk.collect.android.utilities.FileUtils import org.odk.collect.android.utilities.FormDefCache import org.odk.collect.android.utilities.FormUtils +import org.odk.collect.entities.EntitiesRepository +import org.odk.collect.forms.instances.Instance +import org.odk.collect.forms.instances.InstancesRepository import java.io.File object FormEntryUseCases { @JvmStatic - fun loadXForm(xForm: File, formMediaDir: File): FormDef? { + fun loadFormDef(xForm: File, formMediaDir: File): FormDef? { FormUtils.setupReferenceManagerForForm(ReferenceManager.instance(), formMediaDir) return createFormDefFromCacheOrXml(xForm) } @JvmStatic - fun initializeInstance(formDef: FormDef, formMediaDir: File, formEntryControllerFactory: FormEntryControllerFactory, instance: File): FormController { + fun loadDraft( + formDef: FormDef, + formMediaDir: File, + formEntryControllerFactory: FormEntryControllerFactory, + instance: File + ): FormController { val formEntryController = formEntryControllerFactory.create(formDef) val instanceInit = InstanceInitializationFactory() @@ -41,6 +50,54 @@ object FormEntryUseCases { ) } + @JvmStatic + fun finalizeDraft( + formController: FormController, + entitiesRepository: EntitiesRepository, + instancesRepository: InstancesRepository + ): Instance? { + val valid = finalizeInstance(formController, entitiesRepository) + + return if (valid) { + saveFormToDisk(formController) + markInstanceAsComplete(formController, instancesRepository) + } else { + null + } + } + + private fun markInstanceAsComplete( + formController: FormController, + instancesRepository: InstancesRepository + ): Instance { + val instancePath = formController.getInstanceFile()!!.absolutePath + val instance = instancesRepository.getOneByPath(instancePath) + + return instancesRepository.save( + Instance.Builder(instance).also { it.status(Instance.STATUS_COMPLETE) }.build() + ) + } + + private fun saveFormToDisk(formController: FormController) { + val payload = formController.getFilledInFormXml() + FileUtils.write(formController.getInstanceFile(), payload!!.payloadBytes) + } + + @JvmStatic + fun finalizeInstance( + formController: FormController, + entitiesRepository: EntitiesRepository + ): Boolean { + val validationResult = formController.validateAnswers(true) + if (validationResult is FailedValidationResult) { + return false + } + + formController.finalizeForm() + formController.getEntities().forEach { entity -> entitiesRepository.save(entity) } + return true + } + private fun createFormDefFromCacheOrXml(xForm: File): FormDef? { val formDefFromCache = FormDefCache.readCache(xForm) if (formDefFromCache != null) { From a22938318742732b5aedbbc57ff339fad2bd57d4 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Wed, 6 Sep 2023 19:23:53 +0100 Subject: [PATCH 03/46] Improve interface for use with existing loading/saving code --- .../odk/collect/android/formentry/FormEntryUseCases.kt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt index 6b60d874e21..0581a7924c7 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt @@ -13,7 +13,6 @@ import org.odk.collect.android.externaldata.ExternalAnswerResolver import org.odk.collect.android.javarosawrapper.FailedValidationResult import org.odk.collect.android.javarosawrapper.FormController import org.odk.collect.android.javarosawrapper.JavaRosaFormController -import org.odk.collect.android.tasks.FormLoaderTask.FormEntryControllerFactory import org.odk.collect.android.utilities.FileUtils import org.odk.collect.android.utilities.FormDefCache import org.odk.collect.android.utilities.FormUtils @@ -32,16 +31,14 @@ object FormEntryUseCases { @JvmStatic fun loadDraft( - formDef: FormDef, + formEntryController: FormEntryController, formMediaDir: File, - formEntryControllerFactory: FormEntryControllerFactory, instance: File ): FormController { - val formEntryController = formEntryControllerFactory.create(formDef) val instanceInit = InstanceInitializationFactory() importInstance(instance, formEntryController) - formDef.initialize(false, instanceInit) + formEntryController.model.form.initialize(false, instanceInit) return JavaRosaFormController( formMediaDir, @@ -84,7 +81,7 @@ object FormEntryUseCases { } @JvmStatic - fun finalizeInstance( + private fun finalizeInstance( formController: FormController, entitiesRepository: EntitiesRepository ): Boolean { From 104fc93e7c11c60db5301c450fbaa1db4e325613 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Wed, 6 Sep 2023 20:28:54 +0100 Subject: [PATCH 04/46] Allow all instances to be finalized in one click --- .../formmanagement/BulkFinalizationTest.kt | 39 ++++++++++++++ .../android/support/pages/FormEntryPage.java | 6 +++ .../odk/collect/android/support/pages/Page.kt | 9 ++++ .../activities/InstanceChooserList.java | 43 +++++++++++++++ .../drafts/BulkFinalizationViewModel.kt | 52 +++++++++++++++++++ .../drafts/DraftsMenuProvider.kt | 22 ++++++++ collect_app/src/main/res/menu/drafts.xml | 8 +++ 7 files changed, 179 insertions(+) create mode 100644 collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt create mode 100644 collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt create mode 100644 collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/DraftsMenuProvider.kt create mode 100644 collect_app/src/main/res/menu/drafts.xml diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt new file mode 100644 index 00000000000..f9531645be7 --- /dev/null +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt @@ -0,0 +1,39 @@ +package org.odk.collect.android.feature.formmanagement + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.odk.collect.android.support.pages.FormEntryPage.QuestionAndAnswer +import org.odk.collect.android.support.pages.MainMenuPage +import org.odk.collect.android.support.rules.CollectTestRule +import org.odk.collect.android.support.rules.TestRuleChain + +@RunWith(AndroidJUnit4::class) +class BulkFinalizationTest { + + val rule = CollectTestRule() + + @get:Rule + val chain: RuleChain = TestRuleChain.chain().around(rule) + + @Test + fun canBulkFinalizeDrafts() { + rule.startAtMainMenu() + .copyForm("one-question.xml") + .startBlankForm("One Question") + .fillOutAndSave(QuestionAndAnswer("what is your age", "97")) + .startBlankForm("One Question") + .fillOutAndSave(QuestionAndAnswer("what is your age", "98")) + + .clickEditSavedForm(2) + .clickOptionsIcon("Finalize all forms") + .clickOnText("Finalize all forms") + .checkIsSnackbarWithMessageDisplayed("Success! 2 forms finalized.") + .assertTextDoesNotExist("One Question") + .pressBack(MainMenuPage()) + + .assertNumberOfFinalizedForms(2) + } +} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FormEntryPage.java b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FormEntryPage.java index b4b5300c0ae..7b56a20775e 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FormEntryPage.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FormEntryPage.java @@ -75,6 +75,12 @@ public > D fillOutAndSave(D destination, QuestionAndAnswer... .clickSaveChanges(); } + public MainMenuPage fillOutAndSave(QuestionAndAnswer... questionsAndAnswers) { + return fillOut(questionsAndAnswers) + .pressBack(new SaveOrDiscardFormDialog<>(new MainMenuPage())) + .clickSaveChanges(); + } + public MainMenuPage fillOutAndFinalize(QuestionAndAnswer... questionsAndAnswers) { return fillOut(questionsAndAnswers) .swipeToEndScreen() diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt index 0b292629076..4b3dbd0b383 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt @@ -174,6 +174,11 @@ abstract class Page> { return this as T } + fun checkIsSnackbarWithMessageDisplayed(message: String): T { + onView(withText(message)).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) + return this as T + } + fun assertToastNotDisplayed(message: String): T { Espresso.onIdle() if (popRecordedToasts().stream().anyMatch { s: String -> s == message }) { @@ -425,6 +430,10 @@ abstract class Page> { } fun clickOptionsIcon(@StringRes expectedOptionString: Int): T { + return clickOptionsIcon(getTranslatedString(expectedOptionString)) + } + + fun clickOptionsIcon(expectedOptionString: String): T { tryAgainOnFail({ Espresso.openActionBarOverflowOrOptionsMenu(ActivityHelpers.getActivity()) assertText(expectedOptionString) diff --git a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java index 177d0c86b35..c8362a5ec1b 100644 --- a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java +++ b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java @@ -44,16 +44,24 @@ import org.odk.collect.android.analytics.AnalyticsUtils; import org.odk.collect.android.dao.CursorLoaderFactory; import org.odk.collect.android.database.instances.DatabaseInstanceColumns; +import org.odk.collect.android.entities.EntitiesRepositoryProvider; import org.odk.collect.android.external.FormUriActivity; import org.odk.collect.android.external.InstancesContract; import org.odk.collect.android.formlists.sorting.FormListSortingOption; +import org.odk.collect.android.formmanagement.CollectFormEntryControllerFactory; +import org.odk.collect.android.formmanagement.drafts.BulkFinalizationViewModel; +import org.odk.collect.android.formmanagement.drafts.DraftsMenuProvider; import org.odk.collect.android.injection.DaggerUtils; import org.odk.collect.android.projects.CurrentProjectProvider; 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.androidshared.ui.SnackbarUtils; import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickGuard; +import org.odk.collect.async.Scheduler; import org.odk.collect.forms.Form; import org.odk.collect.forms.instances.Instance; +import org.odk.collect.settings.SettingsProvider; import java.util.Arrays; @@ -79,6 +87,18 @@ public class InstanceChooserList extends AppListActivity implements AdapterView. @Inject FormsRepositoryProvider formsRepositoryProvider; + @Inject + Scheduler scheduler; + + @Inject + InstancesRepositoryProvider instancesRepositoryProvider; + + @Inject + EntitiesRepositoryProvider entitiesRepositoryProvider; + + @Inject + SettingsProvider settingsProvider; + private final ActivityResultLauncher formLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { setResult(RESULT_OK, result.getData()); finish(); @@ -119,6 +139,29 @@ public void onCreate(Bundle savedInstanceState) { ); init(); + + BulkFinalizationViewModel bulkFinalizationViewModel = new BulkFinalizationViewModel( + scheduler, + instancesRepositoryProvider.get(), + formsRepositoryProvider.get(), + entitiesRepositoryProvider.get(currentProjectProvider.getCurrentProject().getUuid()), + new CollectFormEntryControllerFactory(settingsProvider.getUnprotectedSettings()) + ); + + DraftsMenuProvider draftsMenuProvider = new DraftsMenuProvider(bulkFinalizationViewModel); + addMenuProvider(draftsMenuProvider); + + bulkFinalizationViewModel.getFinalizedForms().observe(this, finalizedForms -> { + if (!finalizedForms.isConsumed()) { + updateAdapter(); + SnackbarUtils.showLongSnackbar( + this.findViewById(android.R.id.content), + "Success! " + finalizedForms.getValue() + " forms finalized." + ); + + finalizedForms.consume(); + } + }); } private void init() { diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt new file mode 100644 index 00000000000..699d77b9bf0 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt @@ -0,0 +1,52 @@ +package org.odk.collect.android.formmanagement.drafts + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import org.odk.collect.android.formentry.FormEntryUseCases.finalizeDraft +import org.odk.collect.android.formentry.FormEntryUseCases.loadDraft +import org.odk.collect.android.formentry.FormEntryUseCases.loadFormDef +import org.odk.collect.android.tasks.FormLoaderTask.FormEntryControllerFactory +import org.odk.collect.android.utilities.FileUtils +import org.odk.collect.androidshared.data.Consumable +import org.odk.collect.async.Scheduler +import org.odk.collect.entities.EntitiesRepository +import org.odk.collect.forms.FormsRepository +import org.odk.collect.forms.instances.InstancesRepository +import java.io.File + +class BulkFinalizationViewModel( + private val scheduler: Scheduler, + private val instancesRepository: InstancesRepository, + private val formsRepository: FormsRepository, + private val entitiesRepository: EntitiesRepository, + private val formEntryControllerFactory: FormEntryControllerFactory +) { + private val _finalizedForms = MutableLiveData>() + val finalizedForms: LiveData> = _finalizedForms + + fun finalizeAllDrafts() { + scheduler.immediate( + background = { + val instances = instancesRepository.all + + instances.forEach { + val form = formsRepository.getAllByFormId(it.formId)[0] + val xForm = File(form.formFilePath) + val formMediaDir = FileUtils.getFormMediaDir(xForm) + val formDef = loadFormDef(xForm, formMediaDir)!! + + val formEntryController = formEntryControllerFactory.create(formDef) + val instanceFile = File(it.instanceFilePath) + val formController = loadDraft(formEntryController, formMediaDir, instanceFile) + + finalizeDraft(formController, entitiesRepository, instancesRepository) + } + + instances.size + }, + foreground = { + _finalizedForms.value = Consumable(it) + } + ) + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/DraftsMenuProvider.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/DraftsMenuProvider.kt new file mode 100644 index 00000000000..92187a510b0 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/DraftsMenuProvider.kt @@ -0,0 +1,22 @@ +package org.odk.collect.android.formmanagement.drafts + +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.core.view.MenuProvider +import org.odk.collect.android.R + +class DraftsMenuProvider(private val bulkFinalizationViewModel: BulkFinalizationViewModel) : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.drafts, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + if (menuItem.itemId == R.id.bulk_finalize) { + bulkFinalizationViewModel.finalizeAllDrafts() + return true + } + + return false + } +} diff --git a/collect_app/src/main/res/menu/drafts.xml b/collect_app/src/main/res/menu/drafts.xml new file mode 100644 index 00000000000..53d421a5153 --- /dev/null +++ b/collect_app/src/main/res/menu/drafts.xml @@ -0,0 +1,8 @@ + + + + + From 764a18589f87f94817093a8f39ff84e24138efeb Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Thu, 7 Sep 2023 11:29:03 +0100 Subject: [PATCH 05/46] Use InstancesAppState to update drafts list --- .../collect/android/activities/InstanceChooserList.java | 8 ++++++-- .../formmanagement/drafts/BulkFinalizationViewModel.kt | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java index c8362a5ec1b..5803398a30a 100644 --- a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java +++ b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java @@ -49,6 +49,7 @@ import org.odk.collect.android.external.InstancesContract; import org.odk.collect.android.formlists.sorting.FormListSortingOption; import org.odk.collect.android.formmanagement.CollectFormEntryControllerFactory; +import org.odk.collect.android.formmanagement.InstancesAppState; import org.odk.collect.android.formmanagement.drafts.BulkFinalizationViewModel; import org.odk.collect.android.formmanagement.drafts.DraftsMenuProvider; import org.odk.collect.android.injection.DaggerUtils; @@ -99,6 +100,9 @@ public class InstanceChooserList extends AppListActivity implements AdapterView. @Inject SettingsProvider settingsProvider; + @Inject + InstancesAppState instancesAppState; + private final ActivityResultLauncher formLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { setResult(RESULT_OK, result.getData()); finish(); @@ -145,7 +149,8 @@ public void onCreate(Bundle savedInstanceState) { instancesRepositoryProvider.get(), formsRepositoryProvider.get(), entitiesRepositoryProvider.get(currentProjectProvider.getCurrentProject().getUuid()), - new CollectFormEntryControllerFactory(settingsProvider.getUnprotectedSettings()) + new CollectFormEntryControllerFactory(settingsProvider.getUnprotectedSettings()), + instancesAppState ); DraftsMenuProvider draftsMenuProvider = new DraftsMenuProvider(bulkFinalizationViewModel); @@ -153,7 +158,6 @@ public void onCreate(Bundle savedInstanceState) { bulkFinalizationViewModel.getFinalizedForms().observe(this, finalizedForms -> { if (!finalizedForms.isConsumed()) { - updateAdapter(); SnackbarUtils.showLongSnackbar( this.findViewById(android.R.id.content), "Success! " + finalizedForms.getValue() + " forms finalized." diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt index 699d77b9bf0..fe9dfa71b23 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.MutableLiveData import org.odk.collect.android.formentry.FormEntryUseCases.finalizeDraft import org.odk.collect.android.formentry.FormEntryUseCases.loadDraft import org.odk.collect.android.formentry.FormEntryUseCases.loadFormDef +import org.odk.collect.android.formmanagement.InstancesAppState import org.odk.collect.android.tasks.FormLoaderTask.FormEntryControllerFactory import org.odk.collect.android.utilities.FileUtils import org.odk.collect.androidshared.data.Consumable @@ -19,7 +20,8 @@ class BulkFinalizationViewModel( private val instancesRepository: InstancesRepository, private val formsRepository: FormsRepository, private val entitiesRepository: EntitiesRepository, - private val formEntryControllerFactory: FormEntryControllerFactory + private val formEntryControllerFactory: FormEntryControllerFactory, + private val instancesAppState: InstancesAppState ) { private val _finalizedForms = MutableLiveData>() val finalizedForms: LiveData> = _finalizedForms @@ -42,6 +44,7 @@ class BulkFinalizationViewModel( finalizeDraft(formController, entitiesRepository, instancesRepository) } + instancesAppState.update() instances.size }, foreground = { From 90865b7dd66f7531119c7e6353f5ce2441fd0ff7 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Thu, 7 Sep 2023 11:39:04 +0100 Subject: [PATCH 06/46] Only check for hardcoded text on CI --- .circleci/config.yml | 2 +- collect_app/build.gradle | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 0752c080fe6..3104ab4de82 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -91,7 +91,7 @@ jobs: - run: name: Run code quality checks - command: ./gradlew checkCode + command: ./gradlew pmd ktlintCheck checkstyle lintDebug -PlintStrings test_modules: <<: *android_config diff --git a/collect_app/build.gradle b/collect_app/build.gradle index 9988ae5ba5f..6fd9461cbba 100644 --- a/collect_app/build.gradle +++ b/collect_app/build.gradle @@ -216,6 +216,10 @@ android { htmlReport true lintConfig file("$rootDir/config/lint.xml") xmlReport true + + if (!project.hasProperty("lintStrings")) { + disable += ["HardcodedText"] + } } namespace 'org.odk.collect.android' } From fc52e7f07d6ec67920daa7c2165f8b5d224cf342 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Thu, 7 Sep 2023 11:57:13 +0100 Subject: [PATCH 07/46] Rename InstancesAppState to be inline with FormsDataService --- .../android/activities/InstanceChooserList.java | 6 +++--- ...nstancesAppState.kt => InstancesDataService.kt} | 7 +------ .../drafts/BulkFinalizationViewModel.kt | 6 +++--- .../injection/config/AppDependencyComponent.java | 3 --- .../injection/config/AppDependencyModule.java | 14 +++++++------- .../autosend/InstanceAutoSender.kt | 7 +++---- .../collect/android/mainmenu/MainMenuViewModel.kt | 12 ++++++------ .../android/mainmenu/MainMenuViewModelFactory.kt | 6 +++--- .../collect/android/upload/InstanceUploader.java | 8 ++++---- .../android/backgroundwork/AutoSendTaskSpecTest.kt | 4 ++-- .../android/mainmenu/MainMenuActivityTest.kt | 6 +++--- 11 files changed, 35 insertions(+), 44 deletions(-) rename collect_app/src/main/java/org/odk/collect/android/formmanagement/{InstancesAppState.kt => InstancesDataService.kt} (86%) diff --git a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java index 5803398a30a..7a97ff97566 100644 --- a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java +++ b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java @@ -49,7 +49,7 @@ import org.odk.collect.android.external.InstancesContract; import org.odk.collect.android.formlists.sorting.FormListSortingOption; import org.odk.collect.android.formmanagement.CollectFormEntryControllerFactory; -import org.odk.collect.android.formmanagement.InstancesAppState; +import org.odk.collect.android.formmanagement.InstancesDataService; import org.odk.collect.android.formmanagement.drafts.BulkFinalizationViewModel; import org.odk.collect.android.formmanagement.drafts.DraftsMenuProvider; import org.odk.collect.android.injection.DaggerUtils; @@ -101,7 +101,7 @@ public class InstanceChooserList extends AppListActivity implements AdapterView. SettingsProvider settingsProvider; @Inject - InstancesAppState instancesAppState; + InstancesDataService instancesDataService; private final ActivityResultLauncher formLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { setResult(RESULT_OK, result.getData()); @@ -150,7 +150,7 @@ public void onCreate(Bundle savedInstanceState) { formsRepositoryProvider.get(), entitiesRepositoryProvider.get(currentProjectProvider.getCurrentProject().getUuid()), new CollectFormEntryControllerFactory(settingsProvider.getUnprotectedSettings()), - instancesAppState + instancesDataService ); DraftsMenuProvider draftsMenuProvider = new DraftsMenuProvider(bulkFinalizationViewModel); diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesAppState.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt similarity index 86% rename from collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesAppState.kt rename to collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt index c3f7ec57b5b..6042744dd0b 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesAppState.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt @@ -7,15 +7,10 @@ import org.odk.collect.android.external.InstancesContract import org.odk.collect.android.projects.CurrentProjectProvider import org.odk.collect.android.utilities.InstancesRepositoryProvider import org.odk.collect.forms.instances.Instance -import org.odk.collect.forms.instances.InstancesRepository import javax.inject.Singleton -/** - * Stores reactive state of instances. This (as a singleton) can be read or updated by - * different parts of the app without needing reactive data in the [InstancesRepository]. - */ @Singleton -class InstancesAppState( +class InstancesDataService( private val context: Context, private val instancesRepositoryProvider: InstancesRepositoryProvider, private val currentProjectProvider: CurrentProjectProvider diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt index fe9dfa71b23..9060f45b00e 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt @@ -5,7 +5,7 @@ import androidx.lifecycle.MutableLiveData import org.odk.collect.android.formentry.FormEntryUseCases.finalizeDraft import org.odk.collect.android.formentry.FormEntryUseCases.loadDraft import org.odk.collect.android.formentry.FormEntryUseCases.loadFormDef -import org.odk.collect.android.formmanagement.InstancesAppState +import org.odk.collect.android.formmanagement.InstancesDataService import org.odk.collect.android.tasks.FormLoaderTask.FormEntryControllerFactory import org.odk.collect.android.utilities.FileUtils import org.odk.collect.androidshared.data.Consumable @@ -21,7 +21,7 @@ class BulkFinalizationViewModel( private val formsRepository: FormsRepository, private val entitiesRepository: EntitiesRepository, private val formEntryControllerFactory: FormEntryControllerFactory, - private val instancesAppState: InstancesAppState + private val instancesDataService: InstancesDataService ) { private val _finalizedForms = MutableLiveData>() val finalizedForms: LiveData> = _finalizedForms @@ -44,7 +44,7 @@ class BulkFinalizationViewModel( finalizeDraft(formController, entitiesRepository, instancesRepository) } - instancesAppState.update() + instancesDataService.update() instances.size }, foreground = { diff --git a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyComponent.java b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyComponent.java index 98f67b79fe0..93371406646 100644 --- a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyComponent.java +++ b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyComponent.java @@ -41,7 +41,6 @@ import org.odk.collect.android.formlists.blankformlist.BlankFormListActivity; import org.odk.collect.android.formmanagement.FormSourceProvider; import org.odk.collect.android.formmanagement.FormsDataService; -import org.odk.collect.android.formmanagement.InstancesAppState; import org.odk.collect.android.fragments.AppListFragment; import org.odk.collect.android.fragments.BarCodeScannerFragment; import org.odk.collect.android.fragments.SavedFormListFragment; @@ -300,8 +299,6 @@ interface Builder { CurrentProjectProvider currentProjectProvider(); - InstancesAppState instancesAppState(); - StoragePathProvider storagePathProvider(); FormsRepositoryProvider formsRepositoryProvider(); diff --git a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java index 30ddac7a060..22138b81bbd 100644 --- a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java +++ b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java @@ -54,7 +54,7 @@ import org.odk.collect.android.formmanagement.FormMetadataParser; import org.odk.collect.android.formmanagement.FormSourceProvider; import org.odk.collect.android.formmanagement.FormsDataService; -import org.odk.collect.android.formmanagement.InstancesAppState; +import org.odk.collect.android.formmanagement.InstancesDataService; import org.odk.collect.android.formmanagement.ServerFormDownloader; import org.odk.collect.android.formmanagement.ServerFormsDetailsFetcher; import org.odk.collect.android.gdrive.GoogleAccountCredentialGoogleAccountPicker; @@ -457,8 +457,8 @@ public UUIDGenerator providesUUIDGenerator() { @Provides @Singleton - public InstancesAppState providesInstancesAppState(Application application, InstancesRepositoryProvider instancesRepositoryProvider, CurrentProjectProvider currentProjectProvider) { - return new InstancesAppState(application, instancesRepositoryProvider, currentProjectProvider); + public InstancesDataService providesInstancesDataService(Application application, InstancesRepositoryProvider instancesRepositoryProvider, CurrentProjectProvider currentProjectProvider) { + return new InstancesDataService(application, instancesRepositoryProvider, currentProjectProvider); } @Provides @@ -488,12 +488,12 @@ public ProjectPreferencesViewModel.Factory providesProjectPreferencesViewModel(A @Provides public MainMenuViewModelFactory providesMainMenuViewModelFactory(VersionInformation versionInformation, Application application, - SettingsProvider settingsProvider, InstancesAppState instancesAppState, + SettingsProvider settingsProvider, InstancesDataService instancesDataService, Scheduler scheduler, CurrentProjectProvider currentProjectProvider, AnalyticsInitializer analyticsInitializer, PermissionsChecker permissionChecker, FormsRepositoryProvider formsRepositoryProvider, InstancesRepositoryProvider instancesRepositoryProvider, AutoSendSettingsProvider autoSendSettingsProvider) { - return new MainMenuViewModelFactory(versionInformation, application, settingsProvider, instancesAppState, scheduler, currentProjectProvider, analyticsInitializer, permissionChecker, formsRepositoryProvider, instancesRepositoryProvider, autoSendSettingsProvider); + return new MainMenuViewModelFactory(versionInformation, application, settingsProvider, instancesDataService, scheduler, currentProjectProvider, analyticsInitializer, permissionChecker, formsRepositoryProvider, instancesRepositoryProvider, autoSendSettingsProvider); } @Provides @@ -512,9 +512,9 @@ public FormsDataService providesFormsUpdater(Application application, Notifier n } @Provides - public InstanceAutoSender providesInstanceAutoSender(AutoSendSettingsProvider autoSendSettingsProvider, Context context, Notifier notifier, GoogleAccountsManager googleAccountsManager, GoogleApiProvider googleApiProvider, PermissionsProvider permissionsProvider, InstancesAppState instancesAppState, PropertyManager propertyManager) { + public InstanceAutoSender providesInstanceAutoSender(AutoSendSettingsProvider autoSendSettingsProvider, Context context, Notifier notifier, GoogleAccountsManager googleAccountsManager, GoogleApiProvider googleApiProvider, PermissionsProvider permissionsProvider, InstancesDataService instancesDataService, PropertyManager propertyManager) { InstanceAutoSendFetcher instanceAutoSendFetcher = new InstanceAutoSendFetcher(autoSendSettingsProvider); - return new InstanceAutoSender(instanceAutoSendFetcher, context, notifier, googleAccountsManager, googleApiProvider, permissionsProvider, instancesAppState, propertyManager); + return new InstanceAutoSender(instanceAutoSendFetcher, context, notifier, googleAccountsManager, googleApiProvider, permissionsProvider, instancesDataService, propertyManager); } @Provides diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/autosend/InstanceAutoSender.kt b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/autosend/InstanceAutoSender.kt index 73d6fbfba98..94a1d64edda 100644 --- a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/autosend/InstanceAutoSender.kt +++ b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/autosend/InstanceAutoSender.kt @@ -1,8 +1,7 @@ package org.odk.collect.android.instancemanagement.autosend import android.content.Context -import org.odk.collect.android.R -import org.odk.collect.android.formmanagement.InstancesAppState +import org.odk.collect.android.formmanagement.InstancesDataService import org.odk.collect.android.gdrive.GoogleAccountsManager import org.odk.collect.android.gdrive.GoogleApiProvider import org.odk.collect.android.instancemanagement.InstanceSubmitter @@ -21,7 +20,7 @@ class InstanceAutoSender( private val googleAccountsManager: GoogleAccountsManager, private val googleApiProvider: GoogleApiProvider, private val permissionsProvider: PermissionsProvider, - private val instancesAppState: InstancesAppState, + private val instancesDataService: InstancesDataService, private val propertyManager: PropertyManager ) { fun autoSendInstances(projectDependencyProvider: ProjectDependencyProvider): Boolean { @@ -63,7 +62,7 @@ class InstanceAutoSender( } } } - instancesAppState.update() + instancesDataService.update() true } else { false diff --git a/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuViewModel.kt b/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuViewModel.kt index 8c822ed5939..80eadf8bb2e 100644 --- a/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuViewModel.kt +++ b/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuViewModel.kt @@ -4,7 +4,7 @@ import android.app.Application import android.net.Uri import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel -import org.odk.collect.android.formmanagement.InstancesAppState +import org.odk.collect.android.formmanagement.InstancesDataService import org.odk.collect.android.instancemanagement.InstanceDiskSynchronizer import org.odk.collect.android.instancemanagement.autosend.AutoSendSettingsProvider import org.odk.collect.android.instancemanagement.autosend.shouldFormBeSentAutomatically @@ -23,7 +23,7 @@ class MainMenuViewModel( private val application: Application, private val versionInformation: VersionInformation, private val settingsProvider: SettingsProvider, - private val instancesAppState: InstancesAppState, + private val instancesDataService: InstancesDataService, private val scheduler: Scheduler, private val formsRepositoryProvider: FormsRepositoryProvider, private val instancesRepositoryProvider: InstancesRepositoryProvider, @@ -90,19 +90,19 @@ class MainMenuViewModel( fun refreshInstances() { scheduler.immediate({ InstanceDiskSynchronizer(settingsProvider).doInBackground() - instancesAppState.update() + instancesDataService.update() null }) { } } val editableInstancesCount: LiveData - get() = instancesAppState.editableCount + get() = instancesDataService.editableCount val sendableInstancesCount: LiveData - get() = instancesAppState.sendableCount + get() = instancesDataService.sendableCount val sentInstancesCount: LiveData - get() = instancesAppState.sentCount + get() = instancesDataService.sentCount fun getFormSavedSnackbarDetails(uri: Uri): Pair? { val instance = instancesRepositoryProvider.get().get(ContentUriHelper.getIdFromUri(uri)) diff --git a/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuViewModelFactory.kt b/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuViewModelFactory.kt index b6c7ba2cd24..8675562da3a 100644 --- a/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuViewModelFactory.kt +++ b/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuViewModelFactory.kt @@ -4,7 +4,7 @@ import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import org.odk.collect.android.application.initialization.AnalyticsInitializer -import org.odk.collect.android.formmanagement.InstancesAppState +import org.odk.collect.android.formmanagement.InstancesDataService import org.odk.collect.android.instancemanagement.autosend.AutoSendSettingsProvider import org.odk.collect.android.projects.CurrentProjectProvider import org.odk.collect.android.utilities.FormsRepositoryProvider @@ -18,7 +18,7 @@ open class MainMenuViewModelFactory( private val versionInformation: VersionInformation, private val application: Application, private val settingsProvider: SettingsProvider, - private val instancesAppState: InstancesAppState, + private val instancesDataService: InstancesDataService, private val scheduler: Scheduler, private val currentProjectProvider: CurrentProjectProvider, private val analyticsInitializer: AnalyticsInitializer, @@ -33,7 +33,7 @@ open class MainMenuViewModelFactory( application, versionInformation, settingsProvider, - instancesAppState, + instancesDataService, scheduler, formsRepositoryProvider, instancesRepositoryProvider, diff --git a/collect_app/src/main/java/org/odk/collect/android/upload/InstanceUploader.java b/collect_app/src/main/java/org/odk/collect/android/upload/InstanceUploader.java index 00bd7b23389..c11673f7702 100644 --- a/collect_app/src/main/java/org/odk/collect/android/upload/InstanceUploader.java +++ b/collect_app/src/main/java/org/odk/collect/android/upload/InstanceUploader.java @@ -18,7 +18,7 @@ import androidx.annotation.Nullable; import org.odk.collect.android.application.Collect; -import org.odk.collect.android.formmanagement.InstancesAppState; +import org.odk.collect.android.formmanagement.InstancesDataService; import org.odk.collect.android.injection.DaggerUtils; import org.odk.collect.android.utilities.InstancesRepositoryProvider; import org.odk.collect.forms.instances.Instance; @@ -34,7 +34,7 @@ public abstract class InstanceUploader { InstancesRepositoryProvider instancesRepositoryProvider; @Inject - InstancesAppState instancesAppState; + InstancesDataService instancesDataService; public InstanceUploader() { DaggerUtils.getComponent(Collect.getInstance()).inject(this); @@ -75,7 +75,7 @@ public void markSubmissionFailed(Instance instance) { .build() ); - instancesAppState.update(); + instancesDataService.update(); } public void markSubmissionComplete(Instance instance) { @@ -86,6 +86,6 @@ public void markSubmissionComplete(Instance instance) { .build() ); - instancesAppState.update(); + instancesDataService.update(); } } diff --git a/collect_app/src/test/java/org/odk/collect/android/backgroundwork/AutoSendTaskSpecTest.kt b/collect_app/src/test/java/org/odk/collect/android/backgroundwork/AutoSendTaskSpecTest.kt index dfe4b011450..501daf95d9f 100644 --- a/collect_app/src/test/java/org/odk/collect/android/backgroundwork/AutoSendTaskSpecTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/backgroundwork/AutoSendTaskSpecTest.kt @@ -14,7 +14,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.odk.collect.android.TestSettingsProvider import org.odk.collect.android.formmanagement.FormSourceProvider -import org.odk.collect.android.formmanagement.InstancesAppState +import org.odk.collect.android.formmanagement.InstancesDataService import org.odk.collect.android.gdrive.GoogleAccountsManager import org.odk.collect.android.gdrive.GoogleApiProvider import org.odk.collect.android.injection.config.AppDependencyModule @@ -53,7 +53,7 @@ class AutoSendTaskSpecTest { googleAccountsManager: GoogleAccountsManager?, googleApiProvider: GoogleApiProvider?, permissionsProvider: PermissionsProvider?, - instancesAppState: InstancesAppState?, + instancesDataService: InstancesDataService?, propertyManager: PropertyManager? ): InstanceAutoSender { return instanceAutoSender diff --git a/collect_app/src/test/java/org/odk/collect/android/mainmenu/MainMenuActivityTest.kt b/collect_app/src/test/java/org/odk/collect/android/mainmenu/MainMenuActivityTest.kt index 751b36d244b..b5931ea3401 100644 --- a/collect_app/src/test/java/org/odk/collect/android/mainmenu/MainMenuActivityTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/mainmenu/MainMenuActivityTest.kt @@ -32,7 +32,7 @@ import org.odk.collect.android.activities.InstanceUploaderListActivity import org.odk.collect.android.application.initialization.AnalyticsInitializer import org.odk.collect.android.fakes.FakePermissionsProvider import org.odk.collect.android.formlists.blankformlist.BlankFormListActivity -import org.odk.collect.android.formmanagement.InstancesAppState +import org.odk.collect.android.formmanagement.InstancesDataService import org.odk.collect.android.injection.config.AppDependencyModule import org.odk.collect.android.instancemanagement.autosend.AutoSendSettingsProvider import org.odk.collect.android.projects.CurrentProjectProvider @@ -86,7 +86,7 @@ class MainMenuActivityTest { versionInformation: VersionInformation, application: Application, settingsProvider: SettingsProvider, - instancesAppState: InstancesAppState, + instancesDataService: InstancesDataService, scheduler: Scheduler, currentProjectProvider: CurrentProjectProvider, analyticsInitializer: AnalyticsInitializer, @@ -99,7 +99,7 @@ class MainMenuActivityTest { versionInformation, application, settingsProvider, - instancesAppState, + instancesDataService, scheduler, currentProjectProvider, analyticsInitializer, From 9ef6db9a7fea0942ccd5da8e26856b24e4bfba56 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Thu, 7 Sep 2023 15:10:09 +0100 Subject: [PATCH 08/46] Use AppState in InstancesDataService --- .../collect/androidshared/data/AppState.kt | 12 ++++++- .../formmanagement/InstancesDataService.kt | 34 +++++++++++-------- .../injection/config/AppDependencyModule.java | 1 - 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/data/AppState.kt b/androidshared/src/main/java/org/odk/collect/androidshared/data/AppState.kt index 1068fcc8492..3dbe391d097 100644 --- a/androidshared/src/main/java/org/odk/collect/androidshared/data/AppState.kt +++ b/androidshared/src/main/java/org/odk/collect/androidshared/data/AppState.kt @@ -5,6 +5,8 @@ import android.app.Application import android.app.Service import android.content.Context import androidx.fragment.app.Fragment +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel /** @@ -41,13 +43,21 @@ class AppState { @Suppress("UNCHECKED_CAST") fun get(key: String): T? { - return map.get(key) as T? + return map[key] as T? + } + + fun getLive(key: String, default: T): LiveData { + return get(key, MutableLiveData(default)) } fun set(key: String, value: Any?) { map[key] = value } + fun setLive(key: String, value: T?) { + get(key, MutableLiveData()).postValue(value) + } + fun clear() { map.clear() } diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt index 6042744dd0b..0ae6860a288 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt @@ -2,28 +2,22 @@ package org.odk.collect.android.formmanagement import android.content.Context import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import org.odk.collect.android.external.InstancesContract import org.odk.collect.android.projects.CurrentProjectProvider import org.odk.collect.android.utilities.InstancesRepositoryProvider +import org.odk.collect.androidshared.data.getState import org.odk.collect.forms.instances.Instance -import javax.inject.Singleton -@Singleton class InstancesDataService( private val context: Context, private val instancesRepositoryProvider: InstancesRepositoryProvider, private val currentProjectProvider: CurrentProjectProvider ) { + private val appState = context.getState() - private val _editable = MutableLiveData(0) - val editableCount: LiveData = _editable - - private val _sendable = MutableLiveData(0) - val sendableCount: LiveData = _sendable - - private val _sent = MutableLiveData(0) - val sentCount: LiveData = _sent + val editableCount: LiveData = appState.getLive(EDITABLE_COUNT_KEY, 0) + val sendableCount: LiveData = appState.getLive(SENDABLE_COUNT_KEY, 0) + val sentCount: LiveData = appState.getLive(SENT_COUNT_KEY, 0) fun update() { val instancesRepository = instancesRepositoryProvider.get() @@ -32,16 +26,26 @@ class InstancesDataService( Instance.STATUS_COMPLETE, Instance.STATUS_SUBMISSION_FAILED ) - val sentInstances = instancesRepository.getCountByStatus(Instance.STATUS_SUBMITTED, Instance.STATUS_SUBMISSION_FAILED) + val sentInstances = instancesRepository.getCountByStatus( + Instance.STATUS_SUBMITTED, + Instance.STATUS_SUBMISSION_FAILED + ) + val editableInstances = instancesRepository.getCountByStatus(Instance.STATUS_INCOMPLETE) - _sendable.postValue(sendableInstances) - _sent.postValue(sentInstances) - _editable.postValue(editableInstances) + appState.setLive(EDITABLE_COUNT_KEY, editableInstances) + appState.setLive(SENDABLE_COUNT_KEY, sendableInstances) + appState.setLive(SENT_COUNT_KEY, sentInstances) context.contentResolver.notifyChange( InstancesContract.getUri(currentProjectProvider.getCurrentProject().uuid), null ) } + + companion object { + private const val EDITABLE_COUNT_KEY = "instancesEditableCount" + private const val SENDABLE_COUNT_KEY = "instancesSendableCount" + private const val SENT_COUNT_KEY = "instancesSentCount" + } } diff --git a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java index 22138b81bbd..6597b395e23 100644 --- a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java +++ b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java @@ -456,7 +456,6 @@ public UUIDGenerator providesUUIDGenerator() { } @Provides - @Singleton public InstancesDataService providesInstancesDataService(Application application, InstancesRepositoryProvider instancesRepositoryProvider, CurrentProjectProvider currentProjectProvider) { return new InstancesDataService(application, instancesRepositoryProvider, currentProjectProvider); } From 3abf938b2ab429cf629490463c0c14d9095e0925 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Thu, 7 Sep 2023 15:23:53 +0100 Subject: [PATCH 09/46] Move bulk finalization logic to data service --- .../activities/InstanceChooserList.java | 8 ++--- .../formmanagement/InstancesDataService.kt | 34 ++++++++++++++++++ .../drafts/BulkFinalizationViewModel.kt | 35 +++---------------- .../injection/config/AppDependencyModule.java | 4 +-- 4 files changed, 42 insertions(+), 39 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java index 7a97ff97566..e3356d65a9e 100644 --- a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java +++ b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java @@ -48,7 +48,6 @@ import org.odk.collect.android.external.FormUriActivity; import org.odk.collect.android.external.InstancesContract; import org.odk.collect.android.formlists.sorting.FormListSortingOption; -import org.odk.collect.android.formmanagement.CollectFormEntryControllerFactory; import org.odk.collect.android.formmanagement.InstancesDataService; import org.odk.collect.android.formmanagement.drafts.BulkFinalizationViewModel; import org.odk.collect.android.formmanagement.drafts.DraftsMenuProvider; @@ -146,11 +145,8 @@ public void onCreate(Bundle savedInstanceState) { BulkFinalizationViewModel bulkFinalizationViewModel = new BulkFinalizationViewModel( scheduler, - instancesRepositoryProvider.get(), - formsRepositoryProvider.get(), - entitiesRepositoryProvider.get(currentProjectProvider.getCurrentProject().getUuid()), - new CollectFormEntryControllerFactory(settingsProvider.getUnprotectedSettings()), - instancesDataService + instancesDataService, + currentProjectProvider ); DraftsMenuProvider draftsMenuProvider = new DraftsMenuProvider(bulkFinalizationViewModel); diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt index 0ae6860a288..7b51c575b74 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt @@ -2,15 +2,24 @@ package org.odk.collect.android.formmanagement import android.content.Context import androidx.lifecycle.LiveData +import org.odk.collect.android.entities.EntitiesRepositoryProvider import org.odk.collect.android.external.InstancesContract +import org.odk.collect.android.formentry.FormEntryUseCases import org.odk.collect.android.projects.CurrentProjectProvider +import org.odk.collect.android.tasks.FormLoaderTask +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.androidshared.data.getState import org.odk.collect.forms.instances.Instance +import java.io.File class InstancesDataService( private val context: Context, + private val formsRepositoryProvider: FormsRepositoryProvider, private val instancesRepositoryProvider: InstancesRepositoryProvider, + private val entitiesRepositoryProvider: EntitiesRepositoryProvider, + private val formEntryControllerFactory: FormLoaderTask.FormEntryControllerFactory, private val currentProjectProvider: CurrentProjectProvider ) { private val appState = context.getState() @@ -43,6 +52,31 @@ class InstancesDataService( ) } + fun finalizeAllDrafts(projectId: String): Int { + val instancesRepository = instancesRepositoryProvider.get() + val formsRepository = formsRepositoryProvider.get() + val entitiesRepository = entitiesRepositoryProvider.get(projectId) + + val instances = instancesRepository.all + + instances.forEach { + val form = formsRepository.getAllByFormId(it.formId)[0] + val xForm = File(form.formFilePath) + val formMediaDir = FileUtils.getFormMediaDir(xForm) + val formDef = FormEntryUseCases.loadFormDef(xForm, formMediaDir)!! + + val formEntryController = formEntryControllerFactory.create(formDef) + val instanceFile = File(it.instanceFilePath) + val formController = + FormEntryUseCases.loadDraft(formEntryController, formMediaDir, instanceFile) + + FormEntryUseCases.finalizeDraft(formController, entitiesRepository, instancesRepository) + } + + update() + return instances.size + } + companion object { private const val EDITABLE_COUNT_KEY = "instancesEditableCount" private const val SENDABLE_COUNT_KEY = "instancesSendableCount" diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt index 9060f45b00e..5e6ae23511a 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt @@ -2,26 +2,15 @@ package org.odk.collect.android.formmanagement.drafts import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import org.odk.collect.android.formentry.FormEntryUseCases.finalizeDraft -import org.odk.collect.android.formentry.FormEntryUseCases.loadDraft -import org.odk.collect.android.formentry.FormEntryUseCases.loadFormDef import org.odk.collect.android.formmanagement.InstancesDataService -import org.odk.collect.android.tasks.FormLoaderTask.FormEntryControllerFactory -import org.odk.collect.android.utilities.FileUtils +import org.odk.collect.android.projects.CurrentProjectProvider import org.odk.collect.androidshared.data.Consumable import org.odk.collect.async.Scheduler -import org.odk.collect.entities.EntitiesRepository -import org.odk.collect.forms.FormsRepository -import org.odk.collect.forms.instances.InstancesRepository -import java.io.File class BulkFinalizationViewModel( private val scheduler: Scheduler, - private val instancesRepository: InstancesRepository, - private val formsRepository: FormsRepository, - private val entitiesRepository: EntitiesRepository, - private val formEntryControllerFactory: FormEntryControllerFactory, - private val instancesDataService: InstancesDataService + private val instancesDataService: InstancesDataService, + private val currentProjectProvider: CurrentProjectProvider ) { private val _finalizedForms = MutableLiveData>() val finalizedForms: LiveData> = _finalizedForms @@ -29,23 +18,7 @@ class BulkFinalizationViewModel( fun finalizeAllDrafts() { scheduler.immediate( background = { - val instances = instancesRepository.all - - instances.forEach { - val form = formsRepository.getAllByFormId(it.formId)[0] - val xForm = File(form.formFilePath) - val formMediaDir = FileUtils.getFormMediaDir(xForm) - val formDef = loadFormDef(xForm, formMediaDir)!! - - val formEntryController = formEntryControllerFactory.create(formDef) - val instanceFile = File(it.instanceFilePath) - val formController = loadDraft(formEntryController, formMediaDir, instanceFile) - - finalizeDraft(formController, entitiesRepository, instancesRepository) - } - - instancesDataService.update() - instances.size + instancesDataService.finalizeAllDrafts(currentProjectProvider.getCurrentProject().uuid) }, foreground = { _finalizedForms.value = Consumable(it) diff --git a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java index 6597b395e23..0caf186acf4 100644 --- a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java +++ b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java @@ -456,8 +456,8 @@ public UUIDGenerator providesUUIDGenerator() { } @Provides - public InstancesDataService providesInstancesDataService(Application application, InstancesRepositoryProvider instancesRepositoryProvider, CurrentProjectProvider currentProjectProvider) { - return new InstancesDataService(application, instancesRepositoryProvider, currentProjectProvider); + public InstancesDataService providesInstancesDataService(Application application, InstancesRepositoryProvider instancesRepositoryProvider, CurrentProjectProvider currentProjectProvider, FormsRepositoryProvider formsRepositoryProvider, EntitiesRepositoryProvider entitiesRepositoryProvider, FormLoaderTask.FormEntryControllerFactory formEntryControllerFactory) { + return new InstancesDataService(application, formsRepositoryProvider, instancesRepositoryProvider, entitiesRepositoryProvider, formEntryControllerFactory, currentProjectProvider); } @Provides From 1f61d243209a6a7982bd4f3473882e40cde0da72 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Thu, 7 Sep 2023 15:55:10 +0100 Subject: [PATCH 10/46] Remove option to disable predicate caching We're not adding more predicate caching in this release, and this setting makes FormEntryController creation dependent on the current project. --- .../formmanagement/CollectFormEntryControllerFactory.kt | 8 +------- .../android/injection/config/AppDependencyModule.java | 2 +- .../java/org/odk/collect/android/preferences/Defaults.kt | 3 --- collect_app/src/main/res/xml/experimental_preferences.xml | 4 ---- .../java/org/odk/collect/settings/keys/ProjectKeys.kt | 3 --- strings/src/main/res/values-cs/strings.xml | 3 +-- strings/src/main/res/values-de/strings.xml | 1 - strings/src/main/res/values-es/strings.xml | 3 +-- strings/src/main/res/values-fi/strings.xml | 3 +-- strings/src/main/res/values-fr/strings.xml | 3 +-- strings/src/main/res/values-it/strings.xml | 3 +-- strings/src/main/res/values/strings.xml | 1 - 12 files changed, 7 insertions(+), 30 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/CollectFormEntryControllerFactory.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/CollectFormEntryControllerFactory.kt index eb31fa179f3..7be0094fe40 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/CollectFormEntryControllerFactory.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/CollectFormEntryControllerFactory.kt @@ -5,18 +5,12 @@ import org.javarosa.entities.EntityFormFinalizationProcessor import org.javarosa.form.api.FormEntryController import org.javarosa.form.api.FormEntryModel import org.odk.collect.android.tasks.FormLoaderTask.FormEntryControllerFactory -import org.odk.collect.settings.keys.ProjectKeys -import org.odk.collect.shared.settings.Settings -class CollectFormEntryControllerFactory constructor(private val settings: Settings) : +class CollectFormEntryControllerFactory : FormEntryControllerFactory { override fun create(formDef: FormDef): FormEntryController { return FormEntryController(FormEntryModel(formDef)).also { it.addPostProcessor(EntityFormFinalizationProcessor()) - - if (!settings.getBoolean(ProjectKeys.KEY_PREDICATE_CACHING)) { - it.disablePredicateCaching() - } } } } diff --git a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java index 0caf186acf4..5d1488db655 100644 --- a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java +++ b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java @@ -640,7 +640,7 @@ public ImageCompressionController providesImageCompressorManager() { @Provides public FormLoaderTask.FormEntryControllerFactory formEntryControllerFactory(SettingsProvider settingsProvider) { - return new CollectFormEntryControllerFactory(settingsProvider.getUnprotectedSettings()); + return new CollectFormEntryControllerFactory(); } @Provides diff --git a/collect_app/src/main/java/org/odk/collect/android/preferences/Defaults.kt b/collect_app/src/main/java/org/odk/collect/android/preferences/Defaults.kt index 2a8fb0dec92..2aa33ef5a87 100644 --- a/collect_app/src/main/java/org/odk/collect/android/preferences/Defaults.kt +++ b/collect_app/src/main/java/org/odk/collect/android/preferences/Defaults.kt @@ -1,7 +1,6 @@ package org.odk.collect.android.preferences import com.google.android.gms.maps.GoogleMap -import org.odk.collect.android.R import org.odk.collect.android.application.Collect import org.odk.collect.android.utilities.QuestionFontSizeUtils import org.odk.collect.settings.keys.ProjectKeys @@ -54,8 +53,6 @@ object Defaults { hashMap[ProjectKeys.KEY_USGS_MAP_STYLE] = "topographic" hashMap[ProjectKeys.KEY_GOOGLE_MAP_STYLE] = GoogleMap.MAP_TYPE_NORMAL.toString() hashMap[ProjectKeys.KEY_MAPBOX_MAP_STYLE] = "mapbox://styles/mapbox/streets-v11" - // experimental_preferences.xml - hashMap[ProjectKeys.KEY_PREDICATE_CACHING] = true return hashMap } diff --git a/collect_app/src/main/res/xml/experimental_preferences.xml b/collect_app/src/main/res/xml/experimental_preferences.xml index 8bf213fc105..60071d806f3 100644 --- a/collect_app/src/main/res/xml/experimental_preferences.xml +++ b/collect_app/src/main/res/xml/experimental_preferences.xml @@ -2,10 +2,6 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:title="@string/experimental"> - - Aplikace Crash Vynucení nezachycené výjimky, která způsobí pád aplikace - Předpokládané ukládání do mezipaměti - O oprávněních + O oprávněních Budete požádáni o povolení přístupu ODK Collect k níže uvedeným funkcím, pokud je chcete používat, vyberte možnost \"povolit\". Oznámení Vyžaduje se pro zobrazení aktualizací při stahování, aktualizaci a odesílání formulářů. diff --git a/strings/src/main/res/values-de/strings.xml b/strings/src/main/res/values-de/strings.xml index 8496747b701..bda9a749600 100644 --- a/strings/src/main/res/values-de/strings.xml +++ b/strings/src/main/res/values-de/strings.xml @@ -971,7 +971,6 @@ App abstürzen lassen Unbehandelte Ausnahme erzwingen um die App abstürzen zu lassen - Prädikat-Caching Über Berechtigungen Du wirst aufgefordert, ODK Collect den Zugriff auf die unten aufgeführten Funktionen zu erlauben. Wähle \"Zulassen\", wenn du sie nutzen möchten. Benachrichtigungen diff --git a/strings/src/main/res/values-es/strings.xml b/strings/src/main/res/values-es/strings.xml index 1b4f37845d8..d67fd0da301 100644 --- a/strings/src/main/res/values-es/strings.xml +++ b/strings/src/main/res/values-es/strings.xml @@ -975,8 +975,7 @@ Aplicación bloqueada Se forzó una excepción no detectada que hace que la aplicación se bloquee - Caché de predicados - Acerca de los permisos + Acerca de los permisos Se le pedirá que permita el acceso de ODK Collect a las siguientes funciones, seleccione \"permitir\" si desea usarlas. Notificaciones Requerido para mostrar actualizaciones cuando los formularios se descargan, actualizan y envían. diff --git a/strings/src/main/res/values-fi/strings.xml b/strings/src/main/res/values-fi/strings.xml index 52fc6d25557..b0ab19718a5 100644 --- a/strings/src/main/res/values-fi/strings.xml +++ b/strings/src/main/res/values-fi/strings.xml @@ -971,8 +971,7 @@ Kaada sovellus Pakota havaitsematon poikkeus, joka aiheuttaa sovelluksen kaatumisen - Predikaattivälimuisti - Tietoa luvista + Tietoa luvista Sinulta tullaan pyytämään lupaa ODK Collectille pääsy toimintoihin alla, valitse \"Salli\" jos haluat käyttää niitä. Ilmoitukset Tarvitaan päivitysten näyttämiseen kun lomakkeita ladataan, päivitetään tai lähetetään. diff --git a/strings/src/main/res/values-fr/strings.xml b/strings/src/main/res/values-fr/strings.xml index a40705b98d2..22dbb79e25b 100644 --- a/strings/src/main/res/values-fr/strings.xml +++ b/strings/src/main/res/values-fr/strings.xml @@ -976,8 +976,7 @@ Faire planter l\'application Forcer une exception qui fera planter l\'application - Mise en cache des prédicats - A propos des permissions + A propos des permissions Il vous sera demandé d\'autoriser ODK Collect à accéder aux fonctions ci-dessous, sélectionnez \"autoriser\" si vous souhaitez les utiliser. Notifications Requis pour afficher les mises à jours quand les formulaires sont téléchargés, mis à jour ou envoyés. diff --git a/strings/src/main/res/values-it/strings.xml b/strings/src/main/res/values-it/strings.xml index 8ab6b49f5f9..754ddc4d253 100644 --- a/strings/src/main/res/values-it/strings.xml +++ b/strings/src/main/res/values-it/strings.xml @@ -972,8 +972,7 @@ Arresto anomalo dell\'app Forza un\'eccezione non rilevata che causa l\'arresto anomalo dell\'app - Cache dei predicati - In merito ai permessi + In merito ai permessi Ti verrà chiesto di consentire a ODK Collect di accedere alle funzionalità seguenti, seleziona \"consenti\" se desideri utilizzarle. Notifiche Richiesto per mostrare gli aggiornamenti quando i moduli vengono scaricati, aggiornati e inviati. diff --git a/strings/src/main/res/values/strings.xml b/strings/src/main/res/values/strings.xml index 43610a70599..2c82d410ad6 100644 --- a/strings/src/main/res/values/strings.xml +++ b/strings/src/main/res/values/strings.xml @@ -1197,7 +1197,6 @@ Crash app Force an uncaught exception causing the app to crash - Predicate caching About permissions You will be asked to allow ODK Collect access to the features below, select “allow” if you want to use them. From d5b33d3e93f4a83d2d597afe8b08db832d434b1a Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Thu, 7 Sep 2023 15:59:11 +0100 Subject: [PATCH 11/46] Construct FormEntryController factory in data service --- .../collect/android/formmanagement/InstancesDataService.kt | 4 +--- .../collect/android/injection/config/AppDependencyModule.java | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt index 7b51c575b74..295e3edfa32 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt @@ -6,7 +6,6 @@ import org.odk.collect.android.entities.EntitiesRepositoryProvider import org.odk.collect.android.external.InstancesContract import org.odk.collect.android.formentry.FormEntryUseCases import org.odk.collect.android.projects.CurrentProjectProvider -import org.odk.collect.android.tasks.FormLoaderTask import org.odk.collect.android.utilities.FileUtils import org.odk.collect.android.utilities.FormsRepositoryProvider import org.odk.collect.android.utilities.InstancesRepositoryProvider @@ -19,7 +18,6 @@ class InstancesDataService( private val formsRepositoryProvider: FormsRepositoryProvider, private val instancesRepositoryProvider: InstancesRepositoryProvider, private val entitiesRepositoryProvider: EntitiesRepositoryProvider, - private val formEntryControllerFactory: FormLoaderTask.FormEntryControllerFactory, private val currentProjectProvider: CurrentProjectProvider ) { private val appState = context.getState() @@ -65,7 +63,7 @@ class InstancesDataService( val formMediaDir = FileUtils.getFormMediaDir(xForm) val formDef = FormEntryUseCases.loadFormDef(xForm, formMediaDir)!! - val formEntryController = formEntryControllerFactory.create(formDef) + val formEntryController = CollectFormEntryControllerFactory().create(formDef) val instanceFile = File(it.instanceFilePath) val formController = FormEntryUseCases.loadDraft(formEntryController, formMediaDir, instanceFile) diff --git a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java index 5d1488db655..aa30c436048 100644 --- a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java +++ b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java @@ -457,7 +457,7 @@ public UUIDGenerator providesUUIDGenerator() { @Provides public InstancesDataService providesInstancesDataService(Application application, InstancesRepositoryProvider instancesRepositoryProvider, CurrentProjectProvider currentProjectProvider, FormsRepositoryProvider formsRepositoryProvider, EntitiesRepositoryProvider entitiesRepositoryProvider, FormLoaderTask.FormEntryControllerFactory formEntryControllerFactory) { - return new InstancesDataService(application, formsRepositoryProvider, instancesRepositoryProvider, entitiesRepositoryProvider, formEntryControllerFactory, currentProjectProvider); + return new InstancesDataService(application, formsRepositoryProvider, instancesRepositoryProvider, entitiesRepositoryProvider, currentProjectProvider); } @Provides From 3836354ecbe9f2e431422bf3abc9ef3934e9a2ce Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Mon, 11 Sep 2023 11:55:01 +0100 Subject: [PATCH 12/46] Allow EntitiesRepository to be provided for the current project --- .../odk/collect/android/activities/InstanceChooserList.java | 3 +-- .../collect/android/entities/EntitiesRepositoryProvider.kt | 5 +++-- .../collect/android/formmanagement/InstancesDataService.kt | 4 ++-- .../formmanagement/drafts/BulkFinalizationViewModel.kt | 6 ++---- .../android/injection/config/AppDependencyModule.java | 4 ++-- 5 files changed, 10 insertions(+), 12 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java index e3356d65a9e..4e50c81198a 100644 --- a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java +++ b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java @@ -145,8 +145,7 @@ public void onCreate(Bundle savedInstanceState) { BulkFinalizationViewModel bulkFinalizationViewModel = new BulkFinalizationViewModel( scheduler, - instancesDataService, - currentProjectProvider + instancesDataService ); DraftsMenuProvider draftsMenuProvider = new DraftsMenuProvider(bulkFinalizationViewModel); diff --git a/collect_app/src/main/java/org/odk/collect/android/entities/EntitiesRepositoryProvider.kt b/collect_app/src/main/java/org/odk/collect/android/entities/EntitiesRepositoryProvider.kt index e756f909734..636493e8e35 100644 --- a/collect_app/src/main/java/org/odk/collect/android/entities/EntitiesRepositoryProvider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/entities/EntitiesRepositoryProvider.kt @@ -1,15 +1,16 @@ package org.odk.collect.android.entities import android.app.Application +import org.odk.collect.android.projects.CurrentProjectProvider import org.odk.collect.androidshared.data.getState import org.odk.collect.entities.EntitiesRepository -class EntitiesRepositoryProvider(application: Application) { +class EntitiesRepositoryProvider(application: Application, private val currentProjectProvider: CurrentProjectProvider) { private val repositories = application.getState().get(MAP_KEY, mutableMapOf()) - fun get(projectId: String): EntitiesRepository { + fun get(projectId: String = currentProjectProvider.getCurrentProject().uuid): EntitiesRepository { return repositories.getOrPut(projectId) { InMemEntitiesRepository() } diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt index 295e3edfa32..07093ba9840 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt @@ -50,10 +50,10 @@ class InstancesDataService( ) } - fun finalizeAllDrafts(projectId: String): Int { + fun finalizeAllDrafts(): Int { val instancesRepository = instancesRepositoryProvider.get() val formsRepository = formsRepositoryProvider.get() - val entitiesRepository = entitiesRepositoryProvider.get(projectId) + val entitiesRepository = entitiesRepositoryProvider.get() val instances = instancesRepository.all diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt index 5e6ae23511a..c78502e6d4a 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt @@ -3,14 +3,12 @@ package org.odk.collect.android.formmanagement.drafts import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import org.odk.collect.android.formmanagement.InstancesDataService -import org.odk.collect.android.projects.CurrentProjectProvider import org.odk.collect.androidshared.data.Consumable import org.odk.collect.async.Scheduler class BulkFinalizationViewModel( private val scheduler: Scheduler, - private val instancesDataService: InstancesDataService, - private val currentProjectProvider: CurrentProjectProvider + private val instancesDataService: InstancesDataService ) { private val _finalizedForms = MutableLiveData>() val finalizedForms: LiveData> = _finalizedForms @@ -18,7 +16,7 @@ class BulkFinalizationViewModel( fun finalizeAllDrafts() { scheduler.immediate( background = { - instancesDataService.finalizeAllDrafts(currentProjectProvider.getCurrentProject().uuid) + instancesDataService.finalizeAllDrafts() }, foreground = { _finalizedForms.value = Consumable(it) diff --git a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java index aa30c436048..7a2597a6b5e 100644 --- a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java +++ b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java @@ -384,8 +384,8 @@ public AudioRecorder providesAudioRecorder(Application application) { @Provides @Singleton - public EntitiesRepositoryProvider provideEntitiesRepositoryProvider(Application application) { - return new EntitiesRepositoryProvider(application); + public EntitiesRepositoryProvider provideEntitiesRepositoryProvider(Application application, CurrentProjectProvider currentProjectProvider) { + return new EntitiesRepositoryProvider(application, currentProjectProvider); } @Provides From 96fc2e1fb0cf57e55d969f548c05248d23a95409 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Mon, 11 Sep 2023 12:00:36 +0100 Subject: [PATCH 13/46] Remove CurrentProjectProvider dependenccy from InstancesDataService --- .../formmanagement/InstancesDataService.kt | 11 +++-------- .../injection/config/AppDependencyModule.java | 16 ++++++++++++++-- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt index 07093ba9840..82210c807ac 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt @@ -3,9 +3,7 @@ package org.odk.collect.android.formmanagement import android.content.Context import androidx.lifecycle.LiveData import org.odk.collect.android.entities.EntitiesRepositoryProvider -import org.odk.collect.android.external.InstancesContract import org.odk.collect.android.formentry.FormEntryUseCases -import org.odk.collect.android.projects.CurrentProjectProvider import org.odk.collect.android.utilities.FileUtils import org.odk.collect.android.utilities.FormsRepositoryProvider import org.odk.collect.android.utilities.InstancesRepositoryProvider @@ -14,11 +12,11 @@ import org.odk.collect.forms.instances.Instance import java.io.File class InstancesDataService( - private val context: Context, + context: Context, private val formsRepositoryProvider: FormsRepositoryProvider, private val instancesRepositoryProvider: InstancesRepositoryProvider, private val entitiesRepositoryProvider: EntitiesRepositoryProvider, - private val currentProjectProvider: CurrentProjectProvider + private val onUpdate: () -> Unit ) { private val appState = context.getState() @@ -44,10 +42,7 @@ class InstancesDataService( appState.setLive(SENDABLE_COUNT_KEY, sendableInstances) appState.setLive(SENT_COUNT_KEY, sentInstances) - context.contentResolver.notifyChange( - InstancesContract.getUri(currentProjectProvider.getCurrentProject().uuid), - null - ) + onUpdate() } fun finalizeAllDrafts(): Int { diff --git a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java index 7a2597a6b5e..5098e2cc25c 100644 --- a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java +++ b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java @@ -44,6 +44,7 @@ import org.odk.collect.android.database.itemsets.DatabaseFastExternalItemsetsRepository; import org.odk.collect.android.draw.PenColorPickerViewModel; import org.odk.collect.android.entities.EntitiesRepositoryProvider; +import org.odk.collect.android.external.InstancesContract; import org.odk.collect.android.formentry.AppStateFormSessionRepository; import org.odk.collect.android.formentry.FormSessionRepository; import org.odk.collect.android.formentry.media.AudioHelperFactory; @@ -151,6 +152,8 @@ import dagger.Module; import dagger.Provides; +import kotlin.Unit; +import kotlin.jvm.functions.Function0; import okhttp3.OkHttpClient; /** @@ -456,8 +459,17 @@ public UUIDGenerator providesUUIDGenerator() { } @Provides - public InstancesDataService providesInstancesDataService(Application application, InstancesRepositoryProvider instancesRepositoryProvider, CurrentProjectProvider currentProjectProvider, FormsRepositoryProvider formsRepositoryProvider, EntitiesRepositoryProvider entitiesRepositoryProvider, FormLoaderTask.FormEntryControllerFactory formEntryControllerFactory) { - return new InstancesDataService(application, formsRepositoryProvider, instancesRepositoryProvider, entitiesRepositoryProvider, currentProjectProvider); + public InstancesDataService providesInstancesDataService(Application application, InstancesRepositoryProvider instancesRepositoryProvider, CurrentProjectProvider currentProjectProvider, FormsRepositoryProvider formsRepositoryProvider, EntitiesRepositoryProvider entitiesRepositoryProvider) { + Function0 onUpdate = () -> { + application.getContentResolver().notifyChange( + InstancesContract.getUri(currentProjectProvider.getCurrentProject().getUuid()), + null + ); + + return null; + }; + + return new InstancesDataService(application, formsRepositoryProvider, instancesRepositoryProvider, entitiesRepositoryProvider, onUpdate); } @Provides From 279bc9e26a876cacf25005d99ebd23a365ae72db Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Mon, 11 Sep 2023 12:03:34 +0100 Subject: [PATCH 14/46] Remove Context dependency from InstancesDataService --- .../collect/android/formmanagement/InstancesDataService.kt | 7 ++----- .../android/injection/config/AppDependencyModule.java | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt index 82210c807ac..1efc78dab01 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt @@ -1,25 +1,22 @@ package org.odk.collect.android.formmanagement -import android.content.Context import androidx.lifecycle.LiveData import org.odk.collect.android.entities.EntitiesRepositoryProvider import org.odk.collect.android.formentry.FormEntryUseCases 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.androidshared.data.getState +import org.odk.collect.androidshared.data.AppState import org.odk.collect.forms.instances.Instance import java.io.File class InstancesDataService( - context: Context, + private val appState: AppState, private val formsRepositoryProvider: FormsRepositoryProvider, private val instancesRepositoryProvider: InstancesRepositoryProvider, private val entitiesRepositoryProvider: EntitiesRepositoryProvider, private val onUpdate: () -> Unit ) { - private val appState = context.getState() - val editableCount: LiveData = appState.getLive(EDITABLE_COUNT_KEY, 0) val sendableCount: LiveData = appState.getLive(SENDABLE_COUNT_KEY, 0) val sentCount: LiveData = appState.getLive(SENT_COUNT_KEY, 0) diff --git a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java index 5098e2cc25c..dcdd8c3802a 100644 --- a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java +++ b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java @@ -469,7 +469,7 @@ public InstancesDataService providesInstancesDataService(Application application return null; }; - return new InstancesDataService(application, formsRepositoryProvider, instancesRepositoryProvider, entitiesRepositoryProvider, onUpdate); + return new InstancesDataService(getState(application), formsRepositoryProvider, instancesRepositoryProvider, entitiesRepositoryProvider, onUpdate); } @Provides From b8a3003f5d61ea9b249fc12174f83c31f6ad99e7 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Mon, 11 Sep 2023 13:18:02 +0100 Subject: [PATCH 15/46] Only finalize drafts --- .../formmanagement/BulkFinalizationTest.kt | 19 +++++++++++++++++++ .../formmanagement/InstancesDataService.kt | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt index f9531645be7..9afc687adc2 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt @@ -36,4 +36,23 @@ class BulkFinalizationTest { .assertNumberOfFinalizedForms(2) } + + @Test + fun doesNotFinalizeOtherTypesOfInstance() { + rule.startAtMainMenu() + .copyForm("one-question.xml") + .startBlankForm("One Question") + .fillOutAndSave(QuestionAndAnswer("what is your age", "97")) + .startBlankForm("One Question") + .fillOutAndFinalize(QuestionAndAnswer("what is your age", "98")) + + .clickEditSavedForm(1) + .clickOptionsIcon("Finalize all forms") + .clickOnText("Finalize all forms") + .checkIsSnackbarWithMessageDisplayed("Success! 1 forms finalized.") + .assertTextDoesNotExist("One Question") + .pressBack(MainMenuPage()) + + .assertNumberOfFinalizedForms(2) + } } diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt index 1efc78dab01..53c4f9f2073 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt @@ -47,7 +47,7 @@ class InstancesDataService( val formsRepository = formsRepositoryProvider.get() val entitiesRepository = entitiesRepositoryProvider.get() - val instances = instancesRepository.all + val instances = instancesRepository.getAllByStatus(Instance.STATUS_INCOMPLETE) instances.forEach { val form = formsRepository.getAllByFormId(it.formId)[0] From 599d87b0fa7f201db337ea17ae1d196038a72b56 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Mon, 11 Sep 2023 13:26:27 +0100 Subject: [PATCH 16/46] Show progress dialog when finalizing forms --- .../collect/android/activities/InstanceChooserList.java | 7 +++++++ .../formmanagement/drafts/BulkFinalizationViewModel.kt | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java index 4e50c81198a..f8c2258f054 100644 --- a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java +++ b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java @@ -61,6 +61,7 @@ import org.odk.collect.async.Scheduler; import org.odk.collect.forms.Form; import org.odk.collect.forms.instances.Instance; +import org.odk.collect.material.MaterialProgressDialogFragment; import org.odk.collect.settings.SettingsProvider; import java.util.Arrays; @@ -151,6 +152,12 @@ public void onCreate(Bundle savedInstanceState) { DraftsMenuProvider draftsMenuProvider = new DraftsMenuProvider(bulkFinalizationViewModel); addMenuProvider(draftsMenuProvider); + MaterialProgressDialogFragment.showOn(this, bulkFinalizationViewModel.isFinalizing(), getSupportFragmentManager(), () -> { + MaterialProgressDialogFragment dialog = new MaterialProgressDialogFragment(); + dialog.setMessage("Finalizing drafts..."); + return dialog; + }); + bulkFinalizationViewModel.getFinalizedForms().observe(this, finalizedForms -> { if (!finalizedForms.isConsumed()) { SnackbarUtils.showLongSnackbar( diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt index c78502e6d4a..cc677604ee0 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import org.odk.collect.android.formmanagement.InstancesDataService 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.Scheduler class BulkFinalizationViewModel( @@ -13,12 +15,18 @@ class BulkFinalizationViewModel( private val _finalizedForms = MutableLiveData>() val finalizedForms: LiveData> = _finalizedForms + private val _isFinalizing = MutableNonNullLiveData(false) + val isFinalizing: NonNullLiveData = _isFinalizing + fun finalizeAllDrafts() { + _isFinalizing.value = true + scheduler.immediate( background = { instancesDataService.finalizeAllDrafts() }, foreground = { + _isFinalizing.value = false _finalizedForms.value = Consumable(it) } ) From e95aaa36dfec8becfa9a82ed1234437b63861bde Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Mon, 11 Sep 2023 13:41:41 +0100 Subject: [PATCH 17/46] Show different snackbar when form finalization fails --- .../formmanagement/BulkFinalizationTest.kt | 25 +++++++++++++++++++ .../activities/InstanceChooserList.java | 18 ++++++++++--- .../formmanagement/InstancesDataService.kt | 22 +++++++++++----- .../drafts/BulkFinalizationViewModel.kt | 4 +-- 4 files changed, 57 insertions(+), 12 deletions(-) diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt index 9afc687adc2..34513f26450 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt @@ -7,6 +7,7 @@ import org.junit.rules.RuleChain import org.junit.runner.RunWith import org.odk.collect.android.support.pages.FormEntryPage.QuestionAndAnswer import org.odk.collect.android.support.pages.MainMenuPage +import org.odk.collect.android.support.pages.SaveOrDiscardFormDialog import org.odk.collect.android.support.rules.CollectTestRule import org.odk.collect.android.support.rules.TestRuleChain @@ -37,6 +38,30 @@ class BulkFinalizationTest { .assertNumberOfFinalizedForms(2) } + @Test + fun whenThereAreDraftsWithConstraintViolations_marksFormsAsHavingErrors() { + rule.startAtMainMenu() + .copyForm("two-question-required.xml") + .startBlankForm("Two Question Required") + .fillOut(QuestionAndAnswer("What is your name?", "Dan")) + .pressBack(SaveOrDiscardFormDialog(MainMenuPage())) + .clickSaveChanges() + + .startBlankForm("Two Question Required") + .fillOutAndSave( + QuestionAndAnswer("What is your name?", "Tim"), + QuestionAndAnswer("What is your age?", "45", true) + ) + + .clickEditSavedForm(2) + .clickOptionsIcon("Finalize all forms") + .clickOnText("Finalize all forms") + .checkIsSnackbarWithMessageDisplayed("1 forms finalized. 1 forms have errors. Address issues before finalizing all forms.") + .pressBack(MainMenuPage()) + + .assertNumberOfFinalizedForms(1) + } + @Test fun doesNotFinalizeOtherTypesOfInstance() { rule.startAtMainMenu() diff --git a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java index f8c2258f054..763da60e03b 100644 --- a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java +++ b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java @@ -68,6 +68,8 @@ import javax.inject.Inject; +import kotlin.Pair; + /** * Responsible for displaying all the valid instances in the instance directory. * @@ -160,10 +162,18 @@ public void onCreate(Bundle savedInstanceState) { bulkFinalizationViewModel.getFinalizedForms().observe(this, finalizedForms -> { if (!finalizedForms.isConsumed()) { - SnackbarUtils.showLongSnackbar( - this.findViewById(android.R.id.content), - "Success! " + finalizedForms.getValue() + " forms finalized." - ); + Pair pair = finalizedForms.getValue(); + if (pair.getSecond().equals(0)) { + SnackbarUtils.showLongSnackbar( + this.findViewById(android.R.id.content), + "Success! " + pair.getFirst() + " forms finalized." + ); + } else { + SnackbarUtils.showLongSnackbar( + this.findViewById(android.R.id.content), + (pair.getFirst() - pair.getSecond()) + " forms finalized. " + pair.getSecond() + " forms have errors. Address issues before finalizing all forms." + ); + } finalizedForms.consume(); } diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt index 53c4f9f2073..becc42afffb 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt @@ -42,29 +42,39 @@ class InstancesDataService( onUpdate() } - fun finalizeAllDrafts(): Int { + fun finalizeAllDrafts(): Pair { val instancesRepository = instancesRepositoryProvider.get() val formsRepository = formsRepositoryProvider.get() val entitiesRepository = entitiesRepositoryProvider.get() val instances = instancesRepository.getAllByStatus(Instance.STATUS_INCOMPLETE) - instances.forEach { - val form = formsRepository.getAllByFormId(it.formId)[0] + val totalFailed = instances.fold(0) { failCount, instance -> + val form = formsRepository.getAllByFormId(instance.formId)[0] val xForm = File(form.formFilePath) val formMediaDir = FileUtils.getFormMediaDir(xForm) val formDef = FormEntryUseCases.loadFormDef(xForm, formMediaDir)!! val formEntryController = CollectFormEntryControllerFactory().create(formDef) - val instanceFile = File(it.instanceFilePath) + val instanceFile = File(instance.instanceFilePath) val formController = FormEntryUseCases.loadDraft(formEntryController, formMediaDir, instanceFile) - FormEntryUseCases.finalizeDraft(formController, entitiesRepository, instancesRepository) + val instance = FormEntryUseCases.finalizeDraft( + formController, + entitiesRepository, + instancesRepository + ) + + if (instance == null) { + failCount + 1 + } else { + failCount + } } update() - return instances.size + return Pair(instances.size, totalFailed) } companion object { diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt index cc677604ee0..72f1b4ae644 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt @@ -12,8 +12,8 @@ class BulkFinalizationViewModel( private val scheduler: Scheduler, private val instancesDataService: InstancesDataService ) { - private val _finalizedForms = MutableLiveData>() - val finalizedForms: LiveData> = _finalizedForms + private val _finalizedForms = MutableLiveData>>() + val finalizedForms: LiveData>> = _finalizedForms private val _isFinalizing = MutableNonNullLiveData(false) val isFinalizing: NonNullLiveData = _isFinalizing From 58e8f09dad8e3273d431aa85c1d8d41bf2790254 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Mon, 11 Sep 2023 16:52:28 +0100 Subject: [PATCH 18/46] Show instance in different state when validation has failed --- .../formmanagement/BulkFinalizationTest.kt | 2 + .../adapters/InstanceListCursorAdapter.java | 11 ++-- .../adapters/InstanceUploaderAdapter.java | 10 +-- .../android/dao/CursorLoaderFactory.java | 4 +- .../android/external/InstanceProvider.java | 40 ------------ .../android/formentry/FormEntryUseCases.kt | 14 +++- .../formmanagement/InstancesDataService.kt | 10 +-- .../formmap/FormMapViewModel.kt | 9 +-- .../android/instancemanagement/InstanceExt.kt | 62 ++++++++++++++++++ .../formentry/FormEntryUseCasesTest.kt | 39 +++++++++++ .../instancemanagement/InstanceExtTest.kt | 65 +++++++++++++++++++ .../odk/collect/forms/instances/Instance.java | 1 + .../odk/collect/formstest/InstanceFixtures.kt | 15 +++++ strings/src/main/res/values/strings.xml | 2 + 14 files changed, 219 insertions(+), 65 deletions(-) create mode 100644 collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceExt.kt create mode 100644 collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt create mode 100644 collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceExtTest.kt create mode 100644 formstest/src/main/java/org/odk/collect/formstest/InstanceFixtures.kt diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt index 34513f26450..0de6526afd9 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt @@ -57,8 +57,10 @@ class BulkFinalizationTest { .clickOptionsIcon("Finalize all forms") .clickOnText("Finalize all forms") .checkIsSnackbarWithMessageDisplayed("1 forms finalized. 1 forms have errors. Address issues before finalizing all forms.") + .assertText("Two Question Required") .pressBack(MainMenuPage()) + .assertNumberOfEditableForms(1) .assertNumberOfFinalizedForms(1) } diff --git a/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceListCursorAdapter.java b/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceListCursorAdapter.java index 3dedbe5a5f7..a49a6a63f87 100644 --- a/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceListCursorAdapter.java +++ b/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceListCursorAdapter.java @@ -25,11 +25,11 @@ import android.widget.TextView; import org.odk.collect.android.R; -import org.odk.collect.forms.Form; -import org.odk.collect.forms.instances.Instance; -import org.odk.collect.android.external.InstanceProvider; import org.odk.collect.android.database.instances.DatabaseInstanceColumns; +import org.odk.collect.android.instancemanagement.InstanceExtKt; import org.odk.collect.android.utilities.FormsRepositoryProvider; +import org.odk.collect.forms.Form; +import org.odk.collect.forms.instances.Instance; import java.text.SimpleDateFormat; import java.util.Date; @@ -137,7 +137,7 @@ private void setDisabled(View view, String disabledMessage) { private void setUpSubtext(View view) { long lastStatusChangeDate = getCursor().getLong(getCursor().getColumnIndex(DatabaseInstanceColumns.LAST_STATUS_CHANGE_DATE)); String status = getCursor().getString(getCursor().getColumnIndex(DatabaseInstanceColumns.STATUS)); - String subtext = InstanceProvider.getDisplaySubtext(context, status, new Date(lastStatusChangeDate)); + String subtext = InstanceExtKt.getStatusDescription(context, status, new Date(lastStatusChangeDate)); final TextView formSubtitle = view.findViewById(R.id.form_subtitle); formSubtitle.setText(subtext); @@ -154,6 +154,7 @@ private void setImageFromStatus(ImageView imageView) { public static int getFormStateImageResourceIdForStatus(String formStatus) { switch (formStatus) { case Instance.STATUS_INCOMPLETE: + case Instance.STATUS_INVALID: return R.drawable.ic_form_state_saved; case Instance.STATUS_COMPLETE: return R.drawable.ic_form_state_finalized; @@ -163,6 +164,6 @@ public static int getFormStateImageResourceIdForStatus(String formStatus) { return R.drawable.ic_form_state_submission_failed; } - return -1; + throw new IllegalArgumentException(); } } diff --git a/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceUploaderAdapter.java b/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceUploaderAdapter.java index af8658a5e36..3afec448dae 100644 --- a/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceUploaderAdapter.java +++ b/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceUploaderAdapter.java @@ -1,5 +1,8 @@ package org.odk.collect.android.adapters; +import static org.odk.collect.forms.instances.Instance.STATUS_SUBMISSION_FAILED; +import static org.odk.collect.forms.instances.Instance.STATUS_SUBMITTED; + import android.content.Context; import android.database.Cursor; import android.view.LayoutInflater; @@ -13,17 +16,14 @@ import org.odk.collect.android.R; import org.odk.collect.android.application.Collect; -import org.odk.collect.android.external.InstanceProvider; import org.odk.collect.android.database.instances.DatabaseInstanceColumns; +import org.odk.collect.android.instancemanagement.InstanceExtKt; import java.util.Date; import java.util.HashSet; import java.util.Set; import java.util.function.Consumer; -import static org.odk.collect.forms.instances.Instance.STATUS_SUBMISSION_FAILED; -import static org.odk.collect.forms.instances.Instance.STATUS_SUBMITTED; - public class InstanceUploaderAdapter extends CursorAdapter { private final Consumer onItemCheckboxClickListener; private Set selected = new HashSet<>(); @@ -49,7 +49,7 @@ public void bindView(View view, Context context, Cursor cursor) { String status = cursor.getString(cursor.getColumnIndex(DatabaseInstanceColumns.STATUS)); viewHolder.formTitle.setText(cursor.getString(cursor.getColumnIndex(DatabaseInstanceColumns.DISPLAY_NAME))); - viewHolder.formSubtitle.setText(InstanceProvider.getDisplaySubtext(context, status, new Date(lastStatusChangeDate))); + viewHolder.formSubtitle.setText(InstanceExtKt.getStatusDescription(context, status, new Date(lastStatusChangeDate))); switch (status) { case STATUS_SUBMISSION_FAILED: diff --git a/collect_app/src/main/java/org/odk/collect/android/dao/CursorLoaderFactory.java b/collect_app/src/main/java/org/odk/collect/android/dao/CursorLoaderFactory.java index 22bd34fc3f5..af88b08c69b 100644 --- a/collect_app/src/main/java/org/odk/collect/android/dao/CursorLoaderFactory.java +++ b/collect_app/src/main/java/org/odk/collect/android/dao/CursorLoaderFactory.java @@ -46,8 +46,8 @@ public CursorLoader createSentInstancesCursorLoader(CharSequence charSequence, S public CursorLoader createEditableInstancesCursorLoader(CharSequence charSequence, String sortOrder) { CursorLoader cursorLoader; if (charSequence.length() == 0) { - String selection = DatabaseInstanceColumns.STATUS + " =? "; - String[] selectionArgs = {Instance.STATUS_INCOMPLETE}; + String selection = DatabaseInstanceColumns.STATUS + "=? or " + DatabaseInstanceColumns.STATUS + "=?"; + String[] selectionArgs = {Instance.STATUS_INCOMPLETE, Instance.STATUS_INVALID}; cursorLoader = getInstancesCursorLoader(selection, selectionArgs, sortOrder); } else { diff --git a/collect_app/src/main/java/org/odk/collect/android/external/InstanceProvider.java b/collect_app/src/main/java/org/odk/collect/android/external/InstanceProvider.java index cc424fec223..87288c3cf40 100644 --- a/collect_app/src/main/java/org/odk/collect/android/external/InstanceProvider.java +++ b/collect_app/src/main/java/org/odk/collect/android/external/InstanceProvider.java @@ -24,9 +24,7 @@ import android.content.ContentProvider; import android.content.ContentValues; -import android.content.Context; import android.content.UriMatcher; -import android.content.res.Resources; import android.database.Cursor; import android.net.Uri; @@ -48,14 +46,8 @@ import org.odk.collect.projects.ProjectsRepository; import org.odk.collect.settings.SettingsProvider; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Locale; - import javax.inject.Inject; -import timber.log.Timber; - public class InstanceProvider extends ContentProvider { private static final int INSTANCES = 1; @@ -148,38 +140,6 @@ public Uri insert(@NonNull Uri uri, ContentValues initialValues) { return getUri(projectId, newInstance.getDbId()); } - public static String getDisplaySubtext(Context context, String state, Date date) { - return getDisplaySubtext(context.getResources(), state, date); - } - - public static String getDisplaySubtext(Resources resources, String state, Date date) { - try { - if (state == null) { - return new SimpleDateFormat(resources.getString(org.odk.collect.strings.R.string.added_on_date_at_time), - Locale.getDefault()).format(date); - } else if (Instance.STATUS_INCOMPLETE.equalsIgnoreCase(state)) { - return new SimpleDateFormat(resources.getString(org.odk.collect.strings.R.string.saved_on_date_at_time), - Locale.getDefault()).format(date); - } else if (Instance.STATUS_COMPLETE.equalsIgnoreCase(state)) { - return new SimpleDateFormat(resources.getString(org.odk.collect.strings.R.string.finalized_on_date_at_time), - Locale.getDefault()).format(date); - } else if (Instance.STATUS_SUBMITTED.equalsIgnoreCase(state)) { - return new SimpleDateFormat(resources.getString(org.odk.collect.strings.R.string.sent_on_date_at_time), - Locale.getDefault()).format(date); - } else if (Instance.STATUS_SUBMISSION_FAILED.equalsIgnoreCase(state)) { - return new SimpleDateFormat( - resources.getString(org.odk.collect.strings.R.string.sending_failed_on_date_at_time), - Locale.getDefault()).format(date); - } else { - return new SimpleDateFormat(resources.getString(org.odk.collect.strings.R.string.added_on_date_at_time), - Locale.getDefault()).format(date); - } - } catch (IllegalArgumentException e) { - Timber.e(e, "Current locale: %s", Locale.getDefault()); - return ""; - } - } - /** * This method removes the entry from the content provider, and also removes any associated * files. diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt index 0581a7924c7..2ebcdf8f5a3 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt @@ -50,8 +50,8 @@ object FormEntryUseCases { @JvmStatic fun finalizeDraft( formController: FormController, - entitiesRepository: EntitiesRepository, - instancesRepository: InstancesRepository + instancesRepository: InstancesRepository, + entitiesRepository: EntitiesRepository ): Instance? { val valid = finalizeInstance(formController, entitiesRepository) @@ -59,10 +59,20 @@ object FormEntryUseCases { saveFormToDisk(formController) markInstanceAsComplete(formController, instancesRepository) } else { + markInstanceAsInvalid(formController, instancesRepository) null } } + private fun markInstanceAsInvalid(formController: FormController, instancesRepository: InstancesRepository) { + val instancePath = formController.getInstanceFile()!!.absolutePath + val instance = instancesRepository.getOneByPath(instancePath) + + instancesRepository.save( + Instance.Builder(instance).also { it.status(Instance.STATUS_INVALID) }.build() + ) + } + private fun markInstanceAsComplete( formController: FormController, instancesRepository: InstancesRepository diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt index becc42afffb..0f639214031 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt @@ -32,8 +32,10 @@ class InstancesDataService( Instance.STATUS_SUBMITTED, Instance.STATUS_SUBMISSION_FAILED ) - - val editableInstances = instancesRepository.getCountByStatus(Instance.STATUS_INCOMPLETE) + val editableInstances = instancesRepository.getCountByStatus( + Instance.STATUS_INCOMPLETE, + Instance.STATUS_INVALID + ) appState.setLive(EDITABLE_COUNT_KEY, editableInstances) appState.setLive(SENDABLE_COUNT_KEY, sendableInstances) @@ -62,8 +64,8 @@ class InstancesDataService( val instance = FormEntryUseCases.finalizeDraft( formController, - entitiesRepository, - instancesRepository + instancesRepository, + entitiesRepository ) if (instance == null) { diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/formmap/FormMapViewModel.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/formmap/FormMapViewModel.kt index 45e0ae15b77..7e1e7c73017 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/formmap/FormMapViewModel.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/formmap/FormMapViewModel.kt @@ -7,7 +7,7 @@ import androidx.lifecycle.ViewModel import org.json.JSONException import org.json.JSONObject import org.odk.collect.android.R -import org.odk.collect.android.external.InstanceProvider +import org.odk.collect.android.instancemanagement.getStatusDescription import org.odk.collect.androidshared.livedata.MutableNonNullLiveData import org.odk.collect.androidshared.livedata.NonNullLiveData import org.odk.collect.async.Scheduler @@ -21,7 +21,6 @@ import org.odk.collect.settings.SettingsProvider import org.odk.collect.settings.keys.ProtectedProjectKeys import timber.log.Timber import java.text.SimpleDateFormat -import java.util.Date import java.util.Locale class FormMapViewModel( @@ -102,11 +101,7 @@ class FormMapViewModel( latitude: Double, longitude: Double ): MappableSelectItem { - val instanceLastStatusChangeDate = InstanceProvider.getDisplaySubtext( - resources, - instance.status, - Date(instance.lastStatusChangeDate) - ) + val instanceLastStatusChangeDate = instance.getStatusDescription(resources) return if (instance.deletedDate != null) { val deletedTime = resources.getString(org.odk.collect.strings.R.string.deleted_on_date_at_time) diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceExt.kt b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceExt.kt new file mode 100644 index 00000000000..f2323650b2e --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceExt.kt @@ -0,0 +1,62 @@ +package org.odk.collect.android.instancemanagement + +import android.content.Context +import android.content.res.Resources +import org.odk.collect.forms.instances.Instance +import org.odk.collect.strings.R +import timber.log.Timber +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +fun Instance.getStatusDescription(resources: Resources): String { + return getStatusDescription(resources, status, Date(lastStatusChangeDate)) +} + +fun getStatusDescription(context: Context, state: String?, date: Date): String { + return getStatusDescription(context.resources, state, date) +} + +private fun getStatusDescription(resources: Resources, state: String?, date: Date): String { + return try { + if (state == null) { + SimpleDateFormat( + resources.getString(R.string.added_on_date_at_time), + Locale.getDefault() + ).format(date) + } else if (Instance.STATUS_INCOMPLETE.equals(state, ignoreCase = true)) { + SimpleDateFormat( + resources.getString(R.string.saved_on_date_at_time), + Locale.getDefault() + ).format(date) + } else if (Instance.STATUS_COMPLETE.equals(state, ignoreCase = true)) { + SimpleDateFormat( + resources.getString(R.string.finalized_on_date_at_time), + Locale.getDefault() + ).format(date) + } else if (Instance.STATUS_SUBMITTED.equals(state, ignoreCase = true)) { + SimpleDateFormat( + resources.getString(R.string.sent_on_date_at_time), + Locale.getDefault() + ).format(date) + } else if (Instance.STATUS_SUBMISSION_FAILED.equals(state, ignoreCase = true)) { + SimpleDateFormat( + resources.getString(R.string.sending_failed_on_date_at_time), + Locale.getDefault() + ).format(date) + } else if (Instance.STATUS_INVALID.equals(state, ignoreCase = true)) { + SimpleDateFormat( + resources.getString(R.string.validated_on_date_at_time), + Locale.getDefault() + ).format(date) + } else { + SimpleDateFormat( + resources.getString(R.string.added_on_date_at_time), + Locale.getDefault() + ).format(date) + } + } catch (e: IllegalArgumentException) { + Timber.e(e, "Current locale: %s", Locale.getDefault()) + "" + } +} diff --git a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt new file mode 100644 index 00000000000..1ed4366ede1 --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt @@ -0,0 +1,39 @@ +package org.odk.collect.android.formentry + +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.odk.collect.android.entities.InMemEntitiesRepository +import org.odk.collect.android.javarosawrapper.FailedValidationResult +import org.odk.collect.android.javarosawrapper.FormController +import org.odk.collect.forms.instances.Instance +import org.odk.collect.formstest.InMemInstancesRepository +import org.odk.collect.formstest.InstanceFixtures +import java.io.File + +class FormEntryUseCasesTest { + + @Test + fun finalizeDraft_whenValidationFails_marksInstanceAsHavingErrors() { + val instancesRepository = InMemInstancesRepository() + val instance = instancesRepository.save(InstanceFixtures.instance()) + + val formController = mock { + on { validateAnswers(true) } doReturn FailedValidationResult(mock(), 0) + on { getInstanceFile() } doReturn File(instance.instanceFilePath) + } + + FormEntryUseCases.finalizeDraft( + formController, + instancesRepository, + InMemEntitiesRepository() + ) + + assertThat( + instancesRepository.get(instance.dbId)!!.status, + equalTo(Instance.STATUS_INVALID) + ) + } +} diff --git a/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceExtTest.kt b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceExtTest.kt new file mode 100644 index 00000000000..90ca0d86edc --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceExtTest.kt @@ -0,0 +1,65 @@ +package org.odk.collect.android.instancemanagement + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.Test +import org.junit.runner.RunWith +import org.odk.collect.forms.instances.Instance +import org.odk.collect.formstest.InstanceFixtures +import org.odk.collect.strings.R +import java.text.SimpleDateFormat +import java.util.Locale + +@RunWith(AndroidJUnit4::class) +class InstanceExtTest { + + private val resources = ApplicationProvider.getApplicationContext().resources + + @Test + fun getStatusDescriptionTest() { + val incomplete = InstanceFixtures.instance(status = Instance.STATUS_INCOMPLETE) + assertDateFormat( + incomplete.getStatusDescription(resources), + R.string.saved_on_date_at_time + ) + + val invalid = InstanceFixtures.instance(status = Instance.STATUS_INVALID) + assertDateFormat( + invalid.getStatusDescription(resources), + R.string.validated_on_date_at_time + ) + + val complete = InstanceFixtures.instance(status = Instance.STATUS_COMPLETE) + assertDateFormat( + complete.getStatusDescription(resources), + R.string.finalized_on_date_at_time + ) + + val submitted = InstanceFixtures.instance(status = Instance.STATUS_SUBMITTED) + assertDateFormat( + submitted.getStatusDescription(resources), + R.string.sent_on_date_at_time + ) + + val submissionFailed = InstanceFixtures.instance(status = Instance.STATUS_SUBMISSION_FAILED) + assertDateFormat( + submissionFailed.getStatusDescription(resources), + R.string.sending_failed_on_date_at_time + ) + } + + private fun assertDateFormat(description: String, stringId: Int) { + assertThat( + description, + equalTo( + SimpleDateFormat( + resources.getString(stringId), + Locale.getDefault() + ).format(0) + ) + ) + } +} diff --git a/forms/src/main/java/org/odk/collect/forms/instances/Instance.java b/forms/src/main/java/org/odk/collect/forms/instances/Instance.java index 52c910c300b..56e091d5b0e 100644 --- a/forms/src/main/java/org/odk/collect/forms/instances/Instance.java +++ b/forms/src/main/java/org/odk/collect/forms/instances/Instance.java @@ -24,6 +24,7 @@ public final class Instance { // status for instances public static final String STATUS_INCOMPLETE = "incomplete"; + public static final String STATUS_INVALID = "invalid"; public static final String STATUS_COMPLETE = "complete"; public static final String STATUS_SUBMITTED = "submitted"; public static final String STATUS_SUBMISSION_FAILED = "submissionFailed"; diff --git a/formstest/src/main/java/org/odk/collect/formstest/InstanceFixtures.kt b/formstest/src/main/java/org/odk/collect/formstest/InstanceFixtures.kt new file mode 100644 index 00000000000..c712d242a68 --- /dev/null +++ b/formstest/src/main/java/org/odk/collect/formstest/InstanceFixtures.kt @@ -0,0 +1,15 @@ +package org.odk.collect.formstest + +import org.odk.collect.forms.instances.Instance +import org.odk.collect.shared.TempFiles + +object InstanceFixtures { + + fun instance(status: String = Instance.STATUS_INCOMPLETE, lastStatusChangeDate: Long = 0): Instance { + val instancesDir = TempFiles.createTempDir() + return InstanceUtils.buildInstance("formId", "version", instancesDir.absolutePath) + .status(status) + .lastStatusChangeDate(lastStatusChangeDate) + .build() + } +} diff --git a/strings/src/main/res/values/strings.xml b/strings/src/main/res/values/strings.xml index 2c82d410ad6..e4f9f37763d 100644 --- a/strings/src/main/res/values/strings.xml +++ b/strings/src/main/res/values/strings.xml @@ -34,6 +34,8 @@ \'Saved on\' EEE, MMM dd, yyyy \'at\' HH:mm + \'Saved with errors on\' EEE, MMM dd, yyyy \'at\' HH:mm + \'Finalized on\' EEE, MMM dd, yyyy \'at\' HH:mm \'Sent on\' EEE, MMM dd, yyyy \'at\' HH:mm From 19d547973107107e9aac070ec31858864c17ba35 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Mon, 11 Sep 2023 16:59:43 +0100 Subject: [PATCH 19/46] Allow editing invalid drafts --- .../android/external/FormUriActivity.kt | 10 +++++-- .../android/external/FormUriActivityTest.kt | 26 ++++++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) 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 42d2b8fad5a..ad44793b3c6 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 @@ -264,7 +264,13 @@ class FormUriActivity : LocalizedActivity() { } } +private val editableStatuses = arrayOf( + Instance.STATUS_INCOMPLETE, + Instance.STATUS_INVALID, + Instance.STATUS_COMPLETE +) + private fun Instance.canBeEdited(settingsProvider: SettingsProvider): Boolean { - return (this.status == Instance.STATUS_INCOMPLETE || this.status == Instance.STATUS_COMPLETE) && - settingsProvider.getProtectedSettings().getBoolean(ProtectedProjectKeys.KEY_EDIT_SAVED) + return editableStatuses.contains(status) && settingsProvider.getProtectedSettings() + .getBoolean(ProtectedProjectKeys.KEY_EDIT_SAVED) } 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 220fa6fcd7c..91b19abe32a 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 @@ -668,7 +668,7 @@ class FormUriActivityTest { } @Test - fun `When there is project id specified in uri that represents a saved form and it matches current project id then start form filling`() { + fun `When there is project id specified in uri that represents an incomplete form and it matches current project id then start form filling`() { val project = Project.Saved("123", "First project", "A", "#cccccc") projectsRepository.save(project) whenever(currentProjectProvider.getCurrentProject()).thenReturn(project) @@ -691,6 +691,30 @@ class FormUriActivityTest { assertStartSavedFormIntent(project.uuid, instance.dbId, true) } + @Test + fun `When there is project id specified in uri that represents an invalid form and it matches current project id then start form filling`() { + val project = Project.Saved("123", "First project", "A", "#cccccc") + projectsRepository.save(project) + whenever(currentProjectProvider.getCurrentProject()).thenReturn(project) + + formsRepository.save( + FormUtils.buildForm("1", "1", TempFiles.createTempDir().absolutePath).build() + ) + + val instance = instancesRepository.save( + Instance.Builder() + .formId("1") + .formVersion("1") + .instanceFilePath(TempFiles.createTempFile(TempFiles.createTempDir()).absolutePath) + .status(Instance.STATUS_INVALID) + .build() + ) + + launcherRule.launch(getSavedIntent(project.uuid, instance.dbId)) + + assertStartSavedFormIntent(project.uuid, instance.dbId, true) + } + @Test fun `When there is no project id specified in uri that represents a blank form and first available project id matches current project id then start form filling`() { val project = Project.Saved("123", "First project", "A", "#cccccc") From 6721f7ab6e26112a5aae0c2374d7dd1125879763 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Mon, 11 Sep 2023 19:17:35 +0100 Subject: [PATCH 20/46] Don't revalidate invalid forms --- .../formmanagement/BulkFinalizationTest.kt | 19 +++++++++ .../activities/InstanceChooserList.java | 5 +++ .../android/formentry/FormEntryUseCases.kt | 42 ++++++++++--------- .../formmanagement/InstancesDataService.kt | 3 +- .../formentry/FormEntryUseCasesTest.kt | 24 +++++++++++ 5 files changed, 73 insertions(+), 20 deletions(-) diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt index 0de6526afd9..82778d728b6 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt @@ -64,6 +64,25 @@ class BulkFinalizationTest { .assertNumberOfFinalizedForms(1) } + @Test + fun whenADraftPreviouslyHadConstraintViolations_marksFormsAsHavingErrors() { + rule.startAtMainMenu() + .copyForm("two-question-required.xml") + .startBlankForm("Two Question Required") + .fillOut(QuestionAndAnswer("What is your name?", "Dan")) + .pressBack(SaveOrDiscardFormDialog(MainMenuPage())) + .clickSaveChanges() + + .clickEditSavedForm(1) + .clickOptionsIcon("Finalize all forms") + .clickOnText("Finalize all forms") + .checkIsSnackbarWithMessageDisplayed("1 forms have errors. Address issues before finalizing all forms.") + + .clickOptionsIcon("Finalize all forms") + .clickOnText("Finalize all forms") + .checkIsSnackbarWithMessageDisplayed("1 forms have errors. Address issues before finalizing all forms.") + } + @Test fun doesNotFinalizeOtherTypesOfInstance() { rule.startAtMainMenu() diff --git a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java index 763da60e03b..95276ce717d 100644 --- a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java +++ b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java @@ -168,6 +168,11 @@ public void onCreate(Bundle savedInstanceState) { this.findViewById(android.R.id.content), "Success! " + pair.getFirst() + " forms finalized." ); + } else if (pair.getFirst().equals(pair.getSecond())) { + SnackbarUtils.showLongSnackbar( + this.findViewById(android.R.id.content), + pair.getSecond() + " forms have errors. Address issues before finalizing all forms." + ); } else { SnackbarUtils.showLongSnackbar( this.findViewById(android.R.id.content), diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt index 2ebcdf8f5a3..dc9b7776243 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt @@ -53,35 +53,39 @@ object FormEntryUseCases { instancesRepository: InstancesRepository, entitiesRepository: EntitiesRepository ): Instance? { - val valid = finalizeInstance(formController, entitiesRepository) - - return if (valid) { - saveFormToDisk(formController) - markInstanceAsComplete(formController, instancesRepository) + val instance = + getInstanceFromFormController(formController, instancesRepository) + + return if (instance!!.status != Instance.STATUS_INVALID) { + val valid = finalizeInstance(formController, entitiesRepository) + + if (valid) { + saveFormToDisk(formController) + updateInstanceStatus(instancesRepository, instance, Instance.STATUS_COMPLETE) + } else { + updateInstanceStatus(instancesRepository, instance, Instance.STATUS_INVALID) + null + } } else { - markInstanceAsInvalid(formController, instancesRepository) null } } - private fun markInstanceAsInvalid(formController: FormController, instancesRepository: InstancesRepository) { - val instancePath = formController.getInstanceFile()!!.absolutePath - val instance = instancesRepository.getOneByPath(instancePath) - - instancesRepository.save( - Instance.Builder(instance).also { it.status(Instance.STATUS_INVALID) }.build() - ) - } - - private fun markInstanceAsComplete( + private fun getInstanceFromFormController( formController: FormController, instancesRepository: InstancesRepository - ): Instance { + ): Instance? { val instancePath = formController.getInstanceFile()!!.absolutePath - val instance = instancesRepository.getOneByPath(instancePath) + return instancesRepository.getOneByPath(instancePath) + } + private fun updateInstanceStatus( + instancesRepository: InstancesRepository, + instance: Instance, + status: String + ): Instance { return instancesRepository.save( - Instance.Builder(instance).also { it.status(Instance.STATUS_COMPLETE) }.build() + Instance.Builder(instance).also { it.status(status) }.build() ) } diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt index 0f639214031..ff856b880db 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt @@ -49,7 +49,8 @@ class InstancesDataService( val formsRepository = formsRepositoryProvider.get() val entitiesRepository = entitiesRepositoryProvider.get() - val instances = instancesRepository.getAllByStatus(Instance.STATUS_INCOMPLETE) + val instances = + instancesRepository.getAllByStatus(Instance.STATUS_INCOMPLETE, Instance.STATUS_INVALID) val totalFailed = instances.fold(0) { failCount, instance -> val form = formsRepository.getAllByFormId(instance.formId)[0] diff --git a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt index 1ed4366ede1..e9ffe46f51c 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt @@ -3,8 +3,11 @@ package org.odk.collect.android.formentry import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.junit.Test +import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.odk.collect.android.entities.InMemEntitiesRepository import org.odk.collect.android.javarosawrapper.FailedValidationResult import org.odk.collect.android.javarosawrapper.FormController @@ -36,4 +39,25 @@ class FormEntryUseCasesTest { equalTo(Instance.STATUS_INVALID) ) } + + @Test + fun finalizeDraft_whenInsteadIsAlreadyInvalid_doesNotValidateAgain() { + val instancesRepository = InMemInstancesRepository() + val instance = + instancesRepository.save(InstanceFixtures.instance(status = Instance.STATUS_INVALID)) + + val formController = mock { + on { validateAnswers(true) } doReturn FailedValidationResult(mock(), 0) + on { getInstanceFile() } doReturn File(instance.instanceFilePath) + } + + val result = FormEntryUseCases.finalizeDraft( + formController, + instancesRepository, + InMemEntitiesRepository() + ) + + assertThat(result, equalTo(null)) + verify(formController, never()).validateAnswers(any()) + } } From 9d45a71f799f13d30780475a51e6f8f62bbcc0b1 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Tue, 12 Sep 2023 16:50:55 +0100 Subject: [PATCH 21/46] Add test for partial submissions --- .../PartialSubmissionTet.kt | 56 +++++++++++++++++++ .../android/support/StubOpenRosaServer.java | 10 ++++ .../resources/forms/one-question-partial.xml | 20 +++++++ 3 files changed, 86 insertions(+) create mode 100644 collect_app/src/androidTest/java/org/odk/collect/android/feature/instancemanagement/PartialSubmissionTet.kt create mode 100644 test-forms/src/main/resources/forms/one-question-partial.xml diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/instancemanagement/PartialSubmissionTet.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/instancemanagement/PartialSubmissionTet.kt new file mode 100644 index 00000000000..ac1971eeeb8 --- /dev/null +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/instancemanagement/PartialSubmissionTet.kt @@ -0,0 +1,56 @@ +package org.odk.collect.android.feature.instancemanagement + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.kxml2.io.KXmlParser +import org.kxml2.kdom.Document +import org.odk.collect.android.support.TestDependencies +import org.odk.collect.android.support.pages.FormEntryPage +import org.odk.collect.android.support.rules.CollectTestRule +import org.odk.collect.android.support.rules.TestRuleChain +import java.io.File +import java.io.StringReader + +@RunWith(AndroidJUnit4::class) +class PartialSubmissionTet { + + private val testDependencies = TestDependencies() + private val rule = CollectTestRule(useDemoProject = false) + + @get:Rule + val chain: RuleChain = TestRuleChain.chain(testDependencies) + .around(rule) + + @Test + fun canFillAndSubmitAFormWithPartialSubmission() { + rule.withProject(testDependencies.server.url) + .copyForm("one-question-partial.xml", testDependencies.server.hostName) + .startBlankForm("One Question") + .fillOutAndFinalize(FormEntryPage.QuestionAndAnswer("what is your age", "123")) + + .clickSendFinalizedForm(1) + .clickSelectAll() + .clickSendSelected() + + val submissions = testDependencies.server.submissions + assertThat(submissions.size, equalTo(1)) + + val root = parseXml(submissions[0]).rootElement + assertThat(root.name, equalTo("age")) + assertThat(root.childCount, equalTo(1)) + assertThat(root.getChild(0), equalTo("123")) + } + + private fun parseXml(file: File): Document { + return StringReader(String(file.readBytes())).use { reader -> + val parser = KXmlParser() + parser.setInput(reader) + Document().also { it.parse(parser) } + } + } +} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/StubOpenRosaServer.java b/collect_app/src/androidTest/java/org/odk/collect/android/support/StubOpenRosaServer.java index 9294de82d69..3ecaa086b05 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/StubOpenRosaServer.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/StubOpenRosaServer.java @@ -16,6 +16,8 @@ import org.odk.collect.android.openrosa.HttpPostResult; import org.odk.collect.android.openrosa.OpenRosaConstants; import org.odk.collect.android.openrosa.OpenRosaHttpInterface; +import org.odk.collect.android.utilities.FileUtils; +import org.odk.collect.shared.TempFiles; import org.odk.collect.shared.strings.Md5; import org.odk.collect.shared.strings.RandomString; @@ -44,6 +46,8 @@ public class StubOpenRosaServer implements OpenRosaHttpInterface { private boolean noHashPrefixInMediaFiles; private boolean randomHash; + private final File submittedFormsDir = TempFiles.createTempDir(); + @NonNull @Override public HttpGetResult executeGetRequest(@NonNull URI uri, @Nullable String contentType, @Nullable HttpCredentialsInterface credentials) throws Exception { @@ -111,6 +115,8 @@ public HttpPostResult uploadSubmissionAndFiles(@NonNull File submissionFile, @No } else if (credentialsIncorrect(credentials)) { return new HttpPostResult("", 401, ""); } else if (uri.getPath().equals(OpenRosaConstants.SUBMISSION)) { + File destFile = new File(submittedFormsDir, String.valueOf(submittedFormsDir.listFiles().length)); + FileUtils.copyFile(submissionFile, destFile); return new HttpPostResult("", 201, ""); } else { return new HttpPostResult("", 404, ""); @@ -162,6 +168,10 @@ public String getHostName() { return HOST; } + public List getSubmissions() { + return asList(submittedFormsDir.listFiles()); + } + private boolean credentialsIncorrect(HttpCredentialsInterface credentials) { if (username == null && password == null) { return false; diff --git a/test-forms/src/main/resources/forms/one-question-partial.xml b/test-forms/src/main/resources/forms/one-question-partial.xml new file mode 100644 index 00000000000..82154337793 --- /dev/null +++ b/test-forms/src/main/resources/forms/one-question-partial.xml @@ -0,0 +1,20 @@ + + + + One Question + + + + + + + + + + + + + + + + From 24a9b26a3b91b27ec6e59308705c638766d8a24f Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Wed, 13 Sep 2023 13:03:21 +0100 Subject: [PATCH 22/46] Use real FormController to test FormEntryUseCases --- collect_app/build.gradle | 1 + .../android/formentry/FormEntryUseCases.kt | 24 +++--- .../JavaRosaFormController.java | 6 +- .../formentry/FormEntryUseCasesTest.kt | 81 ++++++++++++------- .../java/org/odk/collect/shared/TempFiles.kt | 9 +++ 5 files changed, 77 insertions(+), 44 deletions(-) diff --git a/collect_app/build.gradle b/collect_app/build.gradle index 6fd9461cbba..e98107b6cbf 100644 --- a/collect_app/build.gradle +++ b/collect_app/build.gradle @@ -377,6 +377,7 @@ dependencies { exclude group: 'org.robolectric' // Some tests in `collect_app` don't work with newer Robolectric } testImplementation(project(":shadows")) + testImplementation(project(":test-forms")) testImplementation Dependencies.robolectric diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt index dc9b7776243..e9d5bbf59eb 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt @@ -54,19 +54,15 @@ object FormEntryUseCases { entitiesRepository: EntitiesRepository ): Instance? { val instance = - getInstanceFromFormController(formController, instancesRepository) - - return if (instance!!.status != Instance.STATUS_INVALID) { - val valid = finalizeInstance(formController, entitiesRepository) - - if (valid) { - saveFormToDisk(formController) - updateInstanceStatus(instancesRepository, instance, Instance.STATUS_COMPLETE) - } else { - updateInstanceStatus(instancesRepository, instance, Instance.STATUS_INVALID) - null - } + getInstanceFromFormController(formController, instancesRepository)!! + + val valid = finalizeInstance(formController, entitiesRepository) + + return if (valid) { + saveFormToDisk(formController) + updateInstanceStatus(instancesRepository, instance, Instance.STATUS_COMPLETE) } else { + updateInstanceStatus(instancesRepository, instance, Instance.STATUS_INVALID) null } } @@ -79,7 +75,7 @@ object FormEntryUseCases { return instancesRepository.getOneByPath(instancePath) } - private fun updateInstanceStatus( + fun updateInstanceStatus( instancesRepository: InstancesRepository, instance: Instance, status: String @@ -89,7 +85,7 @@ object FormEntryUseCases { ) } - private fun saveFormToDisk(formController: FormController) { + fun saveFormToDisk(formController: FormController) { val payload = formController.getFilledInFormXml() FileUtils.write(formController.getInstanceFile(), payload!!.payloadBytes) } diff --git a/collect_app/src/main/java/org/odk/collect/android/javarosawrapper/JavaRosaFormController.java b/collect_app/src/main/java/org/odk/collect/android/javarosawrapper/JavaRosaFormController.java index 7160288b76f..ca1a68730ef 100644 --- a/collect_app/src/main/java/org/odk/collect/android/javarosawrapper/JavaRosaFormController.java +++ b/collect_app/src/main/java/org/odk/collect/android/javarosawrapper/JavaRosaFormController.java @@ -1087,6 +1087,10 @@ public IAnswerData getAnswer(TreeReference treeReference) { public Stream getEntities() { Entities extra = formEntryController.getModel().getExtras().get(Entities.class); - return extra.getEntities().stream().map(entity -> new Entity(entity.dataset, entity.properties)); + if (extra != null) { + return extra.getEntities().stream().map(entity -> new Entity(entity.dataset, entity.properties)); + } else { + return Stream.empty(); + } } } diff --git a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt index e9ffe46f51c..da4f934ed72 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt @@ -2,34 +2,41 @@ package org.odk.collect.android.formentry import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo +import org.javarosa.core.model.FormDef +import org.javarosa.core.model.instance.InstanceInitializationFactory +import org.javarosa.form.api.FormEntryController +import org.javarosa.form.api.FormEntryModel +import org.javarosa.xform.util.XFormUtils import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify import org.odk.collect.android.entities.InMemEntitiesRepository -import org.odk.collect.android.javarosawrapper.FailedValidationResult -import org.odk.collect.android.javarosawrapper.FormController +import org.odk.collect.android.javarosawrapper.JavaRosaFormController +import org.odk.collect.android.utilities.FileUtils import org.odk.collect.forms.instances.Instance import org.odk.collect.formstest.InMemInstancesRepository -import org.odk.collect.formstest.InstanceFixtures +import org.odk.collect.shared.TempFiles import java.io.File class FormEntryUseCasesTest { @Test fun finalizeDraft_whenValidationFails_marksInstanceAsHavingErrors() { + val formMediaDir = TempFiles.createTempDir() + val instanceFile = TempFiles.createTempFile("instance", ".xml") val instancesRepository = InMemInstancesRepository() - val instance = instancesRepository.save(InstanceFixtures.instance()) - val formController = mock { - on { validateAnswers(true) } doReturn FailedValidationResult(mock(), 0) - on { getInstanceFile() } doReturn File(instance.instanceFilePath) - } + val xForm = copyTestForm("forms/two-question-required.xml") + val formDef = XFormUtils.getFormFromFormXml(xForm.absolutePath, null) + val newFormController = loadBlankForm(formDef, formMediaDir, instanceFile) + val instance = saveNewDraft(newFormController, instancesRepository, instanceFile) + + val draftController = FormEntryUseCases.loadDraft( + FormEntryController(FormEntryModel(formDef)), + formMediaDir, + instanceFile + ) FormEntryUseCases.finalizeDraft( - formController, + draftController, instancesRepository, InMemEntitiesRepository() ) @@ -40,24 +47,40 @@ class FormEntryUseCasesTest { ) } - @Test - fun finalizeDraft_whenInsteadIsAlreadyInvalid_doesNotValidateAgain() { - val instancesRepository = InMemInstancesRepository() - val instance = - instancesRepository.save(InstanceFixtures.instance(status = Instance.STATUS_INVALID)) + private fun saveNewDraft( + newFormController: JavaRosaFormController, + instancesRepository: InMemInstancesRepository, + instanceFile: File + ): Instance { + FormEntryUseCases.saveFormToDisk(newFormController) + return instancesRepository.save( + Instance.Builder() + .instanceFilePath(instanceFile.absolutePath) + .status(Instance.STATUS_INCOMPLETE) + .build() + ) + } - val formController = mock { - on { validateAnswers(true) } doReturn FailedValidationResult(mock(), 0) - on { getInstanceFile() } doReturn File(instance.instanceFilePath) - } + private fun loadBlankForm( + formDef: FormDef?, + formMediaDir: File, + instanceFile: File + ): JavaRosaFormController { + val instanceInit = InstanceInitializationFactory() + val formEntryController = FormEntryController(FormEntryModel(formDef)) + formEntryController.model.form.initialize(true, instanceInit) - val result = FormEntryUseCases.finalizeDraft( - formController, - instancesRepository, - InMemEntitiesRepository() + val newFormController = JavaRosaFormController( + formMediaDir, + formEntryController, + instanceFile ) + return newFormController + } - assertThat(result, equalTo(null)) - verify(formController, never()).validateAnswers(any()) + private fun copyTestForm(testForm: String): File { + return TempFiles.createTempFile().also { + FileUtils.copyFileFromResources(testForm, it.absolutePath) + } } } diff --git a/shared/src/main/java/org/odk/collect/shared/TempFiles.kt b/shared/src/main/java/org/odk/collect/shared/TempFiles.kt index 494f46b1e18..35ec590eb49 100644 --- a/shared/src/main/java/org/odk/collect/shared/TempFiles.kt +++ b/shared/src/main/java/org/odk/collect/shared/TempFiles.kt @@ -5,6 +5,15 @@ import java.io.File object TempFiles { + @JvmStatic + fun createTempFile(): File { + val tmpDir = getTempDir() + return File(tmpDir, getRandomName(tmpDir)).also { + it.createNewFile() + it.deleteOnExit() + } + } + @JvmStatic fun createTempFile(name: String, extension: String): File { val tmpDir = getTempDir() From 03579b95a203921ca3759820922085678ce80aea Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Wed, 13 Sep 2023 13:10:30 +0100 Subject: [PATCH 23/46] Add operations that could be used in real code to FormEntryUseCases --- .../android/formentry/FormEntryUseCases.kt | 33 ++++++++++++++- .../formentry/FormEntryUseCasesTest.kt | 40 ++++--------------- 2 files changed, 38 insertions(+), 35 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt index e9d5bbf59eb..ebeab969ab3 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt @@ -29,6 +29,21 @@ object FormEntryUseCases { return createFormDefFromCacheOrXml(xForm) } + fun loadBlankForm( + formEntryController: FormEntryController, + formMediaDir: File, + instanceFile: File + ): JavaRosaFormController { + val instanceInit = InstanceInitializationFactory() + formEntryController.model.form.initialize(true, instanceInit) + + return JavaRosaFormController( + formMediaDir, + formEntryController, + instanceFile + ) + } + @JvmStatic fun loadDraft( formEntryController: FormEntryController, @@ -47,6 +62,20 @@ object FormEntryUseCases { ) } + fun saveDraft( + formController: JavaRosaFormController, + instancesRepository: InstancesRepository, + instanceFile: File + ): Instance { + saveFormToDisk(formController) + return instancesRepository.save( + Instance.Builder() + .instanceFilePath(instanceFile.absolutePath) + .status(Instance.STATUS_INCOMPLETE) + .build() + ) + } + @JvmStatic fun finalizeDraft( formController: FormController, @@ -75,7 +104,7 @@ object FormEntryUseCases { return instancesRepository.getOneByPath(instancePath) } - fun updateInstanceStatus( + private fun updateInstanceStatus( instancesRepository: InstancesRepository, instance: Instance, status: String @@ -85,7 +114,7 @@ object FormEntryUseCases { ) } - fun saveFormToDisk(formController: FormController) { + private fun saveFormToDisk(formController: FormController) { val payload = formController.getFilledInFormXml() FileUtils.write(formController.getInstanceFile(), payload!!.payloadBytes) } diff --git a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt index da4f934ed72..b7516bc884d 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt @@ -26,8 +26,13 @@ class FormEntryUseCasesTest { val xForm = copyTestForm("forms/two-question-required.xml") val formDef = XFormUtils.getFormFromFormXml(xForm.absolutePath, null) - val newFormController = loadBlankForm(formDef, formMediaDir, instanceFile) - val instance = saveNewDraft(newFormController, instancesRepository, instanceFile) + val newFormController = FormEntryUseCases.loadBlankForm( + FormEntryController(FormEntryModel(formDef)), + formMediaDir, + instanceFile + ) + val instance = + FormEntryUseCases.saveDraft(newFormController, instancesRepository, instanceFile) val draftController = FormEntryUseCases.loadDraft( FormEntryController(FormEntryModel(formDef)), @@ -47,37 +52,6 @@ class FormEntryUseCasesTest { ) } - private fun saveNewDraft( - newFormController: JavaRosaFormController, - instancesRepository: InMemInstancesRepository, - instanceFile: File - ): Instance { - FormEntryUseCases.saveFormToDisk(newFormController) - return instancesRepository.save( - Instance.Builder() - .instanceFilePath(instanceFile.absolutePath) - .status(Instance.STATUS_INCOMPLETE) - .build() - ) - } - - private fun loadBlankForm( - formDef: FormDef?, - formMediaDir: File, - instanceFile: File - ): JavaRosaFormController { - val instanceInit = InstanceInitializationFactory() - val formEntryController = FormEntryController(FormEntryModel(formDef)) - formEntryController.model.form.initialize(true, instanceInit) - - val newFormController = JavaRosaFormController( - formMediaDir, - formEntryController, - instanceFile - ) - return newFormController - } - private fun copyTestForm(testForm: String): File { return TempFiles.createTempFile().also { FileUtils.copyFileFromResources(testForm, it.absolutePath) From dd5bf1cae4f4de9101f648da25fbf7178daa003e Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Wed, 13 Sep 2023 13:37:30 +0100 Subject: [PATCH 24/46] Handle partial forms in finalizeDraft --- .../android/formentry/FormEntryUseCases.kt | 26 +++--- .../formentry/FormEntryUseCasesTest.kt | 87 ++++++++++++++++--- 2 files changed, 86 insertions(+), 27 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt index ebeab969ab3..c0e01c345cb 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt @@ -89,9 +89,19 @@ object FormEntryUseCases { return if (valid) { saveFormToDisk(formController) - updateInstanceStatus(instancesRepository, instance, Instance.STATUS_COMPLETE) + instancesRepository.save( + Instance.Builder(instance) + .status(Instance.STATUS_COMPLETE) + .canEditWhenComplete(formController.isSubmissionEntireForm()) + .build() + ) } else { - updateInstanceStatus(instancesRepository, instance, Instance.STATUS_INVALID) + instancesRepository.save( + Instance.Builder(instance) + .status(Instance.STATUS_INVALID) + .build() + ) + null } } @@ -104,18 +114,8 @@ object FormEntryUseCases { return instancesRepository.getOneByPath(instancePath) } - private fun updateInstanceStatus( - instancesRepository: InstancesRepository, - instance: Instance, - status: String - ): Instance { - return instancesRepository.save( - Instance.Builder(instance).also { it.status(status) }.build() - ) - } - private fun saveFormToDisk(formController: FormController) { - val payload = formController.getFilledInFormXml() + val payload = formController.getSubmissionXml() FileUtils.write(formController.getInstanceFile(), payload!!.payloadBytes) } diff --git a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt index b7516bc884d..1c9cd636f6a 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt @@ -3,41 +3,44 @@ package org.odk.collect.android.formentry import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.javarosa.core.model.FormDef -import org.javarosa.core.model.instance.InstanceInitializationFactory +import org.javarosa.core.model.data.IntegerData import org.javarosa.form.api.FormEntryController import org.javarosa.form.api.FormEntryModel +import org.javarosa.model.xform.XFormsModule import org.javarosa.xform.util.XFormUtils +import org.junit.Before import org.junit.Test +import org.kxml2.io.KXmlParser +import org.kxml2.kdom.Document import org.odk.collect.android.entities.InMemEntitiesRepository -import org.odk.collect.android.javarosawrapper.JavaRosaFormController +import org.odk.collect.android.javarosawrapper.FormController import org.odk.collect.android.utilities.FileUtils import org.odk.collect.forms.instances.Instance import org.odk.collect.formstest.InMemInstancesRepository import org.odk.collect.shared.TempFiles import java.io.File +import java.io.StringReader class FormEntryUseCasesTest { + private val instancesRepository = InMemInstancesRepository() + private val formMediaDir = TempFiles.createTempDir() + + @Before + fun setup() { + XFormsModule().registerModule() + } + @Test fun finalizeDraft_whenValidationFails_marksInstanceAsHavingErrors() { - val formMediaDir = TempFiles.createTempDir() - val instanceFile = TempFiles.createTempFile("instance", ".xml") - val instancesRepository = InMemInstancesRepository() - val xForm = copyTestForm("forms/two-question-required.xml") val formDef = XFormUtils.getFormFromFormXml(xForm.absolutePath, null) - val newFormController = FormEntryUseCases.loadBlankForm( - FormEntryController(FormEntryModel(formDef)), - formMediaDir, - instanceFile - ) - val instance = - FormEntryUseCases.saveDraft(newFormController, instancesRepository, instanceFile) + val instance = createNewInstance(formDef, formMediaDir, instancesRepository) val draftController = FormEntryUseCases.loadDraft( FormEntryController(FormEntryModel(formDef)), formMediaDir, - instanceFile + File(instance.instanceFilePath) ) FormEntryUseCases.finalizeDraft( @@ -52,9 +55,65 @@ class FormEntryUseCasesTest { ) } + @Test + fun finalizeDraft_canCreatePartialSubmissions() { + val xForm = copyTestForm("forms/one-question-partial.xml") + val formDef = XFormUtils.getFormFromFormXml(xForm.absolutePath, null) + val instance = createNewInstance(formDef, formMediaDir, instancesRepository) { + it.stepToNextScreenEvent() + it.answerQuestion(it.getFormIndex(), IntegerData(64)) + } + + val draftController = FormEntryUseCases.loadDraft( + FormEntryController(FormEntryModel(formDef)), + formMediaDir, + File(instance.instanceFilePath) + ) + + FormEntryUseCases.finalizeDraft( + draftController, + instancesRepository, + InMemEntitiesRepository() + ) + + val updatedInstance = instancesRepository.get(instance.dbId)!! + assertThat(updatedInstance.canEditWhenComplete(), equalTo(false)) + + val root = parseXml(File(updatedInstance.instanceFilePath)).rootElement + assertThat(root.name, equalTo("age")) + assertThat(root.childCount, equalTo(1)) + assertThat(root.getChild(0), equalTo("64")) + } + + private fun createNewInstance( + formDef: FormDef, + formMediaDir: File, + instancesRepository: InMemInstancesRepository, + fillIn: (FormController) -> Any = {} + ): Instance { + val instanceFile = TempFiles.createTempFile("instance", ".xml") + + val formController = FormEntryUseCases.loadBlankForm( + FormEntryController(FormEntryModel(formDef)), + formMediaDir, + instanceFile + ) + + fillIn(formController) + return FormEntryUseCases.saveDraft(formController, instancesRepository, instanceFile) + } + private fun copyTestForm(testForm: String): File { return TempFiles.createTempFile().also { FileUtils.copyFileFromResources(testForm, it.absolutePath) } } + + private fun parseXml(file: File): Document { + return StringReader(String(file.readBytes())).use { reader -> + val parser = KXmlParser() + parser.setInput(reader) + Document().also { it.parse(parser) } + } + } } From ee2af3612af8e157cc289add1147acb9e5acef09 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Wed, 13 Sep 2023 13:53:05 +0100 Subject: [PATCH 25/46] Rename method --- .../odk/collect/android/formentry/FormEntryUseCasesTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt index 1c9cd636f6a..ba3ceb242a4 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt @@ -35,7 +35,7 @@ class FormEntryUseCasesTest { fun finalizeDraft_whenValidationFails_marksInstanceAsHavingErrors() { val xForm = copyTestForm("forms/two-question-required.xml") val formDef = XFormUtils.getFormFromFormXml(xForm.absolutePath, null) - val instance = createNewInstance(formDef, formMediaDir, instancesRepository) + val instance = createDraft(formDef, formMediaDir, instancesRepository) val draftController = FormEntryUseCases.loadDraft( FormEntryController(FormEntryModel(formDef)), @@ -59,7 +59,7 @@ class FormEntryUseCasesTest { fun finalizeDraft_canCreatePartialSubmissions() { val xForm = copyTestForm("forms/one-question-partial.xml") val formDef = XFormUtils.getFormFromFormXml(xForm.absolutePath, null) - val instance = createNewInstance(formDef, formMediaDir, instancesRepository) { + val instance = createDraft(formDef, formMediaDir, instancesRepository) { it.stepToNextScreenEvent() it.answerQuestion(it.getFormIndex(), IntegerData(64)) } @@ -85,7 +85,7 @@ class FormEntryUseCasesTest { assertThat(root.getChild(0), equalTo("64")) } - private fun createNewInstance( + private fun createDraft( formDef: FormDef, formMediaDir: File, instancesRepository: InMemInstancesRepository, From 459b1f5b3424dabb63897602febeb8d7d3b40178 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Wed, 13 Sep 2023 13:54:40 +0100 Subject: [PATCH 26/46] Media dir shouldn't be shared --- .../org/odk/collect/android/formentry/FormEntryUseCasesTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt index ba3ceb242a4..70144d219d3 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt @@ -24,7 +24,6 @@ import java.io.StringReader class FormEntryUseCasesTest { private val instancesRepository = InMemInstancesRepository() - private val formMediaDir = TempFiles.createTempDir() @Before fun setup() { @@ -33,6 +32,7 @@ class FormEntryUseCasesTest { @Test fun finalizeDraft_whenValidationFails_marksInstanceAsHavingErrors() { + val formMediaDir = TempFiles.createTempDir() val xForm = copyTestForm("forms/two-question-required.xml") val formDef = XFormUtils.getFormFromFormXml(xForm.absolutePath, null) val instance = createDraft(formDef, formMediaDir, instancesRepository) @@ -57,6 +57,7 @@ class FormEntryUseCasesTest { @Test fun finalizeDraft_canCreatePartialSubmissions() { + val formMediaDir = TempFiles.createTempDir() val xForm = copyTestForm("forms/one-question-partial.xml") val formDef = XFormUtils.getFormFromFormXml(xForm.absolutePath, null) val instance = createDraft(formDef, formMediaDir, instancesRepository) { From 89f217f9b03c644a9622d9a0141b858cb9ea1d18 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Wed, 13 Sep 2023 14:05:00 +0100 Subject: [PATCH 27/46] Allow reference manager to be setup without a StoragePathProvider --- .../collect/android/formentry/FormEntryUseCases.kt | 4 ++-- .../android/formmanagement/InstancesDataService.kt | 5 ++++- .../injection/config/AppDependencyModule.java | 4 ++-- .../org/odk/collect/android/utilities/FormUtils.java | 12 ++++++++++-- .../android/formentry/FormEntryUseCasesTest.kt | 12 ++++++++++-- 5 files changed, 28 insertions(+), 9 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt index c0e01c345cb..1a7f5ab2c85 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt @@ -24,8 +24,8 @@ import java.io.File object FormEntryUseCases { @JvmStatic - fun loadFormDef(xForm: File, formMediaDir: File): FormDef? { - FormUtils.setupReferenceManagerForForm(ReferenceManager.instance(), formMediaDir) + fun loadFormDef(xForm: File, projectRootDir: File, formMediaDir: File): FormDef? { + FormUtils.setupReferenceManagerForForm(ReferenceManager.instance(), projectRootDir, formMediaDir) return createFormDefFromCacheOrXml(xForm) } diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt index ff856b880db..21ee078ec9e 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt @@ -3,6 +3,7 @@ package org.odk.collect.android.formmanagement import androidx.lifecycle.LiveData import org.odk.collect.android.entities.EntitiesRepositoryProvider import org.odk.collect.android.formentry.FormEntryUseCases +import org.odk.collect.android.storage.StoragePathProvider import org.odk.collect.android.utilities.FileUtils import org.odk.collect.android.utilities.FormsRepositoryProvider import org.odk.collect.android.utilities.InstancesRepositoryProvider @@ -15,6 +16,7 @@ class InstancesDataService( private val formsRepositoryProvider: FormsRepositoryProvider, private val instancesRepositoryProvider: InstancesRepositoryProvider, private val entitiesRepositoryProvider: EntitiesRepositoryProvider, + private val storagePathProvider: StoragePathProvider, private val onUpdate: () -> Unit ) { val editableCount: LiveData = appState.getLive(EDITABLE_COUNT_KEY, 0) @@ -48,6 +50,7 @@ class InstancesDataService( val instancesRepository = instancesRepositoryProvider.get() val formsRepository = formsRepositoryProvider.get() val entitiesRepository = entitiesRepositoryProvider.get() + val projectRootDir = File(storagePathProvider.getProjectRootDirPath()) val instances = instancesRepository.getAllByStatus(Instance.STATUS_INCOMPLETE, Instance.STATUS_INVALID) @@ -56,7 +59,7 @@ class InstancesDataService( val form = formsRepository.getAllByFormId(instance.formId)[0] val xForm = File(form.formFilePath) val formMediaDir = FileUtils.getFormMediaDir(xForm) - val formDef = FormEntryUseCases.loadFormDef(xForm, formMediaDir)!! + val formDef = FormEntryUseCases.loadFormDef(xForm, projectRootDir, formMediaDir)!! val formEntryController = CollectFormEntryControllerFactory().create(formDef) val instanceFile = File(instance.instanceFilePath) diff --git a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java index dcdd8c3802a..e2048698b43 100644 --- a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java +++ b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java @@ -459,7 +459,7 @@ public UUIDGenerator providesUUIDGenerator() { } @Provides - public InstancesDataService providesInstancesDataService(Application application, InstancesRepositoryProvider instancesRepositoryProvider, CurrentProjectProvider currentProjectProvider, FormsRepositoryProvider formsRepositoryProvider, EntitiesRepositoryProvider entitiesRepositoryProvider) { + public InstancesDataService providesInstancesDataService(Application application, InstancesRepositoryProvider instancesRepositoryProvider, CurrentProjectProvider currentProjectProvider, FormsRepositoryProvider formsRepositoryProvider, EntitiesRepositoryProvider entitiesRepositoryProvider, StoragePathProvider storagePathProvider) { Function0 onUpdate = () -> { application.getContentResolver().notifyChange( InstancesContract.getUri(currentProjectProvider.getCurrentProject().getUuid()), @@ -469,7 +469,7 @@ public InstancesDataService providesInstancesDataService(Application application return null; }; - return new InstancesDataService(getState(application), formsRepositoryProvider, instancesRepositoryProvider, entitiesRepositoryProvider, onUpdate); + return new InstancesDataService(getState(application), formsRepositoryProvider, instancesRepositoryProvider, entitiesRepositoryProvider, storagePathProvider, onUpdate); } @Provides diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/FormUtils.java b/collect_app/src/main/java/org/odk/collect/android/utilities/FormUtils.java index 3afc5fccf14..8525d48c824 100644 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/FormUtils.java +++ b/collect_app/src/main/java/org/odk/collect/android/utilities/FormUtils.java @@ -26,6 +26,14 @@ public static List getMediaFiles(@NonNull Form form) { : FileUtils.listFiles(new File(formMediaPath)); } + /** + * @deprecated Use {@link FormUtils#setupReferenceManagerForForm(ReferenceManager, File, File)} instead + */ + @Deprecated + public static void setupReferenceManagerForForm(ReferenceManager referenceManager, File formMediaDir) { + setupReferenceManagerForForm(referenceManager, new File(new StoragePathProvider().getProjectRootDirPath()), formMediaDir); + } + /** * Configures the given reference manager to resolve jr:// URIs to a folder in the root ODK forms * directory with name matching the name of the directory represented by {@code formMediaDir}. @@ -33,11 +41,11 @@ public static List getMediaFiles(@NonNull Form form) { * E.g. if /foo/bar/baz is passed in as {@code formMediaDir}, jr:// URIs will be resolved to * projectRoot/forms/baz. */ - public static void setupReferenceManagerForForm(ReferenceManager referenceManager, File formMediaDir) { + public static void setupReferenceManagerForForm(ReferenceManager referenceManager, File projectRootDir, File formMediaDir) { referenceManager.reset(); // Always build URIs against the project root, regardless of the absolute path of formMediaDir - referenceManager.addReferenceFactory(new FileReferenceFactory(new StoragePathProvider().getProjectRootDirPath())); + referenceManager.addReferenceFactory(new FileReferenceFactory(projectRootDir.getAbsolutePath())); addSessionRootTranslators(referenceManager, buildSessionRootTranslators(formMediaDir.getName(), enumerateHostStrings())); diff --git a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt index 70144d219d3..8d67470f797 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt @@ -4,6 +4,7 @@ import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.javarosa.core.model.FormDef import org.javarosa.core.model.data.IntegerData +import org.javarosa.core.reference.ReferenceManager import org.javarosa.form.api.FormEntryController import org.javarosa.form.api.FormEntryModel import org.javarosa.model.xform.XFormsModule @@ -15,6 +16,7 @@ import org.kxml2.kdom.Document import org.odk.collect.android.entities.InMemEntitiesRepository import org.odk.collect.android.javarosawrapper.FormController import org.odk.collect.android.utilities.FileUtils +import org.odk.collect.android.utilities.FormUtils import org.odk.collect.forms.instances.Instance import org.odk.collect.formstest.InMemInstancesRepository import org.odk.collect.shared.TempFiles @@ -23,6 +25,7 @@ import java.io.StringReader class FormEntryUseCasesTest { + private val projectRootDir = TempFiles.createTempDir() private val instancesRepository = InMemInstancesRepository() @Before @@ -34,7 +37,7 @@ class FormEntryUseCasesTest { fun finalizeDraft_whenValidationFails_marksInstanceAsHavingErrors() { val formMediaDir = TempFiles.createTempDir() val xForm = copyTestForm("forms/two-question-required.xml") - val formDef = XFormUtils.getFormFromFormXml(xForm.absolutePath, null) + val formDef = parseForm(xForm, projectRootDir, formMediaDir) val instance = createDraft(formDef, formMediaDir, instancesRepository) val draftController = FormEntryUseCases.loadDraft( @@ -59,7 +62,7 @@ class FormEntryUseCasesTest { fun finalizeDraft_canCreatePartialSubmissions() { val formMediaDir = TempFiles.createTempDir() val xForm = copyTestForm("forms/one-question-partial.xml") - val formDef = XFormUtils.getFormFromFormXml(xForm.absolutePath, null) + val formDef = parseForm(xForm, projectRootDir, formMediaDir) val instance = createDraft(formDef, formMediaDir, instancesRepository) { it.stepToNextScreenEvent() it.answerQuestion(it.getFormIndex(), IntegerData(64)) @@ -86,6 +89,11 @@ class FormEntryUseCasesTest { assertThat(root.getChild(0), equalTo("64")) } + private fun parseForm(xForm: File, projectRootDir: File, formMediaDir: File): FormDef { + FormUtils.setupReferenceManagerForForm(ReferenceManager.instance(), projectRootDir, formMediaDir) + return XFormUtils.getFormFromFormXml(xForm.absolutePath, null) + } + private fun createDraft( formDef: FormDef, formMediaDir: File, From 5f881ffe31129c4b75c1782f19cb1653e6aea86f Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Thu, 14 Sep 2023 11:15:08 +0100 Subject: [PATCH 28/46] Update instance name when bulk finalizing --- .../android/formentry/FormEntryUseCases.kt | 3 ++ .../formentry/FormEntryUseCasesTest.kt | 32 ++++++++++++++++++- .../forms/one-question-uuid-instance-name.xml | 23 +++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 test-forms/src/main/resources/forms/one-question-uuid-instance-name.xml diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt index 1a7f5ab2c85..54384fca25b 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt @@ -89,10 +89,13 @@ object FormEntryUseCases { return if (valid) { saveFormToDisk(formController) + val instanceName = formController.getSubmissionMetadata()?.instanceName + instancesRepository.save( Instance.Builder(instance) .status(Instance.STATUS_COMPLETE) .canEditWhenComplete(formController.isSubmissionEntireForm()) + .displayName(instanceName ?: instance.displayName) .build() ) } else { diff --git a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt index 8d67470f797..109f248c558 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt @@ -89,8 +89,38 @@ class FormEntryUseCasesTest { assertThat(root.getChild(0), equalTo("64")) } + @Test + fun finalizeDraft_updatesInstanceNameInRepository() { + val formMediaDir = TempFiles.createTempDir() + val xForm = copyTestForm("forms/one-question-uuid-instance-name.xml") + val formDef = parseForm(xForm, projectRootDir, formMediaDir) + val instance = createDraft(formDef, formMediaDir, instancesRepository) + + val draftController = FormEntryUseCases.loadDraft( + FormEntryController(FormEntryModel(formDef)), + formMediaDir, + File(instance.instanceFilePath) + ) + + FormEntryUseCases.finalizeDraft( + draftController, + instancesRepository, + InMemEntitiesRepository() + ) + + val updatedInstance = instancesRepository.get(instance.dbId)!! + assertThat( + updatedInstance.displayName, + equalTo(draftController.getSubmissionMetadata()!!.instanceName) + ) + } + private fun parseForm(xForm: File, projectRootDir: File, formMediaDir: File): FormDef { - FormUtils.setupReferenceManagerForForm(ReferenceManager.instance(), projectRootDir, formMediaDir) + FormUtils.setupReferenceManagerForForm( + ReferenceManager.instance(), + projectRootDir, + formMediaDir + ) return XFormUtils.getFormFromFormXml(xForm.absolutePath, null) } diff --git a/test-forms/src/main/resources/forms/one-question-uuid-instance-name.xml b/test-forms/src/main/resources/forms/one-question-uuid-instance-name.xml new file mode 100644 index 00000000000..ab53fc7ddbf --- /dev/null +++ b/test-forms/src/main/resources/forms/one-question-uuid-instance-name.xml @@ -0,0 +1,23 @@ + + + + One Question + + + + + + + + + + + + + + + + + + + From 8e4aeca1ca02746313e8620917891874e86b81c8 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Thu, 14 Sep 2023 11:38:42 +0100 Subject: [PATCH 29/46] Add string resource --- collect_app/src/main/res/menu/drafts.xml | 2 +- strings/src/main/res/values/strings.xml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/collect_app/src/main/res/menu/drafts.xml b/collect_app/src/main/res/menu/drafts.xml index 53d421a5153..5ece8ef546b 100644 --- a/collect_app/src/main/res/menu/drafts.xml +++ b/collect_app/src/main/res/menu/drafts.xml @@ -3,6 +3,6 @@ + android:title="@string/finalize_all_forms"> diff --git a/strings/src/main/res/values/strings.xml b/strings/src/main/res/values/strings.xml index e4f9f37763d..23ddfd30667 100644 --- a/strings/src/main/res/values/strings.xml +++ b/strings/src/main/res/values/strings.xml @@ -1218,4 +1218,7 @@ Close snackbar In later releases, you will not be able to edit finalized forms. Save forms as draft to edit them later.\n\nYou can check for errors in a draft form by tapping the three dots (⋮) and then Check for errors. + + + Finalize all forms From b0a503a294c7d92eeb4adf54c575eadee82d9770 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Thu, 14 Sep 2023 13:10:36 +0100 Subject: [PATCH 30/46] Add other missing string resources --- .../collect/android/activities/InstanceChooserList.java | 7 ++++--- strings/src/main/res/values/strings.xml | 9 +++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java index 95276ce717d..65c6af3fdf8 100644 --- a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java +++ b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java @@ -63,6 +63,7 @@ import org.odk.collect.forms.instances.Instance; import org.odk.collect.material.MaterialProgressDialogFragment; import org.odk.collect.settings.SettingsProvider; +import org.odk.collect.strings.R.string; import java.util.Arrays; @@ -166,17 +167,17 @@ public void onCreate(Bundle savedInstanceState) { if (pair.getSecond().equals(0)) { SnackbarUtils.showLongSnackbar( this.findViewById(android.R.id.content), - "Success! " + pair.getFirst() + " forms finalized." + getString(string.bulk_finalize_success, pair.getFirst()) ); } else if (pair.getFirst().equals(pair.getSecond())) { SnackbarUtils.showLongSnackbar( this.findViewById(android.R.id.content), - pair.getSecond() + " forms have errors. Address issues before finalizing all forms." + getString(string.bulk_finalize_failure, pair.getSecond()) ); } else { SnackbarUtils.showLongSnackbar( this.findViewById(android.R.id.content), - (pair.getFirst() - pair.getSecond()) + " forms finalized. " + pair.getSecond() + " forms have errors. Address issues before finalizing all forms." + getString(string.bulk_finalize_partial_success, pair.getFirst() - pair.getSecond(), pair.getSecond()) ); } diff --git a/strings/src/main/res/values/strings.xml b/strings/src/main/res/values/strings.xml index 23ddfd30667..3f4f6508e06 100644 --- a/strings/src/main/res/values/strings.xml +++ b/strings/src/main/res/values/strings.xml @@ -1221,4 +1221,13 @@ Finalize all forms + + + Success! %d forms finalized. + + + %d forms have errors. Address issues before finalizing all forms. + + + %d forms finalized. %d forms have errors. Address issues before finalizing all forms. From d07d8c997fe86e5b0ab8e65c1026d2648c66c812 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Thu, 14 Sep 2023 13:24:55 +0100 Subject: [PATCH 31/46] Use translated strings in tests --- .../formmanagement/BulkFinalizationTest.kt | 31 ++++++++++--------- .../odk/collect/android/support/pages/Page.kt | 4 +-- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt index 82778d728b6..b43fdb5b2f2 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt @@ -10,6 +10,7 @@ import org.odk.collect.android.support.pages.MainMenuPage import org.odk.collect.android.support.pages.SaveOrDiscardFormDialog import org.odk.collect.android.support.rules.CollectTestRule import org.odk.collect.android.support.rules.TestRuleChain +import org.odk.collect.strings.R.string @RunWith(AndroidJUnit4::class) class BulkFinalizationTest { @@ -29,9 +30,9 @@ class BulkFinalizationTest { .fillOutAndSave(QuestionAndAnswer("what is your age", "98")) .clickEditSavedForm(2) - .clickOptionsIcon("Finalize all forms") - .clickOnText("Finalize all forms") - .checkIsSnackbarWithMessageDisplayed("Success! 2 forms finalized.") + .clickOptionsIcon(string.finalize_all_forms) + .clickOnString(string.finalize_all_forms) + .checkIsSnackbarWithMessageDisplayed(string.bulk_finalize_success, 2) .assertTextDoesNotExist("One Question") .pressBack(MainMenuPage()) @@ -54,9 +55,9 @@ class BulkFinalizationTest { ) .clickEditSavedForm(2) - .clickOptionsIcon("Finalize all forms") - .clickOnText("Finalize all forms") - .checkIsSnackbarWithMessageDisplayed("1 forms finalized. 1 forms have errors. Address issues before finalizing all forms.") + .clickOptionsIcon(string.finalize_all_forms) + .clickOnString(string.finalize_all_forms) + .checkIsSnackbarWithMessageDisplayed(string.bulk_finalize_partial_success, 1, 1) .assertText("Two Question Required") .pressBack(MainMenuPage()) @@ -74,13 +75,13 @@ class BulkFinalizationTest { .clickSaveChanges() .clickEditSavedForm(1) - .clickOptionsIcon("Finalize all forms") - .clickOnText("Finalize all forms") - .checkIsSnackbarWithMessageDisplayed("1 forms have errors. Address issues before finalizing all forms.") + .clickOptionsIcon(string.finalize_all_forms) + .clickOnString(string.finalize_all_forms) + .checkIsSnackbarWithMessageDisplayed(string.bulk_finalize_failure, 1) - .clickOptionsIcon("Finalize all forms") - .clickOnText("Finalize all forms") - .checkIsSnackbarWithMessageDisplayed("1 forms have errors. Address issues before finalizing all forms.") + .clickOptionsIcon(string.finalize_all_forms) + .clickOnString(string.finalize_all_forms) + .checkIsSnackbarWithMessageDisplayed(string.bulk_finalize_failure, 1) } @Test @@ -93,9 +94,9 @@ class BulkFinalizationTest { .fillOutAndFinalize(QuestionAndAnswer("what is your age", "98")) .clickEditSavedForm(1) - .clickOptionsIcon("Finalize all forms") - .clickOnText("Finalize all forms") - .checkIsSnackbarWithMessageDisplayed("Success! 1 forms finalized.") + .clickOptionsIcon(string.finalize_all_forms) + .clickOnString(string.finalize_all_forms) + .checkIsSnackbarWithMessageDisplayed(string.bulk_finalize_success, 1) .assertTextDoesNotExist("One Question") .pressBack(MainMenuPage()) diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt index 4b3dbd0b383..d5f207dbbaf 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt @@ -169,8 +169,8 @@ abstract class Page> { return this as T } - fun checkIsSnackbarWithMessageDisplayed(message: Int): T { - onView(withText(message)).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) + fun checkIsSnackbarWithMessageDisplayed(message: Int, vararg formatArgs: Any): T { + onView(withText(getTranslatedString(message, *formatArgs))).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) return this as T } From f87b80188925172873ff1e447972ab572c64f964 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Tue, 26 Sep 2023 10:19:58 +0100 Subject: [PATCH 32/46] Use overload in implementation --- .../java/org/odk/collect/android/support/pages/Page.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt index d5f207dbbaf..f114989a310 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt @@ -170,8 +170,7 @@ abstract class Page> { } fun checkIsSnackbarWithMessageDisplayed(message: Int, vararg formatArgs: Any): T { - onView(withText(getTranslatedString(message, *formatArgs))).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) - return this as T + return checkIsSnackbarWithMessageDisplayed(getTranslatedString(message, *formatArgs)) } fun checkIsSnackbarWithMessageDisplayed(message: String): T { From 82f7b8b4389bebf66386d504a635192188d843b7 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Tue, 26 Sep 2023 11:19:18 +0100 Subject: [PATCH 33/46] Use quantity strings for finalization success and failure strings --- .../formmanagement/BulkFinalizationTest.kt | 9 +++++---- .../odk/collect/android/support/pages/Page.kt | 9 +++++++++ .../activities/InstanceChooserList.java | 11 +++++++++-- .../localization/LocalizedApplication.kt | 18 ++++++++++++++++++ strings/src/main/res/values/strings.xml | 10 ++++++++-- 5 files changed, 49 insertions(+), 8 deletions(-) diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt index b43fdb5b2f2..25c9d7bd9da 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt @@ -10,6 +10,7 @@ import org.odk.collect.android.support.pages.MainMenuPage import org.odk.collect.android.support.pages.SaveOrDiscardFormDialog import org.odk.collect.android.support.rules.CollectTestRule import org.odk.collect.android.support.rules.TestRuleChain +import org.odk.collect.strings.R.plurals import org.odk.collect.strings.R.string @RunWith(AndroidJUnit4::class) @@ -32,7 +33,7 @@ class BulkFinalizationTest { .clickEditSavedForm(2) .clickOptionsIcon(string.finalize_all_forms) .clickOnString(string.finalize_all_forms) - .checkIsSnackbarWithMessageDisplayed(string.bulk_finalize_success, 2) + .checkIsSnackbarWithQuantityDisplayed(plurals.bulk_finalize_success, 2) .assertTextDoesNotExist("One Question") .pressBack(MainMenuPage()) @@ -77,11 +78,11 @@ class BulkFinalizationTest { .clickEditSavedForm(1) .clickOptionsIcon(string.finalize_all_forms) .clickOnString(string.finalize_all_forms) - .checkIsSnackbarWithMessageDisplayed(string.bulk_finalize_failure, 1) + .checkIsSnackbarWithQuantityDisplayed(plurals.bulk_finalize_failure, 1) .clickOptionsIcon(string.finalize_all_forms) .clickOnString(string.finalize_all_forms) - .checkIsSnackbarWithMessageDisplayed(string.bulk_finalize_failure, 1) + .checkIsSnackbarWithQuantityDisplayed(plurals.bulk_finalize_failure, 1) } @Test @@ -96,7 +97,7 @@ class BulkFinalizationTest { .clickEditSavedForm(1) .clickOptionsIcon(string.finalize_all_forms) .clickOnString(string.finalize_all_forms) - .checkIsSnackbarWithMessageDisplayed(string.bulk_finalize_success, 1) + .checkIsSnackbarWithQuantityDisplayed(plurals.bulk_finalize_success, 1) .assertTextDoesNotExist("One Question") .pressBack(MainMenuPage()) diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt index f114989a310..9e18e3fb331 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt @@ -1,5 +1,6 @@ package org.odk.collect.android.support.pages +import android.app.Application import android.content.pm.ActivityInfo import androidx.annotation.StringRes import androidx.recyclerview.widget.RecyclerView @@ -43,6 +44,7 @@ import org.odk.collect.android.support.WaitFor.waitFor import org.odk.collect.android.support.actions.RotateAction import org.odk.collect.android.support.matchers.CustomMatchers.withIndex import org.odk.collect.androidshared.ui.ToastUtils.popRecordedToasts +import org.odk.collect.strings.localization.getLocalizedQuantityString import org.odk.collect.strings.localization.getLocalizedString import org.odk.collect.testshared.RecyclerViewMatcher import timber.log.Timber @@ -169,6 +171,13 @@ abstract class Page> { return this as T } + fun checkIsSnackbarWithQuantityDisplayed(message: Int, quantity: Int): T { + return checkIsSnackbarWithMessageDisplayed( + ApplicationProvider.getApplicationContext() + .getLocalizedQuantityString(message, quantity) + ) + } + fun checkIsSnackbarWithMessageDisplayed(message: Int, vararg formatArgs: Any): T { return checkIsSnackbarWithMessageDisplayed(getTranslatedString(message, *formatArgs)) } diff --git a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java index 65c6af3fdf8..7cf9ae43403 100644 --- a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java +++ b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java @@ -64,6 +64,7 @@ import org.odk.collect.material.MaterialProgressDialogFragment; import org.odk.collect.settings.SettingsProvider; import org.odk.collect.strings.R.string; +import org.odk.collect.strings.R.plurals; import java.util.Arrays; @@ -167,12 +168,18 @@ public void onCreate(Bundle savedInstanceState) { if (pair.getSecond().equals(0)) { SnackbarUtils.showLongSnackbar( this.findViewById(android.R.id.content), - getString(string.bulk_finalize_success, pair.getFirst()) + getResources().getQuantityString( + plurals.bulk_finalize_success, + pair.getFirst() + ) ); } else if (pair.getFirst().equals(pair.getSecond())) { SnackbarUtils.showLongSnackbar( this.findViewById(android.R.id.content), - getString(string.bulk_finalize_failure, pair.getSecond()) + getResources().getQuantityString( + plurals.bulk_finalize_failure, + pair.getFirst() + ) ); } else { SnackbarUtils.showLongSnackbar( diff --git a/strings/src/main/java/org/odk/collect/strings/localization/LocalizedApplication.kt b/strings/src/main/java/org/odk/collect/strings/localization/LocalizedApplication.kt index bb9514adf60..fcad37deae8 100644 --- a/strings/src/main/java/org/odk/collect/strings/localization/LocalizedApplication.kt +++ b/strings/src/main/java/org/odk/collect/strings/localization/LocalizedApplication.kt @@ -32,6 +32,24 @@ fun Context.getLocalizedResources(locale: Locale): Resources { return createConfigurationContext(newConfig).resources } +fun Context.getLocalizedQuantityString(stringId: Int, quantity: Int): String { + val locale = when (applicationContext) { + is LocalizedApplication -> (applicationContext as LocalizedApplication).locale + + // Don't explode if the application doesn't implement LocalizedApplication. Useful + // when testing modules in isolation + else -> if (Build.VERSION.SDK_INT >= 24) resources.configuration.locales[0] else resources.configuration.locale + } + + val newConfig = Configuration(resources.configuration).apply { + setLocale(locale) + } + + return createConfigurationContext(newConfig) + .resources + .getQuantityString(stringId, quantity) +} + fun Context.isLTR(): Boolean { return resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_LTR } diff --git a/strings/src/main/res/values/strings.xml b/strings/src/main/res/values/strings.xml index 3f4f6508e06..a27ec9bc2d2 100644 --- a/strings/src/main/res/values/strings.xml +++ b/strings/src/main/res/values/strings.xml @@ -1223,10 +1223,16 @@ Finalize all forms - Success! %d forms finalized. + + Success! %d form finalized. + Success! %d forms finalized. + - %d forms have errors. Address issues before finalizing all forms. + + %d form has an error. Address issues before finalizing all forms. + %d forms have errors. Address issues before finalizing all forms. + %d forms finalized. %d forms have errors. Address issues before finalizing all forms. From f95ef934f82062da147f87e10cabc429356f6bfa Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Wed, 27 Sep 2023 09:39:19 +0100 Subject: [PATCH 34/46] Make test name as IntelliJ expects --- .../{InstanceExtTest.kt => InstanceExtKtTest.kt} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename collect_app/src/test/java/org/odk/collect/android/instancemanagement/{InstanceExtTest.kt => InstanceExtKtTest.kt} (98%) diff --git a/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceExtTest.kt b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceExtKtTest.kt similarity index 98% rename from collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceExtTest.kt rename to collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceExtKtTest.kt index 90ca0d86edc..2a31c6a15fb 100644 --- a/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceExtTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceExtKtTest.kt @@ -14,7 +14,7 @@ import java.text.SimpleDateFormat import java.util.Locale @RunWith(AndroidJUnit4::class) -class InstanceExtTest { +class InstanceExtKtTest { private val resources = ApplicationProvider.getApplicationContext().resources From 01a1194389af494a82c95955305c978c9c212a9a Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Wed, 27 Sep 2023 09:56:26 +0100 Subject: [PATCH 35/46] Use same string for saved and invalid forms --- .../org/odk/collect/android/instancemanagement/InstanceExt.kt | 2 +- .../odk/collect/android/instancemanagement/InstanceExtKtTest.kt | 2 +- strings/src/main/res/values/strings.xml | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceExt.kt b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceExt.kt index f2323650b2e..8a1345663cd 100644 --- a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceExt.kt +++ b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceExt.kt @@ -46,7 +46,7 @@ private fun getStatusDescription(resources: Resources, state: String?, date: Dat ).format(date) } else if (Instance.STATUS_INVALID.equals(state, ignoreCase = true)) { SimpleDateFormat( - resources.getString(R.string.validated_on_date_at_time), + resources.getString(R.string.saved_on_date_at_time), Locale.getDefault() ).format(date) } else { diff --git a/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceExtKtTest.kt b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceExtKtTest.kt index 2a31c6a15fb..405ad6efb20 100644 --- a/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceExtKtTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceExtKtTest.kt @@ -29,7 +29,7 @@ class InstanceExtKtTest { val invalid = InstanceFixtures.instance(status = Instance.STATUS_INVALID) assertDateFormat( invalid.getStatusDescription(resources), - R.string.validated_on_date_at_time + R.string.saved_on_date_at_time ) val complete = InstanceFixtures.instance(status = Instance.STATUS_COMPLETE) diff --git a/strings/src/main/res/values/strings.xml b/strings/src/main/res/values/strings.xml index a27ec9bc2d2..d74cca8f1eb 100644 --- a/strings/src/main/res/values/strings.xml +++ b/strings/src/main/res/values/strings.xml @@ -34,8 +34,6 @@ \'Saved on\' EEE, MMM dd, yyyy \'at\' HH:mm - \'Saved with errors on\' EEE, MMM dd, yyyy \'at\' HH:mm - \'Finalized on\' EEE, MMM dd, yyyy \'at\' HH:mm \'Sent on\' EEE, MMM dd, yyyy \'at\' HH:mm From 3df8d437baa026bcac1ebb96369ff71969af82e2 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Wed, 27 Sep 2023 10:13:33 +0100 Subject: [PATCH 36/46] Use DatabaseObjectMapper in adapter --- .../adapters/InstanceListCursorAdapter.java | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceListCursorAdapter.java b/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceListCursorAdapter.java index a49a6a63f87..9cc450ea86c 100644 --- a/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceListCursorAdapter.java +++ b/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceListCursorAdapter.java @@ -25,8 +25,10 @@ import android.widget.TextView; import org.odk.collect.android.R; -import org.odk.collect.android.database.instances.DatabaseInstanceColumns; +import org.odk.collect.android.database.DatabaseObjectMapper; import org.odk.collect.android.instancemanagement.InstanceExtKt; +import org.odk.collect.android.storage.StoragePathProvider; +import org.odk.collect.android.storage.StorageSubdirectory; import org.odk.collect.android.utilities.FormsRepositoryProvider; import org.odk.collect.forms.Form; import org.odk.collect.forms.instances.Instance; @@ -50,11 +52,15 @@ public InstanceListCursorAdapter(Context context, int layout, Cursor c, String[] @Override public View getView(int position, View convertView, ViewGroup parent) { View view = super.getView(position, convertView, parent); + Instance instance = DatabaseObjectMapper.getInstanceFromCurrentCursorPosition( + getCursor(), + new StoragePathProvider().getOdkDirPath(StorageSubdirectory.INSTANCES) + ); ImageView imageView = view.findViewById(R.id.image); - setImageFromStatus(imageView); + setImageFromStatus(imageView, instance); - setUpSubtext(view); + setUpSubtext(view, instance); // Some form lists never contain disabled items; if so, we're done. // Update: This only seems to be the case in Edit Saved Forms and it's not clear why... @@ -65,8 +71,8 @@ public View getView(int position, View convertView, ViewGroup parent) { boolean formExists = false; boolean isFormEncrypted = false; - String formId = getCursor().getString(getCursor().getColumnIndex(DatabaseInstanceColumns.JR_FORM_ID)); - String formVersion = getCursor().getString(getCursor().getColumnIndex(DatabaseInstanceColumns.JR_VERSION)); + String formId = instance.getFormId(); + String formVersion = instance.getFormVersion(); Form form = new FormsRepositoryProvider(context.getApplicationContext()).get().getLatestByFormIdAndVersion(formId, formVersion); if (form != null) { @@ -75,7 +81,7 @@ public View getView(int position, View convertView, ViewGroup parent) { isFormEncrypted = base64RSAPublicKey != null; } - long date = getCursor().getLong(getCursor().getColumnIndex(DatabaseInstanceColumns.DELETED_DATE)); + long date = instance.getDeletedDate(); if (date != 0 || !formExists || isFormEncrypted) { String disabledMessage; @@ -134,17 +140,15 @@ private void setDisabled(View view, String disabledMessage) { imageView.setAlpha(0.38f); } - private void setUpSubtext(View view) { - long lastStatusChangeDate = getCursor().getLong(getCursor().getColumnIndex(DatabaseInstanceColumns.LAST_STATUS_CHANGE_DATE)); - String status = getCursor().getString(getCursor().getColumnIndex(DatabaseInstanceColumns.STATUS)); - String subtext = InstanceExtKt.getStatusDescription(context, status, new Date(lastStatusChangeDate)); + private void setUpSubtext(View view, Instance instance) { + String subtext = InstanceExtKt.getStatusDescription(instance, context.getResources()); final TextView formSubtitle = view.findViewById(R.id.form_subtitle); formSubtitle.setText(subtext); } - private void setImageFromStatus(ImageView imageView) { - String formStatus = getCursor().getString(getCursor().getColumnIndex(DatabaseInstanceColumns.STATUS)); + private void setImageFromStatus(ImageView imageView, Instance instance) { + String formStatus = instance.getStatus(); int imageResourceId = getFormStateImageResourceIdForStatus(formStatus); imageView.setImageResource(imageResourceId); From 3cbb763acb7c11214754829978cf0fb5446e10bc Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Wed, 27 Sep 2023 10:32:01 +0100 Subject: [PATCH 37/46] Pull instance list item view setup code out --- .../adapters/InstanceListCursorAdapter.java | 110 +--------------- .../InstanceListItemView.kt | 119 ++++++++++++++++++ 2 files changed, 121 insertions(+), 108 deletions(-) create mode 100644 collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt diff --git a/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceListCursorAdapter.java b/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceListCursorAdapter.java index 9cc450ea86c..023f29f035c 100644 --- a/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceListCursorAdapter.java +++ b/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceListCursorAdapter.java @@ -20,32 +20,20 @@ import android.database.Cursor; import android.view.View; import android.view.ViewGroup; -import android.widget.ImageView; import android.widget.SimpleCursorAdapter; -import android.widget.TextView; import org.odk.collect.android.R; import org.odk.collect.android.database.DatabaseObjectMapper; -import org.odk.collect.android.instancemanagement.InstanceExtKt; +import org.odk.collect.android.instancemanagement.InstanceListItemView; import org.odk.collect.android.storage.StoragePathProvider; import org.odk.collect.android.storage.StorageSubdirectory; -import org.odk.collect.android.utilities.FormsRepositoryProvider; -import org.odk.collect.forms.Form; import org.odk.collect.forms.instances.Instance; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Locale; - -import timber.log.Timber; - public class InstanceListCursorAdapter extends SimpleCursorAdapter { - private final Context context; private final boolean shouldCheckDisabled; public InstanceListCursorAdapter(Context context, int layout, Cursor c, String[] from, int[] to, boolean shouldCheckDisabled) { super(context, layout, c, from, to); - this.context = context; this.shouldCheckDisabled = shouldCheckDisabled; } @@ -57,104 +45,10 @@ public View getView(int position, View convertView, ViewGroup parent) { new StoragePathProvider().getOdkDirPath(StorageSubdirectory.INSTANCES) ); - ImageView imageView = view.findViewById(R.id.image); - setImageFromStatus(imageView, instance); - - setUpSubtext(view, instance); - - // Some form lists never contain disabled items; if so, we're done. - // Update: This only seems to be the case in Edit Saved Forms and it's not clear why... - if (!shouldCheckDisabled) { - return view; - } - - boolean formExists = false; - boolean isFormEncrypted = false; - - String formId = instance.getFormId(); - String formVersion = instance.getFormVersion(); - Form form = new FormsRepositoryProvider(context.getApplicationContext()).get().getLatestByFormIdAndVersion(formId, formVersion); - - if (form != null) { - String base64RSAPublicKey = form.getBASE64RSAPublicKey(); - formExists = true; - isFormEncrypted = base64RSAPublicKey != null; - } - - long date = instance.getDeletedDate(); - - if (date != 0 || !formExists || isFormEncrypted) { - String disabledMessage; - - if (date != 0) { - try { - String deletedTime = context.getString(org.odk.collect.strings.R.string.deleted_on_date_at_time); - disabledMessage = new SimpleDateFormat(deletedTime, Locale.getDefault()).format(new Date(date)); - } catch (IllegalArgumentException e) { - Timber.e(e); - disabledMessage = context.getString(org.odk.collect.strings.R.string.submission_deleted); - } - } else if (!formExists) { - disabledMessage = context.getString(org.odk.collect.strings.R.string.deleted_form); - } else { - disabledMessage = context.getString(org.odk.collect.strings.R.string.encrypted_form); - } - - setDisabled(view, disabledMessage); - } else { - setEnabled(view); - } - + InstanceListItemView.setInstance(view, instance, shouldCheckDisabled); return view; } - private void setEnabled(View view) { - final TextView formTitle = view.findViewById(R.id.form_title); - final TextView formSubtitle = view.findViewById(R.id.form_subtitle); - final TextView disabledCause = view.findViewById(R.id.form_subtitle2); - final ImageView imageView = view.findViewById(R.id.image); - - view.setEnabled(true); - disabledCause.setVisibility(View.GONE); - - formTitle.setAlpha(1f); - formSubtitle.setAlpha(1f); - disabledCause.setAlpha(1f); - imageView.setAlpha(1f); - } - - private void setDisabled(View view, String disabledMessage) { - final TextView formTitle = view.findViewById(R.id.form_title); - final TextView formSubtitle = view.findViewById(R.id.form_subtitle); - final TextView disabledCause = view.findViewById(R.id.form_subtitle2); - final ImageView imageView = view.findViewById(R.id.image); - - view.setEnabled(false); - disabledCause.setVisibility(View.VISIBLE); - disabledCause.setText(disabledMessage); - - // Material design "disabled" opacity is 38%. - formTitle.setAlpha(0.38f); - formSubtitle.setAlpha(0.38f); - disabledCause.setAlpha(0.38f); - imageView.setAlpha(0.38f); - } - - private void setUpSubtext(View view, Instance instance) { - String subtext = InstanceExtKt.getStatusDescription(instance, context.getResources()); - - final TextView formSubtitle = view.findViewById(R.id.form_subtitle); - formSubtitle.setText(subtext); - } - - private void setImageFromStatus(ImageView imageView, Instance instance) { - String formStatus = instance.getStatus(); - - int imageResourceId = getFormStateImageResourceIdForStatus(formStatus); - imageView.setImageResource(imageResourceId); - imageView.setTag(imageResourceId); - } - public static int getFormStateImageResourceIdForStatus(String formStatus) { switch (formStatus) { case Instance.STATUS_INCOMPLETE: diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt new file mode 100644 index 00000000000..c8761f1de5d --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt @@ -0,0 +1,119 @@ +package org.odk.collect.android.instancemanagement + +import android.content.Context +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import org.odk.collect.android.R +import org.odk.collect.android.utilities.FormsRepositoryProvider +import org.odk.collect.forms.instances.Instance +import org.odk.collect.strings.R.string +import timber.log.Timber +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object InstanceListItemView { + + @JvmStatic + fun setInstance(view: View, instance: Instance, shouldCheckDisabled: Boolean) { + val context = view.context + + val imageView = view.findViewById(R.id.image) + setImageFromStatus(imageView, instance) + setUpSubtext(view, instance, context) + + // Some form lists never contain disabled items; if so, we're done. + // Update: This only seems to be the case in Edit Saved Forms and it's not clear why... + if (!shouldCheckDisabled) { + return + } + + var formExists = false + var isFormEncrypted = false + val formId = instance.formId + val formVersion = instance.formVersion + val form = FormsRepositoryProvider(context.applicationContext).get() + .getLatestByFormIdAndVersion(formId, formVersion) + + if (form != null) { + val base64RSAPublicKey = form.basE64RSAPublicKey + formExists = true + isFormEncrypted = base64RSAPublicKey != null + } + + val date = instance.deletedDate + if (date != 0L || !formExists || isFormEncrypted) { + val disabledMessage = if (date != 0L) { + try { + val deletedTime: String = context.getString(string.deleted_on_date_at_time) + SimpleDateFormat(deletedTime, Locale.getDefault()).format(Date(date)) + } catch (e: IllegalArgumentException) { + Timber.e(e) + context.getString(string.submission_deleted) + } + } else if (!formExists) { + context.getString(string.deleted_form) + } else { + context.getString(string.encrypted_form) + } + + setDisabled(view, disabledMessage) + } else { + setEnabled(view) + } + } + + private fun setEnabled(view: View) { + val formTitle = view.findViewById(R.id.form_title) + val formSubtitle = view.findViewById(R.id.form_subtitle) + val disabledCause = view.findViewById(R.id.form_subtitle2) + val imageView = view.findViewById(R.id.image) + view.isEnabled = true + disabledCause.visibility = View.GONE + formTitle.alpha = 1f + formSubtitle.alpha = 1f + disabledCause.alpha = 1f + imageView.alpha = 1f + } + + private fun setDisabled(view: View, disabledMessage: String) { + val formTitle = view.findViewById(R.id.form_title) + val formSubtitle = view.findViewById(R.id.form_subtitle) + val disabledCause = view.findViewById(R.id.form_subtitle2) + val imageView = view.findViewById(R.id.image) + view.isEnabled = false + disabledCause.visibility = View.VISIBLE + disabledCause.text = disabledMessage + + // Material design "disabled" opacity is 38%. + formTitle.alpha = 0.38f + formSubtitle.alpha = 0.38f + disabledCause.alpha = 0.38f + imageView.alpha = 0.38f + } + + private fun setUpSubtext(view: View, instance: Instance, context: Context) { + val subtext = instance.getStatusDescription(context.resources) + val formSubtitle = view.findViewById(R.id.form_subtitle) + formSubtitle.text = subtext + } + + private fun setImageFromStatus(imageView: ImageView, instance: Instance) { + val formStatus = instance.status + val imageResourceId = getFormStateImageResourceIdForStatus(formStatus) + imageView.setImageResource(imageResourceId) + imageView.tag = imageResourceId + } + + private fun getFormStateImageResourceIdForStatus(formStatus: String?): Int { + when (formStatus) { + Instance.STATUS_INCOMPLETE, Instance.STATUS_INVALID -> return R.drawable.ic_form_state_saved + Instance.STATUS_COMPLETE -> return R.drawable.ic_form_state_finalized + Instance.STATUS_SUBMITTED -> return R.drawable.ic_form_state_submitted + Instance.STATUS_SUBMISSION_FAILED -> return R.drawable.ic_form_state_submission_failed + } + + throw java.lang.IllegalArgumentException() + } +} From 75a1a63d6f1b14d585bb609f9aff7713cb5c181d Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Wed, 27 Sep 2023 14:04:41 +0100 Subject: [PATCH 38/46] Fix nullable values --- .../android/instancemanagement/InstanceListItemView.kt | 4 ++-- .../main/java/org/odk/collect/forms/instances/Instance.java | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt index c8761f1de5d..3feeb25325f 100644 --- a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt +++ b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt @@ -43,8 +43,8 @@ object InstanceListItemView { } val date = instance.deletedDate - if (date != 0L || !formExists || isFormEncrypted) { - val disabledMessage = if (date != 0L) { + if (date != null || !formExists || isFormEncrypted) { + val disabledMessage = if (date != null) { try { val deletedTime: String = context.getString(string.deleted_on_date_at_time) SimpleDateFormat(deletedTime, Locale.getDefault()).format(Date(date)) diff --git a/forms/src/main/java/org/odk/collect/forms/instances/Instance.java b/forms/src/main/java/org/odk/collect/forms/instances/Instance.java index 56e091d5b0e..a8fd48e15ab 100644 --- a/forms/src/main/java/org/odk/collect/forms/instances/Instance.java +++ b/forms/src/main/java/org/odk/collect/forms/instances/Instance.java @@ -16,6 +16,8 @@ package org.odk.collect.forms.instances; +import org.jetbrains.annotations.Nullable; + /** * A filled form stored on the device. *

@@ -192,6 +194,7 @@ public Long getLastStatusChangeDate() { return lastStatusChangeDate; } + @Nullable public Long getDeletedDate() { return deletedDate; } From 6097ed47afe6d9251522e71d8a11209d4ff1de33 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Wed, 27 Sep 2023 14:36:31 +0100 Subject: [PATCH 39/46] Add incomplete chip to invalid drafts --- .../InstanceListItemView.kt | 67 ++++++++++--------- .../res/layout/form_chooser_list_item.xml | 19 +++++- .../InstanceListItemViewTest.kt | 48 +++++++++++++ 3 files changed, 99 insertions(+), 35 deletions(-) create mode 100644 collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceListItemViewTest.kt diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt index 3feeb25325f..568569a7c5f 100644 --- a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt +++ b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt @@ -23,44 +23,47 @@ object InstanceListItemView { setImageFromStatus(imageView, instance) setUpSubtext(view, instance, context) - // Some form lists never contain disabled items; if so, we're done. - // Update: This only seems to be the case in Edit Saved Forms and it's not clear why... - if (!shouldCheckDisabled) { - return + val chip = view.findViewById(R.id.chip) + if (chip != null) { + if (instance.status == Instance.STATUS_INVALID) { + chip.visibility = View.VISIBLE + } } - var formExists = false - var isFormEncrypted = false - val formId = instance.formId - val formVersion = instance.formVersion - val form = FormsRepositoryProvider(context.applicationContext).get() - .getLatestByFormIdAndVersion(formId, formVersion) - - if (form != null) { - val base64RSAPublicKey = form.basE64RSAPublicKey - formExists = true - isFormEncrypted = base64RSAPublicKey != null - } + if (shouldCheckDisabled) { + var formExists = false + var isFormEncrypted = false + val formId = instance.formId + val formVersion = instance.formVersion + val form = FormsRepositoryProvider(context.applicationContext).get() + .getLatestByFormIdAndVersion(formId, formVersion) + + if (form != null) { + val base64RSAPublicKey = form.basE64RSAPublicKey + formExists = true + isFormEncrypted = base64RSAPublicKey != null + } - val date = instance.deletedDate - if (date != null || !formExists || isFormEncrypted) { - val disabledMessage = if (date != null) { - try { - val deletedTime: String = context.getString(string.deleted_on_date_at_time) - SimpleDateFormat(deletedTime, Locale.getDefault()).format(Date(date)) - } catch (e: IllegalArgumentException) { - Timber.e(e) - context.getString(string.submission_deleted) + val date = instance.deletedDate + if (date != null || !formExists || isFormEncrypted) { + val disabledMessage = if (date != null) { + try { + val deletedTime: String = context.getString(string.deleted_on_date_at_time) + SimpleDateFormat(deletedTime, Locale.getDefault()).format(Date(date)) + } catch (e: IllegalArgumentException) { + Timber.e(e) + context.getString(string.submission_deleted) + } + } else if (!formExists) { + context.getString(string.deleted_form) + } else { + context.getString(string.encrypted_form) } - } else if (!formExists) { - context.getString(string.deleted_form) + + setDisabled(view, disabledMessage) } else { - context.getString(string.encrypted_form) + setEnabled(view) } - - setDisabled(view, disabledMessage) - } else { - setEnabled(view) } } diff --git a/collect_app/src/main/res/layout/form_chooser_list_item.xml b/collect_app/src/main/res/layout/form_chooser_list_item.xml index bf1cdd36767..cc328476512 100644 --- a/collect_app/src/main/res/layout/form_chooser_list_item.xml +++ b/collect_app/src/main/res/layout/form_chooser_list_item.xml @@ -3,15 +3,28 @@ android:layout_width="match_parent" android:layout_height="wrap_content" xmlns:tools="http://schemas.android.com/tools" + xmlns:app="http://schemas.android.com/apk/res-auto" android:padding="@dimen/margin_standard"> + + + android:layout_below="@id/chip"> @@ -21,7 +34,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignTop="@id/imageView" - android:layout_alignParentTop="true" + android:layout_below="@id/chip" android:layout_marginStart="@dimen/margin_standard" android:layout_marginLeft="@dimen/margin_standard" android:layout_marginEnd="@dimen/margin_standard" @@ -39,7 +52,7 @@ android:layout_width="wrap_content" android:layout_height="0dp" - android:layout_alignParentTop="true" + android:layout_below="@id/chip" android:layout_alignBottom="@id/text_view" android:layout_alignParentEnd="true" diff --git a/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceListItemViewTest.kt b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceListItemViewTest.kt new file mode 100644 index 00000000000..53b2d71fae8 --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceListItemViewTest.kt @@ -0,0 +1,48 @@ +package org.odk.collect.android.instancemanagement + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.odk.collect.android.R +import org.odk.collect.android.databinding.FormChooserListItemBinding +import org.odk.collect.forms.instances.Instance +import org.odk.collect.formstest.InstanceFixtures + +@RunWith(AndroidJUnit4::class) +class InstanceListItemViewTest { + + private val context = ApplicationProvider.getApplicationContext() + private val layoutInflater = LayoutInflater.from(context) + + @Before + fun setup() { + context.setTheme(R.style.Theme_Collect) + } + + @Test + fun whenInstanceIsInvalid_showsIncompleteChip() { + val binding = FormChooserListItemBinding.inflate(layoutInflater) + val instance = InstanceFixtures.instance(status = Instance.STATUS_INVALID) + + InstanceListItemView.setInstance(binding.root, instance, false) + + assertThat(binding.chip.visibility, equalTo(View.VISIBLE)) + } + + @Test + fun whenInstanceIsIncomplete_doesNotShowIncompleteChip() { + val binding = FormChooserListItemBinding.inflate(layoutInflater) + val instance = InstanceFixtures.instance(status = Instance.STATUS_INCOMPLETE) + + InstanceListItemView.setInstance(binding.root, instance, false) + + assertThat(binding.chip.visibility, equalTo(View.GONE)) + } +} From e17609e0c2c5bce9cbd243f53e8786135dc512ca Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Wed, 27 Sep 2023 14:45:15 +0100 Subject: [PATCH 40/46] Add basic styling to chip --- collect_app/src/main/res/layout/form_chooser_list_item.xml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/collect_app/src/main/res/layout/form_chooser_list_item.xml b/collect_app/src/main/res/layout/form_chooser_list_item.xml index cc328476512..2c03b7f7046 100644 --- a/collect_app/src/main/res/layout/form_chooser_list_item.xml +++ b/collect_app/src/main/res/layout/form_chooser_list_item.xml @@ -15,10 +15,13 @@ android:layout_alignParentStart="true" android:layout_alignParentTop="true" android:layout_marginBottom="@dimen/margin_extra_small" + android:background="?colorSurfaceContainerLow" android:clickable="false" + android:padding="@dimen/margin_small" android:text="Incomplete" + android:textAppearance="?textAppearanceLabelLarge" android:visibility="gone" - tools:visibility="visible"/> + tools:visibility="visible" /> Date: Wed, 27 Sep 2023 16:26:07 +0100 Subject: [PATCH 41/46] Show incomplete chip for incomplete drafts --- .../instancemanagement/InstanceListItemView.kt | 2 +- .../instancemanagement/InstanceListItemViewTest.kt | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt index 568569a7c5f..afcf1639644 100644 --- a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt +++ b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt @@ -25,7 +25,7 @@ object InstanceListItemView { val chip = view.findViewById(R.id.chip) if (chip != null) { - if (instance.status == Instance.STATUS_INVALID) { + if (instance.status == Instance.STATUS_INVALID || instance.status == Instance.STATUS_INCOMPLETE) { chip.visibility = View.VISIBLE } } diff --git a/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceListItemViewTest.kt b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceListItemViewTest.kt index 53b2d71fae8..35c0acfedeb 100644 --- a/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceListItemViewTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceListItemViewTest.kt @@ -37,12 +37,22 @@ class InstanceListItemViewTest { } @Test - fun whenInstanceIsIncomplete_doesNotShowIncompleteChip() { + fun whenInstanceIsIncomplete_showsIncompleteChip() { val binding = FormChooserListItemBinding.inflate(layoutInflater) val instance = InstanceFixtures.instance(status = Instance.STATUS_INCOMPLETE) InstanceListItemView.setInstance(binding.root, instance, false) + assertThat(binding.chip.visibility, equalTo(View.VISIBLE)) + } + + @Test + fun whenInstanceIsComplete_doesNotShowIncompleteChip() { + val binding = FormChooserListItemBinding.inflate(layoutInflater) + val instance = InstanceFixtures.instance(status = Instance.STATUS_COMPLETE) + + InstanceListItemView.setInstance(binding.root, instance, false) + assertThat(binding.chip.visibility, equalTo(View.GONE)) } } From e35d2f56dc214478afa7c03ce43f9b87b07d003f Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Wed, 27 Sep 2023 16:39:35 +0100 Subject: [PATCH 42/46] Add icon to incomplete chip --- collect_app/src/main/res/drawable/baseline_error_24.xml | 5 +++++ collect_app/src/main/res/layout/form_chooser_list_item.xml | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 collect_app/src/main/res/drawable/baseline_error_24.xml diff --git a/collect_app/src/main/res/drawable/baseline_error_24.xml b/collect_app/src/main/res/drawable/baseline_error_24.xml new file mode 100644 index 00000000000..2e58a1c762b --- /dev/null +++ b/collect_app/src/main/res/drawable/baseline_error_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/collect_app/src/main/res/layout/form_chooser_list_item.xml b/collect_app/src/main/res/layout/form_chooser_list_item.xml index 2c03b7f7046..bc40c3b3084 100644 --- a/collect_app/src/main/res/layout/form_chooser_list_item.xml +++ b/collect_app/src/main/res/layout/form_chooser_list_item.xml @@ -8,7 +8,7 @@ - Date: Wed, 27 Sep 2023 17:44:08 +0100 Subject: [PATCH 43/46] Add string for incomplete --- collect_app/src/main/res/layout/form_chooser_list_item.xml | 2 +- strings/src/main/res/values/strings.xml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/collect_app/src/main/res/layout/form_chooser_list_item.xml b/collect_app/src/main/res/layout/form_chooser_list_item.xml index bc40c3b3084..3cc2f2fa6e6 100644 --- a/collect_app/src/main/res/layout/form_chooser_list_item.xml +++ b/collect_app/src/main/res/layout/form_chooser_list_item.xml @@ -18,7 +18,7 @@ android:background="?colorSurfaceContainerLow" android:gravity="center" android:padding="@dimen/margin_small" - android:text="Incomplete" + android:text="@string/incomplete" android:textAppearance="?textAppearanceLabelLarge" android:visibility="gone" app:drawableStartCompat="@drawable/baseline_error_24" diff --git a/strings/src/main/res/values/strings.xml b/strings/src/main/res/values/strings.xml index d74cca8f1eb..09339886f24 100644 --- a/strings/src/main/res/values/strings.xml +++ b/strings/src/main/res/values/strings.xml @@ -1234,4 +1234,7 @@ %d forms finalized. %d forms have errors. Address issues before finalizing all forms. + + + Incomplete From df74a3f88f4cf9859c9f6ff595e778fca00cd84c Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Fri, 29 Sep 2023 12:03:45 +0100 Subject: [PATCH 44/46] Pull out constant for disabled value --- .../android/instancemanagement/InstanceListItemView.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt index afcf1639644..522b57abdf9 100644 --- a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt +++ b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt @@ -15,6 +15,8 @@ import java.util.Locale object InstanceListItemView { + private const val DISABLED_ALPHA = 0.38f + @JvmStatic fun setInstance(view: View, instance: Instance, shouldCheckDisabled: Boolean) { val context = view.context @@ -90,10 +92,10 @@ object InstanceListItemView { disabledCause.text = disabledMessage // Material design "disabled" opacity is 38%. - formTitle.alpha = 0.38f - formSubtitle.alpha = 0.38f - disabledCause.alpha = 0.38f - imageView.alpha = 0.38f + formTitle.alpha = DISABLED_ALPHA + formSubtitle.alpha = DISABLED_ALPHA + disabledCause.alpha = DISABLED_ALPHA + imageView.alpha = DISABLED_ALPHA } private fun setUpSubtext(view: View, instance: Instance, context: Context) { From cdd3745b8a900c0409ee9bb59f759e0db771ebac Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Fri, 29 Sep 2023 14:34:44 +0100 Subject: [PATCH 45/46] Add deprecation notice to method that should be replaced --- .../collect/android/instancemanagement/InstanceListItemView.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt index 522b57abdf9..ab0c513197c 100644 --- a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt +++ b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt @@ -18,6 +18,7 @@ object InstanceListItemView { private const val DISABLED_ALPHA = 0.38f @JvmStatic + @Deprecated("This should eventually be replaced by a ViewHolder or View implementation") fun setInstance(view: View, instance: Instance, shouldCheckDisabled: Boolean) { val context = view.context From 6dc15fd1daacc8b9ea60c5d712cef97027cb66c4 Mon Sep 17 00:00:00 2001 From: Callum Stott Date: Mon, 2 Oct 2023 10:06:56 +0100 Subject: [PATCH 46/46] Fix quantity strings --- .../java/org/odk/collect/android/support/pages/Page.kt | 2 +- .../odk/collect/android/activities/InstanceChooserList.java | 2 ++ .../odk/collect/strings/localization/LocalizedApplication.kt | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt index 9e18e3fb331..fb4839ba74a 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt @@ -174,7 +174,7 @@ abstract class Page> { fun checkIsSnackbarWithQuantityDisplayed(message: Int, quantity: Int): T { return checkIsSnackbarWithMessageDisplayed( ApplicationProvider.getApplicationContext() - .getLocalizedQuantityString(message, quantity) + .getLocalizedQuantityString(message, quantity, quantity) ) } diff --git a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java index 7cf9ae43403..d0f5b278f48 100644 --- a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java +++ b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java @@ -170,6 +170,7 @@ public void onCreate(Bundle savedInstanceState) { this.findViewById(android.R.id.content), getResources().getQuantityString( plurals.bulk_finalize_success, + pair.getFirst(), pair.getFirst() ) ); @@ -178,6 +179,7 @@ public void onCreate(Bundle savedInstanceState) { this.findViewById(android.R.id.content), getResources().getQuantityString( plurals.bulk_finalize_failure, + pair.getFirst(), pair.getFirst() ) ); diff --git a/strings/src/main/java/org/odk/collect/strings/localization/LocalizedApplication.kt b/strings/src/main/java/org/odk/collect/strings/localization/LocalizedApplication.kt index fcad37deae8..ce57c768e24 100644 --- a/strings/src/main/java/org/odk/collect/strings/localization/LocalizedApplication.kt +++ b/strings/src/main/java/org/odk/collect/strings/localization/LocalizedApplication.kt @@ -32,7 +32,7 @@ fun Context.getLocalizedResources(locale: Locale): Resources { return createConfigurationContext(newConfig).resources } -fun Context.getLocalizedQuantityString(stringId: Int, quantity: Int): String { +fun Context.getLocalizedQuantityString(stringId: Int, quantity: Int, vararg formatArgs: Any): String { val locale = when (applicationContext) { is LocalizedApplication -> (applicationContext as LocalizedApplication).locale @@ -47,7 +47,7 @@ fun Context.getLocalizedQuantityString(stringId: Int, quantity: Int): String { return createConfigurationContext(newConfig) .resources - .getQuantityString(stringId, quantity) + .getQuantityString(stringId, quantity, *formatArgs) } fun Context.isLTR(): Boolean {