/
FormController.java
1257 lines (1122 loc) 路 47.1 KB
/
FormController.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
* 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.support.annotation.NonNull;
import android.support.annotation.Nullable;
import org.javarosa.core.model.CoreModelModule;
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.QuestionDef;
import org.javarosa.core.model.SubmissionProfile;
import org.javarosa.core.model.ValidateOutcome;
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.model.instance.TreeReference;
import org.javarosa.core.services.IPropertyManager;
import org.javarosa.core.services.PrototypeManager;
import org.javarosa.core.services.transport.payload.ByteArrayPayload;
import org.javarosa.core.util.JavaRosaCoreModule;
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.CompactSerializingVisitor;
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.utilities.AuditEventLogger;
import org.odk.collect.android.utilities.FileUtils;
import org.odk.collect.android.utilities.RegexUtils;
import org.odk.collect.android.views.ODKView;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import timber.log.Timber;
import static org.odk.collect.android.utilities.ApplicationConstants.Namespaces.XML_OPENDATAKIT_NAMESPACE;
/**
* 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 {
public static final boolean STEP_INTO_GROUP = true;
public static final boolean STEP_OVER_GROUP = false;
/**
* OpenRosa metadata tag names.
*/
public static final String INSTANCE_ID = "instanceID";
private static final String INSTANCE_NAME = "instanceName";
/*
* Non OpenRosa metadata tag names
*/
private static final String AUDIT = "audit";
public static final String AUDIT_FILE_NAME = "audit.csv";
/*
* Store the auditEventLogger object with the form controller state
*/
private AuditEventLogger auditEventLogger;
/**
* OpenRosa metadata of a form instance.
* <p>
* 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;
public final AuditConfig auditConfig;
public InstanceMetadata(String instanceId, String instanceName, AuditConfig auditConfig) {
this.instanceId = instanceId;
this.instanceName = RegexUtils.normalizeFormName(instanceName, false);
this.auditConfig = auditConfig;
}
}
private static boolean isJavaRosaInitialized;
/**
* 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).
*/
public static synchronized void initializeJavaRosa(IPropertyManager mgr) {
if (!isJavaRosaInitialized) {
// Register prototypes for classes that FormDef uses
PrototypeManager.registerPrototypes(JavaRosaCoreModule.classNames);
PrototypeManager.registerPrototypes(CoreModelModule.classNames);
new XFormsModule().registerModule();
isJavaRosaInitialized = true;
}
// needed to override rms property manager
org.javarosa.core.services.PropertyManager
.setPropertyManager(mgr);
}
private final File mediaFolder;
@Nullable
private File instanceFile;
private final FormEntryController formEntryController;
private FormIndex indexWaitingForData;
public FormController(File mediaFolder, FormEntryController fec, File instanceFile) {
this.mediaFolder = mediaFolder;
formEntryController = fec;
this.instanceFile = instanceFile;
}
public FormDef getFormDef() {
return formEntryController.getModel().getForm();
}
public File getMediaFolder() {
return mediaFolder;
}
@Nullable
public File getInstanceFile() {
return instanceFile;
}
public void setInstanceFile(File instanceFile) {
this.instanceFile = instanceFile;
}
@Nullable
public String getAbsoluteInstancePath() {
return instanceFile != null ? instanceFile.getAbsolutePath() : null;
}
@Nullable
public String getLastSavedPath() {
return mediaFolder != null ? FileUtils.getLastSavedPath(mediaFolder) : null;
}
public void setIndexWaitingForData(FormIndex index) {
indexWaitingForData = index;
}
public FormIndex getIndexWaitingForData() {
return indexWaitingForData;
}
public AuditEventLogger getAuditEventLogger() {
if (auditEventLogger == null) {
setAuditEventLogger(new AuditEventLogger(getInstanceFile(), getSubmissionMetadata().auditConfig));
}
return auditEventLogger;
}
private void setAuditEventLogger(AuditEventLogger logger) {
auditEventLogger = logger;
}
/**
* For logging purposes...
*
* @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) {
switch (xpath) {
case "beginningOfForm":
return FormIndex.createBeginningOfFormIndex();
case "endOfForm":
return FormIndex.createEndOfFormIndex();
case "unexpected":
Timber.e("Unexpected string from XPath");
throw new IllegalArgumentException("unexpected string from XPath");
default:
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.
*/
public int getEvent() {
return formEntryController.getModel().getEvent();
}
/**
* returns the event for the given FormIndex.
*/
public int getEvent(FormIndex index) {
return formEntryController.getModel().getEvent(index);
}
/**
* @return current FormIndex.
*/
public FormIndex getFormIndex() {
return formEntryController.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 formEntryController.getModel().getLanguages();
}
/**
* @return A String containing the title of the current form.
*/
public String getFormTitle() {
return formEntryController.getModel().getFormTitle();
}
/**
* @return the currently selected language.
*/
public String getLanguage() {
return formEntryController.getModel().getLanguage();
}
public String getBindAttribute(String attributeNamespace, String attributeName) {
return getBindAttribute(getFormIndex(), attributeNamespace, attributeName);
}
public String getBindAttribute(FormIndex idx, String attributeNamespace, String attributeName) {
return formEntryController.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 formEntryController.getModel().getCaptionHierarchy();
}
/**
* @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 formEntryController.getModel().getCaptionHierarchy(index);
}
/**
* Returns a caption prompt for the given index. This is used to create a multi-question per
* screen view.
*/
public FormEntryCaption getCaptionPrompt(FormIndex index) {
return formEntryController.getModel().getCaptionPrompt(index);
}
/**
* Return the caption for the current FormIndex. This is usually used for a repeat prompt.
*/
public FormEntryCaption getCaptionPrompt() {
return formEntryController.getModel().getCaptionPrompt();
}
/**
* This fires off the jr:preload actions and events to save values like the
* end time of a form.
*/
public boolean postProcessInstance() {
return formEntryController.getModel().getForm().postProcessInstance();
}
/**
* TODO: We need a good description of what this does, exactly, and why.
*/
private FormInstance getInstance() {
return formEntryController.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.
*/
private boolean groupIsFieldList(FormIndex index) {
// if this isn't a group, return right away
IFormElement element = formEntryController.getModel().getForm().getChild(index);
if (!(element instanceof GroupDef)) {
return false;
}
return ODKView.FIELD_LIST.equalsIgnoreCase(element.getAppearanceAttr());
}
private boolean repeatIsFieldList(FormIndex index) {
return groupIsFieldList(index);
}
/**
* Returns the `appearance` attribute of the current index, if any.
*/
public String getAppearanceAttr(@NonNull FormIndex index) {
// FormDef can't have an appearance, it would throw an exception.
if (index.isBeginningOfFormIndex()) {
return null;
}
IFormElement element = formEntryController.getModel().getForm().getChild(index);
return element.getAppearanceAttr();
}
/**
* Tests if the FormIndex 'index' 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(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;
}
// If at least one of the groups you are inside is a field list, your index is in a field list
for (FormEntryCaption caption : captions) {
if (groupIsFieldList(caption.getIndex())) {
return true;
}
}
return false;
} 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;
}
}
/**
* 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(getFormIndex());
}
public boolean currentPromptIsQuestion() {
return getEvent() == FormEntryController.EVENT_QUESTION
|| ((getEvent() == FormEntryController.EVENT_GROUP
|| getEvent() == FormEntryController.EVENT_REPEAT)
&& indexIsInFieldList());
}
public boolean isCurrentQuestionFirstInForm() {
boolean isFirstQuestion = true;
FormIndex originalFormIndex = getFormIndex();
try {
isFirstQuestion = stepToPreviousScreenEvent() == FormEntryController.EVENT_BEGINNING_OF_FORM
&& stepToNextScreenEvent() != FormEntryController.EVENT_PROMPT_NEW_REPEAT;
} catch (JavaRosaException e) {
Timber.d(e);
}
jumpToIndex(originalFormIndex);
return isFirstQuestion;
}
/**
* Attempts to save answer into the given FormIndex into the data model.
*/
public int answerQuestion(FormIndex index, IAnswerData data) throws JavaRosaException {
try {
return formEntryController.answerQuestion(index, data, true);
} 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.
*
* @return ANSWER_OK and leave index unchanged or change index to bad value and return error
* type.
*/
public int validateAnswers(boolean markCompleted) throws JavaRosaException {
ValidateOutcome outcome = getFormDef().validate(markCompleted);
if (outcome != null) {
this.jumpToIndex(outcome.failedPrompt);
if (indexIsInFieldList()) {
stepToPreviousScreenEvent();
}
return outcome.outcome;
}
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.
*
* @return true if saved successfully, false otherwise.
*/
public boolean saveAnswer(FormIndex index, IAnswerData data) throws JavaRosaException {
try {
return formEntryController.saveAnswer(index, data, true);
} 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() && !isGroupEmpty() && !stepIntoGroup) {
return stepOverGroup();
} else {
return formEntryController.stepToNextEvent();
}
}
/**
* If using a view like HierarchyView that doesn't support multi-question per screen, step over
* the group represented by the FormIndex.
*/
public int stepOverGroup() {
GroupDef gd =
(GroupDef) formEntryController.getModel().getForm()
.getChild(getFormIndex());
List<FormIndex> indices = getIndicesForGroup(gd);
// jump to the end of the group
formEntryController.jumpToIndex(indices.get(indices.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;
*
* @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.
*/
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();
}
// Handle nested field-list group
if (getEvent() == FormEntryController.EVENT_GROUP) {
FormIndex currentIndex = getFormIndex();
IFormElement element = formEntryController.getModel().getForm().getChild(
currentIndex);
if (element instanceof GroupDef) {
GroupDef gd = (GroupDef) element;
if (ODKView.FIELD_LIST.equalsIgnoreCase(gd.getAppearanceAttr())) {
// jump to outermost containing field-list
FormEntryCaption[] fclist = this.getCaptionHierarchy(currentIndex);
for (FormEntryCaption caption : fclist) {
if (groupIsFieldList(caption.getIndex())) {
formEntryController.jumpToIndex(caption.getIndex());
break;
}
}
}
}
}
}
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.
*/
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:
case FormEntryController.EVENT_END_OF_FORM:
break group_skip;
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:
Timber.i("repeat juncture: %s", getFormIndex().getReference().toString());
// skip repeat junctures until we implement them
break;
default:
Timber.w("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 next event of the given type
* (or the end if none is found).
*/
public int stepToNextEventType(int eventType) {
int event = getEvent();
do {
if (event == FormEntryController.EVENT_END_OF_FORM) {
break;
}
event = stepToNextEvent(FormController.STEP_OVER_GROUP);
} while (event != eventType);
return event;
}
/**
* Move the current form index to the index of the first displayable group
* (that is, a repeatable group or a visible group),
* or to the start of the form.
*/
public int stepToOuterScreenEvent() {
FormIndex index = getFormIndex();
// Step out once to begin with if we're coming from a question.
if (getEvent() == FormEntryController.EVENT_QUESTION) {
index = stepIndexOut(index);
}
// Save where we started from.
FormIndex startIndex = index;
// Step out once more no matter what.
index = stepIndexOut(index);
// Step out of any group indexes that are present, unless they're visible.
while (index != null
&& getEvent(index) == FormEntryController.EVENT_GROUP
&& !isDisplayableGroup(index)) {
index = stepIndexOut(index);
}
if (index == null) {
jumpToIndex(FormIndex.createBeginningOfFormIndex());
} else {
if (isDisplayableGroup(startIndex)) {
// We were at a displayable group, so stepping back brought us to the previous level
jumpToIndex(index);
} else {
// We were at a question, so stepping back brought us to either:
// The beginning, or the start of a displayable group. So we need to step
// out again to go past the group.
index = stepIndexOut(index);
if (index == null) {
jumpToIndex(FormIndex.createBeginningOfFormIndex());
} else {
jumpToIndex(index);
}
}
}
return getEvent();
}
/**
* Returns true if the index is either a repeatable group or a visible group.
*/
public boolean isDisplayableGroup(FormIndex index) {
return getEvent(index) == FormEntryController.EVENT_REPEAT ||
(getEvent(index) == FormEntryController.EVENT_GROUP && isPresentationGroup(index) && isLogicalGroup(index));
}
/**
* Returns true if the group has a displayable label,
* i.e. it's a "presentation group".
*/
private boolean isPresentationGroup(FormIndex groupIndex) {
String label = getCaptionPrompt(groupIndex).getShortText();
return label != null;
}
/**
* Returns true if the group has an XML `ref` attribute,
* i.e. it's a "logical group".
*
* TODO: Improve this nasty way to recreate what XFormParser#parseGroup does for nodes without a `ref`.
*/
private boolean isLogicalGroup(FormIndex groupIndex) {
TreeReference groupRef = groupIndex.getReference();
TreeReference parentRef = groupRef.getParentRef();
IDataReference absRef = FormDef.getAbsRef(new XPathReference(groupRef), parentRef);
IDataReference bindRef = getCaptionPrompt(groupIndex).getFormElement().getBind();
// If the group's bind is equal to what it would have been set to during parsing, it must not have a ref.
return !absRef.equals(bindRef);
}
public static class FailedConstraint {
public final FormIndex index;
public final int status;
FailedConstraint(FormIndex index, int status) {
this.index = index;
this.status = status;
}
}
/**
* @return FailedConstraint of first failed constraint or null if all questions were saved.
*/
public FailedConstraint saveAllScreenAnswers(HashMap<FormIndex, IAnswerData> answers,
boolean evaluateConstraints) throws JavaRosaException {
if (currentPromptIsQuestion()) {
for (FormIndex index : answers.keySet()) {
// 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 {
Timber.w("Attempted to save an index referencing something other than a question: %s",
index.getReference().toString());
}
}
}
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.
*/
formEntryController.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 = formEntryController.jumpToIndex(grp.getIndex());
// and test if this group or at least one of its children is relevant...
FormIndex idx = grp.getIndex();
if (!formEntryController.getModel().isIndexRelevant(idx)) {
return stepToPreviousEvent();
}
idx = formEntryController.getModel().incrementIndex(idx, true);
while (FormIndex.isSubElement(grp.getIndex(), idx)) {
if (formEntryController.getModel().isIndexRelevant(idx)) {
return event;
}
idx = formEntryController.getModel().incrementIndex(idx, true);
}
return stepToPreviousEvent();
} else if (indexIsInFieldList() && getEvent() == FormEntryController.EVENT_GROUP) {
FormIndex grpidx = formEntryController.getModel().getFormIndex();
int event = formEntryController.getModel().getEvent();
// and test if this group or at least one of its children is relevant...
if (!formEntryController.getModel().isIndexRelevant(grpidx)) {
return stepToPreviousEvent(); // shouldn't happen?
}
FormIndex idx = formEntryController.getModel().incrementIndex(grpidx, true);
while (FormIndex.isSubElement(grpidx, idx)) {
if (formEntryController.getModel().isIndexRelevant(idx)) {
return event;
}
idx = formEntryController.getModel().incrementIndex(idx, true);
}
return stepToPreviousEvent();
}
return getEvent();
}
/**
* Jumps to a given FormIndex.
*
* @return EVENT for the specified Index.
*/
public int jumpToIndex(FormIndex index) {
return formEntryController.jumpToIndex(index);
}
/**
* Creates a new repeated instance of the group referenced by the current FormIndex.
*/
public void newRepeat() {
formEntryController.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 = formEntryController.deleteRepeat();
formEntryController.jumpToIndex(fi);
}
/**
* Sets the current language.
*/
public void setLanguage(String language) {
formEntryController.setLanguage(language);
}
/**
* Returns an array of question promps.
*/
public FormEntryPrompt[] getQuestionPrompts() throws RuntimeException {
// For questions, there is only one.
// For groups, there could be many, but we set that below
FormEntryPrompt[] questions = new FormEntryPrompt[0];
IFormElement element = formEntryController.getModel().getForm().getChild(getFormIndex());
if (element instanceof GroupDef) {
GroupDef gd = (GroupDef) element;
// we only display relevant questions
List<FormEntryPrompt> questionList = new ArrayList<>();
for (FormIndex index : getIndicesForGroup(gd)) {
if (getEvent(index) != FormEntryController.EVENT_QUESTION) {
String errorMsg =
"Only questions and regular groups are allowed in 'field-list'. Bad node is: "
+ index.getReference().toString(false);
RuntimeException e = new RuntimeException(errorMsg);
Timber.w(errorMsg);
throw e;
}
// we only display relevant questions
if (formEntryController.getModel().isIndexRelevant(index)) {
questionList.add(getQuestionPrompt(index));
}
questions = new FormEntryPrompt[questionList.size()];
questionList.toArray(questions);
}
} else {
// We have a question, so just get the one prompt
questions = new FormEntryPrompt[1];
questions[0] = getQuestionPrompt();
}
return questions;
}
private boolean isGroupEmpty() {
GroupDef group = (GroupDef) formEntryController.getModel().getForm().getChild(getFormIndex());
return getIndicesForGroup(group).isEmpty();
}
/**
* Recursively gets all indices contained in this group and its children
*/
private List<FormIndex> getIndicesForGroup(GroupDef gd) {
return getIndicesForGroup(gd,
formEntryController.getModel().incrementIndex(getFormIndex(), true));
}
private List<FormIndex> getIndicesForGroup(GroupDef gd, FormIndex currentChildIndex) {
List<FormIndex> indices = new ArrayList<>();
for (int i = 0; i < gd.getChildren().size(); i++) {
final FormEntryModel formEntryModel = formEntryController.getModel();
if (getEvent(currentChildIndex) == FormEntryController.EVENT_GROUP) {
IFormElement nestedElement = formEntryModel.getForm().getChild(currentChildIndex);
if (nestedElement instanceof GroupDef) {
indices.addAll(getIndicesForGroup((GroupDef) nestedElement,
formEntryModel.incrementIndex(currentChildIndex, true)));
currentChildIndex = formEntryModel.incrementIndex(currentChildIndex, false);
}
} else {
indices.add(currentChildIndex);
currentChildIndex = formEntryModel.incrementIndex(currentChildIndex, false);
}
}
return indices;
}
public FormEntryPrompt getQuestionPrompt(FormIndex index) {
return formEntryController.getModel().getQuestionPrompt(index);
}
public FormEntryPrompt getQuestionPrompt() {
return formEntryController.getModel().getQuestionPrompt();
}
public String getQuestionPromptConstraintText(FormIndex index) {
return formEntryController.getModel().getQuestionPrompt(index).getConstraintText();
}
public boolean currentCaptionPromptIsQuestion() {
return getCaptionPrompt().getFormElement() instanceof QuestionDef;
}
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 = formEntryController.getModel().getForm();
TreeElement treeElement = form.getMainInstance().resolveReference(
index.getReference());
EvaluationContext ec = new EvaluationContext(form.getEvaluationContext(),
treeElement.getRef());
Object value = xpathRequiredMsg.eval(form.getMainInstance(), ec);
if (!value.equals("")) {
return (String) value;
}
return null;
} catch (Exception e) {
Timber.e(e, "Error evaluating a valid-looking required xpath ");
return constraintText;
}
} else {
return constraintText;
}
}
return null;
}
/**
* Returns an array of FormEntryCaptions for current FormIndex.
*/
public FormEntryCaption[] getGroupsForCurrentIndex() {
// return an empty array if you ask for something impossible
if (!(getEvent() == FormEntryController.EVENT_QUESTION
|| getEvent() == FormEntryController.EVENT_PROMPT_NEW_REPEAT