/* * Copyright (C) 2009 University of Washington * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express * or implied. See the License for the specific language governing permissions and limitations under * the License. */ package org.odk.collect.android.tasks; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import javax.crypto.Cipher; import javax.crypto.CipherOutputStream; import javax.crypto.NoSuchPaddingException; import javax.crypto.spec.SecretKeySpec; 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.transport.payload.ByteArrayPayload; import org.javarosa.form.api.FormEntryController; import org.javarosa.model.xform.XFormSerializingVisitor; import org.odk.collect.android.activities.FormEntryActivity; import org.odk.collect.android.listeners.FormSavedListener; import org.odk.collect.android.logic.FormController; import org.odk.collect.android.provider.FormsProviderAPI.FormsColumns; import org.odk.collect.android.provider.InstanceProviderAPI; import org.odk.collect.android.provider.InstanceProviderAPI.InstanceColumns; import org.odk.collect.android.utilities.EncryptionUtils; import org.odk.collect.android.utilities.EncryptionUtils.EncryptedFormInformation; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.net.Uri; import android.os.AsyncTask; import android.util.Log; /** * Background task for loading a form. * * @author Carl Hartung (carlhartung@gmail.com) * @author Yaw Anokwa (yanokwa@gmail.com) */ public class SaveToDiskTask extends AsyncTask<Void, String, Integer> { private final static String t = "SaveToDiskTask"; private FormSavedListener mSavedListener; private Boolean mSave; private Boolean mMarkCompleted; private Uri mUri; private String mInstanceName; private Context context; private Uri instanceContentUri; SecretKeySpec symetricKey; public static final int SAVED = 500; public static final int SAVE_ERROR = 501; public static final int VALIDATE_ERROR = 502; public static final int VALIDATED = 503; public static final int SAVED_AND_EXIT = 504; public SaveToDiskTask(Uri mUri, Boolean saveAndExit, Boolean markCompleted, String updatedName, Context context, Uri instanceContentUri, SecretKeySpec symetricKey) { this.mUri = mUri; mSave = saveAndExit; mMarkCompleted = markCompleted; mInstanceName = updatedName; this.context = context; this.instanceContentUri = instanceContentUri; this.symetricKey = symetricKey; } /** * (non-Javadoc) * @see android.os.AsyncTask#doInBackground(java.lang.Object[]) * * 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 Integer doInBackground(Void... nothing) { // validation failed, pass specific failure int validateStatus = validateAnswers(mMarkCompleted); if (validateStatus != VALIDATED) { return validateStatus; } FormEntryActivity.mFormController.postProcessInstance(); if (exportData(mMarkCompleted)) { return mSave ? SAVED_AND_EXIT : SAVED; } return SAVE_ERROR; } private void updateInstanceDatabase(boolean incomplete, boolean canEditAfterCompleted) { // Update the instance database... // If FormEntryActivity was started with an Instance, just update that instance if (context.getContentResolver().getType(mUri) == InstanceColumns.CONTENT_ITEM_TYPE) { 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)); context.getContentResolver().update(mUri, values, null, null); } else if (context.getContentResolver().getType(mUri) == FormsColumns.CONTENT_ITEM_TYPE) { // If FormEntryActivity was started with a form, then it's likely the first time we're // saving. // However, it could be a not-first time saving if the user has been using the manual // 'save data' option from the menu. So try to update first, then make a new one if that // fails. 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)); String where = InstanceColumns.INSTANCE_FILE_PATH + "=?"; String[] whereArgs = { FormEntryActivity.mInstancePath }; int updated = context.getContentResolver().update(instanceContentUri, values, where, whereArgs); if (updated > 1) { Log.w(t, "Updated more than one entry, that's not good"); } else if (updated == 1) { Log.i(t, "Instance already exists, updating"); // already existed and updated just fine } else { Log.e(t, "No instance found, creating"); // Entry didn't exist, so create it. Cursor c = null; try { c = context.getContentResolver().query(mUri, null, null, null, null); c.moveToFirst(); String jrformid = c.getString(c.getColumnIndex(FormsColumns.JR_FORM_ID)); String formname = c.getString(c.getColumnIndex(FormsColumns.DISPLAY_NAME)); String submissionUri = c.getString(c.getColumnIndex(FormsColumns.SUBMISSION_URI)); values.put(InstanceColumns.INSTANCE_FILE_PATH, FormEntryActivity.mInstancePath); values.put(InstanceColumns.SUBMISSION_URI, submissionUri); if (mInstanceName != null) { values.put(InstanceColumns.DISPLAY_NAME, mInstanceName); } else { values.put(InstanceColumns.DISPLAY_NAME, formname); } values.put(InstanceColumns.JR_FORM_ID, jrformid); } finally { if ( c != null ) { c.close(); } } mUri = context.getContentResolver().insert(instanceContentUri, values); } } } /** * 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. * @param markCompleted * @return */ private boolean exportData(boolean markCompleted) { ByteArrayPayload payload; try { // assume no binary data inside the model. FormInstance datamodel = FormEntryActivity.mFormController.getInstance(); XFormSerializingVisitor serializer = new XFormSerializingVisitor(markCompleted); payload = (ByteArrayPayload) serializer.createSerializedPayload(datamodel); // write out xml exportXmlFile(payload, createFileOutputStream(FormEntryActivity.mInstancePath)); } catch (IOException e) { Log.e(t, "Error creating serialized payload"); e.printStackTrace(); return false; } // 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... try { payload = FormEntryActivity.mFormController.getSubmissionXml(); } catch (IOException e) { Log.e(t, "Error creating serialized payload"); e.printStackTrace(); return false; } File instanceXml = new File(FormEntryActivity.mInstancePath); File submissionXml = new File(instanceXml.getParentFile(), "submission.xml"); // write out submission.xml -- the data to actually submit to aggregate try { exportXmlFile(payload, createFileOutputStream(submissionXml.getAbsolutePath())); } catch (FileNotFoundException e) { e.printStackTrace(); throw new RuntimeException("Something is blocking acesss to the file at " + submissionXml.getAbsolutePath()); } // 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) ) { return false; } 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(t, "Error deleting " + instanceXml.getAbsolutePath() + " prior to renaming submission.xml"); return true; } // rename the submission.xml to be the instanceXml if ( !submissionXml.renameTo(instanceXml) ) { Log.e(t, "Error renaming submission.xml to " + instanceXml.getAbsolutePath()); return true; } // if encrypted, delete all plaintext files // (anything not named instanceXml or anything not ending in .enc) if ( isEncrypted ) { if ( !EncryptionUtils.deletePlaintextFiles(instanceXml) ) { Log.e(t, "Error deleting plaintext files for " + instanceXml.getAbsolutePath()); } } } } return true; } public OutputStream createFileOutputStream(String path) throws FileNotFoundException { return createFileOutputStream(new File(path)); } private OutputStream createFileOutputStream(File path) throws FileNotFoundException { FileOutputStream fos = new FileOutputStream(path); if(symetricKey == null) { return fos; } else { try { Cipher cipher = Cipher.getInstance("AES"); cipher.init(Cipher.ENCRYPT_MODE, symetricKey); return new CipherOutputStream(fos, cipher); //All of these exceptions imply a bad platform and should be irrecoverable (Don't ever //write out data if the key isn't good, or the crypto isn't available) } catch (InvalidKeyException e) { e.printStackTrace(); throw new RuntimeException(e.getMessage()); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); throw new RuntimeException(e.getMessage()); } catch (NoSuchPaddingException e) { e.printStackTrace(); throw new RuntimeException(e.getMessage()); } } } /** * This method actually writes the xml to disk. * @param payload * @param path * @return */ private boolean exportXmlFile(ByteArrayPayload payload, OutputStream output) { // create data stream InputStream is = payload.getPayloadStream(); try { StreamsUtil.writeFromInputToOutput(is, output); output.close(); return true; } catch (IOException e) { Log.e(t, "Error reading from payload data stream"); e.printStackTrace(); return false; } } /* * (non-Javadoc) * @see android.os.AsyncTask#onPostExecute(java.lang.Object) */ @Override protected void onPostExecute(Integer result) { synchronized (this) { if (mSavedListener != null) mSavedListener.savingComplete(result); } } 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. * * @param markCompleted * @return validatedStatus */ private int validateAnswers(Boolean markCompleted) { FormIndex i = FormEntryActivity.mFormController.getFormIndex(); FormEntryActivity.mFormController.jumpToIndex(FormIndex.createBeginningOfFormIndex()); int event; while ((event = FormEntryActivity.mFormController.stepToNextEvent(FormController.STEP_INTO_GROUP)) != FormEntryController.EVENT_END_OF_FORM) { if (event != FormEntryController.EVENT_QUESTION) { continue; } else { int saveStatus = FormEntryActivity.mFormController .answerQuestion(FormEntryActivity.mFormController.getQuestionPrompt() .getAnswerValue()); if (markCompleted && saveStatus != FormEntryController.ANSWER_OK) { return saveStatus; } } } FormEntryActivity.mFormController.jumpToIndex(i); return VALIDATED; } }