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 +}