diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 422a63e22b..8f183cbc9b 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -10,11 +10,12 @@
## Product Description
-## Safety Assurance
+## PR Checklist
-- [ ] If the PR is high risk, "High Risk" label is set
+- [ ] If I think the PR is high risk, "High Risk" label is set
- [ ] I have confidence that this PR will not introduce a regression for the reasons below
- [ ] Do we need to enhance manual QA test coverage ? If yes, "QA Note" label is set correctly
+- [ ] Does the PR introduce any major changes worth communicating ? If yes, "Release Note" label is set and a "Release Note" is specified in PR description.
### Automated test coverage
diff --git a/README.md b/README.md
index 02e2bafd99..7015b20623 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,12 @@ CommCare is an easily customizable, open source mobile platform that supports fr
This repository represents the Android version of CommCare. It depends on the [CommCare Core](https://github.com/dimagi/commcare-core) repository, which contains the XForm engine and case/lookup table implementations.
+## End-to-End Development
+
+CommCare Android is a mobile CommCare Platform client runtime, and requires a backend environment for full end-to-end usage and to test platform development.
+
+If you don't have an access to another backend, or if you will be doing full platform development, after completing this setup you can follow [the end-to-end development guide](https://github.com/dimagi/commcare-hq/blob/master/local_dev_guide.rst) which explains how to establish a local environment for CommCare's full client/server software.
+
## Setup
To set up an Android dev environment for commcare-android, do the following:
@@ -34,7 +40,6 @@ git clone https://github.com/dimagi/commcare-core.git
- Click "OK" to use the Gradle wrapper
- Wait while Android Studio spins its wheels
- Download any build dependencies that the SDK Manager tells you you need.
-- Disable _Instant Run_ found in Settings > Build, Execution, Deployment > Instant Run. (It does not play well with multidexing, which we have enabled, or with some of the processes we have set up for Google Services)
## Building
diff --git a/app/assets/locales/android_translatable_strings.txt b/app/assets/locales/android_translatable_strings.txt
index 588441a279..46735f4bf1 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.
@@ -896,8 +898,8 @@ repeat.dialog.go.back=Go Back
repeat.dialog.leave=Do Not Add
repeat.dialog.exit=Do Not Add. I'm Finished.
repeat.dialog.add=Add Group
-repeat.dialog.add.another=Add another "${0}" group?
-repeat.dialog.add.new=Add a new "${0}" group?
+repeat.dialog.add.another=Add another ${0}?
+repeat.dialog.add.new=Add a new ${0}?
lookup.table.missing.error=Unable to find lookup table "${0}". Make sure it exists and this user has access to it.
ethiopian_months=Mäskäräm,T’ïk’ïmt,Hïdar,Tahsas,T’ïr,Yäkatit,Mägabit,Miyaziya,Gïnbot,Säne,Hämle,Nähäse,P’agume
diff --git a/app/build.gradle b/app/build.gradle
index fbb777b74e..61b794a701 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -34,7 +34,6 @@ dependencies {
testImplementation('org.robolectric:robolectric:4.8.2') {
exclude(group: 'org.bouncycastle', module: 'bcprov-jdk15on')
}
- testImplementation 'org.robolectric:shadows-multidex:4.8.2'
testImplementation 'androidx.test:core:1.5.0'
testImplementation 'androidx.test:runner:1.5.2'
testImplementation 'androidx.test.ext:junit:1.1.3'
@@ -75,14 +74,13 @@ dependencies {
implementation (name: 'volley-1.1.0', ext: 'aar')
implementation (name: 'storage-2.1.0', ext: 'aar')
implementation 'androidx.appcompat:appcompat:1.2.0'
- implementation 'androidx.multidex:multidex:2.0.1'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.recyclerview:recyclerview:1.1.0'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.legacy:legacy-preference-v14:1.0.0'
implementation 'androidx.gridlayout:gridlayout:1.0.0'
implementation 'org.bouncycastle:bcprov-jdk15to18:1.72'
- implementation 'com.google.android.gms:play-services-maps:17.0.0'
+ implementation 'com.google.android.gms:play-services-maps:19.0.0'
implementation 'joda-time:joda-time:2.9.4'
implementation 'net.zetetic:android-database-sqlcipher:4.5.3@aar'
implementation 'androidx.sqlite:sqlite:2.2.0'
@@ -109,6 +107,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"
@@ -251,7 +250,7 @@ android {
defaultConfig {
minSdkVersion 21
targetSdkVersion 34
- multiDexEnabled true
+
applicationId 'org.commcare.dalvik'
testNamespace 'org.commcare.dalvik.test'
@@ -462,7 +461,6 @@ android {
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard.cfg'
testProguardFiles 'test-proguard.cfg'
- multiDexKeepProguard file('proguard-multidex.pro')
// enable crashlytics
buildConfigField 'boolean', 'USE_CRASHLYTICS', 'true'
ext.enableCrashlytics = true
diff --git a/app/instrumentation-tests/src/org/commcare/androidTests/DateWidgetsTests.kt b/app/instrumentation-tests/src/org/commcare/androidTests/DateWidgetsTests.kt
index d112b9b9d2..ad13ec2aa1 100644
--- a/app/instrumentation-tests/src/org/commcare/androidTests/DateWidgetsTests.kt
+++ b/app/instrumentation-tests/src/org/commcare/androidTests/DateWidgetsTests.kt
@@ -29,7 +29,6 @@ import kotlin.math.absoluteValue
@RunWith(AndroidJUnit4::class)
@LargeTest
-@BrowserstackTests
class DateWidgetsTests : BaseTest() {
companion object {
const val CCZ_NAME = "date_widgets_tests.ccz"
@@ -162,4 +161,4 @@ class DateWidgetsTests : BaseTest() {
val newDate = DateTimeFormatter.ofPattern("yyyy-MM-dd").format(date)
return newDate
}
-}
\ No newline at end of file
+}
diff --git a/app/instrumentation-tests/src/org/commcare/androidTests/DialogTests.kt b/app/instrumentation-tests/src/org/commcare/androidTests/DialogTests.kt
index ddb24a05f1..78f92ce3d5 100644
--- a/app/instrumentation-tests/src/org/commcare/androidTests/DialogTests.kt
+++ b/app/instrumentation-tests/src/org/commcare/androidTests/DialogTests.kt
@@ -2,6 +2,7 @@ package org.commcare.androidTests
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -44,15 +45,16 @@ class DialogTests: BaseTest() {
onView(withId(R.id.nav_btn_next))
.perform(click())
- withText("Add a new \"Error on add\" group?").isDisplayed()
+ onView(withId(R.id.choice_dialog_panel_2)).check(matches(withText("Add a new Error on add?")))
InstrumentationUtility.rotateLeft()
//TODO Expect dialog to not persist due to a activity lifecycle bug in our dialog framework.
- withText("Add a new \"Error on add\" group?").doesNotExist()
+ withText(R.id.choice_dialog_panel_2).doesNotExist()
+
InstrumentationUtility.rotatePortrait()
onView(withId(R.id.nav_btn_next))
.perform(click())
- onView(withText("ADD GROUP"))
+ onView(withId(R.id.choice_dialog_panel_2))
.perform(click())
checkDialogExistence_withRotation("Error Occurred")
diff --git a/app/proguard-multidex.pro b/app/proguard-multidex.pro
deleted file mode 100644
index 8748e2dfb1..0000000000
--- a/app/proguard-multidex.pro
+++ /dev/null
@@ -1,2 +0,0 @@
-#Need to make sure these classes are available in primary dex file
--keep class org.commcare.** { *; }
\ No newline at end of file
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"/>
+
+
diff --git a/app/src/org/commcare/CommCareApplication.java b/app/src/org/commcare/CommCareApplication.java
index 8feecb8960..e421ca9fc9 100644
--- a/app/src/org/commcare/CommCareApplication.java
+++ b/app/src/org/commcare/CommCareApplication.java
@@ -2,6 +2,7 @@
import android.annotation.SuppressLint;
import android.app.ActivityManager;
+import android.app.Application;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
@@ -17,7 +18,10 @@
import android.util.Log;
import androidx.annotation.NonNull;
-import androidx.multidex.MultiDexApplication;
+import androidx.lifecycle.Lifecycle;
+import androidx.lifecycle.LifecycleEventObserver;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.lifecycle.ProcessLifecycleOwner;
import androidx.preference.PreferenceManager;
import androidx.work.BackoffPolicy;
import androidx.work.Constraints;
@@ -143,7 +147,7 @@
import okhttp3.MultipartBody;
import okhttp3.RequestBody;
-public class CommCareApplication extends MultiDexApplication {
+public class CommCareApplication extends Application implements LifecycleEventObserver {
private static final String TAG = CommCareApplication.class.getSimpleName();
@@ -256,6 +260,8 @@ public void onCreate() {
customiseOkHttp();
setRxJavaGlobalHandler();
+
+ ProcessLifecycleOwner.get().getLifecycle().addObserver(this);
}
protected void loadSqliteLibs() {
@@ -1224,4 +1230,13 @@ private void setRxJavaGlobalHandler() {
Thread.currentThread().getUncaughtExceptionHandler().uncaughtException(Thread.currentThread(), throwable);
});
}
+
+ @Override
+ public void onStateChanged(@NonNull LifecycleOwner source, @NonNull Lifecycle.Event event) {
+ switch (event) {
+ case ON_DESTROY:
+ Logger.log(LogTypes.TYPE_MAINTENANCE, "CommCare has been closed");
+ break;
+ }
+ }
}
diff --git a/app/src/org/commcare/CommCareNoficationManager.java b/app/src/org/commcare/CommCareNoficationManager.java
index 6180f9e0e2..8020299d6b 100644
--- a/app/src/org/commcare/CommCareNoficationManager.java
+++ b/app/src/org/commcare/CommCareNoficationManager.java
@@ -27,6 +27,8 @@
import static android.content.Context.NOTIFICATION_SERVICE;
+import static org.commcare.sync.ExternalDataUpdateHelper.sendBroadcastFailSafe;
+
/**
* Handles displaying and clearing pinned notifications for CommCare
*/
@@ -112,7 +114,7 @@ public void clearNotifications(String category) {
public ArrayList 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..d7bff64b32 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 +1065,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 +1108,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 +1152,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 +1275,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 +1283,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 +1294,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 +1313,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 +1332,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 +1384,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 +1416,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..fc4ff0d9b9 100644
--- a/app/src/org/commcare/activities/FormEntryActivityUIController.java
+++ b/app/src/org/commcare/activities/FormEntryActivityUIController.java
@@ -49,6 +49,7 @@
import org.javarosa.core.model.data.InvalidData;
import org.javarosa.core.services.Logger;
import org.javarosa.core.services.locale.Localization;
+import org.javarosa.form.api.FormEntryCaption;
import org.javarosa.form.api.FormEntryController;
import org.javarosa.form.api.FormEntryPrompt;
import org.javarosa.xpath.XPathException;
@@ -82,6 +83,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 +105,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 +201,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());
+ }
}
/**
@@ -493,24 +499,25 @@ private void createRepeatDialog() {
final boolean backExitsForm = !details.relevantBeforeCurrentScreen;
final boolean nextExitsForm = details.relevantAfterCurrentScreen == 0;
+ final FormEntryCaption repeatCaptionPrompt = FormEntryActivity.mFormController.getCaptionPrompt();
+
// Assign title and text strings based on the current state
String backText = Localization.get("repeat.dialog.go.back");
- String addAnotherText = Localization.get("repeat.dialog.add");
- String title, skipText;
+
+ boolean hasRepetitions = FormEntryActivity.mFormController.getLastRepeatCount() > 0;
+ final String addAnotherText = repeatCaptionPrompt.getRepeatText(hasRepetitions ? "add" : "add-empty");
+
+ String skipText;
+
if (!nextExitsForm) {
skipText = Localization.get("repeat.dialog.leave");
} else {
skipText = Localization.get("repeat.dialog.exit");
}
- if (FormEntryActivity.mFormController.getLastRepeatCount() > 0) {
- title = Localization.get("repeat.dialog.add.another", FormEntryActivity.mFormController.getLastGroupText());
- } else {
- title = Localization.get("repeat.dialog.add.new", FormEntryActivity.mFormController.getLastGroupText());
- }
// Create the choice dialog
ContextThemeWrapper wrapper = new ContextThemeWrapper(activity, R.style.DialogBaseTheme);
- final PaneledChoiceDialog dialog = new HorizontalPaneledChoiceDialog(wrapper, title);
+ final PaneledChoiceDialog dialog = new HorizontalPaneledChoiceDialog(wrapper, addAnotherText);
// Panel 1: Back option
View.OnClickListener backListener = v -> {
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/adapters/EntityListAdapter.java b/app/src/org/commcare/adapters/EntityListAdapter.java
index bcf6d2c5ba..40b76c6da9 100644
--- a/app/src/org/commcare/adapters/EntityListAdapter.java
+++ b/app/src/org/commcare/adapters/EntityListAdapter.java
@@ -16,6 +16,7 @@
import org.commcare.cases.entity.NodeEntityFactory;
import org.commcare.dalvik.R;
import org.commcare.interfaces.AndroidSortableEntityAdapter;
+import org.commcare.modern.session.SessionWrapper;
import org.commcare.preferences.MainConfigurablePreferences;
import org.commcare.session.SessionInstanceBuilder;
import org.commcare.suite.model.Action;
@@ -429,7 +430,10 @@ public void loadCalloutDataFromSession() {
public void saveCalloutDataToSession() {
if (isFilteringByCalloutResult) {
- CommCareApplication.instance().getCurrentSession().addExtraToCurrentFrameStep(SessionInstanceBuilder.KEY_ENTITY_LIST_EXTRA_DATA, calloutResponseData);
+ SessionWrapper session = CommCareApplication.instance().getCurrentSession();
+ session.removeExtraFromCurrentFrameStep(SessionInstanceBuilder.KEY_ENTITY_LIST_EXTRA_DATA);
+ session.addExtraToCurrentFrameStep(SessionInstanceBuilder.KEY_ENTITY_LIST_EXTRA_DATA,
+ calloutResponseData);
}
}
diff --git a/app/src/org/commcare/android/database/user/models/FormRecord.java b/app/src/org/commcare/android/database/user/models/FormRecord.java
index 4c0095ace9..5404e616e0 100755
--- a/app/src/org/commcare/android/database/user/models/FormRecord.java
+++ b/app/src/org/commcare/android/database/user/models/FormRecord.java
@@ -430,18 +430,18 @@ private FormRecord updateAndWriteRecord()
this, CommCareApplication.instance().getUserStorage(FormRecord.class));
} catch (InvalidStructureException e1) {
e1.printStackTrace();
- throw new InvalidStateException("Invalid data structure found while parsing form. There's something wrong with the application structure, please contact your supervisor.");
+ throw new InvalidStateException("Invalid data structure found while parsing form. There's something wrong with the application structure, please contact your supervisor", e1);
} catch (XmlPullParserException | IOException e) {
e.printStackTrace();
- throw new InvalidStateException("There was a problem with the local storage and the form could not be read.");
+ throw new InvalidStateException("There was a problem with the local storage and the form could not be read.", e);
} catch (UnfullfilledRequirementsException e) {
throw new RuntimeException(e);
}
}
private static class InvalidStateException extends Exception {
- public InvalidStateException(String message) {
- super(message);
+ public InvalidStateException(String message, Throwable e) {
+ super(message, e);
}
}
diff --git a/app/src/org/commcare/heartbeat/HeartbeatWorker.kt b/app/src/org/commcare/heartbeat/HeartbeatWorker.kt
index d545cba62a..7b5dfd100c 100644
--- a/app/src/org/commcare/heartbeat/HeartbeatWorker.kt
+++ b/app/src/org/commcare/heartbeat/HeartbeatWorker.kt
@@ -29,13 +29,14 @@ class HeartbeatWorker(context: Context, workerParams: WorkerParameters):
Result.retry()
}
else -> {
- Logger.log(LogTypes.TYPE_ERROR_SERVER_COMMS,
- "Encountered unexpected exception during heartbeat communications: "
- + e.message + ". Stopping the heartbeat thread.")
+ Logger.exception(
+ "Encountered unexpected exception during heartbeat communications, stopping the heartbeat thread.",
+ e
+ )
Result.failure()
}
}
}
return Result.success()
}
-}
\ No newline at end of file
+}
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/DeveloperPreferences.java b/app/src/org/commcare/preferences/DeveloperPreferences.java
index 75e4fc4ddb..46af8cbcc2 100644
--- a/app/src/org/commcare/preferences/DeveloperPreferences.java
+++ b/app/src/org/commcare/preferences/DeveloperPreferences.java
@@ -1,6 +1,7 @@
package org.commcare.preferences;
import static org.commcare.preferences.HiddenPreferences.ENABLE_CERTIFICATE_TRANSPARENCY;
+import static org.commcare.preferences.ServerUrls.PREFS_LOG_POST_URL_KEY;
import androidx.appcompat.app.AppCompatActivity;
import android.content.Intent;
@@ -85,6 +86,7 @@ public class DeveloperPreferences extends CommCarePreferenceFragment {
WHITELISTED_DEVELOPER_PREF_KEYS.add(AUTO_PURGE_ENABLED);
WHITELISTED_DEVELOPER_PREF_KEYS.add(ALTERNATE_QUESTION_LAYOUT_ENABLED);
WHITELISTED_DEVELOPER_PREF_KEYS.add(ENABLE_CERTIFICATE_TRANSPARENCY);
+ WHITELISTED_DEVELOPER_PREF_KEYS.add(PREFS_LOG_POST_URL_KEY);
}
/**
@@ -120,6 +122,18 @@ protected int getPreferencesResource() {
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
super.onCreatePreferences(savedInstanceState, rootKey);
setHasOptionsMenu(true);
+ EditTextPreference editTextPreference = (EditTextPreference)findPreference(PREFS_LOG_POST_URL_KEY);
+ if (editTextPreference != null) {
+ editTextPreference.setOnPreferenceChangeListener((preference, newValue) -> {
+ if (newValue == null || newValue.toString().trim().isEmpty()) {
+ SharedPreferences appPreferences = CommCareApplication.instance().getCurrentApp()
+ .getAppPreferences();
+ appPreferences.edit().remove(PREFS_LOG_POST_URL_KEY).apply();
+ return false;
+ }
+ return true;
+ });
+ }
}
@Override
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..ab2543914d 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;
@@ -82,6 +85,11 @@ public class CommCareSessionService extends Service {
*/
public static final ReentrantLock sessionAliveLock = new ReentrantLock();
+ /**
+ * 2h time in Milliseconds to extend the session if needed
+ */
+ private static final long SESSION_EXTENSION_TIME = 2 * 60 * 60 * 1000;
+
private Timer maintenanceTimer;
private CipherPool pool;
@@ -202,44 +210,9 @@ public IBinder onBind(Intent intent) {
*/
@SuppressLint("UnspecifiedImmutableFlag")
public void showLoggedInNotification(@Nullable User user) {
- //We always want this click to simply bring the live stack back to the top
- Intent callable = new Intent(this, DispatchActivity.class);
- callable.setAction("android.intent.action.MAIN");
- callable.addCategory("android.intent.category.LAUNCHER");
-
- // The PendingIntent to launch our activity if the user selects this notification
- PendingIntent contentIntent;
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
- contentIntent = PendingIntent.getActivity(this, 0, callable, PendingIntent.FLAG_IMMUTABLE);
- else
- contentIntent = PendingIntent.getActivity(this, 0, callable, 0);
-
- String notificationText;
- if (AppUtils.getInstalledAppRecords().size() > 1) {
- try {
- notificationText = Localization.get("notification.logged.in",
- new String[]{Localization.get("app.display.name")});
- } catch (NoLocalizedTextException e) {
- notificationText = getString(NOTIFICATION);
- }
- } else {
- notificationText = getString(NOTIFICATION);
- }
-
- // Set the icon, scrolling text and timestamp
- NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this, CommCareNoficationManager.NOTIFICATION_CHANNEL_ERRORS_ID)
- .setContentTitle(notificationText)
- .setSmallIcon(R.drawable.notification)
- .setContentIntent(contentIntent);
-
- if (user != null) {
- String contentText = "Session Expires: " + DateFormat.format("MMM dd h:mmaa", sessionExpireDate);
- notificationBuilder.setContentText(contentText);
- }
-
// Send the notification. This will cause error messages if CommCare doesn't have
// permission to post notifications
- this.startForeground(NOTIFICATION, notificationBuilder.build());
+ this.startForeground(NOTIFICATION, createSessionNotification());
}
/**
@@ -309,7 +282,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 +334,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 +356,7 @@ private void timeToExpireSession() {
}
try {
+ Logger.log(LogTypes.TYPE_USER, "Expiring user session due to session timeout");
saveFormAndCloseSession();
} finally {
CommCareSessionService.sessionAliveLock.unlock();
@@ -406,7 +381,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 +400,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 +444,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");
@@ -709,4 +684,53 @@ public void hideInAppUpdate() {
public boolean shouldShowInAppUpdate() {
return this.showInAppUpdate;
}
+
+ public void extendUserSessionIfNeeded(){
+ long currentTime = new Date().getTime();
+
+ if (sessionExpireDate.getTime() < currentTime + SESSION_EXTENSION_TIME) {
+ sessionExpireDate.setTime(sessionExpireDate.getTime() + SESSION_EXTENSION_TIME);
+ sessionLength += SESSION_EXTENSION_TIME;
+
+ mNM.notify(NOTIFICATION, createSessionNotification());
+ }
+ }
+
+ private Notification createSessionNotification(){
+ //We always want this click to simply bring the live stack back to the top
+ Intent callable = new Intent(this, DispatchActivity.class);
+ callable.setAction("android.intent.action.MAIN");
+ callable.addCategory("android.intent.category.LAUNCHER");
+
+ // The PendingIntent to launch our activity if the user selects this notification
+ PendingIntent contentIntent;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
+ contentIntent = PendingIntent.getActivity(this, 0, callable, PendingIntent.FLAG_IMMUTABLE);
+ else
+ contentIntent = PendingIntent.getActivity(this, 0, callable, 0);
+
+ String notificationText;
+ if (AppUtils.getInstalledAppRecords().size() > 1) {
+ try {
+ notificationText = Localization.get("notification.logged.in",
+ new String[]{Localization.get("app.display.name")});
+ } catch (NoLocalizedTextException e) {
+ notificationText = getString(NOTIFICATION);
+ }
+ } else {
+ notificationText = getString(NOTIFICATION);
+ }
+
+ // Set the icon, scrolling text and timestamp
+ NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this, CommCareNoficationManager.NOTIFICATION_CHANNEL_ERRORS_ID)
+ .setContentTitle(notificationText)
+ .setSmallIcon(R.drawable.notification)
+ .setContentIntent(contentIntent);
+
+ if (user != null) {
+ String contentText = "Session Expires: " + DateFormat.format("MMM dd h:mmaa", sessionExpireDate);
+ notificationBuilder.setContentText(contentText);
+ }
+ return notificationBuilder.build();
+ }
}
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/update/UpdateHelper.java b/app/src/org/commcare/update/UpdateHelper.java
index f1e391fef8..7fe01bc477 100644
--- a/app/src/org/commcare/update/UpdateHelper.java
+++ b/app/src/org/commcare/update/UpdateHelper.java
@@ -18,7 +18,6 @@
import org.commcare.resources.ResourceInstallContext;
import org.commcare.resources.model.InstallCancelled;
import org.commcare.resources.model.InstallCancelledException;
-import org.commcare.resources.model.InstallRequestSource;
import org.commcare.resources.model.InvalidResourceException;
import org.commcare.resources.model.Resource;
import org.commcare.resources.model.ResourceTable;
@@ -84,7 +83,7 @@ public static UpdateHelper getNewInstance(boolean autoUpdate, UpdateProgressList
// Main UpdateHelper function for staging updates
public ResultAndError update(String profileRef, ResourceInstallContext resourceInstallContext) {
- setupUpdate(profileRef);
+ setupUpdate(profileRef, resourceInstallContext);
try {
return new ResultAndError<>(stageUpdate(profileRef, resourceInstallContext));
@@ -123,10 +122,10 @@ public ResultAndError update(String profileRef, ResourceInstal
}
}
- private void setupUpdate(String profileRef) {
+ private void setupUpdate(String profileRef, ResourceInstallContext resourceInstallContext) {
ResourceInstallUtils.recordUpdateAttemptTime(mApp);
Logger.log(LogTypes.TYPE_RESOURCES,
- "Beginning install attempt for profile " + profileRef);
+ "Beginning install attempt as " + resourceInstallContext.getInstallRequestSource() + " for profile " + profileRef);
if (isAutoUpdate) {
ResourceInstallUtils.recordAutoUpdateStart(mApp);
@@ -150,7 +149,7 @@ private AppInstallStatus stageUpdate(String profileRef, ResourceInstallContext r
AppInstallStatus result = mResourceManager.checkAndPrepareUpgradeResources(profileRefWithParams, mAuthority, resourceInstallContext);
- if (result == AppInstallStatus.UpdateStaged) {
+ if (result == AppInstallStatus.UpdateStaged || result == AppInstallStatus.UpToDate) {
RequestStats.markSuccess(resourceInstallContext.getInstallRequestSource());
}
diff --git a/app/src/org/commcare/update/UpdateWorker.kt b/app/src/org/commcare/update/UpdateWorker.kt
index ac221b9df7..5bc74b8d93 100644
--- a/app/src/org/commcare/update/UpdateWorker.kt
+++ b/app/src/org/commcare/update/UpdateWorker.kt
@@ -44,31 +44,31 @@ class UpdateWorker(appContext: Context, workerParams: WorkerParameters)
when {
exception is CancellationException -> handleUpdateResult(ResultAndError(AppInstallStatus.Cancelled))
exception != null -> {
- Logger.exception("Unknown error while app update", exception);
- handleUpdateResult(ResultAndError(AppInstallStatus.UnknownFailure))
+ Logger.exception("Unknown error while app update", exception)
+ Result.failure()
}
}
}
job.await()
}
-
}
private fun doUpdateWork(): Result {
- val updateResult: ResultAndError
-
- // skip if - An update task is already running | no app is seated | user session is not active
- if (UpdateTask.getRunningInstance() == null &&
- CommCareApplication.instance().currentApp != null &&
- CommCareApplication.instance().session.isActive) {
-
- updateHelper.startPinnedNotification(CommCareApplication.instance())
- updateResult = updateHelper.update(ResourceInstallUtils.getDefaultProfileRef(),
- ResourceInstallContext(InstallRequestSource.BACKGROUND_UPDATE))
- } else {
+ if (UpdateTask.getRunningInstance() != null) {
+ // there is already an update running, lets just skip this run
return Result.success()
}
+
+ if (CommCareApplication.instance().currentApp == null) {
+ // we need a seated app to update
+ return Result.failure()
+ }
+
+ updateHelper.startPinnedNotification(CommCareApplication.instance())
+ val updateResult: ResultAndError = updateHelper.update(
+ ResourceInstallUtils.getDefaultProfileRef(),
+ ResourceInstallContext(InstallRequestSource.BACKGROUND_UPDATE))
return handleUpdateResult(updateResult)
}
@@ -82,6 +82,7 @@ class UpdateWorker(appContext: Context, workerParams: WorkerParameters)
return when {
updateResult.data == AppInstallStatus.UpdateStaged -> Result.success()
+ updateResult.data == AppInstallStatus.UpToDate -> Result.success()
updateResult.data.shouldRetryUpdate() -> Result.retry()
else -> Result.failure()
}
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/src/org/commcare/views/widgets/RecordingFragment.java b/app/src/org/commcare/views/widgets/RecordingFragment.java
index f343428fa6..70d071d907 100644
--- a/app/src/org/commcare/views/widgets/RecordingFragment.java
+++ b/app/src/org/commcare/views/widgets/RecordingFragment.java
@@ -194,6 +194,10 @@ private void startRecording() {
recordingDuration.setBase(SystemClock.elapsedRealtime());
recordingInProgress();
Logger.log(LogTypes.TYPE_MEDIA_EVENT, "Recording started");
+
+ // Extend the user extension if about to expire, this is to prevent the session from expiring in the
+ // middle of a recording
+ CommCareApplication.instance().getSession().extendUserSessionIfNeeded();
}
private void recordingInProgress() {
@@ -499,9 +503,7 @@ private boolean hasRecordingGoneSilent(List configs
.findAny();
return currentAudioConfig.isPresent() ? currentAudioConfig.get().isClientSilenced() : false;
} else {
- if (recorder.getMaxAmplitude() == 0) {
- return true;
- }
+ // TODO: Add logic to check if the recording has gone silent for Android 9 and prior
return false;
}
}
diff --git a/app/unit-tests/src/org/commcare/android/tests/application/AppUpdateTest.kt b/app/unit-tests/src/org/commcare/android/tests/application/AppUpdateTest.kt
index 2980320f80..994dff641b 100644
--- a/app/unit-tests/src/org/commcare/android/tests/application/AppUpdateTest.kt
+++ b/app/unit-tests/src/org/commcare/android/tests/application/AppUpdateTest.kt
@@ -82,7 +82,7 @@ class AppUpdateTest {
UpdateUtils.installUpdate(profileRef,
AppInstallStatus.UpToDate,
AppInstallStatus.UnknownFailure)
- checkUpdateComplete(6, true, false)
+ checkUpdateComplete(6, true, true)
}
@Test
@@ -191,4 +191,4 @@ class AppUpdateTest {
private val TAG = AppUpdateTest::class.java.simpleName
private const val REF_BASE_DIR = "jr://resource/commcare-apps/update_tests/"
}
-}
\ No newline at end of file
+}
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"
);
diff --git a/app/unit-tests/src/org/commcare/update/UpdateWorkerTest.kt b/app/unit-tests/src/org/commcare/update/UpdateWorkerTest.kt
index 9f2c89a2c6..903a4bdd8a 100644
--- a/app/unit-tests/src/org/commcare/update/UpdateWorkerTest.kt
+++ b/app/unit-tests/src/org/commcare/update/UpdateWorkerTest.kt
@@ -41,9 +41,8 @@ class UpdateWorkerTest {
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
- TestAppInstaller.installAppAndLogin(
- UpdateUtils.buildResourceRef(REF_BASE_DIR, "base_app", "profile.ccpr"),
- "test", "123")
+ TestAppInstaller.installApp(
+ UpdateUtils.buildResourceRef(REF_BASE_DIR, "base_app", "profile.ccpr"))
}
@Test
@@ -114,4 +113,4 @@ class UpdateWorkerTest {
.putString(PREFS_APP_SERVER_KEY, profileRef)
.apply()
}
-}
\ No newline at end of file
+}