package org.commcare.android.tasks;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Date;
import java.util.Hashtable;
import java.util.Vector;
import javax.crypto.Cipher;
import javax.crypto.CipherInputStream;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.SecretKeySpec;
import org.commcare.android.database.SqlStorage;
import org.commcare.android.database.user.models.ACase;
import org.commcare.android.database.user.models.FormRecord;
import org.commcare.android.database.user.models.SessionStateDescriptor;
import org.commcare.android.javarosa.AndroidLogger;
import org.commcare.android.models.AndroidSessionWrapper;
import org.commcare.android.tasks.templates.CommCareTask;
import org.commcare.android.util.FileUtil;
import org.commcare.android.util.InvalidStateException;
import org.commcare.android.util.SessionUnavailableException;
import org.commcare.cases.model.Case;
import org.commcare.dalvik.application.CommCareApplication;
import org.commcare.dalvik.odk.provider.InstanceProviderAPI.InstanceColumns;
import org.commcare.data.xml.DataModelPullParser;
import org.commcare.data.xml.TransactionParser;
import org.commcare.data.xml.TransactionParserFactory;
import org.commcare.util.CommCarePlatform;
import org.commcare.xml.AndroidCaseXmlParser;
import org.commcare.xml.BestEffortBlockParser;
import org.commcare.xml.CaseXmlParser;
import org.commcare.xml.MetaDataXmlParser;
import org.commcare.xml.util.InvalidStructureException;
import org.commcare.xml.util.UnfullfilledRequirementsException;
import org.javarosa.core.model.utils.DateUtils;
import org.javarosa.core.services.Logger;
import org.javarosa.core.services.storage.StorageFullException;
import org.kxml2.io.KXmlParser;
import org.xmlpull.v1.XmlPullParserException;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
/**
* @author ctsims
*
*/
public abstract class FormRecordCleanupTask<R> extends CommCareTask<Void, Integer, Integer,R> {
Context context;
CommCarePlatform platform;
public static final int STATUS_CLEANUP = -1;
private static final int SUCCESS = -1;
private static final int SKIP = -2;
private static final int DELETE = -4;
public FormRecordCleanupTask(Context context, CommCarePlatform platform, int taskId) {
this.context = context;
this.platform = platform;
this.taskId = taskId;
}
/*
* (non-Javadoc)
* @see org.commcare.android.tasks.templates.CommCareTask#doTaskBackground(java.lang.Object[])
*/
@Override
protected Integer doTaskBackground(Void... params) {
SqlStorage<FormRecord> storage = CommCareApplication._().getUserStorage(FormRecord.class);
Vector<Integer> recordsToRemove = storage.getIDsForValues(new String[] { FormRecord.META_STATUS}, new String[] { FormRecord.STATUS_SAVED });
Vector<Integer> unindexedRecords = storage.getIDsForValues(new String[] { FormRecord.META_STATUS}, new String[] { FormRecord.STATUS_UNINDEXED });
int oldrecords = recordsToRemove.size();
int count = 0;
for(int recordID : unindexedRecords) {
FormRecord r = storage.read(recordID);
switch(cleanupRecord(r, storage)) {
case SUCCESS:
break;
case SKIP:
break;
case DELETE:
recordsToRemove.add(recordID);
break;
}
count++;
this.publishProgress(count, unindexedRecords.size());
}
this.publishProgress(STATUS_CLEANUP);
SqlStorage<SessionStateDescriptor> ssdStorage = CommCareApplication._().getUserStorage(SessionStateDescriptor.class);
for(int recordID : recordsToRemove) {
//We don't know anything about the session yet, so give it -1 to flag that
wipeRecord(context, -1, recordID, storage, ssdStorage);
}
System.out.println("Synced: " + unindexedRecords.size() + ". Removed: " + oldrecords + " old records, and " + (recordsToRemove.size() - oldrecords) + " busted new ones");
return SUCCESS;
}
private int cleanupRecord(FormRecord r, SqlStorage<FormRecord> storage) {
try {
FormRecord updated = getUpdatedRecord(context, platform, r, FormRecord.STATUS_SAVED);
if(updated == null) {
return DELETE;
} else {
storage.write(updated);
}
return SUCCESS;
} catch (FileNotFoundException e) {
// No form, skip and delete the form record;
e.printStackTrace();
return DELETE;
} catch (InvalidStructureException e) {
// Bad form data, skip and delete
e.printStackTrace();
return DELETE;
} catch (IOException e) {
e.printStackTrace();
// No idea, might be temporary, Skip
return SKIP;
} catch (XmlPullParserException e) {
e.printStackTrace();
// No idea, might be temporary, Skip
return SKIP;
} catch (UnfullfilledRequirementsException e) {
e.printStackTrace();
// Can't resolve here, skip.
return SKIP;
} catch (StorageFullException e) {
// Can't resolve here, skip.
throw new RuntimeException(e);
} catch (InvalidStateException e) {
//Bad situation going down, wipe out the record
return DELETE;
}
}
/**
* Parses out a formrecord and fills in the various parse-able details (UUID, date modified, etc), and updates
* it to the provided status.
*
* @param context
* @param r
* @param newStatus
* @return The new form record containing relevant details about this form
* @throws InvalidKeyException
* @throws NoSuchPaddingException
* @throws NoSuchAlgorithmException
* @throws IOException
* @throws InvalidStructureException
* @throws UnfullfilledRequirementsException
* @throws XmlPullParserException
*/
public static FormRecord getUpdatedRecord(Context context, CommCarePlatform platform, FormRecord r, String newStatus) throws InvalidStateException, InvalidStructureException, IOException, XmlPullParserException, UnfullfilledRequirementsException {
//Awful. Just... awful
final String[] caseIDs = new String[1];
final Date[] modified = new Date[] {new Date(0)};
final String[] uuid = new String[1];
//NOTE: This does _not_ parse and process the case data. It's only for getting meta information
//about the entry session.
TransactionParserFactory factory = new TransactionParserFactory() {
public TransactionParser getParser(String name, String namespace, KXmlParser parser) {
if(name == null) { return null;}
if("case".equals(name)) {
//If we have a proper 2.0 namespace, good.
if(CaseXmlParser.CASE_XML_NAMESPACE.equals(namespace)) {
return new AndroidCaseXmlParser(parser, CommCareApplication._().getUserStorage(ACase.STORAGE_KEY, ACase.class)) {
/*
* (non-Javadoc)
* @see org.commcare.xml.CaseXmlParser#commit(org.commcare.cases.model.Case)
*/
@Override
public void commit(Case parsed) throws IOException, SessionUnavailableException{
String incoming = parsed.getCaseId();
if(incoming != null && incoming != "") {
caseIDs[0] = incoming;
}
}
/*
* (non-Javadoc)
* @see org.commcare.xml.CaseXmlParser#retrieve(java.lang.String)
*/
@Override
public ACase retrieve(String entityId) throws SessionUnavailableException{
caseIDs[0] = entityId;
ACase c = new ACase("","");
c.setCaseId(entityId);
return c;
}
};
}else {
//Otherwise, this gets more tricky. Ideally we'd want to skip this block for compatibility purposes,
//but we can at least try to get a caseID (which is all we want)
return new BestEffortBlockParser(parser, null, null, new String[] {"case_id"}) {
/*
* (non-Javadoc)
* @see org.commcare.xml.BestEffortBlockParser#commit(java.util.Hashtable)
*/
@Override
public void commit(Hashtable<String, String> values) {
if(values.containsKey("case_id")) {
caseIDs[0] = values.get("case_id");
}
}
};}
}
else if("meta".equals(name.toLowerCase())) {
return new MetaDataXmlParser(parser) {
/*
* (non-Javadoc)
* @see org.commcare.xml.MetaDataXmlParser#commit(java.lang.String[])
*/
@Override
public void commit(String[] meta) throws IOException, SessionUnavailableException{
if(meta[0] != null) {
modified[0] = DateUtils.parseDateTime(meta[0]);
}
uuid[0] = meta[1];
}
};
}
return null;
}
};
String path = r.getPath(context);
FileInputStream fis;
fis = new FileInputStream(path);
InputStream is = fis;
try {
Cipher decrypter = Cipher.getInstance("AES");
decrypter.init(Cipher.DECRYPT_MODE, new SecretKeySpec(r.getAesKey(), "AES"));
is = new CipherInputStream(fis, decrypter);
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
throw new RuntimeException("No Algorithm while attempting to decode form submission for processing");
} catch (NoSuchPaddingException e) {
e.printStackTrace();
throw new RuntimeException("Invalid cipher data while attempting to decode form submission for processing");
} catch (InvalidKeyException e) {
e.printStackTrace();
throw new RuntimeException("Invalid Key Data while attempting to decode form submission for processing");
}
//Construct parser for this form's internal data.
DataModelPullParser parser = new DataModelPullParser(is, factory);
parser.parse();
//TODO: We should be committing all changes to form record models via the ASW objects, not manually.
FormRecord parsed = new FormRecord(r.getInstanceURI().toString(), newStatus, r.getFormNamespace(), r.getAesKey(),uuid[0], modified[0]);
parsed.setID(r.getID());
//TODO: The platform adds a lot of unfortunate coupling here. Should split out the need to parse completely
//uninitialized form records somewhere else.
if(caseIDs[0] != null && r.getStatus().equals(FormRecord.STATUS_UNINDEXED)) {
AndroidSessionWrapper asw = AndroidSessionWrapper.mockEasiestRoute(platform, r.getFormNamespace(), caseIDs[0]);
asw.setFormRecordId(parsed.getID());
SqlStorage<SessionStateDescriptor> ssdStorage = CommCareApplication._().getUserStorage(SessionStateDescriptor.class);
//Also bad: this is not synchronous with the parsed record write
try {
ssdStorage.write(asw.getSessionStateDescriptor());
} catch (StorageFullException e) {
}
}
//Make sure that the instance is no longer editable
if(!newStatus.equals(FormRecord.STATUS_INCOMPLETE) && !newStatus.equals(FormRecord.STATUS_UNSTARTED)) {
ContentValues cv = new ContentValues();
cv.put(InstanceColumns.CAN_EDIT_WHEN_COMPLETE, Boolean.toString(false));
context.getContentResolver().update(r.getInstanceURI(), cv, null, null);
}
return parsed;
}
public static void wipeRecord(Context c,SessionStateDescriptor existing) {
int ssid = existing.getID();
int formRecordId = existing.getFormRecordId();
wipeRecord(c, ssid, formRecordId);
}
public static void wipeRecord(Context c, AndroidSessionWrapper currentState) {
int formRecordId = currentState.getFormRecordId();
int ssdId = currentState.getSessionDescriptorId();
wipeRecord(c, ssdId, formRecordId);
}
public static void wipeRecord(Context c, FormRecord record) {
wipeRecord(c, -1, record.getID());
}
public static void wipeRecord(Context c, int formRecordId) {
wipeRecord(c, -1, formRecordId);
}
public static void wipeRecord(Context c, int sessionId, int formRecordId) {
wipeRecord(c, sessionId, formRecordId, CommCareApplication._().getUserStorage(FormRecord.class), CommCareApplication._().getUserStorage(SessionStateDescriptor.class));
}
private static void wipeRecord(Context context, int sessionId, int formRecordId, SqlStorage<FormRecord> frStorage, SqlStorage<SessionStateDescriptor> ssdStorage) {
if(sessionId != -1) {
try {
SessionStateDescriptor ssd = ssdStorage.read(sessionId);
int ssdFrid = ssd.getFormRecordId();
if(formRecordId == -1) {
formRecordId = ssdFrid;
} else if(formRecordId != ssdFrid) {
//Not good.
Logger.log(AndroidLogger.TYPE_ERROR_ASSERTION, "Inconsistent formRecordId's in session storage");
}
} catch(Exception e) {
Logger.log(AndroidLogger.TYPE_ERROR_ASSERTION, "Session ID exists, but with no record (or broken record)");
}
}
String dataPath = null;
if(formRecordId != -1 ) {
try {
FormRecord r = frStorage.read(formRecordId);
dataPath = r.getPath(context);
//See if there is a hanging session ID for this
if(sessionId == -1) {
Vector<Integer> sessionIds = ssdStorage.getIDsForValue(SessionStateDescriptor.META_FORM_RECORD_ID, formRecordId);
//We really shouldn't be able to end up with sessionId's that point to more than one thing.
if(sessionIds.size() == 1) {
sessionId = sessionIds.firstElement();
} else if(sessionIds.size() > 1) {
sessionId = sessionIds.firstElement();
Logger.log(AndroidLogger.TYPE_ERROR_ASSERTION, "Multiple session ID's pointing to the same form record");
}
}
} catch(Exception e) {
Logger.log(AndroidLogger.TYPE_ERROR_ASSERTION, "Session ID exists, but with no record (or broken record)");
}
}
//Delete 'em if you got 'em
if(sessionId != -1) {
ssdStorage.remove(sessionId);
}
if(formRecordId != -1) {
frStorage.remove(formRecordId);
}
if(dataPath != null) {
String selection = InstanceColumns.INSTANCE_FILE_PATH +"=?";
Cursor c = context.getContentResolver().query(InstanceColumns.CONTENT_URI, new String[] {InstanceColumns._ID}, selection, new String[] {dataPath}, null);
if(c.moveToFirst()) {
//There's a cursor for this file, good.
long id = c.getLong(0);
//this should take care of the files
context.getContentResolver().delete(ContentUris.withAppendedId(InstanceColumns.CONTENT_URI, id), null, null);
c.close();
} else{
//No instance record for whatever reason, manually wipe files
FileUtil.deleteFileOrDir(dataPath);
}
}
}
}