package org.commcare.provider; import android.content.ContentProvider; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.UriMatcher; import android.database.Cursor; import android.database.SQLException; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.database.sqlite.SQLiteQueryBuilder; import android.net.Uri; import android.support.annotation.NonNull; import android.text.TextUtils; import android.util.Log; import org.commcare.CommCareApplication; import org.commcare.android.logging.ForceCloseLogger; import org.commcare.logging.AndroidLogger; import org.commcare.models.AndroidSessionWrapper; import org.commcare.models.FormRecordProcessor; import org.commcare.android.database.user.models.FormRecord; import org.commcare.tasks.FormRecordCleanupTask; import org.commcare.views.notifications.NotificationMessage; import org.commcare.views.notifications.NotificationMessageFactory; import org.javarosa.core.services.Logger; import org.javarosa.core.services.storage.IStorageUtilityIndexed; import org.javarosa.xml.util.InvalidStructureException; import org.javarosa.xml.util.UnfullfilledRequirementsException; import org.xmlpull.v1.XmlPullParserException; import java.io.File; import java.io.IOException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.HashMap; import javax.crypto.SecretKey; public class InstanceProvider extends ContentProvider { private static final String t = "InstancesProvider"; private static final int DATABASE_VERSION = 2; private static final String INSTANCES_TABLE_NAME = "instances"; private static final HashMap<String, String> sInstancesProjectionMap; private static final int INSTANCES = 1; private static final int INSTANCE_ID = 2; private static final UriMatcher sUriMatcher; /** * This class helps open, create, and upgrade the database file. */ private static class DatabaseHelper extends SQLiteOpenHelper { // the application id of the CCApp for which this db is storing instances private final String appId; public DatabaseHelper(Context c, String databaseName, String appId) { super(c, databaseName, null, DATABASE_VERSION); this.appId = appId; } public String getAppId() { return this.appId; } @Override public void onCreate(SQLiteDatabase db) { db.execSQL("CREATE TABLE " + INSTANCES_TABLE_NAME + " (" + InstanceProviderAPI.InstanceColumns._ID + " integer primary key, " + InstanceProviderAPI.InstanceColumns.DISPLAY_NAME + " text not null, " + InstanceProviderAPI.InstanceColumns.SUBMISSION_URI + " text, " + InstanceProviderAPI.InstanceColumns.CAN_EDIT_WHEN_COMPLETE + " text, " + InstanceProviderAPI.InstanceColumns.INSTANCE_FILE_PATH + " text not null, " + InstanceProviderAPI.InstanceColumns.JR_FORM_ID + " text not null, " + InstanceProviderAPI.InstanceColumns.STATUS + " text not null, " + InstanceProviderAPI.InstanceColumns.LAST_STATUS_CHANGE_DATE + " date not null, " + InstanceProviderAPI.InstanceColumns.DISPLAY_SUBTEXT + " text not null );"); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { Log.w(t, "Upgrading database from version " + oldVersion + " to " + newVersion + ", which will destroy all old data"); db.execSQL("DROP TABLE IF EXISTS instances"); onCreate(db); } } private DatabaseHelper mDbHelper; @Override public boolean onCreate() { //This is so stupid. return true; } private void init() { String appId = ProviderUtils.getSandboxedAppId(); if (mDbHelper == null || !appId.equals(mDbHelper.getAppId())) { String dbName = ProviderUtils.getProviderDbName(ProviderUtils.ProviderType.INSTANCES, appId); mDbHelper = new DatabaseHelper(CommCareApplication.instance(), dbName, appId); } } @Override public Cursor query(@NonNull Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { init(); SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); qb.setTables(INSTANCES_TABLE_NAME); switch (sUriMatcher.match(uri)) { case INSTANCES: qb.setProjectionMap(sInstancesProjectionMap); break; case INSTANCE_ID: qb.setProjectionMap(sInstancesProjectionMap); qb.appendWhere(InstanceProviderAPI.InstanceColumns._ID + "=" + uri.getPathSegments().get(1)); break; default: throw new IllegalArgumentException("Unknown URI " + uri); } // Get the database and run the query SQLiteDatabase db = mDbHelper.getReadableDatabase(); Cursor c = qb.query(db, projection, selection, selectionArgs, null, null, sortOrder); // Tell the cursor what uri to watch, so it knows when its source data changes Context context = getContext(); if (context != null) { c.setNotificationUri(context.getContentResolver(), uri); } return c; } @Override public String getType(@NonNull Uri uri) { switch (sUriMatcher.match(uri)) { case INSTANCES: return InstanceProviderAPI.InstanceColumns.CONTENT_TYPE; case INSTANCE_ID: return InstanceProviderAPI.InstanceColumns.CONTENT_ITEM_TYPE; default: throw new IllegalArgumentException("Unknown URI " + uri); } } /** * {@inheritDoc} * Starting with the ContentValues passed in, finish setting up the * instance entry and write it database. * * @see android.content.ContentProvider#insert(android.net.Uri, android.content.ContentValues) */ @Override public Uri insert(@NonNull Uri uri, ContentValues initialValues) { // Validate the requested uri if (sUriMatcher.match(uri) != INSTANCES) { throw new IllegalArgumentException("Unknown URI " + uri); } init(); ContentValues values; if (initialValues != null) { values = new ContentValues(initialValues); } else { values = new ContentValues(); } // Make sure that the fields are all set if (!values.containsKey(InstanceProviderAPI.InstanceColumns.LAST_STATUS_CHANGE_DATE)) { // set the change date to now values.put(InstanceProviderAPI.InstanceColumns.LAST_STATUS_CHANGE_DATE, System.currentTimeMillis()); } if (!values.containsKey(InstanceProviderAPI.InstanceColumns.DISPLAY_SUBTEXT)) { // set display subtext to detail save date values.put(InstanceProviderAPI.InstanceColumns.DISPLAY_SUBTEXT, getDisplaySubtext(InstanceProviderAPI.STATUS_INCOMPLETE)); } if (!values.containsKey(InstanceProviderAPI.InstanceColumns.STATUS)) { values.put(InstanceProviderAPI.InstanceColumns.STATUS, InstanceProviderAPI.STATUS_INCOMPLETE); } InstanceProviderInsertType insertType = InstanceProviderInsertType.getInsertionType(values); SQLiteDatabase db = mDbHelper.getWritableDatabase(); long rowId = db.insert(INSTANCES_TABLE_NAME, null, values); db.close(); if (rowId > 0) { Uri instanceUri = ContentUris.withAppendedId(InstanceProviderAPI.InstanceColumns.CONTENT_URI, rowId); notifyChangeSafe(getContext(), uri); switch (insertType) { case SESSION_LINKED: finalizeSessionLinkedInsertion(instanceUri, uri); break; case UNINDEXED_IMPORT: finalizeUnindexedInsertion(values, instanceUri); break; case SANDBOX_MIGRATED: break; } return instanceUri; } throw new SQLException("Failed to insert row into " + uri); } private void finalizeSessionLinkedInsertion(Uri instanceUri, Uri uri) { try { linkToSessionFormRecord(instanceUri); } catch (IllegalStateException e) { throw e; } catch (Exception e) { Logger.exception(e); throw new SQLException("Failed to insert row into " + uri); } } private void finalizeUnindexedInsertion(ContentValues values, Uri instanceUri) { String xmlns = values.getAsString(InstanceProviderAPI.InstanceColumns.JR_FORM_ID); SecretKey key; key = CommCareApplication.instance().createNewSymmetricKey(); FormRecord r = new FormRecord(instanceUri.toString(), FormRecord.STATUS_UNINDEXED, xmlns, key.getEncoded(), null, new Date(0), mDbHelper.getAppId()); IStorageUtilityIndexed<FormRecord> storage = CommCareApplication.instance().getUserStorage(FormRecord.class); storage.write(r); } /** * Create display subtext for current date and time * * @param state is the status column of an instance entry */ private String getDisplaySubtext(String state) { String ts = new SimpleDateFormat("EEE, MMM dd, yyyy 'at' HH:mm").format(new Date()); if (state == null) { return "Added on " + ts; } else if (InstanceProviderAPI.STATUS_INCOMPLETE.equalsIgnoreCase(state)) { return "Saved on " + ts; } else if (InstanceProviderAPI.STATUS_COMPLETE.equalsIgnoreCase(state)) { return "Finalized on " + ts; } else if (InstanceProviderAPI.STATUS_SUBMITTED.equalsIgnoreCase(state)) { return "Sent on " + ts; } else if (InstanceProviderAPI.STATUS_SUBMISSION_FAILED.equalsIgnoreCase(state)) { return "Sending failed on " + ts; } else { return "Added on " + ts; } } private void deleteFileOrDir(String fileName) { File file = new File(fileName); if (file.exists()) { if (file.isDirectory()) { // delete all the containing files File[] files = file.listFiles(); if (files != null) { for (File f : files) { // should make this recursive if we get worried about // the media directory containing directories f.delete(); } } } file.delete(); } } /** * This method removes the entry from the content provider, and also removes any associated files. * files: form.xml, [formmd5].formdef, formname-media {directory} */ @Override public int delete(@NonNull Uri uri, String where, String[] whereArgs) { init(); SQLiteDatabase db = mDbHelper.getWritableDatabase(); int count; switch (sUriMatcher.match(uri)) { case INSTANCES: Cursor del = null; try { del = query(uri, null, where, whereArgs, null); if (del != null) { del.moveToPosition(-1); while (del.moveToNext()) { String instanceFile = del.getString(del.getColumnIndex(InstanceProviderAPI.InstanceColumns.INSTANCE_FILE_PATH)); String instanceDir = (new File(instanceFile)).getParent(); deleteFileOrDir(instanceDir); } } } finally { if (del != null) { del.close(); } } count = db.delete(INSTANCES_TABLE_NAME, where, whereArgs); break; case INSTANCE_ID: String instanceId = uri.getPathSegments().get(1); Cursor c = null; try { c = query(uri, null, where, whereArgs, null); // This should only ever return 1 record. I hope. if (c != null) { c.moveToPosition(-1); while (c.moveToNext()) { String instanceFile = c.getString(c.getColumnIndex(InstanceProviderAPI.InstanceColumns.INSTANCE_FILE_PATH)); String instanceDir = (new File(instanceFile)).getParent(); deleteFileOrDir(instanceDir); } } } finally { if (c != null) { c.close(); } } count = db.delete(INSTANCES_TABLE_NAME, InstanceProviderAPI.InstanceColumns._ID + "=" + instanceId + (!TextUtils.isEmpty(where) ? " AND (" + where + ')' : ""), whereArgs); break; default: throw new IllegalArgumentException("Unknown URI " + uri); } db.close(); notifyChangeSafe(getContext(), uri); return count; } private static void notifyChangeSafe(Context context, Uri uri) { if (context != null) { context.getContentResolver().notifyChange(uri, null); } } @Override public int update(@NonNull Uri uri, ContentValues values, String where, String[] whereArgs) { int count; init(); SQLiteDatabase db = mDbHelper.getWritableDatabase(); // Given a value in the status column and none in the display subtext // column, set the display subtext column from the status value. if (values.containsKey(InstanceProviderAPI.InstanceColumns.STATUS) && !values.containsKey(InstanceProviderAPI.InstanceColumns.DISPLAY_SUBTEXT)) { values.put(InstanceProviderAPI.InstanceColumns.DISPLAY_SUBTEXT, getDisplaySubtext(values.getAsString(InstanceProviderAPI.InstanceColumns.STATUS))); } switch (sUriMatcher.match(uri)) { case INSTANCES: // assumes where/whereArgs were constructed to point to the // entry to update count = db.update(INSTANCES_TABLE_NAME, values, where, whereArgs); break; case INSTANCE_ID: // use the uri to manually build an update query String instanceId = uri.getPathSegments().get(1); count = db.update(INSTANCES_TABLE_NAME, values, InstanceProviderAPI.InstanceColumns._ID + "=" + instanceId + (!TextUtils.isEmpty(where) ? " AND (" + where + ')' : ""), whereArgs); break; default: throw new IllegalArgumentException("Unknown URI " + uri); } db.close(); // If we've changed a particular form instance's status, and not // created a new entry (hence count > 0 check), we need to mirror the // change in that form's record. NOTE: this conditional is crucial, // since updating a form record in turn calls this update function, so // we need to break the infinite loop by only updating the form record // when the the status changes. if (values.containsKey(InstanceProviderAPI.InstanceColumns.STATUS) && count > 0) { try { linkToSessionFormRecord(getInstanceRowUri(uri, where, whereArgs)); } catch (IllegalStateException e) { throw e; } catch (Exception e) { throw new SQLException("Failed to update row " + uri); } } notifyChangeSafe(getContext(), uri); return count; } /** * Check if a URI points to a concrete instance; if it doesn't * then rebuild the uri from the result of a query using the where * arguments. * * @param potentialUri URI pointing to the instance table or a particular * entry in that table * @param selection A selection criteria to apply when filtering rows. If * null then all rows are included. * @param selectionArgs You may include ?s in selection, which will be * replaced by the values from selectionArgs, in order that they appear in * the selection. The values will be bound as Strings. * @return URI pointing to a row in the instance table, either the one passed * in or built from a query using the method arguments. */ private Uri getInstanceRowUri(Uri potentialUri, String selection, String[] selectionArgs) { switch (sUriMatcher.match(potentialUri)) { case INSTANCES: // the potential URI points to the instance table, so use the // selection args to find a specific row id. Cursor c = null; try { c = this.query(potentialUri, null, selection, selectionArgs, null); if (c != null) { c.moveToPosition(-1); if (c.moveToNext()) { // there should only be one result for this query String instanceId = c.getString(c.getColumnIndex("_id")); return InstanceProviderAPI.InstanceColumns.CONTENT_URI.buildUpon().appendPath(instanceId).build(); } } } finally { if (c != null) { c.close(); } } break; case INSTANCE_ID: // the potential URI points to a row in the instance table return potentialUri; default: throw new IllegalArgumentException("Unknown URI " + potentialUri); } return null; } /** * Register an instance with the session's form record. * * @param instanceUri points to a concrete instance we want to register */ private void linkToSessionFormRecord(Uri instanceUri) { AndroidSessionWrapper currentState = CommCareApplication.instance().getCurrentSessionWrapper(); if (instanceUri == null) { raiseFormEntryError("Form Entry did not return a form", currentState); return; } if (!InstanceProviderAPI.InstanceColumns.CONTENT_ITEM_TYPE.equals(getType(instanceUri))) { Log.w(t, "Tried to link a FormRecord to a URI that doesn't point " + "to a concrete instance."); return; } String instanceStatus = null; Context context = getContext(); if (context != null) { Cursor c = context.getContentResolver().query(instanceUri, null, null, null, null); try { if (c != null) { c.moveToFirst(); instanceStatus = c.getString(c.getColumnIndexOrThrow(InstanceProviderAPI.InstanceColumns.STATUS)); } } catch (IllegalArgumentException iae) { iae.printStackTrace(); // TODO: Fail more hardcore here? Wipe the form record and its ties? raiseFormEntryError("Unrecoverable error when trying to read form|" + iae.getMessage(), currentState); } finally { if (c != null) { c.close(); } } } boolean complete = InstanceProviderAPI.STATUS_COMPLETE.equals(instanceStatus); FormRecord current; try { current = syncRecordToInstance(currentState.getFormRecord(), instanceUri.toString(), instanceStatus); } catch (Exception e) { // Something went wrong with all of the connections which should exist. if (currentState.getFormRecord() != null) { currentState.getFormRecord().logPendingDeletion(t, "something went wrong trying to sync the record for the current session " + "with the form instance"); } else { Logger.log(AndroidLogger.TYPE_FORM_DELETION, "The current session was missing " + "its form record when trying to sync with the form instance; " + "attempting to delete it if it still exists in the db"); } FormRecordCleanupTask.wipeRecord(getContext(), currentState); // Notify the server of this problem (since we aren't going to crash) ForceCloseLogger.reportExceptionInBg(e); raiseFormEntryError("An error occurred: " + e.getMessage() + " and your data could not be saved.", currentState); return; } Logger.log(AndroidLogger.TYPE_FORM_ENTRY, String.format("Form Entry Completed for record with id %s", current.getInstanceID())); // The form is either ready for processing, or not, depending on how it was saved if (complete) { // Form record should now be up to date now and stored correctly. // ctsims - App stack workflows require us to have processed _this_ // specific form before we can move on, and that needs to be // synchronous. We'll go ahead and try to process just this form // before moving on. We'll catch any errors here and just eat them // (since the task will also try the process and fail if it does. if (FormRecord.STATUS_COMPLETE.equals(current.getStatus())) { try { new FormRecordProcessor(getContext()).process(current); } catch (InvalidStructureException e) { // record should be wiped when form entry is exited Logger.log(AndroidLogger.TYPE_ERROR_WORKFLOW, e.getMessage()); throw new IllegalStateException(e.getMessage()); } catch (Exception e) { NotificationMessage message = NotificationMessageFactory.message(NotificationMessageFactory.StockMessages.FormEntry_Save_Error, new String[]{null, null, e.getMessage()}); CommCareApplication.notificationManager().reportNotificationMessage(message); Logger.log(AndroidLogger.TYPE_ERROR_WORKFLOW, "Error processing form. Should be recaptured during async processing: " + e.getMessage()); throw new RuntimeException(e); } } } } /** * Register a record with a form instance, syncing the record's details * with those of the instance and writing it to storage. * * @param record Attach this record with a form instance, syncing * details and writing it to storage. * @param instanceUri Uri string of the form instance we want to sync * with the record. * @param instanceStatus The form instance's status (in/commplete, submitted, * failed submission) * @return The updated form record, which has been written to storage. */ private FormRecord syncRecordToInstance(FormRecord record, String instanceUri, String instanceStatus) throws InvalidStateException { if (record == null) { throw new InvalidStateException("No form record found when trying to save form."); } // update the form record to mirror the sessions instance uri and // status. if (InstanceProviderAPI.STATUS_COMPLETE.equals(instanceStatus)) { record = record.updateInstanceAndStatus(instanceUri, FormRecord.STATUS_COMPLETE); } else { record = record.updateInstanceAndStatus(instanceUri, FormRecord.STATUS_INCOMPLETE); } // save the updated form record try { return FormRecordCleanupTask.updateAndWriteRecord(CommCareApplication.instance(), record, 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."); } catch (XmlPullParserException | IOException e) { e.printStackTrace(); throw new InvalidStateException("There was a problem with the local storage and the form could not be read."); } catch (UnfullfilledRequirementsException e) { throw new RuntimeException(e); } } static { sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); sUriMatcher.addURI(InstanceProviderAPI.AUTHORITY, "instances", INSTANCES); sUriMatcher.addURI(InstanceProviderAPI.AUTHORITY, "instances/#", INSTANCE_ID); sInstancesProjectionMap = new HashMap<>(); sInstancesProjectionMap.put(InstanceProviderAPI.InstanceColumns._ID, InstanceProviderAPI.InstanceColumns._ID); sInstancesProjectionMap.put(InstanceProviderAPI.InstanceColumns.DISPLAY_NAME, InstanceProviderAPI.InstanceColumns.DISPLAY_NAME); sInstancesProjectionMap.put(InstanceProviderAPI.InstanceColumns.SUBMISSION_URI, InstanceProviderAPI.InstanceColumns.SUBMISSION_URI); sInstancesProjectionMap.put(InstanceProviderAPI.InstanceColumns.CAN_EDIT_WHEN_COMPLETE, InstanceProviderAPI.InstanceColumns.CAN_EDIT_WHEN_COMPLETE); sInstancesProjectionMap.put(InstanceProviderAPI.InstanceColumns.INSTANCE_FILE_PATH, InstanceProviderAPI.InstanceColumns.INSTANCE_FILE_PATH); sInstancesProjectionMap.put(InstanceProviderAPI.InstanceColumns.JR_FORM_ID, InstanceProviderAPI.InstanceColumns.JR_FORM_ID); sInstancesProjectionMap.put(InstanceProviderAPI.InstanceColumns.STATUS, InstanceProviderAPI.InstanceColumns.STATUS); sInstancesProjectionMap.put(InstanceProviderAPI.InstanceColumns.LAST_STATUS_CHANGE_DATE, InstanceProviderAPI.InstanceColumns.LAST_STATUS_CHANGE_DATE); sInstancesProjectionMap.put(InstanceProviderAPI.InstanceColumns.DISPLAY_SUBTEXT, InstanceProviderAPI.InstanceColumns.DISPLAY_SUBTEXT); } /** * Throw and Log FormEntry-related errors * * @param loggerText String sent to javarosa logger * @param currentState session to be cleared */ private void raiseFormEntryError(String loggerText, AndroidSessionWrapper currentState) { Logger.log(AndroidLogger.TYPE_ERROR_WORKFLOW, loggerText); currentState.reset(); throw new RuntimeException(loggerText); } private static class InvalidStateException extends Exception { public InvalidStateException(String message) { super(message); } } }