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/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/build.gradle b/collect_app/build.gradle index 9401a7c27ee..ef61df867dd 100644 --- a/collect_app/build.gradle +++ b/collect_app/build.gradle @@ -207,6 +207,10 @@ android { htmlReport true lintConfig file("$rootDir/config/lint.xml") xmlReport true + + if (!project.hasProperty("lintStrings")) { + disable += ["HardcodedText"] + } } namespace 'org.odk.collect.android' } 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..6693aa54406 --- /dev/null +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt @@ -0,0 +1,106 @@ +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.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) +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(string.finalize_all_drafts) + .clickOnString(string.finalize_all_drafts) + .checkIsSnackbarWithQuantityDisplayed(plurals.bulk_finalize_success, 2) + .assertTextDoesNotExist("One Question") + .pressBack(MainMenuPage()) + + .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(string.finalize_all_drafts) + .clickOnString(string.finalize_all_drafts) + .checkIsSnackbarWithMessageDisplayed(string.bulk_finalize_partial_success, 1, 1) + .assertText("Two Question Required") + .pressBack(MainMenuPage()) + + .assertNumberOfEditableForms(1) + .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(string.finalize_all_drafts) + .clickOnString(string.finalize_all_drafts) + .checkIsSnackbarWithQuantityDisplayed(plurals.bulk_finalize_failure, 1) + + .clickOptionsIcon(string.finalize_all_drafts) + .clickOnString(string.finalize_all_drafts) + .checkIsSnackbarWithQuantityDisplayed(plurals.bulk_finalize_failure, 1) + } + + @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(string.finalize_all_drafts) + .clickOnString(string.finalize_all_drafts) + .checkIsSnackbarWithQuantityDisplayed(plurals.bulk_finalize_success, 1) + .assertTextDoesNotExist("One Question") + .pressBack(MainMenuPage()) + + .assertNumberOfFinalizedForms(2) + } +} 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/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 cd69d2881e9..104adcd5df7 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 958b9d9c5aa..656fb540ce4 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 @@ -181,7 +182,18 @@ abstract class Page> { return this as T } - fun checkIsSnackbarWithMessageDisplayed(message: Int): T { + fun checkIsSnackbarWithQuantityDisplayed(message: Int, quantity: Int): T { + return checkIsSnackbarWithMessageDisplayed( + ApplicationProvider.getApplicationContext() + .getLocalizedQuantityString(message, quantity, quantity) + ) + } + + fun checkIsSnackbarWithMessageDisplayed(message: Int, vararg formatArgs: Any): T { + return checkIsSnackbarWithMessageDisplayed(getTranslatedString(message, *formatArgs)) + } + + fun checkIsSnackbarWithMessageDisplayed(message: String): T { onView(withText(message)).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) return this as T } @@ -441,6 +453,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 a4c9059a572..f03834b522b 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,22 +44,35 @@ 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.InstancesDataService; +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.ProjectsDataService; import org.odk.collect.android.utilities.ApplicationConstants; import org.odk.collect.android.utilities.FormsRepositoryProvider; +import org.odk.collect.android.utilities.InstancesRepositoryProvider; import org.odk.collect.android.views.EmptyListView; +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.material.MaterialProgressDialogFragment; +import org.odk.collect.settings.SettingsProvider; +import org.odk.collect.strings.R.plurals; +import org.odk.collect.strings.R.string; import java.util.Arrays; import javax.inject.Inject; +import kotlin.Pair; + /** * Responsible for displaying all the valid instances in the instance directory. * @@ -80,6 +93,21 @@ public class InstanceChooserList extends AppListActivity implements AdapterView. @Inject FormsRepositoryProvider formsRepositoryProvider; + @Inject + Scheduler scheduler; + + @Inject + InstancesRepositoryProvider instancesRepositoryProvider; + + @Inject + EntitiesRepositoryProvider entitiesRepositoryProvider; + + @Inject + SettingsProvider settingsProvider; + + @Inject + InstancesDataService instancesDataService; + private final ActivityResultLauncher formLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { setResult(RESULT_OK, result.getData()); finish(); @@ -123,6 +151,52 @@ public void onCreate(Bundle savedInstanceState) { ); init(); + + BulkFinalizationViewModel bulkFinalizationViewModel = new BulkFinalizationViewModel( + scheduler, + instancesDataService + ); + + 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()) { + Pair pair = finalizedForms.getValue(); + if (pair.getSecond().equals(0)) { + SnackbarUtils.showLongSnackbar( + this.findViewById(android.R.id.content), + getResources().getQuantityString( + plurals.bulk_finalize_success, + pair.getFirst(), + pair.getFirst() + ) + ); + } else if (pair.getFirst().equals(pair.getSecond())) { + SnackbarUtils.showLongSnackbar( + this.findViewById(android.R.id.content), + getResources().getQuantityString( + plurals.bulk_finalize_failure, + pair.getFirst(), + pair.getFirst() + ) + ); + } else { + SnackbarUtils.showLongSnackbar( + this.findViewById(android.R.id.content), + getString(string.bulk_finalize_partial_success, pair.getFirst() - pair.getSecond(), pair.getSecond()) + ); + } + + finalizedForms.consume(); + } + }); } private void init() { 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..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,140 +20,39 @@ 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.forms.Form; +import org.odk.collect.android.database.DatabaseObjectMapper; +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.forms.instances.Instance; -import org.odk.collect.android.external.InstanceProvider; -import org.odk.collect.android.database.instances.DatabaseInstanceColumns; -import org.odk.collect.android.utilities.FormsRepositoryProvider; - -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; } @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); - - setUpSubtext(view); - - // 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 = getCursor().getString(getCursor().getColumnIndex(DatabaseInstanceColumns.JR_FORM_ID)); - String formVersion = getCursor().getString(getCursor().getColumnIndex(DatabaseInstanceColumns.JR_VERSION)); - Form form = new FormsRepositoryProvider(context.getApplicationContext()).get().getLatestByFormIdAndVersion(formId, formVersion); - - if (form != null) { - String base64RSAPublicKey = form.getBASE64RSAPublicKey(); - formExists = true; - isFormEncrypted = base64RSAPublicKey != null; - } - - long date = getCursor().getLong(getCursor().getColumnIndex(DatabaseInstanceColumns.DELETED_DATE)); - - 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) { - 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)); - - 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)); - - int imageResourceId = getFormStateImageResourceIdForStatus(formStatus); - imageView.setImageResource(imageResourceId); - imageView.setTag(imageResourceId); - } - 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 +62,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 d27ce687b8e..c402d4d4119 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/entities/EntitiesRepositoryProvider.kt b/collect_app/src/main/java/org/odk/collect/android/entities/EntitiesRepositoryProvider.kt index e756f909734..6d62ec55388 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.ProjectsDataService import org.odk.collect.androidshared.data.getState import org.odk.collect.entities.EntitiesRepository -class EntitiesRepositoryProvider(application: Application) { +class EntitiesRepositoryProvider(application: Application, private val projectsDataService: ProjectsDataService) { private val repositories = application.getState().get(MAP_KEY, mutableMapOf()) - fun get(projectId: String): EntitiesRepository { + fun get(projectId: String = projectsDataService.getCurrentProject().uuid): EntitiesRepository { return repositories.getOrPut(projectId) { InMemEntitiesRepository() } 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 new file mode 100644 index 00000000000..54384fca25b --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt @@ -0,0 +1,194 @@ +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.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.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 loadFormDef(xForm: File, projectRootDir: File, formMediaDir: File): FormDef? { + FormUtils.setupReferenceManagerForForm(ReferenceManager.instance(), projectRootDir, formMediaDir) + 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, + formMediaDir: File, + instance: File + ): FormController { + val instanceInit = InstanceInitializationFactory() + + importInstance(instance, formEntryController) + formEntryController.model.form.initialize(false, instanceInit) + + return JavaRosaFormController( + formMediaDir, + formEntryController, + instance + ) + } + + 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, + instancesRepository: InstancesRepository, + entitiesRepository: EntitiesRepository + ): Instance? { + val instance = + getInstanceFromFormController(formController, instancesRepository)!! + + val valid = finalizeInstance(formController, entitiesRepository) + + 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 { + instancesRepository.save( + Instance.Builder(instance) + .status(Instance.STATUS_INVALID) + .build() + ) + + null + } + } + + private fun getInstanceFromFormController( + formController: FormController, + instancesRepository: InstancesRepository + ): Instance? { + val instancePath = formController.getInstanceFile()!!.absolutePath + return instancesRepository.getOneByPath(instancePath) + } + + private fun saveFormToDisk(formController: FormController) { + val payload = formController.getSubmissionXml() + FileUtils.write(formController.getInstanceFile(), payload!!.payloadBytes) + } + + @JvmStatic + private 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) { + 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 + ) + } + } +} 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/formmanagement/InstancesAppState.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesAppState.kt deleted file mode 100644 index f4d3d62c5b1..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesAppState.kt +++ /dev/null @@ -1,52 +0,0 @@ -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.ProjectsDataService -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( - private val context: Context, - private val instancesRepositoryProvider: InstancesRepositoryProvider, - private val projectsDataService: ProjectsDataService -) { - - 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 - - fun update() { - val instancesRepository = instancesRepositoryProvider.get() - - val sendableInstances = instancesRepository.getCountByStatus( - Instance.STATUS_COMPLETE, - 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) - - context.contentResolver.notifyChange( - InstancesContract.getUri(projectsDataService.getCurrentProject().uuid), - null - ) - } -} 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 new file mode 100644 index 00000000000..21ee078ec9e --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt @@ -0,0 +1,91 @@ +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 +import org.odk.collect.androidshared.data.AppState +import org.odk.collect.forms.instances.Instance +import java.io.File + +class InstancesDataService( + private val appState: AppState, + 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) + val sendableCount: LiveData = appState.getLive(SENDABLE_COUNT_KEY, 0) + val sentCount: LiveData = appState.getLive(SENT_COUNT_KEY, 0) + + fun update() { + val instancesRepository = instancesRepositoryProvider.get() + + val sendableInstances = instancesRepository.getCountByStatus( + Instance.STATUS_COMPLETE, + Instance.STATUS_SUBMISSION_FAILED + ) + val sentInstances = instancesRepository.getCountByStatus( + Instance.STATUS_SUBMITTED, + Instance.STATUS_SUBMISSION_FAILED + ) + val editableInstances = instancesRepository.getCountByStatus( + Instance.STATUS_INCOMPLETE, + Instance.STATUS_INVALID + ) + + appState.setLive(EDITABLE_COUNT_KEY, editableInstances) + appState.setLive(SENDABLE_COUNT_KEY, sendableInstances) + appState.setLive(SENT_COUNT_KEY, sentInstances) + + onUpdate() + } + + fun finalizeAllDrafts(): Pair { + 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) + + 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, projectRootDir, formMediaDir)!! + + val formEntryController = CollectFormEntryControllerFactory().create(formDef) + val instanceFile = File(instance.instanceFilePath) + val formController = + FormEntryUseCases.loadDraft(formEntryController, formMediaDir, instanceFile) + + val instance = FormEntryUseCases.finalizeDraft( + formController, + instancesRepository, + entitiesRepository + ) + + if (instance == null) { + failCount + 1 + } else { + failCount + } + } + + update() + return Pair(instances.size, totalFailed) + } + + 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/formmanagement/drafts/BulkFinalizationViewModel.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt new file mode 100644 index 00000000000..72f1b4ae644 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt @@ -0,0 +1,34 @@ +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.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( + private val scheduler: Scheduler, + private val instancesDataService: InstancesDataService +) { + 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) + } + ) + } +} 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/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/injection/config/AppDependencyComponent.java b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyComponent.java index 1b8b1b3cf95..0a92b293317 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; @@ -297,8 +296,6 @@ interface Builder { ProjectsDataService 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 01dbb62cc80..8b81d6345e2 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 @@ -45,6 +45,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; @@ -55,7 +56,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; @@ -152,6 +153,8 @@ import dagger.Module; import dagger.Provides; +import kotlin.Unit; +import kotlin.jvm.functions.Function0; import okhttp3.OkHttpClient; /** @@ -385,8 +388,8 @@ public AudioRecorder providesAudioRecorder(Application application) { @Provides @Singleton - public EntitiesRepositoryProvider provideEntitiesRepositoryProvider(Application application) { - return new EntitiesRepositoryProvider(application); + public EntitiesRepositoryProvider provideEntitiesRepositoryProvider(Application application, ProjectsDataService projectsDataService) { + return new EntitiesRepositoryProvider(application, projectsDataService); } @Provides @@ -457,9 +460,17 @@ public UUIDGenerator providesUUIDGenerator() { } @Provides - @Singleton - public InstancesAppState providesInstancesAppState(Application application, InstancesRepositoryProvider instancesRepositoryProvider, ProjectsDataService projectsDataService) { - return new InstancesAppState(application, instancesRepositoryProvider, projectsDataService); + public InstancesDataService providesInstancesDataService(Application application, InstancesRepositoryProvider instancesRepositoryProvider, ProjectsDataService projectsDataService, FormsRepositoryProvider formsRepositoryProvider, EntitiesRepositoryProvider entitiesRepositoryProvider, StoragePathProvider storagePathProvider) { + Function0 onUpdate = () -> { + application.getContentResolver().notifyChange( + InstancesContract.getUri(projectsDataService.getCurrentProject().getUuid()), + null + ); + + return null; + }; + + return new InstancesDataService(getState(application), formsRepositoryProvider, instancesRepositoryProvider, entitiesRepositoryProvider, storagePathProvider, onUpdate); } @Provides @@ -494,12 +505,12 @@ public ReadyToSendViewModel.Factory providesReadyToSendViewModel(InstancesReposi @Provides public MainMenuViewModelFactory providesMainMenuViewModelFactory(VersionInformation versionInformation, Application application, - SettingsProvider settingsProvider, InstancesAppState instancesAppState, + SettingsProvider settingsProvider, InstancesDataService instancesDataService, Scheduler scheduler, ProjectsDataService projectsDataService, AnalyticsInitializer analyticsInitializer, PermissionsChecker permissionChecker, FormsRepositoryProvider formsRepositoryProvider, InstancesRepositoryProvider instancesRepositoryProvider, AutoSendSettingsProvider autoSendSettingsProvider) { - return new MainMenuViewModelFactory(versionInformation, application, settingsProvider, instancesAppState, scheduler, projectsDataService, analyticsInitializer, permissionChecker, formsRepositoryProvider, instancesRepositoryProvider, autoSendSettingsProvider); + return new MainMenuViewModelFactory(versionInformation, application, settingsProvider, instancesDataService, scheduler, projectsDataService, analyticsInitializer, permissionChecker, formsRepositoryProvider, instancesRepositoryProvider, autoSendSettingsProvider); } @Provides @@ -518,9 +529,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 @@ -647,6 +658,6 @@ public ImageCompressionController providesImageCompressorManager() { @Provides public FormLoaderTask.FormEntryControllerFactory formEntryControllerFactory(SettingsProvider settingsProvider) { - return new CollectFormEntryControllerFactory(settingsProvider.getUnprotectedSettings()); + return new CollectFormEntryControllerFactory(); } } 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 19798ce256d..ff18110b5b7 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 @@ -1,10 +1,74 @@ 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.settings.SettingsProvider import org.odk.collect.settings.keys.ProtectedProjectKeys +import org.odk.collect.strings.R +import timber.log.Timber +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private val editableStatuses = arrayOf( + Instance.STATUS_INCOMPLETE, + Instance.STATUS_INVALID +) fun Instance.canBeEdited(settingsProvider: SettingsProvider): Boolean { - return this.status == Instance.STATUS_INCOMPLETE && + return editableStatuses.contains(this.status) && settingsProvider.getProtectedSettings().getBoolean(ProtectedProjectKeys.KEY_EDIT_SAVED) } + +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.saved_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/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..ab0c513197c --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt @@ -0,0 +1,125 @@ +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 { + + 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 + + val imageView = view.findViewById(R.id.image) + setImageFromStatus(imageView, instance) + setUpSubtext(view, instance, context) + + val chip = view.findViewById(R.id.chip) + if (chip != null) { + if (instance.status == Instance.STATUS_INVALID || instance.status == Instance.STATUS_INCOMPLETE) { + chip.visibility = View.VISIBLE + } + } + + 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) + } + } 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 = DISABLED_ALPHA + formSubtitle.alpha = DISABLED_ALPHA + disabledCause.alpha = DISABLED_ALPHA + imageView.alpha = DISABLED_ALPHA + } + + 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() + } +} 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/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/main/java/org/odk/collect/android/mainmenu/MainMenuViewModel.kt b/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuViewModel.kt index 10821f81564..eba26566c5c 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 @@ -5,7 +5,7 @@ import android.net.Uri import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel 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.instancemanagement.InstanceDiskSynchronizer import org.odk.collect.android.instancemanagement.autosend.AutoSendSettingsProvider import org.odk.collect.android.instancemanagement.autosend.shouldFormBeSentAutomatically @@ -25,7 +25,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, @@ -92,19 +92,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 353e8a31d11..f95c25c82c7 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.ProjectsDataService 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 projectsDataService: ProjectsDataService, 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/preferences/Defaults.kt b/collect_app/src/main/java/org/odk/collect/android/preferences/Defaults.kt index 4933db3b206..8ac233b04e0 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 @@ -53,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/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/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/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 bf1cdd36767..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 @@ -3,15 +3,33 @@ 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 +39,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 +57,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/main/res/menu/drafts.xml b/collect_app/src/main/res/menu/drafts.xml new file mode 100644 index 00000000000..a1411d3013a --- /dev/null +++ b/collect_app/src/main/res/menu/drafts.xml @@ -0,0 +1,8 @@ + + + + + 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"> - - (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") 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..109f248c558 --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt @@ -0,0 +1,158 @@ +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.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 +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.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 +import java.io.File +import java.io.StringReader + +class FormEntryUseCasesTest { + + private val projectRootDir = TempFiles.createTempDir() + private val instancesRepository = InMemInstancesRepository() + + @Before + fun setup() { + XFormsModule().registerModule() + } + + @Test + fun finalizeDraft_whenValidationFails_marksInstanceAsHavingErrors() { + val formMediaDir = TempFiles.createTempDir() + val xForm = copyTestForm("forms/two-question-required.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() + ) + + assertThat( + instancesRepository.get(instance.dbId)!!.status, + equalTo(Instance.STATUS_INVALID) + ) + } + + @Test + fun finalizeDraft_canCreatePartialSubmissions() { + val formMediaDir = TempFiles.createTempDir() + val xForm = copyTestForm("forms/one-question-partial.xml") + val formDef = parseForm(xForm, projectRootDir, formMediaDir) + val instance = createDraft(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")) + } + + @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 + ) + return XFormUtils.getFormFromFormXml(xForm.absolutePath, null) + } + + private fun createDraft( + 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) } + } + } +} 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 new file mode 100644 index 00000000000..405ad6efb20 --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceExtKtTest.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 InstanceExtKtTest { + + 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.saved_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/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..35c0acfedeb --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceListItemViewTest.kt @@ -0,0 +1,58 @@ +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_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)) + } +} 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 e494527d0e2..aebce300dbc 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 @@ -31,7 +31,7 @@ import org.odk.collect.android.activities.InstanceChooserList 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.instancemanagement.send.InstanceUploaderListActivity @@ -86,7 +86,7 @@ class MainMenuActivityTest { versionInformation: VersionInformation, application: Application, settingsProvider: SettingsProvider, - instancesAppState: InstancesAppState, + instancesDataService: InstancesDataService, scheduler: Scheduler, projectsDataService: ProjectsDataService, analyticsInitializer: AnalyticsInitializer, @@ -99,7 +99,7 @@ class MainMenuActivityTest { versionInformation, application, settingsProvider, - instancesAppState, + instancesDataService, scheduler, projectsDataService, analyticsInitializer, 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..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. *

@@ -24,6 +26,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"; @@ -191,6 +194,7 @@ public Long getLastStatusChangeDate() { return lastStatusChangeDate; } + @Nullable public Long getDeletedDate() { return deletedDate; } 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/settings/src/main/java/org/odk/collect/settings/keys/ProjectKeys.kt b/settings/src/main/java/org/odk/collect/settings/keys/ProjectKeys.kt index e3908bb6c0f..2e255b9b67a 100644 --- a/settings/src/main/java/org/odk/collect/settings/keys/ProjectKeys.kt +++ b/settings/src/main/java/org/odk/collect/settings/keys/ProjectKeys.kt @@ -54,9 +54,6 @@ object ProjectKeys { const val KEY_BACKGROUND_LOCATION = "background_location" const val KEY_BACKGROUND_RECORDING = "background_recording" - // experimental_preferences.xml - const val KEY_PREDICATE_CACHING = "predicate_caching" - // values const val PROTOCOL_SERVER = "odk_default" const val PROTOCOL_GOOGLE_SHEETS = "google_sheets" 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() diff --git a/strings/src/main/res/values-cs/strings.xml b/strings/src/main/res/values-cs/strings.xml index 0196667ebd3..a56bf54d098 100644 --- a/strings/src/main/res/values-cs/strings.xml +++ b/strings/src/main/res/values-cs/strings.xml @@ -961,8 +961,7 @@ 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 c0ee7720881..672591d0e5f 100644 --- a/strings/src/main/res/values-de/strings.xml +++ b/strings/src/main/res/values-de/strings.xml @@ -958,7 +958,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 83f453844c9..32853e00915 100644 --- a/strings/src/main/res/values-es/strings.xml +++ b/strings/src/main/res/values-es/strings.xml @@ -962,8 +962,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 7b8ca2889d0..c5bff5a33db 100644 --- a/strings/src/main/res/values-fi/strings.xml +++ b/strings/src/main/res/values-fi/strings.xml @@ -958,8 +958,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 eacf8b08323..28fed5b17e0 100644 --- a/strings/src/main/res/values-fr/strings.xml +++ b/strings/src/main/res/values-fr/strings.xml @@ -963,8 +963,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 65f3e9dcefe..1dd6cacca5d 100644 --- a/strings/src/main/res/values-it/strings.xml +++ b/strings/src/main/res/values-it/strings.xml @@ -959,8 +959,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 04f0cec6096..267f5c019bb 100644 --- a/strings/src/main/res/values/strings.xml +++ b/strings/src/main/res/values/strings.xml @@ -1184,7 +1184,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. 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 + + + + + + + + + + + + + + + + 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 + + + + + + + + + + + + + + + + + + +