package org.commcare.tasks; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.net.Uri; import android.util.Log; import org.commcare.activities.FormEntryActivity; import org.commcare.activities.components.FormEntryInstanceState; import org.commcare.android.logging.ForceCloseLogger; import org.commcare.interfaces.FormSavedListener; import org.commcare.logging.AndroidLogger; import org.commcare.logging.XPathErrorLogger; import org.commcare.models.encryption.EncryptionIO; import org.commcare.preferences.DeveloperPreferences; import org.commcare.provider.FormsProviderAPI.FormsColumns; import org.commcare.provider.InstanceProviderAPI; import org.commcare.provider.InstanceProviderAPI.InstanceColumns; import org.commcare.tasks.templates.CommCareTask; import org.commcare.utils.EncryptionUtils; import org.commcare.utils.EncryptionUtils.EncryptedFormInformation; import org.javarosa.core.io.StreamsUtil; import org.javarosa.core.model.FormDef; import org.javarosa.core.model.FormIndex; import org.javarosa.core.model.instance.FormInstance; import org.javarosa.core.services.Logger; import org.javarosa.core.services.locale.Localization; import org.javarosa.core.services.transport.payload.ByteArrayPayload; import org.javarosa.form.api.FormEntryController; import org.javarosa.model.xform.XFormSerializingVisitor; import org.javarosa.xform.util.XFormSerializer; import org.javarosa.xpath.XPathException; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import javax.crypto.spec.SecretKeySpec; /** * @author Carl Hartung (carlhartung@gmail.com) * @author Yaw Anokwa (yanokwa@gmail.com) */ public class SaveToDiskTask extends CommCareTask<Void, String, ResultAndError<SaveToDiskTask.SaveStatus>, FormEntryActivity> { // callback to run upon saving private FormSavedListener mSavedListener; private final Boolean exitAfterSave; private final Boolean mMarkCompleted; // URI to the thing we are saving private Uri mUri; // The name of the form we are saving private final String mInstanceName; private final Context context; // URI to the table we are saving to private final Uri instanceContentUri; private final SecretKeySpec symetricKey; public enum SaveStatus { SAVED_COMPLETE, SAVED_INCOMPLETE, SAVE_ERROR, INVALID_ANSWER, SAVED_AND_EXIT } public static final int SAVING_TASK_ID = 17; public SaveToDiskTask(Uri mUri, Boolean saveAndExit, Boolean markCompleted, String updatedName, Context context, Uri instanceContentUri, SecretKeySpec symetricKey, boolean headless) { TAG = SaveToDiskTask.class.getSimpleName(); this.mUri = mUri; exitAfterSave = saveAndExit; mMarkCompleted = markCompleted; mInstanceName = updatedName; this.context = context; this.instanceContentUri = instanceContentUri; this.symetricKey = symetricKey; if (headless) { this.taskId = -1; } else { this.taskId = SAVING_TASK_ID; } } /** * Initialize {@link FormEntryController} with {@link FormDef} from binary or from XML. If given * an instance, it will be used to fill the {@link FormDef}. */ @Override protected ResultAndError<SaveStatus> doTaskBackground(Void... nothing) { try{ if (hasInvalidAnswers(mMarkCompleted)) { return new ResultAndError<>(SaveStatus.INVALID_ANSWER); } } catch(XPathException xpe) { String cleanedMessage = "An error in your form prevented it from saving: \n" + xpe.getMessage(); return new ResultAndError<>(SaveStatus.SAVE_ERROR, cleanedMessage); } FormEntryActivity.mFormController.postProcessInstance(); try { exportData(mMarkCompleted); } catch (FileNotFoundException e) { e.printStackTrace(); return new ResultAndError<>(SaveStatus.SAVE_ERROR, "Something is blocking acesss to the submission file in " + FormEntryInstanceState.mInstancePath); } catch(XFormSerializer.UnsupportedUnicodeSurrogatesException e) { Logger.log(AndroidLogger.TYPE_ERROR_CONFIG_STRUCTURE, "Form contains invalid data encoding\n\n" + ForceCloseLogger.getStackTrace(e)); return new ResultAndError<>(SaveStatus.SAVE_ERROR, Localization.get("form.entry.save.invalid.unicode", e.getMessage())); } catch (IOException e) { Logger.log(AndroidLogger.TYPE_ERROR_STORAGE, "I/O Error when serializing form\n\n" + ForceCloseLogger.getStackTrace(e)); return new ResultAndError<>(SaveStatus.SAVE_ERROR, "Unable to write xml to " + FormEntryInstanceState.mInstancePath); } catch (FormInstanceTransactionException e) { e.printStackTrace(); // Passing exceptions through content providers make error message strings messy. String cleanedMessage = e.getMessage().replace("java.lang.IllegalStateException: ", ""); // 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); } if (exitAfterSave) { return new ResultAndError<>(SaveStatus.SAVED_AND_EXIT); } else if (mMarkCompleted) { return new ResultAndError<>(SaveStatus.SAVED_COMPLETE); } else { return new ResultAndError<>(SaveStatus.SAVED_INCOMPLETE); } } /** * Update or create a new entry in the form table for the */ private void updateInstanceDatabase(boolean incomplete, boolean canEditAfterCompleted) throws FormInstanceTransactionException { ContentValues values = new ContentValues(); if (mInstanceName != null) { values.put(InstanceColumns.DISPLAY_NAME, mInstanceName); } if (incomplete || !mMarkCompleted) { values.put(InstanceColumns.STATUS, InstanceProviderAPI.STATUS_INCOMPLETE); } else { values.put(InstanceColumns.STATUS, InstanceProviderAPI.STATUS_COMPLETE); } // update this whether or not the status is complete. values.put(InstanceColumns.CAN_EDIT_WHEN_COMPLETE, Boolean.toString(canEditAfterCompleted)); // Insert or update the form instance into the database. String resolverType = context.getContentResolver().getType(mUri); if (InstanceColumns.CONTENT_ITEM_TYPE.equals(resolverType)) { // Started with a concrete instance (e.i. by editing an existing // form), so just update it. try { context.getContentResolver().update(mUri, values, null, null); } catch (IllegalStateException e) { throw new FormInstanceTransactionException(e); } } else if (FormsColumns.CONTENT_ITEM_TYPE.equals(resolverType)) { // Started with an empty form or possibly a manually saved form. // Try updating, and create a new instance if that fails. String[] whereArgs = {FormEntryInstanceState.mInstancePath}; int rowsUpdated; try { rowsUpdated = context.getContentResolver().update(instanceContentUri, values, InstanceColumns.INSTANCE_FILE_PATH + "=?", whereArgs); } catch (IllegalStateException e) { throw new FormInstanceTransactionException(e); } if (rowsUpdated == 0) { // Form instance didn't exist in the table, so create it. Log.e(TAG, "No instance found, creating"); Cursor c = null; try { // grab the first entry in the instance table for the form c = context.getContentResolver().query(mUri, null, null, null, null); c.moveToFirst(); // copy data out of that entry, into the entry we are creating values.put(InstanceColumns.JR_FORM_ID, c.getString(c.getColumnIndex(FormsColumns.JR_FORM_ID))); values.put(InstanceColumns.SUBMISSION_URI, c.getString(c.getColumnIndex(FormsColumns.SUBMISSION_URI))); if (mInstanceName == null) { values.put(InstanceColumns.DISPLAY_NAME, c.getString(c.getColumnIndex(FormsColumns.DISPLAY_NAME))); } } finally { if (c != null) { c.close(); } } values.put(InstanceColumns.INSTANCE_FILE_PATH, FormEntryInstanceState.mInstancePath); try { mUri = context.getContentResolver().insert(instanceContentUri, values); } catch (IllegalStateException e) { throw new FormInstanceTransactionException(e); } } else if (rowsUpdated == 1) { Log.i(TAG, "Instance already exists, updating"); } else{ Log.w(TAG, "Updated more than one entry, that's not good"); } } } /** * Write's the data to the sdcard, and updates the instances content * provider. In theory we don't have to write to disk, and this is where * you'd add other methods. * * @throws IOException Issue serializing form and * storing to filesystem * @throws FormInstanceTransactionException Issue performing transactions * associated with form saving, * like case updates and updating * the associated form record */ private void exportData(boolean markCompleted) throws IOException, FormInstanceTransactionException { ByteArrayPayload payload; // assume no binary data inside the model. FormInstance datamodel = FormEntryActivity.mFormController.getInstance(); XFormSerializingVisitor serializer = new XFormSerializingVisitor(markCompleted); payload = (ByteArrayPayload)serializer.createSerializedPayload(datamodel); writeXmlToStream(payload, EncryptionIO.createFileOutputStream(FormEntryInstanceState.mInstancePath, symetricKey)); // update the mUri. We've saved the reloadable instance, so update status... updateInstanceDatabase(true, true); if (markCompleted) { // now see if it is to be finalized and perhaps update everything... boolean canEditAfterCompleted = FormEntryActivity.mFormController.isSubmissionEntireForm(); boolean isEncrypted = false; // build a submission.xml to hold the data being submitted // and (if appropriate) encrypt the files on the side // pay attention to the ref attribute of the submission profile... payload = FormEntryActivity.mFormController.getSubmissionXml(); File instanceXml = new File(FormEntryInstanceState.mInstancePath); File submissionXml = new File(instanceXml.getParentFile(), "submission.xml"); // write out submission.xml -- the data to actually submit to aggregate writeXmlToStream(payload, EncryptionIO.createFileOutputStream(submissionXml.getAbsolutePath(), symetricKey)); // see if the form is encrypted and we can encrypt it... EncryptedFormInformation formInfo = EncryptionUtils.getEncryptedFormInformation(mUri, FormEntryActivity.mFormController.getSubmissionMetadata(), context, instanceContentUri); if ( formInfo != null ) { // if we are encrypting, the form cannot be reopened afterward canEditAfterCompleted = false; // and encrypt the submission (this is a one-way operation)... if (!EncryptionUtils.generateEncryptedSubmission(instanceXml, submissionXml, formInfo)) { throw new RuntimeException("Unable to encrypt form submission."); } isEncrypted = true; } // At this point, we have: // 1. the saved original instanceXml, // 2. all the plaintext attachments // 2. the submission.xml that is the completed xml (whether encrypting or not) // 3. all the encrypted attachments if encrypting (isEncrypted = true). // // NEXT: // 1. Update the instance database (with status complete). // 2. Overwrite the instanceXml with the submission.xml // and remove the plaintext attachments if encrypting updateInstanceDatabase(false, canEditAfterCompleted); if (!canEditAfterCompleted) { // AT THIS POINT, there is no going back. We are committed // to returning "success" (true) whether or not we can // rename "submission.xml" to instanceXml and whether or // not we can delete the plaintext media files. // // Handle the fall-out for a failed "submission.xml" rename // in the InstanceUploader task. Leftover plaintext media // files are handled during form deletion. // delete the restore Xml file. if ( !instanceXml.delete() ) { Log.e(TAG, "Error deleting " + instanceXml.getAbsolutePath() + " prior to renaming submission.xml"); return; } // rename the submission.xml to be the instanceXml if (!submissionXml.renameTo(instanceXml)) { Log.e(TAG, "Error renaming submission.xml to " + instanceXml.getAbsolutePath()); return; } // if encrypted, delete all plaintext files // (anything not named instanceXml or anything not ending in .enc) if (isEncrypted) { if (!EncryptionUtils.deletePlaintextFiles(instanceXml)) { Log.e(TAG, "Error deleting plaintext files for " + instanceXml.getAbsolutePath()); } } } } } private void writeXmlToStream(ByteArrayPayload payload, OutputStream output) throws IOException { try { InputStream is = payload.getPayloadStream(); StreamsUtil.writeFromInputToOutput(is, output); } finally { output.close(); } } @Override protected void onPostExecute(ResultAndError<SaveStatus> result) { super.onPostExecute(result); synchronized (this) { if (mSavedListener != null) { if (result == null) { mSavedListener.savingComplete(SaveStatus.SAVE_ERROR, "Unknown Error"); } else { mSavedListener.savingComplete(result.data, result.errorMessage); } } } } @Override protected void deliverResult(FormEntryActivity receiver, ResultAndError<SaveStatus> result) { } @Override protected void deliverUpdate(FormEntryActivity receiver, String... update) { } @Override protected void deliverError(FormEntryActivity receiver, Exception e) { } public void setFormSavedListener(FormSavedListener fsl) { synchronized (this) { mSavedListener = fsl; } } /** * Goes through the entire form to make sure all entered answers comply * with their constraints. Constraints are ignored on 'jump to', so * answers can be outside of constraints. We don't allow saving to disk, * though, until all answers conform to their constraints/requirements. */ private boolean hasInvalidAnswers(boolean markCompleted) { FormIndex i = FormEntryActivity.mFormController.getFormIndex(); FormEntryActivity.mFormController.jumpToIndex(FormIndex.createBeginningOfFormIndex()); int event; while ((event = FormEntryActivity.mFormController.stepToNextEvent(FormEntryController.STEP_INTO_GROUP)) != FormEntryController.EVENT_END_OF_FORM) { if (event == FormEntryController.EVENT_QUESTION) { int saveStatus = FormEntryActivity.mFormController.checkCurrentQuestionConstraint(); if (markCompleted && (saveStatus == FormEntryController.ANSWER_REQUIRED_BUT_EMPTY || saveStatus == FormEntryController.ANSWER_CONSTRAINT_VIOLATED)) { return true; } } } FormEntryActivity.mFormController.jumpToIndex(i); return false; } private static class FormInstanceTransactionException extends Exception { FormInstanceTransactionException(Throwable throwable) { super(throwable); } } }