/* * 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 android.content.ContentValues; import android.database.Cursor; import android.net.Uri; import android.os.AsyncTask; import android.util.Log; import org.javarosa.core.model.FormDef; import org.javarosa.core.services.transport.payload.ByteArrayPayload; import org.javarosa.form.api.FormEntryController; import org.odk.collect.android.R; import org.odk.collect.android.application.Collect; import org.odk.collect.android.exception.EncryptionException; 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 org.odk.collect.android.utilities.FileUtils; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.RandomAccessFile; /** * Background task for savig a form to disk. * * @author Carl Hartung (carlhartung@gmail.com) * @author Yaw Anokwa (yanokwa@gmail.com) */ public class SaveToDiskTask extends AsyncTask<Void, String, SaveResult> { private final static String t = "SaveToDiskTask"; private FormSavedListener mSavedListener; private Boolean mSave; private Boolean mMarkCompleted; private Uri mUri; private String mInstanceName; 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 uri, Boolean saveAndExit, Boolean markCompleted, String updatedName) { mUri = uri; mSave = saveAndExit; mMarkCompleted = markCompleted; mInstanceName = updatedName; } /** * 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 SaveResult doInBackground(Void... nothing) { SaveResult saveResult = new SaveResult(); FormController formController = Collect.getInstance().getFormController(); publishProgress(Collect.getInstance().getApplication().getString(R.string.survey_saving_validating_message)); try { int validateStatus = formController.validateAnswers(mMarkCompleted); if (validateStatus != FormEntryController.ANSWER_OK) { // validation failed, pass specific failure saveResult.setSaveResult(validateStatus); return saveResult; } } catch (Exception e) { Log.e(t, e.getMessage(), e); // SCTO-825 // that means that we have a bad design // save the exception to be used in the error dialog. saveResult.setSaveErrorMessage(e.getMessage()); saveResult.setSaveResult(SAVE_ERROR); return saveResult; } // check if the "Cancel" was hit and exit. if (isCancelled()) { return null; } if (mMarkCompleted) { formController.postProcessInstance(); } Collect.getInstance().getActivityLogger().logInstanceAction(this, "save", Boolean.toString(mMarkCompleted)); // close all open databases of external data. Collect.getInstance().getExternalDataManager().close(); // if there is a meta/instanceName field, be sure we are using the latest value // just in case the validate somehow triggered an update. String updatedSaveName = formController.getSubmissionMetadata().instanceName; if ( updatedSaveName != null ) { mInstanceName = updatedSaveName; } try { exportData(mMarkCompleted); // attempt to remove any scratch file File shadowInstance = savepointFile(formController.getInstancePath()); if (shadowInstance.exists()) { FileUtils.deleteAndReport(shadowInstance); } saveResult.setSaveResult(mSave ? SAVED_AND_EXIT : SAVED); } catch (Exception e) { Log.e(t, e.getMessage(), e); saveResult.setSaveErrorMessage(e.getMessage()); saveResult.setSaveResult(SAVE_ERROR); } return saveResult; } private void updateInstanceDatabase(boolean incomplete, boolean canEditAfterCompleted) { FormController formController = Collect.getInstance().getFormController(); // Update the instance database... 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)); // If FormEntryActivity was started with an Instance, just update that instance if (Collect.getInstance().getApplication().getContentResolver().getType(mUri).equals(InstanceColumns.CONTENT_ITEM_TYPE)) { int updated = Collect.getInstance().getApplication().getContentResolver().update(mUri, values, null, null); if (updated > 1) { Log.w(t, "Updated more than one entry, that's not good: " + mUri.toString()); } else if (updated == 1) { Log.i(t, "Instance successfully updated"); } else { Log.e(t, "Instance doesn't exist but we have its Uri!! " + mUri.toString()); } } else if (Collect.getInstance().getApplication().getContentResolver().getType(mUri).equals(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. String instancePath = formController.getInstancePath().getAbsolutePath(); String where = InstanceColumns.INSTANCE_FILE_PATH + "=?"; String[] whereArgs = { instancePath }; int updated = Collect.getInstance().getApplication().getContentResolver() .update(InstanceColumns.CONTENT_URI, values, where, whereArgs); if (updated > 1) { Log.w(t, "Updated more than one entry, that's not good: " + instancePath); } else if (updated == 1) { Log.i(t, "Instance found and successfully updated: " + instancePath); // already existed and updated just fine } else { Log.i(t, "No instance found, creating"); // Entry didn't exist, so create it. Cursor c = null; try { // retrieve the form definition... c = Collect.getInstance().getApplication().getContentResolver().query(mUri, null, null, null, null); c.moveToFirst(); String jrformid = c.getString(c.getColumnIndex(FormsColumns.JR_FORM_ID)); String jrversion = c.getString(c.getColumnIndex(FormsColumns.JR_VERSION)); String formname = c.getString(c.getColumnIndex(FormsColumns.DISPLAY_NAME)); String submissionUri = null; if ( !c.isNull(c.getColumnIndex(FormsColumns.SUBMISSION_URI)) ) { submissionUri = c.getString(c.getColumnIndex(FormsColumns.SUBMISSION_URI)); } // add missing fields into values values.put(InstanceColumns.INSTANCE_FILE_PATH, instancePath); 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); values.put(InstanceColumns.JR_VERSION, jrversion); } finally { if ( c != null ) { c.close(); } } mUri = Collect.getInstance().getApplication().getContentResolver() .insert(InstanceColumns.CONTENT_URI, values); } } } /** * Return the name of the savepoint file for a given instance. * * @param instancePath * @return */ public static File savepointFile(File instancePath) { File tempDir = new File(Collect.getInstance().getCachePath()); return new File(tempDir, instancePath.getName() + ".save"); } /** * 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 void exportData(boolean markCompleted) throws IOException, EncryptionException { FormController formController = Collect.getInstance().getFormController(); publishProgress(Collect.getInstance().getApplication().getString(R.string.survey_saving_collecting_message)); ByteArrayPayload payload = formController.getFilledInFormXml(); // write out xml String instancePath = formController.getInstancePath().getAbsolutePath(); publishProgress(Collect.getInstance().getApplication().getString(R.string.survey_saving_saving_message)); exportXmlFile(payload, instancePath); // update the mUri. We have exported the reloadable instance, so update status... // Since we saved a reloadable instance, it is flagged as re-openable so that if any error // occurs during the packaging of the data for the server fails (e.g., encryption), // we can still reopen the filled-out form and re-save it at a later time. updateInstanceDatabase(true, true); if ( markCompleted ) { // now see if the packaging of the data for the server would make it // non-reopenable (e.g., encryption or send an SMS or other fraction of the form). boolean canEditAfterCompleted = formController.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... File instanceXml = formController.getInstancePath(); File submissionXml = new File(instanceXml.getParentFile(), "submission.xml"); payload = formController.getSubmissionXml(); // write out submission.xml -- the data to actually submit to aggregate publishProgress(Collect.getInstance().getApplication().getString(R.string.survey_saving_finalizing_message)); exportXmlFile(payload, submissionXml.getAbsolutePath()); // see if the form is encrypted and we can encrypt it... EncryptedFormInformation formInfo = EncryptionUtils.getEncryptedFormInformation(mUri, formController.getSubmissionMetadata()); 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)... publishProgress(Collect.getInstance().getApplication().getString(R.string.survey_saving_encrypting_message)); EncryptionUtils.generateEncryptedSubmission(instanceXml, submissionXml, formInfo); 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() ) { String msg = "Error deleting " + instanceXml.getAbsolutePath() + " prior to renaming submission.xml"; Log.e(t, msg); throw new IOException(msg); } // rename the submission.xml to be the instanceXml if ( !submissionXml.renameTo(instanceXml) ) { String msg = "Error renaming submission.xml to " + instanceXml.getAbsolutePath(); Log.e(t, msg); throw new IOException(msg); } } else { // try to delete the submissionXml file, since it is // identical to the existing instanceXml file // (we don't need to delete and rename anything). if ( !submissionXml.delete() ) { String msg = "Error deleting " + submissionXml.getAbsolutePath() + " (instance is re-openable)"; Log.w(t, msg); } } // 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()); } } } } /** * This method actually writes the xml to disk. * @param payload * @param path * @return */ static void exportXmlFile(ByteArrayPayload payload, String path) throws IOException { File file = new File(path); if (file.exists() && !file.delete()) { throw new IOException("Cannot overwrite " + path + ". Perhaps the file is locked?"); } // create data stream InputStream is = payload.getPayloadStream(); int len = (int) payload.getLength(); // read from data stream byte[] data = new byte[len]; // try { int read = is.read(data, 0, len); if (read > 0) { // write xml file RandomAccessFile randomAccessFile = null; try { // String filename = path + File.separator + // path.substring(path.lastIndexOf(File.separator) + 1) + ".xml"; randomAccessFile = new RandomAccessFile(file, "rws"); randomAccessFile.write(data); } finally { if (randomAccessFile != null) { try { randomAccessFile.close(); } catch (IOException e) { Log.e(t, "Error closing RandomAccessFile: " + path, e); } } } } // } catch (IOException e) { // Log.e(t, "Error reading from payload data stream"); // e.printStackTrace(); // return false; // } // // return false; } @Override protected void onProgressUpdate(String... values) { super.onProgressUpdate(values); if (mSavedListener != null && values != null) { if (values.length == 1) { mSavedListener.onProgressStep(values[0]); } } } @Override protected void onPostExecute(SaveResult result) { synchronized (this) { if (mSavedListener != null && result != null) { mSavedListener.savingComplete(result); } } } public void setFormSavedListener(FormSavedListener fsl) { synchronized (this) { mSavedListener = fsl; } } }