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"
);