package org.commcare.android.tasks;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
import java.util.NoSuchElementException;
import org.apache.http.HttpResponse;
import org.apache.http.client.ClientProtocolException;
import org.commcare.android.crypt.CryptUtil;
import org.commcare.android.database.SqlStorage;
import org.commcare.android.database.app.models.UserKeyRecord;
import org.commcare.android.database.user.UserSandboxUtils;
import org.commcare.android.database.user.models.User;
import org.commcare.android.db.legacy.LegacyInstallUtils;
import org.commcare.android.javarosa.AndroidLogger;
import org.commcare.android.net.HttpRequestGenerator;
import org.commcare.android.tasks.templates.HttpCalloutTask;
import org.commcare.android.tasks.templates.HttpCalloutTask.HttpCalloutOutcomes;
import org.commcare.android.util.SessionUnavailableException;
import org.commcare.dalvik.application.CommCareApp;
import org.commcare.dalvik.application.CommCareApplication;
import org.commcare.data.xml.TransactionParser;
import org.commcare.data.xml.TransactionParserFactory;
import org.commcare.xml.KeyRecordParser;
import org.javarosa.core.services.Logger;
import org.javarosa.core.services.locale.Localization;
import org.javarosa.core.services.storage.StorageFullException;
import org.kxml2.io.KXmlParser;
import android.content.Context;
/**
* This task is responsible for taking user credentials and attempting to
* log in a user with local data. If the credentials represent a user who
* doesn't exist, this task will attempt to fetch and create a key record
* for the user specified.
*
* This task uses three steps
* 1) Clean up user key records and figure out whether we need to look for
* new records
* 2) Fetch new records [HTTP step]
* 3) Process the new records and perform any necessary data migration
*
* @author ctsims
*
*/
public abstract class ManageKeyRecordTask<R> extends HttpCalloutTask<R> {
String username;
String password;
CommCareApp app;
String keyServerUrl;
ArrayList<UserKeyRecord> keyRecords;
ManageKeyRecordListener<R> listener;
boolean calloutNeeded = false;
boolean calloutRequired = false;
User loggedIn = null;
public ManageKeyRecordTask(Context c, int taskId, String username, String password, CommCareApp app, ManageKeyRecordListener<R> listener) {
super(c);
this.username = username;
this.password = password;
this.app = app;
keyServerUrl = app.getAppPreferences().getString("key_server", null);
//long story
keyServerUrl = "".equals(keyServerUrl) ? null : keyServerUrl;
this.listener = listener;
this.taskId = taskId;
}
/* (non-Javadoc)
* @see android.os.AsyncTask#onCancelled()
*/
@Override
protected void onCancelled() {
super.onCancelled();
}
/* (non-Javadoc)
* @see org.commcare.android.tasks.templates.CommCareTask#deliverResult(java.lang.Object, java.lang.Object)
*/
@Override
protected void deliverResult(R receiver, HttpCalloutOutcomes result) {
//If this task completed and we logged in.
if(result == HttpCalloutOutcomes.Success) {
if(loggedIn == null) {
//If we got here, we didn't "log in" fully. IE: We have a key record and a
//functional sandbox, but this user has never been synced, so we aren't
//really "logged in".
CommCareApplication._().logout();
listener.keysReadyForSync(receiver);
return;
} else {
listener.keysLoginComplete(receiver);
return;
}
}
//For any other result make sure we're logged out.
CommCareApplication._().logout();
//TODO: Do we wanna split this up at all? Seems unlikely. We don't have, like, a ton
//more context that the receiving activity will
listener.keysDoneOther(receiver, result);
}
protected void deliverError(R receiver, Exception e) {
Logger.log(AndroidLogger.TYPE_ERROR_WORKFLOW, "Error executing task in background: " + e.getMessage());
listener.keysDoneOther(receiver, HttpCalloutOutcomes.UnkownError);
}
/* (non-Javadoc)
* @see org.commcare.android.tasks.templates.HttpCalloutTask#doSetupTaskBeforeRequest()
*/
@Override
protected HttpCalloutOutcomes doSetupTaskBeforeRequest() {
//This step needs to determine three things
//1) Whether we are doing remote key management
//2) Whether we should look for new key records
//3) Whether we _need_ new key records, or can proceed without them
//if the fetch fails.
//Otherwise, we're going to need to look through our key records, so
//clean up the existing key records and make sure we're in a consistent state
cleanupUserKeyRecords();
//Now, see whether we have a valid record for this username/password combo
boolean hasRecord = false;
UserKeyRecord valid = null;
Date now = new Date();
for(UserKeyRecord ukr : app.getStorage(UserKeyRecord.class).getRecordsForValue(UserKeyRecord.META_USERNAME, username)) {
if(!ukr.isPasswordValid(password)) {
//This record is for a different password
continue;
}
//regardless of whether it's "valid", we have a record.
hasRecord = true;
//ok, now check whether this record is fully valid, or we need to look for an update
if(ukr.isCurrentlyValid()) {
valid = ukr;
}
}
//If we don't have any records and we aren't doing remote key management, this is as
//far as we're going
if(!hasRecord && keyServerUrl == null) { return HttpCalloutOutcomes.Success;}
//If we don't have any records, we need to do a callout
calloutRequired = !hasRecord;
calloutNeeded = (calloutRequired || valid == null) && keyServerUrl != null;
if(calloutNeeded) {
Logger.log(AndroidLogger.TYPE_USER, "Performing key record callout." + (calloutRequired ? " Success is required for login" : ""));
this.publishProgress(Localization.get("key.manage.callout"));
}
return null;
}
//Functionality that doesn't necessarily "belong" to this workflow:
private void cleanupUserKeyRecords() {
UserKeyRecord currentlyValid = null;
//For all "new" entries: If there's another sandbox record (regardless of user)
//which shares the sandbox ID, we can set the status of the new record to be
//the same as the old record.
//TODO: We dont' need to read these records, we can read the metadata straight.
SqlStorage<UserKeyRecord> storage = app.getStorage(UserKeyRecord.class);
for(UserKeyRecord record : storage) {
if(record.getType() == UserKeyRecord.TYPE_NORMAL) {
if(record.getUsername().equals(username) && record.isCurrentlyValid() && record.isPasswordValid(password)) {
if(currentlyValid == null) {
currentlyValid = record;
} else {
Logger.log(AndroidLogger.TYPE_ERROR_ASSERTION, "User " + username + " has more than one currently valid key record!!");
}
}
continue;
}
else if(record.getType() == UserKeyRecord.TYPE_NEW) {
//See if we have another sandbox with this ID that is fully initialized.
if(app.getStorage(UserKeyRecord.class).getIDsForValues(new String[] {UserKeyRecord.META_SANDBOX_ID, UserKeyRecord.META_KEY_STATUS}, new Object[] {record.getUuid(), UserKeyRecord.TYPE_NORMAL}).size() > 0) {
Logger.log(AndroidLogger.TYPE_MAINTENANCE, "Marking new sandbox " + record.getUuid() + " as initialized, since it's already in use on this device");
//If so, this sandbox _has_ to have already been initialized, and we should treat it as such.
record.setType(UserKeyRecord.TYPE_NORMAL);
storage.write(record);
}
} else if (record.getType() == UserKeyRecord.TYPE_PENDING_DELETE) {
Logger.log(AndroidLogger.TYPE_MAINTENANCE, "Cleaning up sandbox which is pending removal");
//See if there are more records in this sandbox. (If so, we can just wipe this record and move on)
if(storage.getIDsForValue(UserKeyRecord.META_SANDBOX_ID, record.getUuid()).size() > 2) {
Logger.log(AndroidLogger.TYPE_MAINTENANCE, "Record for sandbox " + record.getUuid() + " has siblings. Removing record");
//TODO: Will this invalidate our iterator?
storage.remove(record);
} else {
//Otherwise, we should see if we can read the data, and if so, wipe it as well as the record.
if(record.isPasswordValid(password)) {
Logger.log(AndroidLogger.TYPE_MAINTENANCE, "Current user has access to purgable sandbox " + record.getUuid() + ". Wiping that sandbox");
UserSandboxUtils.purgeSandbox(this.getContext(), app, record,record.unWrapKey(password));
}
//Do we do anything here if we couldn't open the sandbox?
}
}
//TODO: Specifically we should never have two sandboxes which can be opened by the same password (I think...)
}
}
//CTS: These will be fleshed out to comply with the server's Key Request/response protocol
/* (non-Javadoc)
* @see org.commcare.android.tasks.templates.HttpCalloutTask#doHttpRequest()
*/
@Override
protected HttpResponse doHttpRequest() throws ClientProtocolException, IOException {
HttpRequestGenerator requestor = new HttpRequestGenerator(username, password);
return requestor.makeKeyFetchRequest(keyServerUrl, null);
}
/* (non-Javadoc)
* @see org.commcare.android.tasks.templates.HttpCalloutTask#getTransactionParserFactory()
*/
@Override
protected TransactionParserFactory getTransactionParserFactory() {
TransactionParserFactory factory = new TransactionParserFactory() {
/*
* (non-Javadoc)
* @see org.commcare.data.xml.TransactionParserFactory#getParser(java.lang.String, java.lang.String, org.kxml2.io.KXmlParser)
*/
@Override
public TransactionParser getParser(String name, String namespace, KXmlParser parser) {
if("auth_keys".equals(name)) {
return new KeyRecordParser(parser, username, password, keyRecords) {
/*
* (non-Javadoc)
* @see org.commcare.data.xml.TransactionParser#commit(java.lang.Object)
*/
@Override
public void commit(ArrayList<UserKeyRecord> parsed) throws IOException {
ManageKeyRecordTask.this.keyRecords = parsed;
}
};
} else {
return null;
}
}
};
return factory;
}
/* (non-Javadoc)
* @see org.commcare.android.tasks.templates.HttpCalloutTask#HttpCalloutNeeded()
*/
@Override
protected boolean HttpCalloutNeeded() {
return calloutNeeded;
}
/* (non-Javadoc)
* @see org.commcare.android.tasks.templates.HttpCalloutTask#HttpCalloutRequired()
*/
@Override
protected boolean HttpCalloutRequired() {
return calloutRequired;
}
/*
* (non-Javadoc)
* @see org.commcare.android.tasks.templates.HttpCalloutTask#processSuccesfulRequest()
*/
@Override
protected boolean processSuccesfulRequest() {
if(keyRecords == null || keyRecords.size() == 0) {
Logger.log(AndroidLogger.TYPE_USER, "No key records received on server request!");
return false;
}
try {
Logger.log(AndroidLogger.TYPE_USER, "Key record request complete. Received: " + keyRecords.size() + " key records from server");
SqlStorage<UserKeyRecord> storage = app.getStorage(UserKeyRecord.class);
//We successfully received and parsed out some key records! Let's update the db
for(UserKeyRecord record : keyRecords) {
//See if we already have a key record for this sandbox and user (There should _definitely_ only be one if there is one)
UserKeyRecord existing;
try {
existing = storage.getRecordForValues(new String[] {UserKeyRecord.META_SANDBOX_ID, UserKeyRecord.META_USERNAME} , new Object[] {record.getUuid(), record.getUsername()});
Logger.log(AndroidLogger.TYPE_MAINTENANCE, "Got new record for existing sandbox " + existing.getUuid() + " . Merging");
//So we have an existing record. Either we're updating our current record and we're updating the key details
//or our password has changed and we need to overwrite the existing key record. Either way, all
//we should need to do is merge the records.
UserKeyRecord ukr = new UserKeyRecord(record.getUsername(), record.getPasswordHash(), record.getEncryptedKey(), record.getValidFrom(), record.getValidTo(), record.getUuid(), existing.getType());
ukr.setID(existing.getID());
storage.write(ukr);
} catch(NoSuchElementException nsee) {
//If there's no existing record, write this new one (we'll handle updating the status later)
storage.write(record);
}
}
return true;
} catch (StorageFullException e) {
e.printStackTrace();
return false;
}
}
/*
* (non-Javadoc)
* @see org.commcare.android.tasks.templates.HttpCalloutTask#doPostCalloutTask(boolean)
*/
@Override
protected HttpCalloutTask.HttpCalloutOutcomes doPostCalloutTask(boolean calloutFailed) {
//Now we need to complete our login
//First, check for consistency in our key records
cleanupUserKeyRecords();
//Now identify the current record (If we didn't get one, something bad happened)
UserKeyRecord current = getCurrentValidRecord(app, username, password, !HttpCalloutNeeded() || (calloutFailed && !HttpCalloutRequired()));
if(current == null) {
//TODO: What is this failure mode
}
//Now, see if we need to do anything to process our new record.
if(current.getType() != UserKeyRecord.TYPE_NORMAL) {
if(current.getType() == UserKeyRecord.TYPE_NEW) {
//See if we can migrate an old sandbox's data to the new sandbox.
if(!lookForAndMigrateOldSandbox(current)) {
//Problem during migration! We should try again? Maybe?
//Or just leave the old one?
//Switching over to using the old record instead of failing
current = getInUseSandbox(current.getUsername(), app.getStorage(UserKeyRecord.class));
//Make sure we didn't somehow not get a new sandbox
if(current == null ){
Logger.log(AndroidLogger.TYPE_ERROR_ASSERTION, "Somehow we both failed to migrate an old DB and also didn't _havE_ an old db");
return HttpCalloutTask.HttpCalloutOutcomes.UnkownError;
}
//otherwise we're now keyed up with the old DB and we should be fine to log in
}
} else if (current.getType() == UserKeyRecord.TYPE_LEGACY_TRANSITION) {
//Transition the legacy storage to the new format. We don't have a new record, so don't
//worry
try {
this.publishProgress(Localization.get("key.manage.legacy.begin"));
LegacyInstallUtils.transitionLegacyUserStorage(getContext(), CommCareApplication._().getCurrentApp(), current.unWrapKey(password), current);
} catch(Exception e) {
e.printStackTrace();
//Ugh, high level trap catch
//Problem during migration! We should try again? Maybe?
//Or just leave the old one?
Logger.log(AndroidLogger.TYPE_ERROR_ASSERTION, "Error while trying to migrate legacy database! Exception: " + e.getMessage());
//For now, fail.
return HttpCalloutTask.HttpCalloutOutcomes.UnkownError;
}
}
}
//Ok, so we're done with everything now. We should log in our local sandbox and proceed to the next step.
CommCareApplication._().logIn(current.unWrapKey(password), current);
//So we may have logged in a key record but not a user (if we just received the
//key, but not the user's data, for instance).
try {
User u = CommCareApplication._().getSession().getLoggedInUser();
if(u != null) {
u.setCachedPwd(password);
loggedIn = u;
}
} catch(SessionUnavailableException sue) {
}
return HttpCalloutTask.HttpCalloutOutcomes.Success;
}
private UserKeyRecord getInUseSandbox(String username, SqlStorage<UserKeyRecord> storage) {
UserKeyRecord oldSandboxToMigrate = null;
for(UserKeyRecord ukr : storage.getRecordsForValue(UserKeyRecord.META_USERNAME, username)) {
if(ukr.getType() == UserKeyRecord.TYPE_NEW) {
//This record is also new (which is kind of sketchy, actually), so it's not helpful.
continue;
}
//Ok, so we have an old record that's been in use for this user. See if this password is the same
if(!ukr.isPasswordValid(password)) {
//Otherwise, this was saved for a different password. We would have simply overwritten the record
//if our sandboxes matched, so we can't do anything with it.
continue;
}
//Ok, so only one more question: We may have migrated a sandbox in the past already, so we should only
//overwrite this record if it's the newest (although it's a bad sign if we have two existing sandboxes
//which can be unlocked with the same password)
if(oldSandboxToMigrate == null || ukr.getValidFrom().after(oldSandboxToMigrate.getValidFrom())) {
oldSandboxToMigrate = ukr;
} else {
Logger.log(AndroidLogger.TYPE_ERROR_ASSERTION, "Two old sandboxes exist with the same username");
}
}
return oldSandboxToMigrate;
}
//TODO: This can be its own method/process somewhere
private boolean lookForAndMigrateOldSandbox(UserKeyRecord newRecord) {
//So we have a new record here. We want to look through our old records now and see if we can
//(A) Migrate over any of their old data to this new sandbox.
//(B) Wipe that old record once the migrated record is completed (and see if we should wipe the
//sandbox's data).
SqlStorage<UserKeyRecord> storage = app.getStorage(UserKeyRecord.class);
UserKeyRecord oldSandboxToMigrate = getInUseSandbox(newRecord.getUsername(), storage);
//Our new record is completely new. Easy and awesome. Record and move on.
if(oldSandboxToMigrate == null) {
newRecord.setType(UserKeyRecord.TYPE_NORMAL);
storage.write(newRecord);
//No worries
return true;
}
//Otherwise we should start migrating that data over.
byte[] oldKey = oldSandboxToMigrate.unWrapKey(password);
//First see if the old sandbox is legacy and needs to be transfered over.
if(oldSandboxToMigrate.getType() == UserKeyRecord.TYPE_LEGACY_TRANSITION) {
//transition the old storage into the new format before we copy the DB over.
LegacyInstallUtils.transitionLegacyUserStorage(getContext(), CommCareApplication._().getCurrentApp(), oldKey, oldSandboxToMigrate);
publishProgress(Localization.get("key.manage.legacy.begin"));
}
//TODO: Ok, so what error handling do we need here?
try {
//Otherwise we need to copy the old sandbox to a new location atomically (in case we fail).
UserSandboxUtils.migrateData(this.getContext(), app, oldSandboxToMigrate, oldKey, newRecord, CryptUtil.unWrapKey(newRecord.getEncryptedKey(), password));
publishProgress(Localization.get("key.manage.migrate"));
return true;
} catch(IOException ioe) {
ioe.printStackTrace();
Logger.log(AndroidLogger.TYPE_MAINTENANCE, "IO Error while migrating database: " + ioe.getMessage());
return false;
} catch(Exception e) {
Logger.log(AndroidLogger.TYPE_MAINTENANCE, "Unexpected error while migrating database: " + ExceptionReportTask.getStackTrace(e));
return false;
}
}
//TODO: this shouldn't go here. Where should it go?
public static UserKeyRecord getCurrentValidRecord(CommCareApp app, String username, String password, boolean acceptExpired) {
Date now = new Date();
UserKeyRecord validIsh = null;
for(UserKeyRecord ukr : app.getStorage(UserKeyRecord.class).getRecordsForValue(UserKeyRecord.META_USERNAME, username)) {
if(!ukr.isPasswordValid(password)) {
//This record is for a different password
continue;
}
//ok, now check whether this record is fully valid, or we need to look for an update
if(ukr.isCurrentlyValid()) {
return ukr;
} else {
validIsh = ukr;
}
}
if(acceptExpired) { return validIsh; }
return null;
}
/* (non-Javadoc)
* @see org.commcare.android.tasks.templates.HttpCalloutTask#doResponseOther(org.apache.http.HttpResponse)
*/
@Override
protected HttpCalloutOutcomes doResponseOther(HttpResponse response) {
return HttpCalloutOutcomes.BadResponse;
}
}