/** * */ package org.commcare.android.models; import java.io.IOException; import java.util.Date; import java.util.Hashtable; import java.util.Vector; import javax.crypto.SecretKey; 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.tasks.FormRecordCleanupTask; import org.commcare.android.util.AndroidCommCarePlatform; import org.commcare.android.util.CommCareInstanceInitializer; import org.commcare.android.util.CommCareUtil; import org.commcare.android.util.InvalidStateException; import org.commcare.dalvik.R; import org.commcare.dalvik.application.CommCareApplication; import org.commcare.dalvik.odk.provider.InstanceProviderAPI; import org.commcare.dalvik.odk.provider.InstanceProviderAPI.InstanceColumns; import org.commcare.suite.model.Entry; import org.commcare.suite.model.Menu; import org.commcare.suite.model.SessionDatum; import org.commcare.suite.model.StackOperation; import org.commcare.suite.model.Suite; import org.commcare.suite.model.Text; import org.commcare.util.CommCarePlatform; import org.commcare.util.CommCareSession; import org.commcare.util.SessionFrame; import org.commcare.xml.util.InvalidStructureException; import org.commcare.xml.util.UnfullfilledRequirementsException; import org.javarosa.core.model.condition.EvaluationContext; import org.javarosa.core.model.instance.TreeReference; import org.javarosa.core.services.storage.StorageFullException; import org.javarosa.model.xform.XPathReference; import org.javarosa.xpath.expr.XPathEqExpr; import org.javarosa.xpath.expr.XPathExpression; import org.javarosa.xpath.expr.XPathStringLiteral; import org.xmlpull.v1.XmlPullParserException; import android.content.Context; import android.database.Cursor; import android.net.Uri; /** * This is a container class which maintains all of the appropriate hooks for managing the details * of the current "state" of an application (the session, the relevant forms) and the hooks for * manipulating them in a single place. * * @author ctsims * */ public class AndroidSessionWrapper { //The state descriptor will need these protected CommCareSession session; private CommCarePlatform platform; protected int formRecordId = -1; protected int sessionStateRecordId = -1; //These are only to be used by the local (not recoverable) session private String instanceUri = null; private String instanceStatus = null; public AndroidSessionWrapper(CommCarePlatform platform) { session = new CommCareSession(platform); this.platform = platform; } /** * Serialize the state of this session so it can be restored * at a later time. * * @return */ public SessionStateDescriptor getSessionStateDescriptor() { return new SessionStateDescriptor(this); } public void loadFromStateDescription(SessionStateDescriptor descriptor) { this.reset(); this.sessionStateRecordId = descriptor.getID(); this.formRecordId = descriptor.getFormRecordId(); descriptor.loadSession(this.session); } /** * Clear all local state and return this session to completely fresh */ public void reset() { this.session.clearAllState(); cleanVolatiles(); } /** * Clears out all of the elements of this wrapper which are for an individual traversal. * Includes any cached info (since the casedb might have changed) and the individual id's * and such. * */ private void cleanVolatiles() { formRecordId = -1; instanceUri = null; instanceStatus = null; sessionStateRecordId = -1; //CTS - Added to fix bugs where casedb didn't get renewed between sessions (possibly //we want to "update" the casedb rather than rebuild it, but this is safest for now. initializer = null; } public CommCareSession getSession() { return session; } public FormRecord getFormRecord() { if(formRecordId == -1) { return null; } SqlStorage<FormRecord> storage = CommCareApplication._().getUserStorage(FormRecord.class); return storage.read(formRecordId); } public void setFormRecordId(int formRecordId) { this.formRecordId = formRecordId; } /** * Registers the instance data returned from form entry about this session, and specifies * whether the returned data is complete * * @param c A cursor which points to at least one record of an ODK instance. * @return True if the record in question was marked completed, false otherwise * @throws IllegalArgumentException If the cursor provided doesn't point to any records, * or doesn't point to the appropriate columns */ public boolean beginRecordTransaction(Uri uri, Cursor c) throws IllegalArgumentException { if(!c.moveToFirst()) { throw new IllegalArgumentException("Empty query for instance record!"); } instanceUri = uri.toString(); instanceStatus = c.getString(c.getColumnIndexOrThrow(InstanceColumns.STATUS)); if(InstanceProviderAPI.STATUS_COMPLETE.equals(instanceStatus)) { return true; } else { return false; } } public FormRecord commitRecordTransaction() throws InvalidStateException { FormRecord current = getFormRecord(); String recordStatus = null; if(InstanceProviderAPI.STATUS_COMPLETE.equals(instanceStatus)) { recordStatus = FormRecord.STATUS_COMPLETE; } else { recordStatus = FormRecord.STATUS_INCOMPLETE; } current = current.updateStatus(instanceUri, recordStatus); try { FormRecord updated = FormRecordCleanupTask.getUpdatedRecord(CommCareApplication._(), platform, current, recordStatus); SqlStorage<FormRecord> storage = CommCareApplication._().getUserStorage(FormRecord.class); storage.write(updated); return updated; } 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 (IOException e1) { throw new InvalidStateException("There was a problem with the local storage and the form could not be read."); } catch (XmlPullParserException e1) { e1.printStackTrace(); throw new InvalidStateException("There was a problem with the local storage and the form could not be read."); } catch (UnfullfilledRequirementsException e1) { throw new RuntimeException(e1); } catch (StorageFullException e) { throw new RuntimeException(e); } } public int getFormRecordId() { return formRecordId; } /** * A helper method to search for any saved sessions which match this current one * * @return The descriptor of the first saved session which matches this, if any, * null otherwise. */ public SessionStateDescriptor searchForDuplicates() { SqlStorage<FormRecord> storage = CommCareApplication._().getUserStorage(FormRecord.class); SqlStorage<SessionStateDescriptor> sessionStorage = CommCareApplication._().getUserStorage(SessionStateDescriptor.class); //TODO: This is really a join situation. Need a way to outline connections between tables to enable joining //First, we need to see if this session's unique hash corresponds to any pending forms. Vector<Integer> ids = sessionStorage.getIDsForValue(SessionStateDescriptor.META_DESCRIPTOR_HASH, getSessionStateDescriptor().getHash()); SessionStateDescriptor ssd = null; //Filter for forms which have actually been started. for(int id : ids) { try { int recordId = Integer.valueOf(sessionStorage.getMetaDataFieldForRecord(id, SessionStateDescriptor.META_FORM_RECORD_ID)); if(!storage.exists(recordId)) { sessionStorage.remove(id); System.out.println("Removing stale ssd record: " + id); continue; } if(FormRecord.STATUS_INCOMPLETE.equals(storage.getMetaDataFieldForRecord(recordId, FormRecord.META_STATUS))) { ssd = sessionStorage.read(id); break; } } catch(NumberFormatException nfe) { //TODO: Clean up this record continue; } } return ssd; } public void commitStub() throws StorageFullException { //TODO: This should now be locked somehow SqlStorage<FormRecord> storage = CommCareApplication._().getUserStorage(FormRecord.class); SqlStorage<SessionStateDescriptor> sessionStorage = CommCareApplication._().getUserStorage(SessionStateDescriptor.class); SecretKey key = CommCareApplication._().createNewSymetricKey(); //TODO: this has two components which can fail. be able to roll them back FormRecord r = new FormRecord("", FormRecord.STATUS_UNSTARTED, getSession().getForm(), key.getEncoded(), null, new Date(0)); storage.write(r); setFormRecordId(r.getID()); SessionStateDescriptor ssd = getSessionStateDescriptor(); sessionStorage.write(ssd); sessionStateRecordId = ssd.getID(); } public int getSessionDescriptorId() { return sessionStateRecordId; } public String getHeaderTitle(Context context, AndroidCommCarePlatform platform) { String descriptor = context.getString(R.string.application_name); Hashtable<String, String> menus = new Hashtable<String, String>(); for(Suite s : platform.getInstalledSuites()) { for(Menu m : s.getMenus()) { menus.put(m.getId(), m.getName().evaluate()); } } Hashtable<String, Entry> entries = platform.getMenuMap(); for(String[] step : session.getFrame().getSteps()) { String val = null; if(step[0] == SessionFrame.STATE_COMMAND_ID) { //Menu or form. if(menus.containsKey(step[1])) { val = menus.get(step[1]); } else if(entries.containsKey(step[1])) { val = entries.get(step[1]).getText().evaluate(); } } else if(step[0] == SessionFrame.STATE_DATUM_VAL || step[0] == SessionFrame.STATE_DATUM_COMPUTED) { //nothing much to be done here... } if(val != null) { descriptor += " > " + val; } } return descriptor.trim(); } public String getTitle() { //TODO: Most of this mimicks what we need to do in entrydetail activity, remove it from there //and generalize the walking //TODO: This manipulates the state of the session. We should instead grab and make a copy of the frame, and make a new session to //investigate this. //Walk backwards until we find something with a long detail while(session.getFrame().getSteps().size() > 0 && (session.getNeededData() != SessionFrame.STATE_DATUM_VAL || session.getNeededDatum().getLongDetail() == null)) { session.stepBack(); } if(session.getFrame().getSteps().size() == 0) { return null;} EvaluationContext ec = getEvaluationContext(); //Get the value that was chosen for this item String value = session.getPoppedStep()[2]; SessionDatum datum = session.getNeededDatum(); //Now determine what nodeset that was going to be used to load this select TreeReference nodesetRef = datum.getNodeset().clone(); Vector<XPathExpression> predicates = nodesetRef.getPredicate(nodesetRef.size() -1); predicates.add(new XPathEqExpr(true, XPathReference.getPathExpr(datum.getValue()), new XPathStringLiteral(value))); Vector<TreeReference> elements = ec.expandReference(nodesetRef); //If we got our ref, awesome. Otherwise we need to bail. if(elements.size() != 1 ) { return null;} //Now generate a context for our element EvaluationContext element = new EvaluationContext(ec, elements.firstElement()); //Ok, so get our Text. Text t = session.getDetail(datum.getLongDetail()).getTitle().getText(); boolean isPrettyPrint = true; //CTS: this is... not awesome. //But we're going to use this to test whether we _need_ an evaluation context //for this. (If not, the title doesn't have prettyprint for us) try { String outcome = t.evaluate(); if(outcome != null) { isPrettyPrint = false; } } catch(Exception e) { //Cool. Got us a fancy string. } if(isPrettyPrint) { //Now just get the detail title for that element return t.evaluate(element); } else { //Otherwise, this is _almost certainly_ a case. See if it is, and //if so, grab the case name. otherwise, who knows? SqlStorage<ACase> storage = CommCareApplication._().getUserStorage(ACase.STORAGE_KEY, ACase.class); try { ACase ourCase = storage.getRecordForValue(ACase.INDEX_CASE_ID, value); if(ourCase != null) { return ourCase.getName(); } else { return null; } } catch(Exception e) { return null; } } } public EvaluationContext getEvaluationContext() { return session.getEvaluationContext(getIIF()); } CommCareInstanceInitializer initializer; protected CommCareInstanceInitializer getIIF() { if(initializer == null) { initializer = new CommCareInstanceInitializer(session); } return initializer; } public static AndroidSessionWrapper mockEasiestRoute(CommCarePlatform platform, String formNamespace, String selectedValue) { AndroidSessionWrapper wrapper = null; int curPredicates = -1; Hashtable<String, Entry> menuMap = platform.getMenuMap(); for(String key : menuMap.keySet()) { Entry e = menuMap.get(key); if(formNamespace.equals(e.getXFormNamespace())) { //We have an entry. Don't worry too much about how we're supposed to get there for now. //The ideal is that we only need one piece of data if(e.getSessionDataReqs().size() == 1) { //This should fit the bill. Single selection. SessionDatum datum = e.getSessionDataReqs().firstElement(); //The only thing we need to know now is whether we have a better option available int countPredicates = CommCareUtil.countPreds(datum.getNodeset()); if(wrapper == null) { //No previous value! Yay. //Record the degree of specificity of this selection for now (we'll //actually create the wrapper later curPredicates = countPredicates; } else { //There's already a path to this form. Only keep going //if the current choice is less specific if(countPredicates >= curPredicates) { continue;} } wrapper = new AndroidSessionWrapper(platform); wrapper.session.setCommand(key); wrapper.session.setCommand(e.getCommandId()); wrapper.session.setDatum(datum.getDataId(), selectedValue); } //We don't really have a good thing to do with this yet. For now, just //hope there's another easy path to this form continue; } } return wrapper; } /** * Finish and seal the current session. Run any stack operations mandated by the current entry * and pop a new frame from the stack, if one exists. */ public boolean terminateSession() { //Possible should re-name this one. We no longer go "home" by default. We might start a new session's frame. //CTS: note, this maybe should just be clearing volitiles either way (rather than cherry picking this one), //but this is necessary to ensure that stack ops don't re-use the case casedb as the form if the form //modified the case database before stack ops fire initializer = null; //TODO: should this section get wrapped up in the session, maybe? Vector<StackOperation> ops = session.getCurrentEntry().getPostEntrySessionOperations(); //Let the session know that the current frame shouldn't work its way back onto the stack session.markCurrentFrameForDeath(); //First, see if we have operations to run if(ops.size() > 0) { EvaluationContext ec = getEvaluationContext(); session.executeStackOperations(ops, ec); } //Ok, now we just need to figure out if it's time to go home, or time to fire up a new session from the stack if(session.finishAndPop()) { //We just built a new session stack into the session, so we want to keep that, //clear out the internal state vars, though. cleanVolatiles(); return true; } else { //start from scratch reset(); return false; } } /** * Execute a stack action in the current session environment. Note: This action will * always require a fresh jump to the central controller. */ public void executeStackActions(Vector<StackOperation> ops) { session.executeStackOperations(ops, getEvaluationContext()); //regardless of whether we just updated the current stack, we need to //assume our current volatile states are no longer relevant cleanVolatiles(); } }