diff --git a/app/assets/locales/android_translatable_strings.txt b/app/assets/locales/android_translatable_strings.txt index 588441a279..f8a7db0323 100644 --- a/app/assets/locales/android_translatable_strings.txt +++ b/app/assets/locales/android_translatable_strings.txt @@ -167,7 +167,9 @@ form.entry.incomplete.save.success=Form saved as incomplete form.entry.save.error=Sorry, form save failed. Please contact CommCare Support to look into the issue. form.entry.save.invalid.unicode=Could not save '${0}' text in form. form.entry.finish.button=FINISH +form.entry.exit.button=EXIT form.entry.restart.after.expiration=You were logged out due to session expiration. The form you were in the middle of has been saved and resumed. +form.entry.restart.after.session.pause=CommCare was closed and the form you were in the middle of has been saved and resumed. login.attempt.badcred=Username or password are incorrect. Please try again. diff --git a/app/build.gradle b/app/build.gradle index 36ad2d888a..afd2d4d746 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -105,6 +105,7 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version" // Markdown implementation "io.noties.markwon:core:$markwon_version" diff --git a/app/res/xml/main_preferences.xml b/app/res/xml/main_preferences.xml index 82de52108e..11985e64df 100644 --- a/app/res/xml/main_preferences.xml +++ b/app/res/xml/main_preferences.xml @@ -54,6 +54,13 @@ android:entryValues="@array/pref_enabled_vals" android:key="cc-enable-tts" android:title="Enable Text To Speech"/> + purgeNotifications() { synchronized (pendingMessages) { - context.sendBroadcast(new Intent(ACTION_PURGE_NOTIFICATIONS)); + sendBroadcastFailSafe(context, new Intent(ACTION_PURGE_NOTIFICATIONS), null); ArrayList cloned = (ArrayList)pendingMessages.clone(); clearNotifications(null); return cloned; diff --git a/app/src/org/commcare/activities/FormEntryActivity.java b/app/src/org/commcare/activities/FormEntryActivity.java index f127f1b6a2..0f80dfed31 100644 --- a/app/src/org/commcare/activities/FormEntryActivity.java +++ b/app/src/org/commcare/activities/FormEntryActivity.java @@ -56,8 +56,10 @@ import org.commcare.logic.AndroidFormController; import org.commcare.models.AndroidSessionWrapper; import org.commcare.models.FormRecordProcessor; +import org.commcare.models.database.InterruptedFormState; import org.commcare.models.database.SqlStorage; import org.commcare.preferences.HiddenPreferences; +import org.commcare.preferences.MainConfigurablePreferences; import org.commcare.services.FCMMessageData; import org.commcare.services.PendingSyncAlertBroadcastReceiver; import org.commcare.tasks.FormLoaderTask; @@ -105,8 +107,12 @@ import androidx.appcompat.app.ActionBar; import androidx.core.app.ActivityCompat; +import static org.commcare.activities.components.FormEntryConstants.DO_NOT_EXIT; +import static org.commcare.activities.components.FormEntryConstants.EXIT; import static org.commcare.android.database.user.models.FormRecord.QuarantineReason_LOCAL_PROCESSING_ERROR; +import static org.commcare.android.database.user.models.FormRecord.QuarantineReason_RECORD_ERROR; import static org.commcare.sync.FirebaseMessagingDataSyncer.PENGING_SYNC_ALERT_ACTION; +import static org.commcare.tasks.SaveToDiskTask.SaveStatus.SAVE_UNRECOVERABLE_ERROR; /** * Displays questions, animates transitions between @@ -148,6 +154,7 @@ public class FormEntryActivity extends SaveSessionCommCareActivity formDefStorage = CommCareApplication.instance() .getAppStorage(FormDefRecord.class); if (intent.hasExtra(KEY_FORM_RECORD_ID)) { - Pair instanceAndStatus = instanceState.getFormDefIdForRecord( - formDefStorage, - intent.getIntExtra(KEY_FORM_RECORD_ID, -1), - instanceState); + int formRecordId = intent.getIntExtra(KEY_FORM_RECORD_ID, -1); + Pair instanceAndStatus = instanceState.getFormDefIdForRecord(formDefStorage, + formRecordId, instanceState); + formId = instanceAndStatus.first; instanceIsReadOnly = instanceAndStatus.second; + + // only retrieve a potentially stored form index when loading an existing form record + savedFormSession = retrieveAndValidateFormIndex( + CommCareApplication.instance().getCurrentSessionWrapper()); + if (savedFormSession != null) { + Logger.log(LogTypes.TYPE_FORM_ENTRY, "Recovering form entry session"); + } } else if (intent.hasExtra(KEY_FORM_DEF_ID)) { formId = intent.getIntExtra(KEY_FORM_DEF_ID, -1); instanceState.setFormDefPath(FormFileSystemHelpers.getFormDefPath(formDefStorage, formId)); @@ -1020,7 +1066,7 @@ private void loadForm() { } mFormLoaderTask = new FormLoaderTask(symetricKey, instanceIsReadOnly, - formEntryRestoreSession.isRecording(), FormEntryInstanceState.mFormRecordPath, this) { + formEntryRestoreSession.isRecording(), FormEntryInstanceState.mFormRecordPath, this, savedFormSession) { @Override protected void deliverResult(FormEntryActivity receiver, FECWrapper wrapperResult) { receiver.handleFormLoadCompletion(wrapperResult.getController()); @@ -1063,7 +1109,22 @@ protected void deliverError(FormEntryActivity receiver, Exception e) { } } + private InterruptedFormState retrieveAndValidateFormIndex(AndroidSessionWrapper androidSessionWrapper) { + InterruptedFormState interruptedFormState = HiddenPreferences.getInterruptedFormState(); + if (interruptedFormState!= null + && interruptedFormState.getSessionStateDescriptorId() == androidSessionWrapper.getSessionDescriptorId() + && (interruptedFormState.getFormRecordId() == -1 + || interruptedFormState.getFormRecordId() == androidSessionWrapper.getFormRecordId())) { + return interruptedFormState; + } + // data format is invalid, so better to clear the data + HiddenPreferences.clearInterruptedFormState(); + return null; + } + private void handleFormLoadCompletion(AndroidFormController fc) { + HiddenPreferences.clearInterruptedFormState(); + if (PollSensorAction.XPATH_ERROR_ACTION.equals(locationRecieverErrorAction)) { handleXpathErrorBroadcast(); } @@ -1092,11 +1153,18 @@ private void handleFormLoadCompletion(AndroidFormController fc) { formEntryRestoreSession.replaySession(this); + // jump to form index, no action if null + mFormController.returnToStoredIndex(); + uiController.refreshView(); FormNavigationUI.updateNavigationCues(this, mFormController, uiController.questionsView); if (isRestartAfterSessionExpiration) { - Toast.makeText(this, - Localization.get("form.entry.restart.after.expiration"), Toast.LENGTH_LONG).show(); + // InterruptedFormState null check is important to ensure backward compatibility + String localeKey = + (fc.getInterruptedFormState() == null + || fc.getInterruptedFormState().isInterruptedDueToSessionExpiration()) + ? "form.entry.restart.after.expiration" : "form.entry.restart.after.session.pause"; + Toast.makeText(this, Localization.get(localeKey), Toast.LENGTH_LONG).show(); } } @@ -1208,7 +1276,7 @@ private void registerSessionFormSaveCallback() { * continue closing the session/logging out. */ @Override - public void savingComplete(SaveToDiskTask.SaveStatus saveStatus, String errorMessage) { + public void savingComplete(SaveToDiskTask.SaveStatus saveStatus, String errorMessage, boolean exit, boolean userTriggered) { // Did we just save a form because the key session // (CommCareSessionService) is ending? if (customFormSaveCallback != null) { @@ -1216,7 +1284,9 @@ public void savingComplete(SaveToDiskTask.SaveStatus saveStatus, String errorMes customFormSaveCallback = null; toCall.run(); - returnAsInterrupted(); + if (exit) { + returnAsInterrupted(); + } } else if (saveStatus != null) { String toastMessage = ""; switch (saveStatus) { @@ -1225,7 +1295,9 @@ public void savingComplete(SaveToDiskTask.SaveStatus saveStatus, String errorMes hasSaved = true; break; case SAVED_INCOMPLETE: - toastMessage = Localization.get("form.entry.incomplete.save.success"); + if (userTriggered) { + toastMessage = Localization.get("form.entry.incomplete.save.success"); + } hasSaved = true; break; case SAVED_AND_EXIT: @@ -1242,13 +1314,15 @@ public void savingComplete(SaveToDiskTask.SaveStatus saveStatus, String errorMes uiController.refreshView(); saveAnswersForCurrentScreen(true); return; - case SAVE_ERROR: + case SAVE_ERROR, SAVE_UNRECOVERABLE_ERROR: if (!CommCareApplication.instance().isConsumerApp()) { new UserfacingErrorHandling<>().createErrorDialog(this, errorMessage, Localization.get("notification.formentry.save_error.title"), FormEntryConstants.EXIT); } - quarantineRecordOnError(errorMessage); + String reasonType = (saveStatus == SAVE_UNRECOVERABLE_ERROR) ? + QuarantineReason_LOCAL_PROCESSING_ERROR : QuarantineReason_RECORD_ERROR; + quarantineRecordOnError(errorMessage, reasonType); return; } if (!"".equals(toastMessage) && !CommCareApplication.instance().isConsumerApp()) { @@ -1259,17 +1333,13 @@ public void savingComplete(SaveToDiskTask.SaveStatus saveStatus, String errorMes } // clean the form record in case it was saved - private void quarantineRecordOnError(String errorMessage) { + private void quarantineRecordOnError(String errorMessage, String reasonType) { AndroidSessionWrapper currentState = CommCareApplication.instance().getCurrentSessionWrapper(); FormRecord toBeQuarantined = currentState.getFormRecord(); // quarantine in case the form record was saved if (toBeQuarantined != null) { - new FormRecordProcessor(this).quarantineRecord( - toBeQuarantined, - QuarantineReason_LOCAL_PROCESSING_ERROR, - errorMessage - ); + new FormRecordProcessor(this).quarantineRecord(toBeQuarantined, reasonType, errorMessage); } } @@ -1315,6 +1385,9 @@ private void finishReturnInstance() { * activity */ private void finishReturnInstance(boolean reportSaved) { + HiddenPreferences.clearInterruptedFormState(); + HiddenPreferences.clearInterruptedSSD(); + String action = getIntent().getAction(); if (Intent.ACTION_PICK.equals(action) || Intent.ACTION_EDIT.equals(action)) { Intent formReturnIntent = new Intent(); @@ -1344,6 +1417,7 @@ private void finishReturnInstance(boolean reportSaved) { dismissCurrentProgressDialog(); reportFormExitTime(); + triggeredExit = true; finish(); } diff --git a/app/src/org/commcare/activities/FormEntryActivityUIController.java b/app/src/org/commcare/activities/FormEntryActivityUIController.java index f917a62542..e7388a9753 100644 --- a/app/src/org/commcare/activities/FormEntryActivityUIController.java +++ b/app/src/org/commcare/activities/FormEntryActivityUIController.java @@ -82,6 +82,7 @@ public class FormEntryActivityUIController implements CommCareActivityUIControll private boolean formRelevanciesUpdateInProgress = false; private static final String KEY_LAST_CHANGED_WIDGET = "index-of-last-changed-widget"; + private TextView finishText; enum AnimationType { LEFT, RIGHT, FADE @@ -103,7 +104,7 @@ public void setupUI() { View finishButton = activity.findViewById(R.id.nav_btn_finish); - TextView finishText = finishButton.findViewById(R.id.nav_btn_finish_text); + finishText = finishButton.findViewById(R.id.nav_btn_finish_text); finishText.setText(Localization.get("form.entry.finish.button").toUpperCase()); nextButton.setOnClickListener(v -> { @@ -199,6 +200,10 @@ protected void refreshCurrentView(boolean animateLastView) { QuestionsView current = createView(); showView(current, AnimationType.FADE, animateLastView); } + + if (finishText != null && FormEntryActivity.mFormController.isFormReadOnly()) { + finishText.setText(Localization.get("form.entry.exit.button").toUpperCase()); + } } /** diff --git a/app/src/org/commcare/activities/HomeScreenBaseActivity.java b/app/src/org/commcare/activities/HomeScreenBaseActivity.java index e5eb7e1c94..fdac431f72 100644 --- a/app/src/org/commcare/activities/HomeScreenBaseActivity.java +++ b/app/src/org/commcare/activities/HomeScreenBaseActivity.java @@ -415,6 +415,12 @@ private boolean tryRestoringFormFromSessionExpiration() { if (existing != null) { AndroidSessionWrapper state = CommCareApplication.instance().getCurrentSessionWrapper(); state.loadFromStateDescription(existing); + + FormRecord formRecord = state.getFormRecord(); + //TODO: Temporary for GD, to remove + Logger.log(LogTypes.TYPE_FORM_ENTRY, "Restoring form from expired Session |" + + (formRecord.getInstanceID() == null ? "" : formRecord.getInstanceID() + "|") + + formRecord.getFormNamespace()); formEntry(CommCareApplication.instance().getCommCarePlatform() .getFormDefId(state.getSession().getForm()), state.getFormRecord(), null, true); @@ -632,6 +638,11 @@ int record = intent.getIntExtra("FORMRECORDS", -1); AndroidCommCarePlatform platform = CommCareApplication.instance().getCommCarePlatform(); + + //TODO: Temporary for GD, to remove + Logger.log(LogTypes.TYPE_FORM_ENTRY, "Loading an incomplete form |" + + (r.getInstanceID() == null ? "" : r.getInstanceID() + "|") + + r.getFormNamespace()); formEntry(platform.getFormDefId(r.getFormNamespace()), r); return; } diff --git a/app/src/org/commcare/interfaces/FormSaveCallback.java b/app/src/org/commcare/interfaces/FormSaveCallback.java index f404af1692..9029fb7d23 100644 --- a/app/src/org/commcare/interfaces/FormSaveCallback.java +++ b/app/src/org/commcare/interfaces/FormSaveCallback.java @@ -8,5 +8,5 @@ public interface FormSaveCallback { * Starts a task to save the current form being edited. Will be expected to call the provided * listener when saving is complete and the current session state is no longer volatile */ - void formSaveCallback(Runnable callback); + void formSaveCallback(boolean sessionExpired, boolean userTriggered, Runnable callback); } diff --git a/app/src/org/commcare/interfaces/FormSavedListener.java b/app/src/org/commcare/interfaces/FormSavedListener.java index d5ee318387..6179e100ee 100644 --- a/app/src/org/commcare/interfaces/FormSavedListener.java +++ b/app/src/org/commcare/interfaces/FormSavedListener.java @@ -10,5 +10,5 @@ public interface FormSavedListener { /** * Callback to be run after a form has been saved. */ - void savingComplete(SaveToDiskTask.SaveStatus formSaveStatus, String errorMessage); + void savingComplete(SaveToDiskTask.SaveStatus formSaveStatus, String errorMessage, boolean exit, boolean userTriggered); } diff --git a/app/src/org/commcare/logic/AndroidFormController.java b/app/src/org/commcare/logic/AndroidFormController.java index 79892da3f0..65e6741a09 100644 --- a/app/src/org/commcare/logic/AndroidFormController.java +++ b/app/src/org/commcare/logic/AndroidFormController.java @@ -1,18 +1,17 @@ package org.commcare.logic; + import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.commcare.google.services.analytics.FormAnalyticsHelper; -import org.commcare.utils.FileUtil; +import org.commcare.models.database.InterruptedFormState; import org.commcare.views.widgets.WidgetFactory; import org.javarosa.core.model.FormDef; import org.javarosa.core.model.FormIndex; import org.javarosa.form.api.FormController; import org.javarosa.form.api.FormEntryController; -import java.io.File; -import java.util.Date; - /** * Wrapper around FormController to handle Android-specific form entry actions */ @@ -23,12 +22,18 @@ public class AndroidFormController extends FormController implements PendingCall private boolean wasPendingCalloutCancelled; private FormIndex formIndexToReturnTo = null; private boolean formCompleteAndSaved = false; + @Nullable + private InterruptedFormState interruptedFormState; private FormAnalyticsHelper formAnalyticsHelper; - public AndroidFormController(FormEntryController fec, boolean readOnly) { + public AndroidFormController(FormEntryController fec, boolean readOnly, @Nullable InterruptedFormState interruptedFormState) { super(fec, readOnly); formAnalyticsHelper = new FormAnalyticsHelper(); + this.interruptedFormState = interruptedFormState; + if (interruptedFormState !=null) { + formIndexToReturnTo = interruptedFormState.getFormIndex(); + } } @Override @@ -88,4 +93,8 @@ public FormAnalyticsHelper getFormAnalyticsHelper() { public FormDef getFormDef() { return mFormEntryController.getModel().getForm(); } + + public InterruptedFormState getInterruptedFormState(){ + return interruptedFormState; + } } diff --git a/app/src/org/commcare/models/AndroidSessionWrapper.java b/app/src/org/commcare/models/AndroidSessionWrapper.java index 308ece8254..9419371875 100755 --- a/app/src/org/commcare/models/AndroidSessionWrapper.java +++ b/app/src/org/commcare/models/AndroidSessionWrapper.java @@ -5,11 +5,12 @@ import org.commcare.CommCareApplication; import org.commcare.android.database.user.models.FormRecord; import org.commcare.android.database.user.models.SessionStateDescriptor; -import org.commcare.core.interfaces.RemoteInstanceFetcher; import org.commcare.models.database.AndroidSandbox; +import org.commcare.models.database.InterruptedFormState; import org.commcare.models.database.SqlStorage; import org.commcare.modern.session.SessionWrapper; import org.commcare.modern.session.SessionWrapperInterface; +import org.commcare.modern.util.Pair; import org.commcare.preferences.HiddenPreferences; import org.commcare.session.CommCareSession; import org.commcare.session.SessionDescriptorUtil; @@ -20,12 +21,12 @@ import org.commcare.suite.model.Entry; import org.commcare.suite.model.FormEntry; import org.commcare.suite.model.SessionDatum; -import org.commcare.suite.model.StackFrameStep; import org.commcare.suite.model.StackOperation; import org.commcare.util.CommCarePlatform; import org.commcare.utils.AndroidInstanceInitializer; import org.commcare.utils.CommCareUtil; import org.commcare.utils.CrashUtil; +import org.javarosa.core.model.FormIndex; import org.javarosa.core.model.condition.EvaluationContext; import org.javarosa.xpath.analysis.InstanceNameAccumulatingAnalyzer; import org.javarosa.xpath.analysis.XPathAnalyzable; @@ -184,12 +185,16 @@ private static boolean ssdHasValidFormRecordId(int ssdId, formRecordStorage.getMetaDataFieldForRecord(correspondingFormRecordId, FormRecord.META_STATUS)); } - public void setCurrentStateAsInterrupted() { + public void setCurrentStateAsInterrupted(FormIndex formIndex, boolean sessionExpired) { if (sessionStateRecordId != -1) { SqlStorage sessionStorage = CommCareApplication.instance().getUserStorage(SessionStateDescriptor.class); SessionStateDescriptor current = sessionStorage.read(sessionStateRecordId); + + InterruptedFormState interruptedFormState = + new InterruptedFormState(current.getID(), formIndex, current.getFormRecordId(), sessionExpired); HiddenPreferences.setInterruptedSSD(current.getID()); + HiddenPreferences.setInterruptedFormState(interruptedFormState); } } diff --git a/app/src/org/commcare/models/database/InterruptedFormState.java b/app/src/org/commcare/models/database/InterruptedFormState.java new file mode 100644 index 0000000000..4cbf00bd81 --- /dev/null +++ b/app/src/org/commcare/models/database/InterruptedFormState.java @@ -0,0 +1,72 @@ +package org.commcare.models.database; + +import org.javarosa.core.model.FormIndex; +import org.javarosa.core.util.externalizable.DeserializationException; +import org.javarosa.core.util.externalizable.ExtUtil; +import org.javarosa.core.util.externalizable.Externalizable; +import org.javarosa.core.util.externalizable.PrototypeFactory; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; + +/** + * Model to store info about an interrupted form + */ +public class InterruptedFormState implements Externalizable { + + private int sessionStateDescriptorId; + private FormIndex formIndex; + private int formRecordId = -1; + private boolean interruptedDueToSessionExpiration = false; + + public InterruptedFormState(int sessionStateDescriptorId, FormIndex formIndex, int formRecordId, boolean sessionExpired) { + this.sessionStateDescriptorId = sessionStateDescriptorId; + this.formIndex = formIndex; + this.formRecordId = formRecordId; + this.interruptedDueToSessionExpiration = sessionExpired; + } + + public InterruptedFormState() { + // serialization only + } + + @Override + public void readExternal(DataInputStream in, PrototypeFactory pf) + throws IOException, DeserializationException { + sessionStateDescriptorId = ExtUtil.readInt(in); + formIndex = (FormIndex)ExtUtil.read(in, FormIndex.class, pf); + try { + formRecordId = ExtUtil.readInt(in); + interruptedDueToSessionExpiration = ExtUtil.readBool(in); + } catch(EOFException e){ + // this is to catch errors caused by EOF when updating from the previous model which didn't have the + // formRecordId and interruptedDueToSessionExpiration fields + } + } + + @Override + public void writeExternal(DataOutputStream out) throws IOException { + ExtUtil.writeNumeric(out, sessionStateDescriptorId); + ExtUtil.write(out, formIndex); + ExtUtil.writeNumeric(out, formRecordId); + ExtUtil.writeBool(out, interruptedDueToSessionExpiration); + } + + public int getSessionStateDescriptorId() { + return sessionStateDescriptorId; + } + + public FormIndex getFormIndex() { + return formIndex; + } + + public boolean isInterruptedDueToSessionExpiration(){ + return interruptedDueToSessionExpiration; + } + + public int getFormRecordId() { + return formRecordId; + } +} diff --git a/app/src/org/commcare/preferences/HiddenPreferences.java b/app/src/org/commcare/preferences/HiddenPreferences.java index b638f93a0b..f435f5831e 100644 --- a/app/src/org/commcare/preferences/HiddenPreferences.java +++ b/app/src/org/commcare/preferences/HiddenPreferences.java @@ -6,11 +6,15 @@ import org.commcare.CommCareApplication; import org.commcare.activities.GeoPointActivity; import org.commcare.android.logging.ReportingUtils; +import org.commcare.models.database.InterruptedFormState; import org.commcare.services.FCMMessageData; +import org.commcare.util.LogTypes; import org.commcare.utils.AndroidCommCarePlatform; import org.commcare.utils.FirebaseMessagingUtil; import org.commcare.utils.GeoUtils; import org.commcare.utils.MapLayer; +import org.commcare.utils.SerializationUtil; +import org.javarosa.core.services.Logger; import java.util.Date; import java.util.concurrent.TimeUnit; @@ -112,6 +116,7 @@ public class HiddenPreferences { * be used to remove it form the user domain name to match how the domain represented in the backend */ public static final String USER_DOMAIN_SERVER_URL_SUFFIX = ".commcarehq.org"; + private static final String INTERRUPTED_FORM_INDEX = "interrupted-form-index"; /** * @return How many seconds should a user session remain open before expiring? @@ -292,6 +297,7 @@ public static void setPostUpdateSyncNeeded(boolean b) { } public static void setInterruptedSSD(int ssdId) { + Logger.log(LogTypes.TYPE_MAINTENANCE, "Saving interrupted state"); String currentUserId = CommCareApplication.instance().getCurrentUserId(); CommCareApplication.instance().getCurrentApp().getAppPreferences().edit() .putInt(ID_OF_INTERRUPTED_SSD + currentUserId, ssdId).apply(); @@ -304,6 +310,7 @@ public static int getIdOfInterruptedSSD() { } public static void clearInterruptedSSD() { + Logger.log(LogTypes.TYPE_MAINTENANCE, "Clearing interrupted state"); String currentUserId = CommCareApplication.instance().getCurrentUserId(); CommCareApplication.instance().getCurrentApp().getAppPreferences().edit() .putInt(ID_OF_INTERRUPTED_SSD + currentUserId, -1).apply(); @@ -629,4 +636,37 @@ public static void setPendingSyncDialogDisabled(boolean dialogDisabled) { public static boolean isBackgroundSyncEnabled() { return DeveloperPreferences.doesPropertyMatch(ENABLE_BACKGROUND_SYNC, PrefValues.NO, PrefValues.YES); } + + public static void setInterruptedFormState(InterruptedFormState interruptedFormState) { + try { + String currentUserId = CommCareApplication.instance().getCurrentUserId(); + CommCareApplication.instance().getCurrentApp().getAppPreferences().edit() + .putString(INTERRUPTED_FORM_INDEX + currentUserId, SerializationUtil.serializeToString(interruptedFormState)) + .apply(); + } catch (Exception e) { + Logger.exception("Error while trying to save interrupted form state into prefs", e); + } + } + + public static InterruptedFormState getInterruptedFormState() { + try { + String currentUserId = CommCareApplication.instance().getCurrentUserId(); + String interruptedFormStateStr = CommCareApplication.instance().getCurrentApp().getAppPreferences() + .getString(INTERRUPTED_FORM_INDEX + currentUserId, null); + if (interruptedFormStateStr != null) { + return SerializationUtil.deserializeFromString(interruptedFormStateStr, + InterruptedFormState.class); + } + } catch (Exception e) { + Logger.exception("Error while trying to load interrupted form state from prefs", e); + } + return null; + } + + public static void clearInterruptedFormState() { + String currentUserId = CommCareApplication.instance().getCurrentUserId(); + CommCareApplication.instance().getCurrentApp().getAppPreferences().edit() + .remove(INTERRUPTED_FORM_INDEX + currentUserId) + .apply(); + } } diff --git a/app/src/org/commcare/preferences/MainConfigurablePreferences.java b/app/src/org/commcare/preferences/MainConfigurablePreferences.java index aecdf0f9b8..2f6f671436 100755 --- a/app/src/org/commcare/preferences/MainConfigurablePreferences.java +++ b/app/src/org/commcare/preferences/MainConfigurablePreferences.java @@ -35,6 +35,7 @@ public class MainConfigurablePreferences public final static String ANALYTICS_ENABLED = "cc-analytics-enabled"; public final static String INTENT_CALLOUT_FOR_SCANNER = "cc-intent-callout-for-scanner"; public final static String ENABLE_TEXT_TO_SPEECH = "cc-enable-tts"; + public final static String AUTO_SAVE_FORM_ON_PAUSE = "cc-auto-form-save-on-pause"; // Fake settings that really act as buttons to open a new activity or choice dialog private final static String DEVELOPER_SETTINGS = "developer-settings-button"; @@ -182,6 +183,14 @@ public static boolean useIntentCalloutForScanner() { return app.getAppPreferences().getString(INTENT_CALLOUT_FOR_SCANNER, PrefValues.NO).equals(PrefValues.YES); } + public static boolean isAutoSaveFormOnPause() { + CommCareApp app = CommCareApplication.instance().getCurrentApp(); + if (app == null) { + return false; + } + return app.getAppPreferences().getString(AUTO_SAVE_FORM_ON_PAUSE, PrefValues.NO).equals(PrefValues.YES); + } + public static boolean isTTSEnabled() { CommCareApp app = CommCareApplication.instance().getCurrentApp(); if (app == null) { diff --git a/app/src/org/commcare/services/CommCareSessionService.java b/app/src/org/commcare/services/CommCareSessionService.java index 1ed06a1e51..2446ba6e9c 100644 --- a/app/src/org/commcare/services/CommCareSessionService.java +++ b/app/src/org/commcare/services/CommCareSessionService.java @@ -1,5 +1,7 @@ package org.commcare.services; +import static org.commcare.sync.ExternalDataUpdateHelper.sendBroadcastFailSafe; + import android.annotation.SuppressLint; import android.app.Notification; import android.app.NotificationManager; @@ -29,6 +31,7 @@ import org.commcare.models.database.user.UserSandboxUtils; import org.commcare.models.encryption.CipherPool; import org.commcare.preferences.HiddenPreferences; +import org.commcare.sync.ExternalDataUpdateHelper; import org.commcare.sync.FormSubmissionHelper; import org.commcare.tasks.DataSubmissionListener; import org.commcare.util.LogTypes; @@ -309,7 +312,7 @@ public void startSession(User user, UserKeyRecord record) { //Let anyone who is listening know! Intent i = new Intent("org.commcare.dalvik.api.action.session.login"); - this.sendBroadcast(i); + sendBroadcastFailSafe(this, i, null); } this.user = user; @@ -361,6 +364,7 @@ private void timeToExpireSession() { return; } try { + Logger.log(LogTypes.TYPE_USER, "Expiring user session forcefully due to session timeout"); CommCareApplication.instance().expireUserSession(); } finally { CommCareSessionService.sessionAliveLock.unlock(); @@ -382,6 +386,7 @@ private void timeToExpireSession() { } try { + Logger.log(LogTypes.TYPE_USER, "Expiring user session due to session timeout"); saveFormAndCloseSession(); } finally { CommCareSessionService.sessionAliveLock.unlock(); @@ -406,7 +411,7 @@ private void saveFormAndCloseSession() { // save form progress, if any synchronized (lock) { if (formSaver != null) { - formSaver.formSaveCallback(() -> { + formSaver.formSaveCallback(true, false, () -> { CommCareApplication.instance().expireUserSession(); }); } else { @@ -425,7 +430,7 @@ public void proceedWithSavedSessionIfNeeded(Runnable callback) { if (formSaver != null) { Toast.makeText(CommCareApplication.instance(), "Suspending existing form entry session...", Toast.LENGTH_LONG).show(); - formSaver.formSaveCallback(callback); + formSaver.formSaveCallback(true, false, callback); formSaver = null; return; } @@ -469,7 +474,7 @@ public void closeServiceResources() { // Let anyone who is listening know! Intent i = new Intent("org.commcare.dalvik.api.action.session.logout"); - this.sendBroadcast(i); + sendBroadcastFailSafe(this, i, null); Logger.log(LogTypes.TYPE_MAINTENANCE, "Logging out service login"); diff --git a/app/src/org/commcare/sync/ExternalDataUpdateHelper.java b/app/src/org/commcare/sync/ExternalDataUpdateHelper.java index c7c1f2bc3d..3d8c97f601 100644 --- a/app/src/org/commcare/sync/ExternalDataUpdateHelper.java +++ b/app/src/org/commcare/sync/ExternalDataUpdateHelper.java @@ -7,6 +7,7 @@ import android.content.Intent; import org.commcare.CommCareApplication; +import org.javarosa.core.services.Logger; import java.util.ArrayList; @@ -32,12 +33,12 @@ public static void broadcastDataUpdate(Context c, if (CommCareApplication.instance().getSession().isActive()) { i.putExtra("cc-logged-in-user-id", CommCareApplication.instance().getCurrentUserId()); } - c.sendBroadcast(i, COMMCARE_CASE_READ_PERMISSION); + sendBroadcastFailSafe(c, i, COMMCARE_CASE_READ_PERMISSION); // send explicit broadcast to CommCare Reminders App i.setComponent(new ComponentName("org.commcare.dalvik.reminders", "org.commcare.dalvik.reminders.CommCareReceiver")); - c.sendBroadcast(i); + sendBroadcastFailSafe(c, i, null); // Broadcast to CommCare, there is the option to handle the permission required by the // broadcast above @@ -48,6 +49,14 @@ public static void broadcastDataUpdate(Context c, private static void broadcastDataUpdateToCommCare(Context c){ Intent i = new Intent(COMMCARE_DATA_UPDATE_ACTION); i.setPackage(c.getPackageName()); - c.sendBroadcast(i); + sendBroadcastFailSafe(c, i, null); + } + + public static void sendBroadcastFailSafe(Context context, Intent intent, @Nullable String receiverPermission) { + try { + context.sendBroadcast(intent, receiverPermission); + } catch (Exception e) { + Logger.exception("Exception when sending a broadcast with intent " + intent, e); + } } } diff --git a/app/src/org/commcare/sync/FirebaseMessagingDataSyncer.java b/app/src/org/commcare/sync/FirebaseMessagingDataSyncer.java index db7c3ef34c..03392b3bd5 100644 --- a/app/src/org/commcare/sync/FirebaseMessagingDataSyncer.java +++ b/app/src/org/commcare/sync/FirebaseMessagingDataSyncer.java @@ -194,8 +194,7 @@ private void informUserAboutPendingSync(FCMMessageData fcmMessageData) { Bundle b = new Bundle(); b.putSerializable(FCM_MESSAGE_DATA, FirebaseMessagingUtil.serializeFCMMessageData(fcmMessageData)); intent.putExtra(FCM_MESSAGE_DATA_KEY, b); - - context.sendBroadcast(intent); + ExternalDataUpdateHelper.sendBroadcastFailSafe(context, intent, null); } @Override diff --git a/app/src/org/commcare/tasks/FormLoaderTask.java b/app/src/org/commcare/tasks/FormLoaderTask.java index 694250d600..be04c28072 100644 --- a/app/src/org/commcare/tasks/FormLoaderTask.java +++ b/app/src/org/commcare/tasks/FormLoaderTask.java @@ -1,7 +1,6 @@ package org.commcare.tasks; import android.content.Context; -import android.os.Environment; import android.util.Log; import org.commcare.CommCareApplication; @@ -14,7 +13,7 @@ import org.commcare.logging.UserCausedRuntimeException; import org.commcare.logging.XPathErrorLogger; import org.commcare.logic.AndroidFormController; -import org.commcare.logic.FileReferenceFactory; +import org.commcare.models.database.InterruptedFormState; import org.commcare.models.encryption.EncryptionIO; import org.commcare.preferences.DeveloperPreferences; import org.commcare.tasks.templates.CommCareTask; @@ -23,6 +22,7 @@ import org.commcare.utils.GlobalConstants; import org.javarosa.core.io.StreamsUtil; import org.javarosa.core.model.FormDef; +import org.javarosa.core.model.FormIndex; import org.javarosa.core.model.instance.InstanceInitializationFactory; import org.javarosa.core.model.instance.TreeElement; import org.javarosa.core.model.instance.TreeReference; @@ -64,6 +64,7 @@ public abstract class FormLoaderTask extends CommCareTask extends CommCareTask doTaskBackground(Void... nothing) { } catch (XPathException xpe) { String cleanedMessage = "An error in your form prevented it from saving: \n" + xpe.getMessage(); - return new ResultAndError<>(SaveStatus.SAVE_ERROR, cleanedMessage); + return new ResultAndError<>(SaveStatus.SAVE_UNRECOVERABLE_ERROR, cleanedMessage); } FormEntryActivity.mFormController.postProcessInstance(); @@ -111,15 +111,15 @@ protected ResultAndError doTaskBackground(Void... nothing) { exportData(mMarkCompleted); } catch (FileNotFoundException e) { e.printStackTrace(); - return new ResultAndError<>(SaveStatus.SAVE_ERROR, + return new ResultAndError<>(SaveStatus.SAVE_UNRECOVERABLE_ERROR, "Something is blocking acesss to the submission file in " + mFormRecordPath); } catch (XFormSerializer.UnsupportedUnicodeSurrogatesException e) { Logger.log(LogTypes.TYPE_ERROR_CONFIG_STRUCTURE, "Form contains invalid data encoding\n\n" + ForceCloseLogger.getStackTrace(e)); - return new ResultAndError<>(SaveStatus.SAVE_ERROR, + return new ResultAndError<>(SaveStatus.SAVE_UNRECOVERABLE_ERROR, Localization.get("form.entry.save.invalid.unicode", e.getMessage())); } catch (IOException e) { Logger.log(LogTypes.TYPE_ERROR_STORAGE, "I/O Error when serializing form\n\n" + ForceCloseLogger.getStackTrace(e)); - return new ResultAndError<>(SaveStatus.SAVE_ERROR, + return new ResultAndError<>(SaveStatus.SAVE_UNRECOVERABLE_ERROR, "Unable to write xml to " + mFormRecordPath); } catch (FormInstanceTransactionException e) { e.printStackTrace(); @@ -128,7 +128,7 @@ protected ResultAndError doTaskBackground(Void... nothing) { // Likely a user level issue, so send error to HQ as a app build error XPathErrorLogger.INSTANCE.logErrorToCurrentApp(cleanedMessage); - return new ResultAndError<>(SaveStatus.SAVE_ERROR, cleanedMessage); + return new ResultAndError<>(SaveStatus.SAVE_UNRECOVERABLE_ERROR, cleanedMessage); } if (mMarkCompleted) { @@ -272,9 +272,9 @@ protected void onPostExecute(ResultAndError result) { synchronized (this) { if (mSavedListener != null) { if (result == null) { - mSavedListener.savingComplete(SaveStatus.SAVE_ERROR, "Unknown Error"); + mSavedListener.savingComplete(SaveStatus.SAVE_ERROR, "Unknown Error", exitAfterSave, userTriggered); } else { - mSavedListener.savingComplete(result.data, result.errorMessage); + mSavedListener.savingComplete(result.data, result.errorMessage, exitAfterSave, userTriggered); } } } diff --git a/app/src/org/commcare/utils/SerializationUtil.java b/app/src/org/commcare/utils/SerializationUtil.java index b5d5c97155..a62561ecb5 100644 --- a/app/src/org/commcare/utils/SerializationUtil.java +++ b/app/src/org/commcare/utils/SerializationUtil.java @@ -2,6 +2,7 @@ import android.content.Intent; import android.os.Bundle; +import android.util.Base64; import org.commcare.CommCareApplication; import org.javarosa.core.util.externalizable.DeserializationException; @@ -44,6 +45,15 @@ public static T deserialize(byte[] bytes, Class ty public static void serializeToIntent(Intent i, String name, Externalizable data) { i.putExtra(name, serialize(data)); } + + public static String serializeToString(Externalizable data) { + return Base64.encodeToString(serialize(data), Base64.DEFAULT); + } + + public static T deserializeFromString(String data, Class type) { + byte[] decodedData = Base64.decode(data, Base64.DEFAULT); + return deserialize(decodedData, type); + } public static T deserializeFromIntent(Intent i, String name, Class type) { if(!i.hasExtra(name)) { return null;} diff --git a/app/src/org/commcare/utils/StringUtils.java b/app/src/org/commcare/utils/StringUtils.java index bacc412eca..6a21bb8751 100755 --- a/app/src/org/commcare/utils/StringUtils.java +++ b/app/src/org/commcare/utils/StringUtils.java @@ -1,18 +1,19 @@ package org.commcare.utils; -import android.annotation.SuppressLint; import android.content.Context; -import android.os.Build; import android.text.Spannable; +import androidx.annotation.NonNull; + +import com.google.gson.Gson; +import com.google.gson.JsonIOException; + +import org.commcare.modern.util.Pair; +import org.javarosa.core.services.Logger; import org.javarosa.core.services.locale.Localization; import org.javarosa.core.util.NoLocalizedTextException; -import java.text.Normalizer; -import java.util.regex.Pattern; - -import androidx.annotation.NonNull; -import androidx.collection.LruCache; +import java.io.Serializable; /** * @author ctsims diff --git a/app/unit-tests/src/org/commcare/android/tests/processing/FormStorageTest.java b/app/unit-tests/src/org/commcare/android/tests/processing/FormStorageTest.java index 3283f13401..4a1bc4fc5a 100644 --- a/app/unit-tests/src/org/commcare/android/tests/processing/FormStorageTest.java +++ b/app/unit-tests/src/org/commcare/android/tests/processing/FormStorageTest.java @@ -357,6 +357,10 @@ public class FormStorageTest { , "org.commcare.suite.model.QueryGroup" , "org.commcare.android.database.global.models.ConnectKeyRecord" , "org.commcare.android.database.global.models.ConnectKeyRecordV6" + + // Added in 2.55 + , "org.javarosa.core.model.FormIndex" + , "org.commcare.models.database.InterruptedFormState" );