// Copyright 2015 The Project Buendia Authors // // 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 distrib- // uted 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 // specific language governing permissions and limitations under the License. package org.projectbuendia.client.ui; import android.app.Activity; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.database.Cursor; import android.net.Uri; import com.android.volley.Response; import com.android.volley.TimeoutError; import com.android.volley.VolleyError; import com.google.common.base.Charsets; import com.google.common.base.Joiner; import org.javarosa.core.model.data.IAnswerData; import org.javarosa.core.model.instance.TreeElement; import org.javarosa.xform.parse.XFormParser; import org.joda.time.DateTime; import org.joda.time.format.ISODateTimeFormat; import org.json.JSONObject; import org.odk.collect.android.activities.FormEntryActivity; import org.odk.collect.android.application.Collect; import org.odk.collect.android.model.Preset; import org.odk.collect.android.provider.FormsProviderAPI; import org.odk.collect.android.tasks.DeleteInstancesTask; import org.odk.collect.android.utilities.FileUtils; import org.projectbuendia.client.App; import org.projectbuendia.client.AppSettings; import org.projectbuendia.client.events.FetchXformFailedEvent; import org.projectbuendia.client.events.SubmitXformFailedEvent; import org.projectbuendia.client.events.SubmitXformSucceededEvent; import org.projectbuendia.client.exception.ValidationException; import org.projectbuendia.client.json.JsonUser; import org.projectbuendia.client.net.OdkDatabase; import org.projectbuendia.client.net.OdkXformSyncTask; import org.projectbuendia.client.net.OpenMrsXformIndexEntry; import org.projectbuendia.client.net.OpenMrsXformsConnection; import org.projectbuendia.client.providers.Contracts; import org.projectbuendia.client.utils.Logger; import org.projectbuendia.client.utils.Utils; import java.io.BufferedReader; import java.io.File; import java.io.FileReader; import java.io.IOException; import java.net.HttpURLConnection; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; import javax.annotation.Nullable; import de.greenrobot.event.EventBus; import static android.provider.BaseColumns._ID; import static java.lang.String.format; import static org.odk.collect.android.provider.InstanceProviderAPI.InstanceColumns.CONTENT_ITEM_TYPE; import static org.odk.collect.android.provider.InstanceProviderAPI.InstanceColumns.INSTANCE_FILE_PATH; /** Convenience class for launching ODK to display an Xform. */ public class OdkActivityLauncher { private static final Logger LOG = Logger.create(); /** * Fetches all xforms from the server and caches them. If any error occurs during fetching, * a failed event is triggered. */ public static void fetchAndCacheAllXforms() { new OpenMrsXformsConnection(App.getConnectionDetails()).listXforms( new Response.Listener<List<OpenMrsXformIndexEntry>>() { @Override public void onResponse(final List<OpenMrsXformIndexEntry> response) { for (OpenMrsXformIndexEntry formEntry : response) { fetchAndCacheXForm(formEntry); } } }, new Response.ErrorListener() { @Override public void onErrorResponse(VolleyError error) { handleFetchError(error); } }); } /** * Fetches the xform specified by the form uuid. * * @param formEntry the {@link OpenMrsXformIndexEntry} object containing the uuid form */ public static void fetchAndCacheXForm(OpenMrsXformIndexEntry formEntry) { new OdkXformSyncTask(null).fetchAndAddXFormToDb(formEntry.uuid, formEntry.makeFileForForm()); } /** * Loads the xform from the cache and launches ODK using it. If the cache is not available, * the app tries to fetch it from the server. If no form is got, it is triggered a failed event. * @param callingActivity the {@link Activity} requesting the xform; when ODK closes, the user * will be returned to this activity * @param uuidToShow UUID of the form to show * @param requestCode if >= 0, this code will be returned in onActivityResult() when the * activity exits * @param patient the {@link org.odk.collect.android.model.Patient} that this form entry will * correspond to * @param fields a {@link Preset} object with any form fields that should be * pre-populated */ public static void fetchAndShowXform( final Activity callingActivity, final String uuidToShow, final int requestCode, @Nullable final org.odk.collect.android.model.Patient patient, @Nullable final Preset fields) { LOG.i("Trying to fetch it from cache."); if (loadXformFromCache(callingActivity, uuidToShow, requestCode, patient, fields)) { return; } new OpenMrsXformsConnection(App.getConnectionDetails()).listXforms( new Response.Listener<List<OpenMrsXformIndexEntry>>() { @Override public void onResponse(final List<OpenMrsXformIndexEntry> response) { if (response.isEmpty()) { LOG.i("No forms found"); EventBus.getDefault().post(new FetchXformFailedEvent( FetchXformFailedEvent.Reason.NO_FORMS_FOUND)); return; } showForm(callingActivity, requestCode, patient, fields, findUuid(response, uuidToShow)); } }, new Response.ErrorListener() { @Override public void onErrorResponse(VolleyError error) { LOG.e(error, "Fetching xform list from server failed. "); handleFetchError(error); } }); } /** * Shows the form with the given id in ODK collect. * @param callingActivity the {@link Activity} requesting the xform; when ODK closes, the user * will be returned to this activity * @param requestCode if >= 0, this code will be returned in onActivityResult() when the * activity exits * @param formId the id of the form to fetch * @param patient the {@link org.odk.collect.android.model.Patient} that this form entry will * correspond to * @param fields a {@link Preset} object with any form fields that should be * pre-populated */ public static void showOdkCollect( Activity callingActivity, int requestCode, long formId, @Nullable org.odk.collect.android.model.Patient patient, @Nullable Preset fields) { Intent intent = new Intent(callingActivity, FormEntryActivity.class); Uri formUri = ContentUris.withAppendedId(FormsProviderAPI.FormsColumns.CONTENT_URI, formId); intent.setData(formUri); intent.setAction(Intent.ACTION_PICK); if (patient != null) { intent.putExtra("patient", patient); } if (fields != null) { intent.putExtra("fields", fields); } callingActivity.startActivityForResult(intent, requestCode); } /** * Loads the xform from the cache and launches ODK using it. Return true if the cache is * available. * @param callingActivity the {@link Activity} requesting the xform; when ODK closes, the user * will be returned to this activity * @param uuidToShow UUID of the form to show * @param requestCode if >= 0, this code will be returned in onActivityResult() when the * activity exits * @param patient the {@link org.odk.collect.android.model.Patient} that this form entry will * correspond to * @param fields a {@link Preset} object with any form fields that should be * pre-populated */ private static boolean loadXformFromCache(final Activity callingActivity, final String uuidToShow, final int requestCode, @Nullable final org.odk.collect.android.model.Patient patient, @Nullable final Preset fields) { List<OpenMrsXformIndexEntry> entries = getLocalFormEntries(); OpenMrsXformIndexEntry formToShow = findUuid(entries, uuidToShow); if (!formToShow.makeFileForForm().exists()) return false; LOG.i(format("Using form %s from local cache.", uuidToShow)); showForm(callingActivity, requestCode, patient, fields, formToShow); return true; } private static List<OpenMrsXformIndexEntry> getLocalFormEntries() { List<OpenMrsXformIndexEntry> entries = new ArrayList<>(); final ContentResolver resolver = App.getInstance().getContentResolver(); Cursor c = resolver.query(Contracts.Forms.CONTENT_URI, new String[] {Contracts.Forms.UUID, Contracts.Forms.NAME}, null, null, null); try { while (c.moveToNext()) { String uuid = Utils.getString(c, Contracts.Forms.UUID); String name = Utils.getString(c, Contracts.Forms.NAME); long date = 0; // date is not important here entries.add(new OpenMrsXformIndexEntry(uuid, name, date)); } } finally { c.close(); } return entries; } /** * Launches ODK using the requested form. * @param callingActivity the {@link Activity} requesting the xform; when ODK closes, the user * will be returned to this activity * @param requestCode if >= 0, this code will be returned in onActivityResult() when the * activity exits * @param patient the {@link org.odk.collect.android.model.Patient} that this form entry will * correspond to * @param fields a {@link Preset} object with any form fields that should be * pre-populated * @param formToShow a {@link OpenMrsXformIndexEntry} object representing the form that * should be opened */ private static void showForm(final Activity callingActivity, final int requestCode, @Nullable final org.odk.collect.android.model.Patient patient, @Nullable final Preset fields, final OpenMrsXformIndexEntry formToShow) { new OdkXformSyncTask(new OdkXformSyncTask.FormWrittenListener() { @Override public void formWritten(File path, String uuid) { LOG.i("wrote form " + path); showOdkCollect( callingActivity, requestCode, OdkDatabase.getFormIdForPath(path), patient, fields); } }).execute(formToShow); } // Out of a list of OpenMRS Xform entries, find the form that matches the given uuid, or // return null if no xform is found. private static OpenMrsXformIndexEntry findUuid( List<OpenMrsXformIndexEntry> allEntries, String uuid) { for (OpenMrsXformIndexEntry entry : allEntries) { if (entry.uuid.equals(uuid)) { return entry; } } return null; } /** * Convenient shared code for handling an ODK activity result. * @param context the application context * @param settings the application settings * @param patientUuid the patient to add an observation to, or null to create a new patient * @param resultCode the result code sent from Android activity transition * @param data the incoming intent */ public static void sendOdkResultToServer( final Context context, final AppSettings settings, @Nullable final String patientUuid, int resultCode, Intent data) { if(isActivityCanceled(resultCode, data)) return; try { final Uri uri = data.getData(); if(!validateContentUriType(context, uri, CONTENT_ITEM_TYPE)) { throw new ValidationException("Tried to load a content URI of the wrong type: " + uri); } final String filePath = getFormFilePath(context, uri); if(!validateFilePath(filePath, uri)) { throw new ValidationException("No file path for form instance: " + uri); } final Long formIdToDelete = getIdToDeleteAfterUpload(context, uri); if(!validateIdToDeleteAfterUpload(formIdToDelete, uri)) { throw new ValidationException("No id to delete for after upload: " + uri); } // Temporary code for messing about with xform instance, reading values. byte[] fileBytes = FileUtils.getFileAsBytes(new File(filePath)); // get the root of the saved and template instances final TreeElement savedRoot = XFormParser.restoreDataModel(fileBytes, null).getRoot(); final String xml = readFromPath(filePath); if(!validateXml(xml)) { throw new ValidationException("Xml form is not valid for uri: " + uri); } sendFormToServer(patientUuid, xml, new Response.Listener<JSONObject>() { @Override public void onResponse(JSONObject response) { LOG.i("Created new encounter successfully on server" + response.toString()); // Only locally cache new observations, not new patients. if (patientUuid != null) { updateObservationCache(patientUuid, savedRoot, context.getContentResolver()); } if (!settings.getKeepFormInstancesLocally()) { deleteLocalFormInstances(formIdToDelete); } EventBus.getDefault().post(new SubmitXformSucceededEvent()); } }, new Response.ErrorListener() { @Override public void onErrorResponse(VolleyError error) { LOG.e(error, "Error submitting form to server"); handleSubmitError(error); } }); } catch(ValidationException ve) { LOG.e(ve.getMessage()); EventBus.getDefault().post( new SubmitXformFailedEvent(SubmitXformFailedEvent.Reason.CLIENT_ERROR)); } } /** * Checks if the file path is valid. If so, it returns {@code true}. Otherwise returns * {@code false} * @param filePath the file path to be validated * @param uri the form uri */ private static boolean validateFilePath(String filePath, Uri uri) { return filePath != null; } /** Checks if the URI has a valid type. If so, returns {@code true}. Otherwise, returns {@code false} * @param context the application context * @param uri the URI to be checked * @param validType the accepted type for URI */ private static boolean validateContentUriType(final Context context, final Uri uri, final String validType) { return context.getContentResolver().getType(uri).equals(validType); } /** * Validates the id to be deleted after the form upload. If id is valid, it returns * {@code true}. Otherwise, returns {@code false}. * @param id the id to be deleted * @param uri the URI containing the id to be deleted */ private static boolean validateIdToDeleteAfterUpload(final Long id, Uri uri) { return id != null; } /** * Validates the xml. Returns {@code true} if it is valid. Otherwise, returns {@code false} */ private static boolean validateXml(String xml) { return xml != null; } private static void deleteLocalFormInstances(Long formIdToDelete) { //Code largely copied from InstanceUploaderTask to delete on upload DeleteInstancesTask dit = new DeleteInstancesTask(); dit.setContentResolver( Collect.getInstance().getApplication() .getContentResolver()); dit.execute(formIdToDelete); } /** * Returns the form file path queried from the given {@link Uri}. If no file path was found, * it returns <code>null</code>. * @param context the application context * @param uri the URI containing the form file path */ private static String getFormFilePath(final Context context, final Uri uri) { Cursor instanceCursor = null; try { instanceCursor = getCursorAtRightPosition(context, uri); if(instanceCursor == null) return null; return instanceCursor.getString(instanceCursor.getColumnIndex(INSTANCE_FILE_PATH)); } finally { if (instanceCursor != null) { instanceCursor.close(); } } } /** * Returns the id to be deleted after the form upload, which was queried from the given * {@link Uri}. If no id was found, it returns <code>null</code>. * @param context the application context * @param uri the URI containing the id to be deleted */ private static Long getIdToDeleteAfterUpload(final Context context, final Uri uri) { Cursor instanceCursor = null; try { instanceCursor = getCursorAtRightPosition(context, uri); if(instanceCursor == null) return null; int columnIndex = instanceCursor.getColumnIndex(_ID); if (columnIndex == -1) return null; return instanceCursor.getLong(columnIndex); } finally { if (instanceCursor != null) { instanceCursor.close(); } } } /** * Returns the form {@link Cursor} ready to be used. If no form was found, it triggers a * {@link SubmitXformFailedEvent} event and returns <code>null</code>. * @param context the application context * @param uri the URI to be queried */ private static Cursor getCursorAtRightPosition(final Context context, final Uri uri) { Cursor instanceCursor = context.getContentResolver().query(uri, null, null, null, null); if (instanceCursor.getCount() != 1) { LOG.e("The form that we tried to load did not exist: " + uri); EventBus.getDefault().post( new SubmitXformFailedEvent(SubmitXformFailedEvent.Reason.CLIENT_ERROR)); return null; } instanceCursor.moveToFirst(); return instanceCursor; } /** * Returns true if the activity was canceled * @param resultCode the result code sent from Android activity transition * @param data the incoming intent */ private static boolean isActivityCanceled(int resultCode, Intent data) { if (resultCode == Activity.RESULT_CANCELED) return true; if (data == null || data.getData() == null) { LOG.i("No data for form result, probably cancelled."); return true; } return false; } private static void sendFormToServer(String patientUuid, String xml, Response.Listener<JSONObject> successListener, Response.ErrorListener errorListener) { OpenMrsXformsConnection connection = new OpenMrsXformsConnection(App.getConnectionDetails()); JsonUser activeUser = App.getUserManager().getActiveUser(); connection.postXformInstance( patientUuid, activeUser.id, xml, successListener, errorListener); } private static void handleSubmitError(VolleyError error) { SubmitXformFailedEvent.Reason reason = SubmitXformFailedEvent.Reason.UNKNOWN; if (error instanceof TimeoutError) { reason = SubmitXformFailedEvent.Reason.SERVER_TIMEOUT; } else if (error.networkResponse != null) { switch (error.networkResponse.statusCode) { case HttpURLConnection.HTTP_UNAUTHORIZED: case HttpURLConnection.HTTP_FORBIDDEN: reason = SubmitXformFailedEvent.Reason.SERVER_AUTH; break; case HttpURLConnection.HTTP_NOT_FOUND: reason = SubmitXformFailedEvent.Reason.SERVER_BAD_ENDPOINT; break; case HttpURLConnection.HTTP_INTERNAL_ERROR: if (error.networkResponse.data == null) { LOG.e("Server error, but no internal error stack trace available."); } else { LOG.e(new String(error.networkResponse.data, Charsets.UTF_8)); LOG.e("Server error. Internal error stack trace:\n"); } reason = SubmitXformFailedEvent.Reason.SERVER_ERROR; break; default: reason = SubmitXformFailedEvent.Reason.SERVER_ERROR; break; } } EventBus.getDefault().post(new SubmitXformFailedEvent(reason, error)); } private static void handleFetchError(VolleyError error) { FetchXformFailedEvent.Reason reason = FetchXformFailedEvent.Reason.SERVER_UNKNOWN; if (error.networkResponse != null) { switch (error.networkResponse.statusCode) { case HttpURLConnection.HTTP_FORBIDDEN: case HttpURLConnection.HTTP_UNAUTHORIZED: reason = FetchXformFailedEvent.Reason.SERVER_AUTH; break; case HttpURLConnection.HTTP_NOT_FOUND: reason = FetchXformFailedEvent.Reason.SERVER_BAD_ENDPOINT; break; case HttpURLConnection.HTTP_INTERNAL_ERROR: default: reason = FetchXformFailedEvent.Reason.SERVER_UNKNOWN; } } EventBus.getDefault().post(new FetchXformFailedEvent(reason, error)); } /** * Returns the xml form as a String from the path. If for any reason, the file couldn't be read, * it returns <code>null</code> * @param path the path to be read */ private static String readFromPath(String path) { try { StringBuilder sb = new StringBuilder(); BufferedReader reader = new BufferedReader(new FileReader(path)); String line; while ((line = reader.readLine()) != null) { sb.append(line).append("\n"); } return sb.toString(); } catch (IOException e) { LOG.e(e, format("Failed to read xml form into a String. FilePath= ", path)); return null; } } /** * Caches the observation changes locally for a given patient. */ private static void updateObservationCache(String patientUuid, TreeElement savedRoot, ContentResolver resolver) { ContentValues common = new ContentValues(); // It's critical that UUID is {@code null} for temporary observations, so we make it // explicit here. See {@link Contracts.Observations.UUID} for details. common.put(Contracts.Observations.UUID, (String) null); common.put(Contracts.Observations.PATIENT_UUID, patientUuid); final DateTime encounterTime = getEncounterAnswerDateTime(savedRoot); if(encounterTime == null) return; common.put(Contracts.Observations.ENCOUNTER_MILLIS, encounterTime.getMillis()); common.put(Contracts.Observations.ENCOUNTER_UUID, UUID.randomUUID().toString()); Set<Integer> xformConceptIds = new HashSet<>(); List<ContentValues> toInsert = getAnsweredObservations(common, savedRoot, xformConceptIds); Map<String, String> xformIdToUuid = mapFormConceptIdToUuid(xformConceptIds, resolver); // Remap concept ids to uuids, skipping anything we can't remap. for (Iterator<ContentValues> i = toInsert.iterator(); i.hasNext(); ) { ContentValues values = i.next(); if (!mapIdToUuid(xformIdToUuid, values, Contracts.Observations.CONCEPT_UUID)) { i.remove(); } mapIdToUuid(xformIdToUuid, values, Contracts.Observations.VALUE); } resolver.bulkInsert(Contracts.Observations.CONTENT_URI, toInsert.toArray(new ContentValues[toInsert.size()])); } /** Get a map from XForm ids to UUIDs from our local concept database. */ private static Map<String, String> mapFormConceptIdToUuid(Set<Integer> xformConceptIds, ContentResolver resolver) { String inClause = Joiner.on(",").join(xformConceptIds); HashMap<String, String> xformIdToUuid = new HashMap<>(); Cursor cursor = resolver.query(Contracts.Concepts.CONTENT_URI, new String[] {Contracts.Concepts.UUID, Contracts.Concepts.XFORM_ID}, Contracts.Concepts.XFORM_ID + " IN (" + inClause + ")", null, null); try { while (cursor.moveToNext()) { xformIdToUuid.put(Utils.getString(cursor, Contracts.Concepts.XFORM_ID), Utils.getString(cursor, Contracts.Concepts.UUID)); } } finally { cursor.close(); } return xformIdToUuid; } /** * Returns a {@link ContentValues} list containing the id concept and the answer valeu from * all answered observations. Returns a empty {@link List} if no observation was answered. * * @param common the current content values. * @param savedRoot the root tree form element * @param xformConceptIdsAccumulator the set to store the form concept ids found */ private static List<ContentValues> getAnsweredObservations(ContentValues common, TreeElement savedRoot, Set<Integer> xformConceptIdsAccumulator) { List<ContentValues> answeredObservations = new ArrayList<>(); for (int i = 0; i < savedRoot.getNumChildren(); i++) { TreeElement group = savedRoot.getChildAt(i); if (group.getNumChildren() == 0) continue; for (int j = 0; j < group.getNumChildren(); j++) { TreeElement question = group.getChildAt(j); TreeElement openmrsConcept = question.getAttribute(null, "openmrs_concept"); TreeElement openmrsDatatype = question.getAttribute(null, "openmrs_datatype"); if (openmrsConcept == null || openmrsDatatype == null) continue; // Get the concept for the question. // eg "5088^Temperature (C)^99DCT" String encodedConcept = (String) openmrsConcept.getValue().getValue(); Integer id = getConceptId(xformConceptIdsAccumulator, encodedConcept); if (id == null) continue; // Also get for the answer if a coded question TreeElement valueChild = question.getChild("value", 0); IAnswerData answer = valueChild.getValue(); if (answer == null || answer.getValue() == null) continue; Object answerObject = answer.getValue(); String value; if ("CWE".equals(openmrsDatatype.getValue().getValue())) { value = getConceptId(xformConceptIdsAccumulator, answerObject.toString()).toString(); } else { value = answerObject.toString(); } ContentValues observation = new ContentValues(common); // Set to the id for now, we'll replace with uuid later observation.put(Contracts.Observations.CONCEPT_UUID, id.toString()); observation.put(Contracts.Observations.VALUE, value); answeredObservations.add(observation); } } return answeredObservations; } /** * Returns the encounter's answer date time. Returns <code>null</code> if it cannot be retrieved. */ private static DateTime getEncounterAnswerDateTime(TreeElement root) { TreeElement encounter = root.getChild("encounter", 0); if (encounter == null) { LOG.e("No encounter found in instance"); return null; } TreeElement encounterDatetime = encounter.getChild("encounter.encounter_datetime", 0); if (encounterDatetime == null) { LOG.e("No encounter date time found in instance"); return null; } IAnswerData dateTimeValue = encounterDatetime.getValue(); try { return ISODateTimeFormat.dateTime().parseDateTime((String) dateTimeValue.getValue()); } catch (IllegalArgumentException e) { LOG.e("Could not parse datetime" + dateTimeValue.getValue()); return null; } } private static Integer getConceptId(Set<Integer> accumulator, String encodedConcept) { Integer id = getConceptId(encodedConcept); if (id != null) { accumulator.add(id); } return id; } private static boolean mapIdToUuid( Map<String, String> idToUuid, ContentValues values, String key) { String id = (String) values.get(key); String uuid = idToUuid.get(id); if (uuid == null) { return false; } values.put(key, uuid); return true; } private static Integer getConceptId(String encodedConcept) { int idEnd = encodedConcept.indexOf('^'); if (idEnd == -1) { return null; } String idString = encodedConcept.substring(0, idEnd); try { return Integer.parseInt(idString); } catch (NumberFormatException ex) { LOG.w("Strangely formatted id String " + idString); return null; } } }