/*
* 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 java.io.IOException;
import java.util.ArrayList;
import java.util.Vector;
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.data.IAnswerData;
import org.javarosa.core.model.instance.FormInstance;
import org.javarosa.core.model.instance.TreeElement;
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.FormEntryPrompt;
import org.javarosa.model.xform.XFormSerializingVisitor;
import org.javarosa.model.xform.XPathReference;
import org.odk.collect.android.views.ODKView;
import org.odk.collect.android.widgets.WidgetFactory;
import android.util.Log;
/**
* 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";
private FormEntryController mFormEntryController;
private boolean mReadOnly;
public static final boolean STEP_OVER_GROUP = true;
public static final boolean STEP_INTO_GROUP = false;
/**
* OpenRosa metadata tag names.
*/
private static final String INSTANCE_ID = "instanceID";
/**
* 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;
InstanceMetadata( String instanceId ) {
this.instanceId = instanceId;
}
};
public FormController(FormEntryController fec) {
this(fec, false);
}
public FormController(FormEntryController fec, boolean readOnly) {
mFormEntryController = fec;
mReadOnly = readOnly;
}
/**
* 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 true if current FormIndex is readonly. false otherwise.
*/
public boolean isIndexReadonly() {
return mFormEntryController.getModel().isIndexReadonly();
}
/**
* @return true if this form session is in read only mode
*/
public boolean isFormReadOnly() {
return mReadOnly;
}
/**
* @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();
}
/**
* @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
*/
public FormEntryCaption[] getCaptionHierarchy() {
return mFormEntryController.getModel().getCaptionHierarchy();
}
/**
* 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();
}
/**
* TODO: We need a good description of what this does, exactly, and why.
*
* @return
*/
public boolean postProcessInstance() {
return mFormEntryController.getModel().getForm().postProcessInstance();
}
/**
* TODO: We need a good description of what this does, exactly, and why.
*
* @return
*/
public FormInstance getInstance() {
return mFormEntryController.getModel().getForm().getInstance();
}
/**
* A convenience method for determining if the current FormIndex is a group that is/should be
* displayed as a multi-question view of all of its descendants. This is useful for returning
* from the formhierarchy view to a selected index.
*
* @param index
* @return
*/
private boolean isFieldListHost(FormIndex index) {
// if this isn't a group, return right away
if (!(mFormEntryController.getModel().getForm().getChild(index) instanceof GroupDef)) {
return false;
}
//TODO: Is it possible we need to make sure this group isn't inside of another group which
//is itself a field list? That would make the top group the field list host, not the
//descendant group
GroupDef gd = (GroupDef) mFormEntryController.getModel().getForm().getChild(index); // 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.
*/
public boolean indexIsInFieldList(FormIndex index) {
FormIndex fieldListHost = this.getFieldListHost(index);
return fieldListHost != null;
}
/**
* 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 indexIsInFieldList(mFormEntryController.getModel().getFormIndex());
}
/**
* Attempts to save answer at the current FormIndex into the data model.
*
* @param data
* @return
*/
public 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) {
return mFormEntryController.answerQuestion(index, data);
}
/**
* 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) {
return mFormEntryController.saveAnswer(index, data);
}
/**
* 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 index
* @param data
* @return true if saved successfully, false otherwise.
*/
public boolean saveAnswer(IAnswerData data) {
return mFormEntryController.saveAnswer(data);
}
/**
* Navigates forward in the form, expanding any repeats encountered.
*
* @return the next event that should be handled by a view.
*/
public int stepToNextEvent(boolean stepOverGroup) {
return stepToNextEvent(stepOverGroup, true);
}
/**
* Navigates forward in the form.
*
* @return the next event that should be handled by a view.
*/
public int stepToNextEvent(boolean stepOverGroup, boolean expandRepeats) {
//TODO: this won't actually catch the case where there are nested field lists properly
if (mFormEntryController.getModel().getEvent() == FormEntryController.EVENT_GROUP && indexIsInFieldList() && stepOverGroup) {
return stepOverGroup();
} else {
int event = mFormEntryController.stepToNextEvent(expandRepeats);
//
if(event == FormEntryController.EVENT_PROMPT_NEW_REPEAT && this.mReadOnly) {
return stepToNextEvent(stepOverGroup, expandRepeats);
}
return event;
}
}
/**
* From the current state of the form controller, whose current form index
* must be a group element, move the form to the next index which is outside
* of the current group.
*
* @return
*/
private int stepOverGroup() {
//Get this group's index
FormIndex groupIndex = this.getFormIndex();
FormIndex walker = groupIndex;
int event = -1;
//Walk until the next index is outside of this one.
while(FormIndex.isSubElement(groupIndex, walker)) {
event = this.stepToNextEvent(false);
walker = this.getFormIndex();
}
//Walker must represent the last index outside of the group now.
return event;
}
/**
* 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.
*/
int event = mFormEntryController.stepToPreviousEvent();
if(event == FormEntryController.EVENT_PROMPT_NEW_REPEAT &&
this.mReadOnly) {
return stepToPreviousEvent();
}
// If after we've stepped, we're in a field-list, jump back to the beginning of the group
FormIndex host = getFieldListHost(this.getFormIndex());
if (host != null) {
return mFormEntryController.jumpToIndex(host);
}
return mFormEntryController.getModel().getEvent();
}
/**
* Retrieves the index of the Group that is the host of a given field list.
*
* @param child
* @return
*/
private FormIndex getFieldListHost(FormIndex child) {
int event = mFormEntryController.getModel().getEvent(child);
if (event == FormEntryController.EVENT_QUESTION || event == FormEntryController.EVENT_GROUP || event == FormEntryController.EVENT_REPEAT) {
// caption[0..len-1]
// caption[len-1] == the event itself
// caption[len-2] == the groups containing this group
FormEntryCaption[] captions = mFormEntryController.getModel().getCaptionHierarchy();
//This starts at the beginning of the heirarchy, so it'll catch the top-level
//host index.
for(FormEntryCaption caption : captions ) {
FormIndex parentIndex = caption.getIndex();
if(isFieldListHost(parentIndex)) {
return parentIndex;
}
}
//none of this node's parents are field lists
return null;
} else {
// Non-host elements can't have field list hosts.
return null;
}
}
/**
* 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 specified FormIndex.
*
* @param questionIndex
*/
public void newRepeat(FormIndex questionIndex) {
mFormEntryController.newRepeat(questionIndex);
}
/**
* 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 relevant question prompts that should be displayed as a single screen.
* If the current form index is a question, it is returned. Otherwise if the
* current index is a field list (and _only_ when it is a field list)
*
* @return
*/
public FormEntryPrompt[] getQuestionPrompts() throws RuntimeException {
FormIndex currentIndex = mFormEntryController.getModel().getFormIndex();
IFormElement element = mFormEntryController.getModel().getForm().getChild(currentIndex);
//If we're in a group, we will collect of the questions in this group
if (element instanceof GroupDef) {
//Assert that this is a valid condition (only field lists return prompts)
if(!this.isFieldListHost(currentIndex)) { throw new RuntimeException("Cannot get question prompts from a non-field-list group"); }
// Questions to collect
ArrayList<FormEntryPrompt> questionList = new ArrayList<FormEntryPrompt>();
//Step over all events in this field list and collect them
FormIndex walker = currentIndex;
int event = this.getEvent();
while(FormIndex.isSubElement(currentIndex, walker)) {
if(event == FormEntryController.EVENT_QUESTION) {
questionList.add(mFormEntryController.getModel().getQuestionPrompt());
}
if(event == FormEntryController.EVENT_PROMPT_NEW_REPEAT) {
//TODO: What if there is a non-deterministic repeat up in the field list?
}
//this handles relevance for us
event = this.mFormEntryController.stepToNextEvent();
walker = this.getFormIndex();
}
//Reset the controller
this.mFormEntryController.jumpToIndex(currentIndex);
FormEntryPrompt[] questions = new FormEntryPrompt[questionList.size()];
//Populate the array with the collected questions
questionList.toArray(questions);
return questions;
} else {
// We have a quesion, so just get the one prompt
return new FormEntryPrompt[] { mFormEntryController.getModel().getQuestionPrompt()};
}
}
public FormEntryPrompt getQuestionPrompt(FormIndex index) {
return mFormEntryController.getModel().getQuestionPrompt(index);
}
public FormEntryPrompt getQuestionPrompt() {
return mFormEntryController.getModel().getQuestionPrompt();
}
/**
* Returns an array of FormEntryCaptions for current FormIndex.
*
* @return
*/
public FormEntryCaption[] getGroupsForCurrentIndex() {
// return an empty array if you ask for something impossible
if (!(mFormEntryController.getModel().getEvent() == FormEntryController.EVENT_QUESTION
|| mFormEntryController.getModel().getEvent() == FormEntryController.EVENT_PROMPT_NEW_REPEAT || mFormEntryController
.getModel().getEvent() == FormEntryController.EVENT_GROUP)) {
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 (mFormEntryController.getModel().getEvent() == FormEntryController.EVENT_PROMPT_NEW_REPEAT
|| mFormEntryController.getModel().getEvent() == FormEntryController.EVENT_GROUP) {
lastquestion = 0;
}
FormEntryCaption[] v = mFormEntryController.getModel().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 = mFormEntryController.getModel().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 = mFormEntryController.getModel().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 = mFormEntryController.getModel().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 = mFormEntryController.getModel().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 );
}
/**
* 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;
if ( e != null ) {
Vector<TreeElement> v;
// instance id...
v = e.getChildrenWithName(INSTANCE_ID);
if ( v.size() == 1 ) {
instanceId = v.get(0).getValue().uncast().toString();
}
}
return new InstanceMetadata(instanceId);
}
//CTS: Added this to protect the JR internal classes, although it's not awesome that
//this ended up in the "logic" division.
public WidgetFactory getWidgetFactory() {
return new WidgetFactory(mFormEntryController.getModel().getForm());
}
}