/* * Copyright (C) 2009 JavaRosa * * 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 distributed 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 the specific language governing permissions and limitations under * the License. */ package org.odk.collect.android.logic; import android.util.Log; import org.javarosa.core.model.FormDef; import org.javarosa.core.model.FormIndex; import org.javarosa.core.model.GroupDef; import org.javarosa.core.model.IDataReference; import org.javarosa.core.model.IFormElement; import org.javarosa.core.model.SubmissionProfile; import org.javarosa.core.model.condition.EvaluationContext; import org.javarosa.core.model.data.IAnswerData; import org.javarosa.core.model.data.StringData; import org.javarosa.core.model.instance.FormInstance; import org.javarosa.core.model.instance.TreeElement; import org.javarosa.core.services.IPropertyManager; import org.javarosa.core.services.PrototypeManager; import org.javarosa.core.services.transport.payload.ByteArrayPayload; import org.javarosa.form.api.FormEntryCaption; import org.javarosa.form.api.FormEntryController; import org.javarosa.form.api.FormEntryModel; import org.javarosa.form.api.FormEntryPrompt; import org.javarosa.model.xform.XFormSerializingVisitor; import org.javarosa.model.xform.XFormsModule; import org.javarosa.model.xform.XPathReference; import org.javarosa.xform.parse.XFormParser; import org.javarosa.xpath.XPathParseTool; import org.javarosa.xpath.expr.XPathExpression; import org.odk.collect.android.exception.JavaRosaException; import org.odk.collect.android.serializers.XFormUtcDateAnswerDataSerializer; import org.odk.collect.android.views.ODKView; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.Vector; /** * This class is a wrapper for Javarosa's FormEntryController. In theory, if you wanted to replace * javarosa as the form engine, you should only need to replace the methods in this file. Also, we * haven't wrapped every method provided by FormEntryController, only the ones we've needed so far. * Feel free to add more as necessary. * * @author carlhartung */ public class FormController { private static final String t = "FormController"; public static final boolean STEP_INTO_GROUP = true; public static final boolean STEP_OVER_GROUP = false; /** * OpenRosa metadata tag names. */ private static final String INSTANCE_ID = "instanceID"; private static final String INSTANCE_NAME = "instanceName"; /** * OpenRosa metadata of a form instance. * * Contains the values for the required metadata * fields and nothing else. * * @author mitchellsundt@gmail.com * */ public static final class InstanceMetadata { public final String instanceId; public final String instanceName; InstanceMetadata( String instanceId, String instanceName ) { this.instanceId = instanceId; this.instanceName = instanceName; } }; /** * Classes needed to serialize objects. Need to put anything from JR in here. */ private final static String[] SERIALIABLE_CLASSES = { "org.javarosa.core.services.locale.ResourceFileDataSource", // JavaRosaCoreModule "org.javarosa.core.services.locale.TableLocaleSource", // JavaRosaCoreModule "org.javarosa.core.model.FormDef", "org.javarosa.core.model.SubmissionProfile", // CoreModelModule "org.javarosa.core.model.QuestionDef", // CoreModelModule "org.javarosa.core.model.GroupDef", // CoreModelModule "org.javarosa.core.model.instance.FormInstance", // CoreModelModule "org.javarosa.core.model.data.BooleanData", // CoreModelModule "org.javarosa.core.model.data.DateData", // CoreModelModule "org.javarosa.core.model.data.DateTimeData", // CoreModelModule "org.javarosa.core.model.data.DecimalData", // CoreModelModule "org.javarosa.core.model.data.GeoPointData", // CoreModelModule "org.javarosa.core.model.data.GeoShapeData", // CoreModelModule "org.javarosa.core.model.data.GeoTraceData", // CoreModelModule "org.javarosa.core.model.data.IntegerData", // CoreModelModule "org.javarosa.core.model.data.LongData", // CoreModelModule "org.javarosa.core.model.data.MultiPointerAnswerData", // CoreModelModule "org.javarosa.core.model.data.PointerAnswerData", // CoreModelModule "org.javarosa.core.model.data.SelectMultiData", // CoreModelModule "org.javarosa.core.model.data.SelectOneData", // CoreModelModule "org.javarosa.core.model.data.StringData", // CoreModelModule "org.javarosa.core.model.data.TimeData", // CoreModelModule "org.javarosa.core.model.data.UncastData", // CoreModelModule "org.javarosa.core.model.data.helper.BasicDataPointer", // CoreModelModule "org.javarosa.core.model.Action", // CoreModelModule "org.javarosa.core.model.actions.SetValueAction" // CoreModelModule }; private static boolean isJavaRosaInitialized = false; /** * Isolate the initialization of JavaRosa into one method, called first * by the Collect Application. Called subsequently whenever the Preferences * dialogs are exited (to potentially update username and email fields). * * @param mgr */ public static synchronized void initializeJavaRosa(IPropertyManager mgr) { if ( !isJavaRosaInitialized ) { // need a list of classes that formdef uses // unfortunately, the JR registerModule() functions do more than this. // register just the classes that would have been registered by: // new JavaRosaCoreModule().registerModule(); // new CoreModelModule().registerModule(); // replace with direct call to PrototypeManager PrototypeManager.registerPrototypes(SERIALIABLE_CLASSES); new XFormsModule().registerModule(); isJavaRosaInitialized = true; } // needed to override rms property manager org.javarosa.core.services.PropertyManager .setPropertyManager(mgr); } private File mMediaFolder; private File mInstancePath; private FormEntryController mFormEntryController; private FormIndex mIndexWaitingForData = null; public FormController(File mediaFolder, FormEntryController fec, File instancePath) { mMediaFolder = mediaFolder; mFormEntryController = fec; mInstancePath = instancePath; } public FormDef getFormDef() { return mFormEntryController.getModel().getForm(); } public File getMediaFolder() { return mMediaFolder; } public File getInstancePath() { return mInstancePath; } public void setInstancePath(File instancePath) { mInstancePath = instancePath; } public void setIndexWaitingForData(FormIndex index) { mIndexWaitingForData = index; } public FormIndex getIndexWaitingForData() { return mIndexWaitingForData; } /** * For logging purposes... * * @param index * @return xpath value for this index */ public String getXPath(FormIndex index) { String value; switch ( getEvent() ) { case FormEntryController.EVENT_BEGINNING_OF_FORM: value = "beginningOfForm"; break; case FormEntryController.EVENT_END_OF_FORM: value = "endOfForm"; break; case FormEntryController.EVENT_GROUP: value = "group." + index.getReference().toString(); break; case FormEntryController.EVENT_QUESTION: value = "question." + index.getReference().toString(); break; case FormEntryController.EVENT_PROMPT_NEW_REPEAT: value = "promptNewRepeat." + index.getReference().toString(); break; case FormEntryController.EVENT_REPEAT: value = "repeat." + index.getReference().toString(); break; case FormEntryController.EVENT_REPEAT_JUNCTURE: value = "repeatJuncture." + index.getReference().toString(); break; default: value = "unexpected"; break; } return value; } public FormIndex getIndexFromXPath(String xPath) { if ( xPath.equals("beginningOfForm") ) { return FormIndex.createBeginningOfFormIndex(); } else if ( xPath.equals("endOfForm") ) { return FormIndex.createEndOfFormIndex(); } else if ( xPath.equals("unexpected") ) { Log.e(t, "Unexpected string from XPath"); throw new IllegalArgumentException("unexpected string from XPath"); } else { FormIndex returned = null; FormIndex saved = getFormIndex(); // the only way I know how to do this is to step through the entire form // until the XPath of a form entry matches that of the supplied XPath try { jumpToIndex(FormIndex.createBeginningOfFormIndex()); int event = stepToNextEvent(true); while ( event != FormEntryController.EVENT_END_OF_FORM ) { String candidateXPath = getXPath(getFormIndex()); // Log.i(t, "xpath: " + candidateXPath); if ( candidateXPath.equals(xPath) ) { returned = getFormIndex(); break; } event = stepToNextEvent(true); } } finally { jumpToIndex(saved); } return returned; } } /** * returns the event for the current FormIndex. * * @return */ public int getEvent() { return mFormEntryController.getModel().getEvent(); } /** * returns the event for the given FormIndex. * * @param index * @return */ public int getEvent(FormIndex index) { return mFormEntryController.getModel().getEvent(index); } /** * @return current FormIndex. */ public FormIndex getFormIndex() { return mFormEntryController.getModel().getFormIndex(); } /** * Return the langauges supported by the currently loaded form. * * @return Array of Strings containing the languages embedded in the XForm. */ public String[] getLanguages() { return mFormEntryController.getModel().getLanguages(); } /** * @return A String containing the title of the current form. */ public String getFormTitle() { return mFormEntryController.getModel().getFormTitle(); } /** * @return the currently selected language. */ public String getLanguage() { return mFormEntryController.getModel().getLanguage(); } public String getBindAttribute( String attributeNamespace, String attributeName) { return getBindAttribute( getFormIndex(), attributeNamespace, attributeName ); } public String getBindAttribute(FormIndex idx, String attributeNamespace, String attributeName) { return mFormEntryController.getModel().getForm().getMainInstance().resolveReference( idx.getReference()).getBindAttributeValue(attributeNamespace, attributeName); } /** * @return an array of FormEntryCaptions for the current FormIndex. This is how we get group * information Group 1 > Group 2> etc... The element at [size-1] is the current question * text, with group names decreasing in hierarchy until array element at [0] is the root */ private FormEntryCaption[] getCaptionHierarchy() { return mFormEntryController.getModel().getCaptionHierarchy(); } /** * @param index * @return an array of FormEntryCaptions for the supplied FormIndex. This is how we get group * information Group 1 > Group 2> etc... The element at [size-1] is the current question * text, with group names decreasing in hierarchy until array element at [0] is the root */ private FormEntryCaption[] getCaptionHierarchy(FormIndex index) { return mFormEntryController.getModel().getCaptionHierarchy(index); } /** * Returns a caption prompt for the given index. This is used to create a multi-question per * screen view. * * @param index * @return */ public FormEntryCaption getCaptionPrompt(FormIndex index) { return mFormEntryController.getModel().getCaptionPrompt(index); } /** * Return the caption for the current FormIndex. This is usually used for a repeat prompt. * * @return */ public FormEntryCaption getCaptionPrompt() { return mFormEntryController.getModel().getCaptionPrompt(); } /** * This fires off the jr:preload actions and events to save values like the * end time of a form. * * @return */ public boolean postProcessInstance() { return mFormEntryController.getModel().getForm().postProcessInstance(); } /** * TODO: We need a good description of what this does, exactly, and why. * * @return */ private FormInstance getInstance() { return mFormEntryController.getModel().getForm().getInstance(); } /** * A convenience method for determining if the current FormIndex is in a group that is/should be * displayed as a multi-question view. This is useful for returning from the formhierarchy view * to a selected index. * * @param index * @return */ private boolean groupIsFieldList(FormIndex index) { // if this isn't a group, return right away IFormElement element = mFormEntryController.getModel().getForm().getChild(index); return element instanceof GroupDef; // if (!(element instanceof GroupDef)) { // return false; // } // // GroupDef gd = (GroupDef) element; // exceptions? // return (ODKView.FIELD_LIST.equalsIgnoreCase(gd.getAppearanceAttr())); } private boolean repeatIsFieldList(FormIndex index) { // if this isn't a group, return right away IFormElement element = mFormEntryController.getModel().getForm().getChild(index); return element instanceof GroupDef; // if (!(element instanceof GroupDef)) { // return false; // } // // GroupDef gd = (GroupDef) element; // exceptions? // return (ODKView.FIELD_LIST.equalsIgnoreCase(gd.getAppearanceAttr())); } /** * Tests if the FormIndex 'index' is located inside a group that is marked as a "field-list" * * @param index * @return true if index is in a "field-list". False otherwise. */ private boolean indexIsInFieldList(FormIndex index) { int event = getEvent(index); if (event == FormEntryController.EVENT_QUESTION) { // caption[0..len-1] // caption[len-1] == the question itself // caption[len-2] == the first group it is contained in. FormEntryCaption[] captions = getCaptionHierarchy(index); if (captions.length < 2) { // no group return false; } FormEntryCaption grp = captions[captions.length - 2]; return groupIsFieldList(grp.getIndex()); } else if (event == FormEntryController.EVENT_GROUP) { return groupIsFieldList(index); } else if (event == FormEntryController.EVENT_REPEAT) { return repeatIsFieldList(index); } else { // right now we only test Questions and Groups. Should we also handle // repeats? return false; } } public boolean currentPromptIsQuestion() { return (getEvent() == FormEntryController.EVENT_QUESTION || ((getEvent() == FormEntryController.EVENT_GROUP || getEvent() == FormEntryController.EVENT_REPEAT) && indexIsInFieldList())); } /** * Tests if the current FormIndex is located inside a group that is marked as a "field-list" * * @return true if index is in a "field-list". False otherwise. */ public boolean indexIsInFieldList() { return true; //return indexIsInFieldList(getFormIndex()); } /** * Attempts to save answer at the current FormIndex into the data model. * * @param data * @return */ private int answerQuestion(IAnswerData data) { return mFormEntryController.answerQuestion(data); } /** * Attempts to save answer into the given FormIndex into the data model. * * @param index * @param data * @return */ public int answerQuestion(FormIndex index, IAnswerData data) throws JavaRosaException { try { return mFormEntryController.answerQuestion(index, data); } catch (Exception e) { throw new JavaRosaException(e); } } /** * Goes through the entire form to make sure all entered answers comply with their constraints. * Constraints are ignored on 'jump to', so answers can be outside of constraints. We don't * allow saving to disk, though, until all answers conform to their constraints/requirements. * * * @param markCompleted * @return ANSWER_OK and leave index unchanged or change index to bad value and return error type. */ public int validateAnswers(Boolean markCompleted) { FormEntryController formEntryController = this.mFormEntryController; FormEntryModel formEntryModel = formEntryController.getModel(); FormEntryModel formEntryModelToBeValidated = new FormEntryModel(formEntryModel.getForm()); FormEntryController formEntryControllerToBeValidated = new FormEntryController(formEntryModelToBeValidated); FormController formControllerToBeValidated = new FormController(this.getMediaFolder(), formEntryControllerToBeValidated, this.getInstancePath()); formControllerToBeValidated.jumpToIndex(FormIndex.createBeginningOfFormIndex()); int event; while ((event = formControllerToBeValidated.stepToNextEvent(FormController.STEP_INTO_GROUP)) != FormEntryController.EVENT_END_OF_FORM) { if (event != FormEntryController.EVENT_QUESTION) { continue; } else { FormIndex formControllerToBeValidatedFormIndex = formControllerToBeValidated.getFormIndex(); int saveStatus = formControllerToBeValidated.answerQuestion(formControllerToBeValidated.getQuestionPrompt().getAnswerValue()); if (markCompleted && saveStatus != FormEntryController.ANSWER_OK) { // jump to the error this.jumpToIndex(formControllerToBeValidatedFormIndex); return saveStatus; } } } return FormEntryController.ANSWER_OK; } /** * saveAnswer attempts to save the current answer into the data model without doing any * constraint checking. Only use this if you know what you're doing. For normal form filling you * should always use answerQuestion or answerCurrentQuestion. * * @param index * @param data * @return true if saved successfully, false otherwise. */ public boolean saveAnswer(FormIndex index, IAnswerData data) throws JavaRosaException { try { return mFormEntryController.saveAnswer(index, data); } catch (Exception e) { throw new JavaRosaException(e); } } /** * saveAnswer attempts to save the current answer into the data model without doing any * constraint checking. Only use this if you know what you're doing. For normal form filling you * should always use answerQuestion(). * * @param data * @return true if saved successfully, false otherwise. */ public boolean saveAnswer(IAnswerData data) throws JavaRosaException { try { return mFormEntryController.saveAnswer(data); } catch (Exception e) { throw new JavaRosaException(e); } } /** * Navigates forward in the form. * * @return the next event that should be handled by a view. */ public int stepToNextEvent(boolean stepIntoGroup) { if ((getEvent() == FormEntryController.EVENT_GROUP || getEvent() == FormEntryController.EVENT_REPEAT) && indexIsInFieldList() && !stepIntoGroup) { return stepOverGroup(); } else { return mFormEntryController.stepToNextEvent(); } } /** * If using a view like HierarchyView that doesn't support multi-question per screen, step over * the group represented by the FormIndex. * * @return */ private int stepOverGroup() { ArrayList<FormIndex> indicies = new ArrayList<FormIndex>(); GroupDef gd = (GroupDef) mFormEntryController.getModel().getForm() .getChild(getFormIndex()); FormIndex idxChild = mFormEntryController.getModel().incrementIndex( getFormIndex(), true); // descend into group for (int i = 0; i < gd.getChildren().size(); i++) { indicies.add(idxChild); // don't descend idxChild = mFormEntryController.getModel().incrementIndex(idxChild, false); } // jump to the end of the group mFormEntryController.jumpToIndex(indicies.get(indicies.size() - 1)); return stepToNextEvent(STEP_OVER_GROUP); } /** * used to go up one level in the formIndex. That is, if you're at 5_0, 1 (the second question * in a repeating group), this method will return a FormInex of 5_0 (the start of the repeating * group). If your at index 16 or 5_0, this will return null; * * @param index * @return index */ public FormIndex stepIndexOut(FormIndex index) { if (index.isTerminal()) { return null; } else { return new FormIndex(stepIndexOut(index.getNextLevel()), index); } } /** * Move the current form index to the index of the previous question in the form. * Step backward out of repeats and groups as needed. If the resulting question * is itself within a field-list, move upward to the group or repeat defining that * field-list. * * @return */ public int stepToPreviousScreenEvent() throws JavaRosaException { try { if (getEvent() != FormEntryController.EVENT_BEGINNING_OF_FORM) { int event = stepToPreviousEvent(); while (event == FormEntryController.EVENT_REPEAT_JUNCTURE || event == FormEntryController.EVENT_PROMPT_NEW_REPEAT || (event == FormEntryController.EVENT_QUESTION && indexIsInFieldList()) || ((event == FormEntryController.EVENT_GROUP || event == FormEntryController.EVENT_REPEAT) && !indexIsInFieldList())) { event = stepToPreviousEvent(); } // Work-around for broken field-list handling from 1.1.7 which breaks either // build-generated forms or XLSForm-generated forms. If the current group // is a GROUP with field-list and it is nested within a group or repeat with just // this containing group, and that is also a field-list, then return the parent group. if ( getEvent() == FormEntryController.EVENT_GROUP ) { FormIndex currentIndex = getFormIndex(); IFormElement element = mFormEntryController.getModel().getForm().getChild(currentIndex); if (element instanceof GroupDef) { GroupDef gd = (GroupDef) element; if ( ODKView.FIELD_LIST.equalsIgnoreCase(gd.getAppearanceAttr()) ) { // OK this group is a field-list... see what the parent is... FormEntryCaption[] fclist = this.getCaptionHierarchy(currentIndex); if ( fclist.length > 1) { FormEntryCaption fc = fclist[fclist.length-2]; GroupDef pd = (GroupDef) fc.getFormElement(); if ( pd.getChildren().size() == 1 && ODKView.FIELD_LIST.equalsIgnoreCase(pd.getAppearanceAttr()) ) { mFormEntryController.jumpToIndex(fc.getIndex()); } } } } } } return getEvent(); } catch (RuntimeException e) { throw new JavaRosaException(e); } } /** * Move the current form index to the index of the next question in the form. * Stop if we should ask to create a new repeat group or if we reach the end of the form. * If we enter a group or repeat, return that if it is a field-list definition. * Otherwise, descend into the group or repeat searching for the first question. * * @return */ public int stepToNextScreenEvent() throws JavaRosaException { try { if (getEvent() != FormEntryController.EVENT_END_OF_FORM) { int event; group_skip: do { event = stepToNextEvent(FormController.STEP_OVER_GROUP); switch (event) { case FormEntryController.EVENT_QUESTION: break group_skip; case FormEntryController.EVENT_END_OF_FORM: break; case FormEntryController.EVENT_PROMPT_NEW_REPEAT: break group_skip; case FormEntryController.EVENT_GROUP: case FormEntryController.EVENT_REPEAT: if (indexIsInFieldList() && getQuestionPrompts().length != 0) { break group_skip; } // otherwise it's not a field-list group, so just skip it break; case FormEntryController.EVENT_REPEAT_JUNCTURE: Log.i(t, "repeat juncture: " + getFormIndex().getReference()); // skip repeat junctures until we implement them break; default: Log.w(t, "JavaRosa added a new EVENT type and didn't tell us... shame on them."); break; } } while (event != FormEntryController.EVENT_END_OF_FORM); } return getEvent(); } catch (RuntimeException e) { throw new JavaRosaException(e); } } /** * Move the current form index to the index of the first enclosing repeat * or to the start of the form. * * @return */ public int stepToOuterScreenEvent() { FormIndex index = stepIndexOut(getFormIndex()); int currentEvent = getEvent(); // Step out of any group indexes that are present. while (index != null && getEvent(index) == FormEntryController.EVENT_GROUP) { index = stepIndexOut(index); } if (index == null) { jumpToIndex(FormIndex.createBeginningOfFormIndex()); } else { if (currentEvent == FormEntryController.EVENT_REPEAT) { // We were at a repeat, so stepping back brought us to then previous level jumpToIndex(index); } else { // We were at a question, so stepping back brought us to either: // The beginning. or The start of a repeat. So we need to step // out again to go passed the repeat. index = stepIndexOut(index); if (index == null) { jumpToIndex(FormIndex.createBeginningOfFormIndex()); } else { jumpToIndex(index); } } } return getEvent(); } public static class FailedConstraint { public final FormIndex index; public final int status; FailedConstraint(FormIndex index, int status) { this.index = index; this.status = status; } } /** * * @param answers * @param evaluateConstraints * @return FailedConstraint of first failed constraint or null if all questions were saved. */ public FailedConstraint saveAnswers(LinkedHashMap<FormIndex, IAnswerData> answers, boolean evaluateConstraints) throws JavaRosaException { Iterator<FormIndex> it = answers.keySet().iterator(); while (it.hasNext()) { FormIndex index = it.next(); // Within a group, you can only save for question events if (getEvent(index) == FormEntryController.EVENT_QUESTION) { int saveStatus; IAnswerData answer = answers.get(index); if (evaluateConstraints) { saveStatus = answerQuestion(index, answer); if (saveStatus != FormEntryController.ANSWER_OK) { return new FailedConstraint(index, saveStatus); } } else { saveAnswer(index, answer); } } else { Log.w(t, "Attempted to save an index referencing something other than a question: " + index.getReference()); } } return null; } /** * Navigates backward in the form. * * @return the event that should be handled by a view. */ public int stepToPreviousEvent() { /* * Right now this will always skip to the beginning of a group if that group is represented * as a 'field-list'. Should a need ever arise to step backwards by only one step in a * 'field-list', this method will have to be updated. */ mFormEntryController.stepToPreviousEvent(); // If after we've stepped, we're in a field-list, jump back to the beginning of the group // if (indexIsInFieldList() && getEvent() == FormEntryController.EVENT_QUESTION) { // caption[0..len-1] // caption[len-1] == the question itself // caption[len-2] == the first group it is contained in. FormEntryCaption[] captions = getCaptionHierarchy(); FormEntryCaption grp = captions[captions.length - 2]; int event = mFormEntryController.jumpToIndex(grp.getIndex()); // and test if this group or at least one of its children is relevant... FormIndex idx = grp.getIndex(); if ( !mFormEntryController.getModel().isIndexRelevant(idx) ) { return stepToPreviousEvent(); } idx = mFormEntryController.getModel().incrementIndex(idx, true); while ( FormIndex.isSubElement(grp.getIndex(), idx) ) { if ( mFormEntryController.getModel().isIndexRelevant(idx) ) { return event; } idx = mFormEntryController.getModel().incrementIndex(idx, true); } return stepToPreviousEvent(); } else if ( indexIsInFieldList() && getEvent() == FormEntryController.EVENT_GROUP) { FormIndex grpidx = mFormEntryController.getModel().getFormIndex(); int event = mFormEntryController.getModel().getEvent(); // and test if this group or at least one of its children is relevant... if ( !mFormEntryController.getModel().isIndexRelevant(grpidx) ) { return stepToPreviousEvent(); // shouldn't happen? } FormIndex idx = mFormEntryController.getModel().incrementIndex(grpidx, true); while ( FormIndex.isSubElement(grpidx, idx) ) { if ( mFormEntryController.getModel().isIndexRelevant(idx) ) { return event; } idx = mFormEntryController.getModel().incrementIndex(idx, true); } return stepToPreviousEvent(); } return getEvent(); } /** * Jumps to a given FormIndex. * * @param index * @return EVENT for the specified Index. */ public int jumpToIndex(FormIndex index) { return mFormEntryController.jumpToIndex(index); } /** * Creates a new repeated instance of the group referenced by the current FormIndex. * * @param questionIndex */ public void newRepeat() { mFormEntryController.newRepeat(); } /** * If the current FormIndex is within a repeated group, will find the innermost repeat, delete * it, and jump the FormEntryController to the previous valid index. That is, if you have group1 * (2) > group2 (3) and you call deleteRepeat, it will delete the 3rd instance of group2. */ public void deleteRepeat() { FormIndex fi = mFormEntryController.deleteRepeat(); mFormEntryController.jumpToIndex(fi); } /** * Sets the current language. * * @param language */ public void setLanguage(String language) { mFormEntryController.setLanguage(language); } /** * Returns an array of question promps. * * @return */ public FormEntryPrompt[] getQuestionPrompts() throws RuntimeException { ArrayList<FormIndex> indicies = new ArrayList<FormIndex>(); FormIndex currentIndex = getFormIndex(); // For questions, there is only one. // For groups, there could be many, but we set that below FormEntryPrompt[] questions = new FormEntryPrompt[1]; IFormElement element = mFormEntryController.getModel().getForm().getChild(currentIndex); if (element instanceof GroupDef) { GroupDef gd = (GroupDef) element; // descend into group FormIndex idxChild = mFormEntryController.getModel().incrementIndex(currentIndex, true); if ( gd.getChildren().size() == 1 && getEvent(idxChild) == FormEntryController.EVENT_GROUP ) { // if we have a group definition within a field-list attribute group, and this is the // only child in the group, check to see if it is also a field-list appearance. // If it is, then silently recurse into it to pick up its elements. // Work-around for the inconsistent treatment of field-list groups and repeats in 1.1.7 that // either breaks forms generated by build or breaks forms generated by XLSForm. IFormElement nestedElement = mFormEntryController.getModel().getForm().getChild(idxChild); if (nestedElement instanceof GroupDef) { GroupDef nestedGd = (GroupDef) nestedElement; if ( ODKView.FIELD_LIST.equalsIgnoreCase(nestedGd.getAppearanceAttr()) ) { gd = nestedGd; idxChild = mFormEntryController.getModel().incrementIndex(idxChild, true); } } } for (int i = 0; i < gd.getChildren().size(); i++) { indicies.add(idxChild); // don't descend idxChild = mFormEntryController.getModel().incrementIndex(idxChild, false); } // we only display relevant questions ArrayList<FormEntryPrompt> questionList = new ArrayList<FormEntryPrompt>(); for (int i = 0; i < indicies.size(); i++) { FormIndex index = indicies.get(i); if (getEvent(index) != FormEntryController.EVENT_QUESTION) { String errorMsg = "Only questions are allowed in 'field-list'. Bad node is: " + index.getReference().toString(false); RuntimeException e = new RuntimeException(errorMsg); Log.e(t, errorMsg); throw e; } // we only display relevant questions if (mFormEntryController.getModel().isIndexRelevant(index)) { questionList.add(getQuestionPrompt(index)); } questions = new FormEntryPrompt[questionList.size()]; questionList.toArray(questions); } } else { // We have a quesion, so just get the one prompt questions[0] = getQuestionPrompt(); } return questions; } public FormEntryPrompt getQuestionPrompt(FormIndex index) { return mFormEntryController.getModel().getQuestionPrompt(index); } public FormEntryPrompt getQuestionPrompt() { return mFormEntryController.getModel().getQuestionPrompt(); } public String getQuestionPromptConstraintText(FormIndex index) { return mFormEntryController.getModel().getQuestionPrompt(index).getConstraintText(); } public String getQuestionPromptRequiredText(FormIndex index) { // look for the text under the requiredMsg bind attribute String constraintText = getBindAttribute(index, XFormParser.NAMESPACE_JAVAROSA, "requiredMsg"); if (constraintText != null) { XPathExpression xPathRequiredMsg; try { xPathRequiredMsg = XPathParseTool.parseXPath("string(" + constraintText + ")"); } catch(Exception e) { // Expected in probably most cases. // This is a string literal, so no need to evaluate anything. return constraintText; } if(xPathRequiredMsg != null) { try{ FormDef form = mFormEntryController.getModel().getForm(); TreeElement mTreeElement = form.getMainInstance().resolveReference(index.getReference()); EvaluationContext ec = new EvaluationContext(form.exprEvalContext, mTreeElement.getRef()); Object value = xPathRequiredMsg.eval(form.getMainInstance(), ec); if(value != "") { return (String)value; } return null; } catch(Exception e) { Log.e(t,"Error evaluating a valid-looking required xpath ", e); return constraintText; } } else { return constraintText; } } return null; } /** * Returns an array of FormEntryCaptions for current FormIndex. * * @return */ public FormEntryCaption[] getGroupsForCurrentIndex() { // return an empty array if you ask for something impossible if (!(getEvent() == FormEntryController.EVENT_QUESTION || getEvent() == FormEntryController.EVENT_PROMPT_NEW_REPEAT || getEvent() == FormEntryController.EVENT_GROUP || getEvent() == FormEntryController.EVENT_REPEAT)) { return new FormEntryCaption[0]; } // the first caption is the question, so we skip it if it's an EVENT_QUESTION // otherwise, the first caption is a group so we start at index 0 int lastquestion = 1; if (getEvent() == FormEntryController.EVENT_PROMPT_NEW_REPEAT || getEvent() == FormEntryController.EVENT_GROUP || getEvent() == FormEntryController.EVENT_REPEAT) { lastquestion = 0; } FormEntryCaption[] v = getCaptionHierarchy(); FormEntryCaption[] groups = new FormEntryCaption[v.length - lastquestion]; for (int i = 0; i < v.length - lastquestion; i++) { groups[i] = v[i]; } return groups; } /** * This is used to enable/disable the "Delete Repeat" menu option. * * @return */ public boolean indexContainsRepeatableGroup() { FormEntryCaption[] groups = getCaptionHierarchy(); if (groups.length == 0) { return false; } for (int i = 0; i < groups.length; i++) { if (groups[i].repeats()) return true; } return false; } /** * The count of the closest group that repeats or -1. */ public int getLastRepeatedGroupRepeatCount() { FormEntryCaption[] groups = getCaptionHierarchy(); if (groups.length > 0) { for (int i = groups.length - 1; i > -1; i--) { if (groups[i].repeats()) { return groups[i].getMultiplicity(); } } } return -1; } /** * The name of the closest group that repeats or null. */ public String getLastRepeatedGroupName() { FormEntryCaption[] groups = getCaptionHierarchy(); // no change if (groups.length > 0) { for (int i = groups.length - 1; i > -1; i--) { if (groups[i].repeats()) { return groups[i].getLongText(); } } } return null; } /** * The closest group the prompt belongs to. * * @return FormEntryCaption */ private FormEntryCaption getLastGroup() { FormEntryCaption[] groups = getCaptionHierarchy(); if (groups == null || groups.length == 0) return null; else return groups[groups.length - 1]; } /** * The repeat count of closest group the prompt belongs to. */ public int getLastRepeatCount() { if (getLastGroup() != null) { return getLastGroup().getMultiplicity(); } return -1; } /** * The text of closest group the prompt belongs to. */ public String getLastGroupText() { if (getLastGroup() != null) { return getLastGroup().getLongText(); } return null; } /** * Find the portion of the form that is to be submitted * * @return */ private IDataReference getSubmissionDataReference() { FormDef formDef = mFormEntryController.getModel().getForm(); // Determine the information about the submission... SubmissionProfile p = formDef.getSubmissionProfile(); if (p == null || p.getRef() == null) { return new XPathReference("/"); } else { return p.getRef(); } } /** * Once a submission is marked as complete, it is saved in the * submission format, which might be a fragment of the original * form or might be a SMS text string, etc. * * @return true if the submission is the entire form. If it is, * then the submission can be re-opened for editing * after it was marked-as-complete (provided it has * not been encrypted). */ public boolean isSubmissionEntireForm() { IDataReference sub = getSubmissionDataReference(); return ( getInstance().resolveReference(sub) == null ); } /** * Constructs the XML payload for a filled-in form instance. This payload * enables a filled-in form to be re-opened and edited. * * @return * @throws IOException */ public ByteArrayPayload getFilledInFormXml() throws IOException { // assume no binary data inside the model. FormInstance datamodel = getInstance(); XFormSerializingVisitor serializer = new XFormSerializingVisitor(); serializer.setAnswerDataSerializer(new XFormUtcDateAnswerDataSerializer()); ByteArrayPayload payload = (ByteArrayPayload) serializer.createSerializedPayload(datamodel); return payload; } /** * Extract the portion of the form that should be uploaded to the server. * * @return * @throws IOException */ public ByteArrayPayload getSubmissionXml() throws IOException { FormInstance instance = getInstance(); XFormSerializingVisitor serializer = new XFormSerializingVisitor(); ByteArrayPayload payload = (ByteArrayPayload) serializer.createSerializedPayload(instance, getSubmissionDataReference()); return payload; } /** * Traverse the submission looking for the first matching tag in depth-first order. * * @param parent * @param name * @return */ private TreeElement findDepthFirst(TreeElement parent, String name) { int len = parent.getNumChildren(); for ( int i = 0; i < len ; ++i ) { TreeElement e = parent.getChildAt(i); if ( name.equals(e.getName()) ) { return e; } else if ( e.getNumChildren() != 0 ) { TreeElement v = findDepthFirst(e, name); if ( v != null ) return v; } } return null; } /** * Get the OpenRosa required metadata of the portion of the form beng submitted * @return */ public InstanceMetadata getSubmissionMetadata() { FormDef formDef = mFormEntryController.getModel().getForm(); TreeElement rootElement = formDef.getInstance().getRoot(); TreeElement trueSubmissionElement; // Determine the information about the submission... SubmissionProfile p = formDef.getSubmissionProfile(); if ( p == null || p.getRef() == null ) { trueSubmissionElement = rootElement; } else { IDataReference ref = p.getRef(); trueSubmissionElement = formDef.getInstance().resolveReference(ref); // resolveReference returns null if the reference is to the root element... if ( trueSubmissionElement == null ) { trueSubmissionElement = rootElement; } } // and find the depth-first meta block in this... TreeElement e = findDepthFirst(trueSubmissionElement, "meta"); String instanceId = null; String instanceName = null; if ( e != null ) { Vector<TreeElement> v; // instance id... v = e.getChildrenWithName(INSTANCE_ID); if ( v.size() == 1 ) { StringData sa = (StringData) v.get(0).getValue(); if ( sa != null ) { instanceId = (String) sa.getValue(); } } // instance name... v = e.getChildrenWithName(INSTANCE_NAME); if ( v.size() == 1 ) { StringData sa = (StringData) v.get(0).getValue(); if ( sa != null ) { instanceName = (String) sa.getValue(); } } } return new InstanceMetadata(instanceId,instanceName); } }