/* * Copyright (C) 2009 University of Washington * * 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.activities; import java.io.File; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Set; import javax.crypto.spec.SecretKeySpec; import org.javarosa.core.model.Constants; import org.javarosa.core.model.FormIndex; import org.javarosa.core.model.data.IAnswerData; import org.javarosa.core.services.Logger; import org.javarosa.core.services.locale.Localization; import org.javarosa.core.services.locale.Localizer; import org.javarosa.form.api.FormEntryController; import org.javarosa.form.api.FormEntryPrompt; import org.javarosa.model.xform.XFormsModule; import org.javarosa.xpath.XPathException; import org.javarosa.xpath.XPathTypeMismatchException; import org.odk.collect.android.R; import org.odk.collect.android.application.Collect; import org.odk.collect.android.jr.extensions.IntentCallout; import org.odk.collect.android.listeners.AdvanceToNextListener; import org.odk.collect.android.listeners.FormLoaderListener; import org.odk.collect.android.listeners.FormSavedListener; import org.odk.collect.android.listeners.WidgetChangedListener; import org.odk.collect.android.logic.FormController; import org.odk.collect.android.logic.PropertyManager; import org.odk.collect.android.preferences.PreferencesActivity; import org.odk.collect.android.preferences.PreferencesActivity.ProgressBarMode; import org.odk.collect.android.provider.FormsProviderAPI.FormsColumns; import org.odk.collect.android.provider.InstanceProviderAPI; import org.odk.collect.android.provider.InstanceProviderAPI.InstanceColumns; import org.odk.collect.android.tasks.FormLoaderTask; import org.odk.collect.android.tasks.SaveToDiskTask; import org.odk.collect.android.utilities.Base64Wrapper; import org.odk.collect.android.utilities.FileUtils; import org.odk.collect.android.utilities.GeoUtils; import org.odk.collect.android.utilities.StringUtils; import org.odk.collect.android.views.ODKView; import org.odk.collect.android.views.ResizingImageView; import org.odk.collect.android.widgets.DateTimeWidget; import org.odk.collect.android.widgets.IntentWidget; import org.odk.collect.android.widgets.QuestionWidget; import org.odk.collect.android.widgets.TimeWidget; import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.app.AlertDialog; import android.app.Dialog; import android.app.ProgressDialog; import android.content.BroadcastReceiver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; import android.database.Cursor; import android.graphics.Rect; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.location.LocationManager; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; import android.preference.PreferenceManager; import android.provider.MediaStore; import android.provider.MediaStore.Images; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentActivity; import android.support.v4.app.FragmentManager; import android.text.InputFilter; import android.text.Spanned; import android.util.DisplayMetrics; import android.util.Log; import android.util.Pair; import android.view.ContextMenu; import android.view.ContextMenu.ContextMenuInfo; import android.view.ContextThemeWrapper; import android.view.GestureDetector; import android.view.GestureDetector.OnGestureListener; import android.view.Gravity; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; import android.view.MotionEvent; import android.view.View; import android.view.View.OnClickListener; import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; import android.view.animation.Animation; import android.view.animation.Animation.AnimationListener; import android.view.animation.AnimationUtils; import android.view.inputmethod.InputMethodManager; import android.widget.Button; import android.widget.CheckBox; import android.widget.EditText; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.ProgressBar; import android.widget.RelativeLayout; import android.widget.TextView; import android.widget.Toast; /** * FormEntryActivity is responsible for displaying questions, animating transitions between * questions, and allowing the user to enter data. * * @author Carl Hartung (carlhartung@gmail.com) */ public class FormEntryActivity extends FragmentActivity implements AnimationListener, FormLoaderListener, FormSavedListener, AdvanceToNextListener, OnGestureListener, WidgetChangedListener { private static final String t = "FormEntryActivity"; // Defines for FormEntryActivity private static final boolean EXIT = true; private static final boolean DO_NOT_EXIT = false; private static final boolean EVALUATE_CONSTRAINTS = true; private static final boolean DO_NOT_EVALUATE_CONSTRAINTS = false; // Request codes for returning data from specified intent. public static final int IMAGE_CAPTURE = 1; public static final int BARCODE_CAPTURE = 2; public static final int AUDIO_CAPTURE = 3; public static final int VIDEO_CAPTURE = 4; public static final int LOCATION_CAPTURE = 5; public static final int HIERARCHY_ACTIVITY = 6; public static final int IMAGE_CHOOSER = 7; public static final int AUDIO_CHOOSER = 8; public static final int VIDEO_CHOOSER = 9; public static final int INTENT_CALLOUT = 10; public static final int HIERARCHY_ACTIVITY_FIRST_START = 11; public static final int SIGNATURE_CAPTURE = 12; // Extra returned from gp activity public static final String LOCATION_RESULT = "LOCATION_RESULT"; // Identifies the gp of the form used to launch form entry public static final String KEY_FORMPATH = "formpath"; public static final String KEY_INSTANCEDESTINATION = "instancedestination"; public static final String KEY_INSTANCES = "instances"; public static final String KEY_SUCCESS = "success"; public static final String KEY_ERROR = "error"; public static final String KEY_FORM_CONTENT_URI = "form_content_uri"; public static final String KEY_INSTANCE_CONTENT_URI = "instance_content_uri"; public static final String KEY_AES_STORAGE_KEY = "key_aes_storage"; public static final String KEY_HEADER_STRING = "form_header"; public static final String KEY_INCOMPLETE_ENABLED = "org.odk.collect.form.management"; public static final String KEY_RESIZING_ENABLED = "org.odk.collect.resizing.enabled"; public static final String KEY_HAS_SAVED = "org.odk.collect.form.has.saved"; // Identifies whether this is a new form, or reloading a form after a screen // rotation (or similar) private static final String NEWFORM = "newform"; private static final int MENU_LANGUAGES = Menu.FIRST; private static final int MENU_HIERARCHY_VIEW = Menu.FIRST + 1; private static final int MENU_SAVE = Menu.FIRST + 2; private static final int MENU_PREFERENCES = Menu.FIRST + 3; private static final int PROGRESS_DIALOG = 1; private static final int SAVING_DIALOG = 2; // Random ID private static final int DELETE_REPEAT = 654321; private String mFormPath; public static String mInstancePath; private String mInstanceDestination; private GestureDetector mGestureDetector; private SecretKeySpec symetricKey = null; public static FormController mFormController; private Animation mInAnimation; private Animation mOutAnimation; private ViewGroup mViewPane; private View mCurrentView; private AlertDialog mRepeatDialog; private AlertDialog mAlertDialog; private ProgressDialog mProgressDialog; private String mErrorMessage; private boolean mIncompleteEnabled = true; // used to limit forward/backward swipes to one per question private boolean mBeenSwiped; private FormLoaderTask mFormLoaderTask; private SaveToDiskTask mSaveToDiskTask; private Uri formProviderContentURI = FormsColumns.CONTENT_URI; private Uri instanceProviderContentURI = InstanceColumns.CONTENT_URI; private static String mHeaderString; public boolean hasSaved = false; private BroadcastReceiver mNoGPSReceiver; enum AnimationType { LEFT, RIGHT, FADE } /* * (non-Javadoc) * @see android.support.v4.app.FragmentActivity#onCreate(android.os.Bundle) */ @Override @SuppressLint("NewApi") public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // See if this form needs GPS to be turned on mNoGPSReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { context.removeStickyBroadcast(intent); LocationManager manager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE); Set<String> providers = GeoUtils.evaluateProviders(manager); if (providers.isEmpty()) { DialogInterface.OnClickListener onChangeListener = new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int i) { if (i == DialogInterface.BUTTON_POSITIVE) { Intent intent = new Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS); startActivity(intent); } } }; GeoUtils.showNoGpsDialog(FormEntryActivity.this, onChangeListener); } } }; registerReceiver(mNoGPSReceiver, new IntentFilter(GeoUtils.ACTION_CHECK_GPS_ENABLED)); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { String fragmentClass = this.getIntent().getStringExtra("odk_title_fragment"); if(fragmentClass != null) { FragmentManager fm = this.getSupportFragmentManager(); //Add breadcrumb bar Fragment bar = (Fragment) fm.findFragmentByTag(TITLE_FRAGMENT_TAG); // If the state holder is null, create a new one for this activity if (bar == null) { try { bar = ((Class<Fragment>)Class.forName(fragmentClass)).newInstance(); getActionBar().setDisplayShowCustomEnabled(true); getActionBar().setDisplayShowTitleEnabled(false); fm.beginTransaction().add(bar, TITLE_FRAGMENT_TAG).commit(); } catch(Exception e) { Log.w("odk-collect", "couldn't instantiate fragment: " + fragmentClass); } } else { getActionBar().setDisplayShowCustomEnabled(true); getActionBar().setDisplayShowTitleEnabled(false); } } } // must be at the beginning of any activity that can be called from an external intent try { Collect.createODKDirs(); } catch (RuntimeException e) { createErrorDialog(e.getMessage(), EXIT); return; } setContentView(R.layout.screen_form_entry); setNavBarVisibility(); ImageButton nextButton = (ImageButton)this.findViewById(R.id.nav_btn_next); ImageButton prevButton = (ImageButton)this.findViewById(R.id.nav_btn_prev); nextButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { if(!"done".equals(v.getTag())) { FormEntryActivity.this.showNextView(); } else { FormEntryActivity.this.triggerUserFormComplete(); } } }); prevButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { if(!"quit".equals(v.getTag())) { FormEntryActivity.this.showPreviousView(); } else { FormEntryActivity.this.triggerUserQuitInput(); } } }); mViewPane = (ViewGroup)findViewById(R.id.form_entry_pane); mBeenSwiped = false; mAlertDialog = null; mCurrentView = null; mInAnimation = null; mOutAnimation = null; mGestureDetector = new GestureDetector(this); // Load JavaRosa modules. needed to restore forms. new XFormsModule().registerModule(); // needed to override rms property manager org.javarosa.core.services.PropertyManager.setPropertyManager(new PropertyManager( getApplicationContext())); Boolean newForm = true; if (savedInstanceState != null) { if (savedInstanceState.containsKey(KEY_FORMPATH)) { mFormPath = savedInstanceState.getString(KEY_FORMPATH); } if (savedInstanceState.containsKey(NEWFORM)) { newForm = savedInstanceState.getBoolean(NEWFORM, true); } if (savedInstanceState.containsKey(KEY_ERROR)) { mErrorMessage = savedInstanceState.getString(KEY_ERROR); } if (savedInstanceState.containsKey(KEY_FORM_CONTENT_URI)) { formProviderContentURI = Uri.parse(savedInstanceState.getString(KEY_FORM_CONTENT_URI)); } if (savedInstanceState.containsKey(KEY_INSTANCE_CONTENT_URI)) { instanceProviderContentURI = Uri.parse(savedInstanceState.getString(KEY_INSTANCE_CONTENT_URI)); } if (savedInstanceState.containsKey(KEY_INSTANCEDESTINATION)) { mInstanceDestination = savedInstanceState.getString(KEY_INSTANCEDESTINATION); } if(savedInstanceState.containsKey(KEY_INCOMPLETE_ENABLED)) { mIncompleteEnabled = savedInstanceState.getBoolean(KEY_INCOMPLETE_ENABLED); } if(savedInstanceState.containsKey(KEY_RESIZING_ENABLED)) { ResizingImageView.resizeMethod = savedInstanceState.getString(KEY_RESIZING_ENABLED); } if (savedInstanceState.containsKey(KEY_AES_STORAGE_KEY)) { String base64Key = savedInstanceState.getString(KEY_AES_STORAGE_KEY); try { byte[] storageKey = new Base64Wrapper().decode(base64Key); symetricKey = new SecretKeySpec(storageKey, "AES"); } catch (ClassNotFoundException e) { throw new RuntimeException("Base64 encoding not available on this platform"); } } if(savedInstanceState.containsKey(KEY_HEADER_STRING)) { mHeaderString = savedInstanceState.getString(KEY_HEADER_STRING); } if(savedInstanceState.containsKey(KEY_HAS_SAVED)) { hasSaved = savedInstanceState.getBoolean(KEY_HAS_SAVED); } } // If a parse error message is showing then nothing else is loaded // Dialogs mid form just disappear on rotation. if (mErrorMessage != null) { createErrorDialog(mErrorMessage, EXIT); return; } // Check to see if this is a screen flip or a new form load. Object data = this.getLastCustomNonConfigurationInstance(); if (data instanceof FormLoaderTask) { mFormLoaderTask = (FormLoaderTask) data; } else if (data instanceof SaveToDiskTask) { mSaveToDiskTask = (SaveToDiskTask) data; } else if (data == null) { if (!newForm) { refreshCurrentView(); return; } boolean readOnly = false; // Not a restart from a screen orientation change (or other). mFormController = null; mInstancePath = null; Intent intent = getIntent(); if (intent != null) { Uri uri = intent.getData(); if(intent.hasExtra(KEY_FORM_CONTENT_URI)) { this.formProviderContentURI = Uri.parse(intent.getStringExtra(KEY_FORM_CONTENT_URI)); } if(intent.hasExtra(KEY_INSTANCE_CONTENT_URI)) { this.instanceProviderContentURI = Uri.parse(intent.getStringExtra(KEY_INSTANCE_CONTENT_URI)); } if(intent.hasExtra(KEY_INSTANCEDESTINATION)) { this.mInstanceDestination = intent.getStringExtra(KEY_INSTANCEDESTINATION); } else { mInstanceDestination = Collect.INSTANCES_PATH; } if(intent.hasExtra(KEY_AES_STORAGE_KEY)) { String base64Key = intent.getStringExtra(KEY_AES_STORAGE_KEY); try { byte[] storageKey = new Base64Wrapper().decode(base64Key); symetricKey = new SecretKeySpec(storageKey, "AES"); } catch (ClassNotFoundException e) { throw new RuntimeException("Base64 encoding not available on this platform"); } } if(intent.hasExtra(KEY_HEADER_STRING)) { this.mHeaderString = intent.getStringExtra(KEY_HEADER_STRING); } if(intent.hasExtra(KEY_INCOMPLETE_ENABLED)) { this.mIncompleteEnabled = intent.getBooleanExtra(KEY_INCOMPLETE_ENABLED, true); } if(intent.hasExtra(KEY_RESIZING_ENABLED)) { ResizingImageView.resizeMethod = intent.getStringExtra(KEY_RESIZING_ENABLED); } if(mHeaderString != null) { setTitle(mHeaderString); } else { setTitle(StringUtils.getStringRobust(this, R.string.app_name) + " > " + StringUtils.getStringRobust(this, R.string.loading_form)); } //csims@dimagi.com - Jan 24, 2012 //Since these are parceled across the content resolver, there's no guarantee of reference equality. //We need to manually check value equality on the type String contentType = getContentResolver().getType(uri); Uri formUri = null;; if (contentType.equals(InstanceColumns.CONTENT_ITEM_TYPE)) { Cursor instanceCursor = this.managedQuery(uri, null, null, null, null); if (instanceCursor.getCount() != 1) { this.createErrorDialog("Bad URI: " + uri, EXIT); return; } else { instanceCursor.moveToFirst(); mInstancePath = instanceCursor.getString(instanceCursor .getColumnIndex(InstanceColumns.INSTANCE_FILE_PATH)); String jrFormId = instanceCursor.getString(instanceCursor .getColumnIndex(InstanceColumns.JR_FORM_ID)); //If this form is both already completed if(InstanceProviderAPI.STATUS_COMPLETE.equals(instanceCursor.getString(instanceCursor.getColumnIndex(InstanceColumns.STATUS)))) { if(!Boolean.parseBoolean(instanceCursor.getString(instanceCursor.getColumnIndex(InstanceColumns.CAN_EDIT_WHEN_COMPLETE)))) { readOnly = true; } } String[] selectionArgs = { jrFormId }; String selection = FormsColumns.JR_FORM_ID + " like ?"; Cursor formCursor = managedQuery(formProviderContentURI, null, selection, selectionArgs,null); if (formCursor.getCount() == 1) { formCursor.moveToFirst(); mFormPath = formCursor.getString(formCursor .getColumnIndex(FormsColumns.FORM_FILE_PATH)); formUri = ContentUris.withAppendedId(formProviderContentURI, formCursor.getLong(formCursor.getColumnIndex(FormsColumns._ID))); } else if (formCursor.getCount() < 1) { this.createErrorDialog("Parent form does not exist", EXIT); return; } else if (formCursor.getCount() > 1) { this.createErrorDialog("More than one possible parent form", EXIT); return; } } } else if (contentType.equals(FormsColumns.CONTENT_ITEM_TYPE)) { Cursor c = this.managedQuery(uri, null, null, null, null); if (c.getCount() != 1) { this.createErrorDialog("Bad URI: " + uri, EXIT); return; } else { c.moveToFirst(); mFormPath = c.getString(c.getColumnIndex(FormsColumns.FORM_FILE_PATH)); formUri = uri; } } else { Log.e(t, "unrecognized URI"); this.createErrorDialog("unrecognized URI: " + uri, EXIT); return; } if(formUri == null) { Log.e(t, "unrecognized URI"); this.createErrorDialog("couldn't locate FormDB entry for the item at: " + uri, EXIT); return; } mFormLoaderTask = new FormLoaderTask(this, symetricKey, readOnly); mFormLoaderTask.execute(formUri); showDialog(PROGRESS_DIALOG); } } } public static final String TITLE_FRAGMENT_TAG = "odk_title_fragment"; /* * (non-Javadoc) * @see android.support.v4.app.FragmentActivity#onSaveInstanceState(android.os.Bundle) */ @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putString(KEY_FORMPATH, mFormPath); outState.putBoolean(NEWFORM, false); outState.putString(KEY_ERROR, mErrorMessage); outState.putString(KEY_FORM_CONTENT_URI, formProviderContentURI.toString()); outState.putString(KEY_INSTANCE_CONTENT_URI, instanceProviderContentURI.toString()); outState.putString(KEY_INSTANCEDESTINATION, mInstanceDestination); outState.putBoolean(KEY_INCOMPLETE_ENABLED, mIncompleteEnabled); outState.putBoolean(KEY_HAS_SAVED, hasSaved); outState.putString(KEY_RESIZING_ENABLED, ResizingImageView.resizeMethod); if(symetricKey != null) { try { outState.putString(KEY_AES_STORAGE_KEY, new Base64Wrapper().encodeToString(symetricKey.getEncoded())); } catch (ClassNotFoundException e) { // we can't really get here anyway, since we couldn't have decoded the string to begin with throw new RuntimeException("Base 64 encoding unavailable! Can't pass storage key"); } } } /* * (non-Javadoc) * @see android.support.v4.app.FragmentActivity#onActivityResult(int, int, android.content.Intent) */ @Override protected void onActivityResult(int requestCode, int resultCode, Intent intent) { super.onActivityResult(requestCode, resultCode, intent); if (resultCode == RESULT_CANCELED) { if(requestCode == HIERARCHY_ACTIVITY_FIRST_START) { //they pressed 'back' on the first heirarchy screen. we should assume they want to //back out of form entry all together finishReturnInstance(false); } // request was canceled, so do nothing return; } ContentValues values; Uri imageURI; switch (requestCode) { case BARCODE_CAPTURE: String sb = intent.getStringExtra("SCAN_RESULT"); ((ODKView) mCurrentView).setBinaryData(sb); saveAnswersForCurrentScreen(DO_NOT_EVALUATE_CONSTRAINTS); break; case INTENT_CALLOUT: processIntentResponse(intent); break; case IMAGE_CAPTURE: case SIGNATURE_CAPTURE: /* * We saved the image to the tempfile_path, but we really want it to be in: * /sdcard/odk/instances/[current instnace]/something.jpg so we move it there before * inserting it into the content provider. Once the android image capture bug gets * fixed, (read, we move on from Android 1.6) we want to handle images the audio and * video */ // The intent is empty, but we know we saved the image to the temp file File fi = new File(Collect.TMPFILE_PATH); String mInstanceFolder = mInstancePath.substring(0, mInstancePath.lastIndexOf("/") + 1); String s = mInstanceFolder + "/" + System.currentTimeMillis() + ".jpg"; File nf = new File(s); if (!fi.renameTo(nf)) { Log.e(t, "Failed to rename " + fi.getAbsolutePath()); } else { Log.i(t, "renamed " + fi.getAbsolutePath() + " to " + nf.getAbsolutePath()); } // Add the new image to the Media content provider so that the // viewing is fast in Android 2.0+ values = new ContentValues(6); values.put(Images.Media.TITLE, nf.getName()); values.put(Images.Media.DISPLAY_NAME, nf.getName()); values.put(Images.Media.DATE_TAKEN, System.currentTimeMillis()); values.put(Images.Media.MIME_TYPE, "image/jpeg"); values.put(Images.Media.DATA, nf.getAbsolutePath()); imageURI = getContentResolver().insert(Images.Media.EXTERNAL_CONTENT_URI, values); Log.i(t, "Inserting image returned uri = " + imageURI.toString()); ((ODKView) mCurrentView).setBinaryData(imageURI); saveAnswersForCurrentScreen(DO_NOT_EVALUATE_CONSTRAINTS); refreshCurrentView(); break; case IMAGE_CHOOSER: /* * We have a saved image somewhere, but we really want it to be in: * /sdcard/odk/instances/[current instnace]/something.jpg so we move it there before * inserting it into the content provider. Once the android image capture bug gets * fixed, (read, we move on from Android 1.6) we want to handle images the audio and * video */ // get gp of chosen file String sourceImagePath = null; Uri selectedImage = intent.getData(); sourceImagePath = FileUtils.getPath(this, selectedImage); // Copy file to sdcard String mInstanceFolder1 = mInstancePath.substring(0, mInstancePath.lastIndexOf("/") + 1); String destImagePath = mInstanceFolder1 + "/" + System.currentTimeMillis() + ".jpg"; File source = new File(sourceImagePath); File newImage = new File(destImagePath); FileUtils.copyFile(source, newImage); if (newImage.exists()) { // Add the new image to the Media content provider so that the // viewing is fast in Android 2.0+ values = new ContentValues(6); values.put(Images.Media.TITLE, newImage.getName()); values.put(Images.Media.DISPLAY_NAME, newImage.getName()); values.put(Images.Media.DATE_TAKEN, System.currentTimeMillis()); values.put(Images.Media.MIME_TYPE, "image/jpeg"); values.put(Images.Media.DATA, newImage.getAbsolutePath()); imageURI = getContentResolver().insert(Images.Media.EXTERNAL_CONTENT_URI, values); Log.i(t, "Inserting image returned uri = " + imageURI.toString()); ((ODKView) mCurrentView).setBinaryData(imageURI); saveAnswersForCurrentScreen(DO_NOT_EVALUATE_CONSTRAINTS); } else { Log.e(t, "NO IMAGE EXISTS at: " + source.getAbsolutePath()); } refreshCurrentView(); break; case AUDIO_CAPTURE: case VIDEO_CAPTURE: case AUDIO_CHOOSER: case VIDEO_CHOOSER: // For audio/video capture/chooser, we get the URI from the content provider // then the widget copies the file and makes a new entry in the content provider. Uri media = intent.getData(); ((ODKView) mCurrentView).setBinaryData(media); saveAnswersForCurrentScreen(DO_NOT_EVALUATE_CONSTRAINTS); refreshCurrentView(); break; case LOCATION_CAPTURE: String sl = intent.getStringExtra(LOCATION_RESULT); ((ODKView) mCurrentView).setBinaryData(sl); saveAnswersForCurrentScreen(DO_NOT_EVALUATE_CONSTRAINTS); break; case HIERARCHY_ACTIVITY: // We may have jumped to a new index in hierarchy activity, so refresh refreshCurrentView(false); break; } } private void processIntentResponse(Intent response) { //We need to go grab our intent callout object to process the results here IntentWidget bestMatch = null; //Ugh, copied from the odkview mostly, that's stupid for(QuestionWidget q : ((ODKView)mCurrentView).getWidgets()) { //Figure out if we have a pending intent widget if (q instanceof IntentWidget) { if(((IntentWidget) q).isWaitingForBinaryData() || bestMatch == null) { bestMatch = (IntentWidget)q; } } } if(bestMatch != null) { //Set our instance destination for binary data if needed String destination = mInstancePath.substring(0, mInstancePath.lastIndexOf("/") + 1); //get the original intent callout IntentCallout ic = bestMatch.getIntentCallout(); //And process it ic.processResponse(response, (ODKView)mCurrentView, mFormController.getInstance(), new File(destination)); } saveAnswersForCurrentScreen(DO_NOT_EVALUATE_CONSTRAINTS); } public void updateFormRelevencies(){ saveAnswersForCurrentScreen(DO_NOT_EVALUATE_CONSTRAINTS); if(!(mCurrentView instanceof ODKView)){ throw new RuntimeException("Tried to update form relevency not on compound view"); } ODKView oldODKV = (ODKView)mCurrentView; FormEntryPrompt[] newValidPrompts = mFormController.getQuestionPrompts(); Set<FormEntryPrompt> used = new HashSet<FormEntryPrompt>(); ArrayList<QuestionWidget> oldWidgets = oldODKV.getWidgets(); ArrayList<Integer> removeList = new ArrayList<Integer>(); for(int i=0;i<oldWidgets.size();i++){ QuestionWidget oldWidget = oldWidgets.get(i); boolean stillRelevent = false; for(FormEntryPrompt prompt : newValidPrompts) { if(prompt.getIndex().equals(oldWidget.getPrompt().getIndex())) { stillRelevent = true; used.add(prompt); } } if(!stillRelevent){ removeList.add(Integer.valueOf(i)); } } // remove "atomically" to not mess up iterations oldODKV.removeQuestionsFromIndex(removeList); //Now go through add add any new prompts that we need for(int i = 0 ; i < newValidPrompts.length; ++i) { FormEntryPrompt prompt = newValidPrompts[i]; if(used.contains(prompt)) { //nothing to do here continue; } oldODKV.addQuestionToIndex(prompt, mFormController.getWidgetFactory(), i); } } private class NavigationDetails { public int totalQuestions = 0; public int completedQuestions = 0; public boolean relevantBeforeCurrentScreen = false; public boolean isFirstScreen = false; public int answeredOnScreen = 0; public int requiredOnScreen = 0; public int relevantAfterCurrentScreen = 0; public FormIndex currentScreenExit = null; } private NavigationDetails calculateNavigationStatus() { NavigationDetails details = new NavigationDetails(); FormIndex currentFormIndex = mFormController.getFormIndex(); int event = mFormController.jumpToIndex(FormIndex.createBeginningOfFormIndex()); try { // keep track of whether there is a question that exists before the // current screen boolean onCurrentScreen = false; // TODO: We can probably evaluate this with a FormIndex walk that // _doesn't_ // affect this form's index. while (event != FormEntryController.EVENT_END_OF_FORM) { int comparison = mFormController.getFormIndex().compareTo(currentFormIndex); if (comparison == 0) { onCurrentScreen = true; mFormController.stepToNextEvent(true); details.currentScreenExit = mFormController.getFormIndex(); mFormController.stepToPreviousEvent(); } if (onCurrentScreen && mFormController.getFormIndex().equals(details.currentScreenExit)) { onCurrentScreen = false; } // Figure out if there are any events before this screen (either // new repeat or relevant questions are valid) if (event == FormEntryController.EVENT_QUESTION || event == FormEntryController.EVENT_PROMPT_NEW_REPEAT) { // Figure out whether we're on the last screen if (!details.relevantBeforeCurrentScreen && !details.isFirstScreen) { // We got to the current screen without finding a // relevant question, // I guess we're on the first one. if (onCurrentScreen && !details.relevantBeforeCurrentScreen) { details.isFirstScreen = true; } else { // We're using the built in steps (and they take // relevancy into account) // so if there are prompts they have to be relevant details.relevantBeforeCurrentScreen = true; } } } if (event == FormEntryController.EVENT_QUESTION) { FormEntryPrompt[] prompts = mFormController.getQuestionPrompts(); if (!onCurrentScreen && details.currentScreenExit != null) { details.relevantAfterCurrentScreen += prompts.length; } details.totalQuestions += prompts.length; // Current questions are complete only if they're answered. // Past questions are always complete. // Future questions are never complete. if (onCurrentScreen) { for (FormEntryPrompt prompt : prompts) { if (this.mCurrentView instanceof ODKView) { ODKView odkv = (ODKView) this.mCurrentView; prompt = getOnScreenPrompt(prompt, odkv); } boolean isAnswered = prompt.getAnswerValue() != null || prompt.getDataType() == Constants.DATATYPE_NULL; if (prompt.isRequired()) { details.requiredOnScreen++; if (isAnswered) { details.answeredOnScreen++; } } if (isAnswered) { details.completedQuestions++; } } } else if (comparison < 0) { // For previous questions, consider all "complete" details.completedQuestions += prompts.length; // TODO: This doesn't properly capture state to // determine whether we will end up out of the form if // we hit back! // Need to test _until_ we get a question that is // relevant, then we can skip the relevancy tests } } else if (event == FormEntryController.EVENT_PROMPT_NEW_REPEAT) { // If we've already passed the current screen, this repeat // junction is coming up in the future and we will need to // know // about it if (!onCurrentScreen && details.currentScreenExit != null) { details.totalQuestions++; details.relevantAfterCurrentScreen++; } else { // Otherwise we already passed it and it no longer // affects the count } } event = mFormController.stepToNextEvent(FormController.STEP_INTO_GROUP, false); } } catch (XPathTypeMismatchException e) { FormEntryActivity.this.createErrorDialog(e.getMessage(), EXIT); } // Set form back to correct state mFormController.jumpToIndex(currentFormIndex); return details; } /** * Update progress bar's max and value, and the various buttons and navigation cues * associated with navigation * * @param odkv ODKView to update */ public void updateNavigationCues(View view) { updateFloatingLabels(view); ProgressBarMode mode = PreferencesActivity.getProgressBarMode(this); setNavBarVisibility(); if(mode == ProgressBarMode.None) { return; } NavigationDetails details = calculateNavigationStatus(); if(mode == ProgressBarMode.ProgressOnly && view instanceof ODKView) { ((ODKView)view).updateProgressBar(details.completedQuestions, details.totalQuestions); return; } ProgressBar progressBar = (ProgressBar)this.findViewById(R.id.nav_prog_bar); ImageButton nextButton = (ImageButton)this.findViewById(R.id.nav_btn_next); ImageButton prevButton = (ImageButton)this.findViewById(R.id.nav_btn_prev); if(!details.relevantBeforeCurrentScreen) { prevButton.setImageResource(R.drawable.icon_exit); prevButton.setTag("quit"); } else { prevButton.setImageResource(R.drawable.icon_back); prevButton.setTag("back"); } //Apparently in Android 2.3 setting the drawable resource for the progress bar //causes it to lose it bounds. It's a bit cheaper to keep them around than it //is to invalidate the view, though. Rect bounds = progressBar.getProgressDrawable().getBounds(); //Save the drawable bound if(details.relevantAfterCurrentScreen == 0 && (details.requiredOnScreen == details.answeredOnScreen || details.requiredOnScreen < 1)) { nextButton.setImageResource(R.drawable.icon_done); //TODO: _really_? This doesn't seem right nextButton.setTag("done"); progressBar.setProgressDrawable(this.getResources().getDrawable(R.drawable.progressbar_full)); } else { nextButton.setImageResource(R.drawable.icon_next); //TODO: _really_? This doesn't seem right nextButton.setTag("next"); progressBar.setProgressDrawable(this.getResources().getDrawable(R.drawable.progressbar)); } progressBar.getProgressDrawable().setBounds(bounds); //Set the bounds to the saved value progressBar.setMax(details.totalQuestions); progressBar.setProgress(details.completedQuestions); //We should probably be doing this based on the widgets, maybe, not the model? Hard to call. updateBadgeInfo(details.requiredOnScreen, details.answeredOnScreen); } private void setNavBarVisibility() { //Make sure the nav bar visibility is set int navBarVisibility = PreferencesActivity.getProgressBarMode(this).useNavigationBar() ? View.VISIBLE : View.GONE; View nav = this.findViewById(R.id.nav_pane); if(nav.getVisibility() != navBarVisibility) { nav.setVisibility(navBarVisibility); this.findViewById(R.id.nav_badge_border_drawer).setVisibility(navBarVisibility); this.findViewById(R.id.nav_badge).setVisibility(navBarVisibility); } } enum FloatingLabel { good ("floating-good", R.drawable.label_floating_good), caution ("floating-caution", R.drawable.label_floating_caution), bad ("floating-bad", R.drawable.label_floating_bad); String label; int resourceId; FloatingLabel(String label, int resourceId) { this.label = label; this.resourceId = resourceId; } public String getAppearance() { return label;} public int getBackgroundDrawable() { return resourceId; } }; private void updateFloatingLabels(View currentView) { //TODO: this should actually be set up to scale per screen size. ArrayList<Pair<String, FloatingLabel>> smallLabels = new ArrayList<Pair<String, FloatingLabel>>(); ArrayList<Pair<String, FloatingLabel>> largeLabels = new ArrayList<Pair<String, FloatingLabel>>(); FloatingLabel[] labelTypes = FloatingLabel.values(); if(currentView instanceof ODKView) { for(QuestionWidget widget : ((ODKView)currentView).getWidgets()) { String hint = widget.getPrompt().getAppearanceHint(); if(hint == null) { continue; } for(FloatingLabel type : labelTypes) { if(type.getAppearance().equals(hint)) { String widgetText = widget.getPrompt().getQuestionText(); if(widgetText != null && widgetText.length() < 15) { smallLabels.add(new Pair(widgetText, type)); } else { largeLabels.add(new Pair(widgetText, type)); } } } } } ViewGroup parent = (ViewGroup)this.findViewById(R.id.form_entry_label_layout); parent.removeAllViews(); //Ok, now go ahead and add all of the small labels for(int i = 0 ; i < smallLabels.size(); i = i + 2 ) { if(i + 1 < smallLabels.size()) { LinearLayout.LayoutParams lpp = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); lpp.setMargins(0, 1, 0, 0); LinearLayout layout = new LinearLayout(this); layout.setOrientation(LinearLayout.HORIZONTAL); layout.setLayoutParams(lpp); layout.setWeightSum(2); LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1); TextView left = (TextView)View.inflate(this, R.layout.component_floating_label, null); left.setLayoutParams(lp); left.setText(smallLabels.get(i).first); left.setBackgroundResource(smallLabels.get(i).second.resourceId); layout.addView(left); lp.setMargins(1, 0,0,0); TextView right = (TextView)View.inflate(this, R.layout.component_floating_label, null); right.setLayoutParams(lp); right.setText(smallLabels.get(i+1).first); right.setBackgroundResource(smallLabels.get(i+1).second.resourceId); layout.addView(right); parent.addView(layout); } else { largeLabels.add(smallLabels.get(i)); } } for(int i = 0 ; i < largeLabels.size(); ++i ) { TextView view = (TextView)View.inflate(this, R.layout.component_floating_label, null); LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); lp.setMargins(0, 1, 0, 0); view.setLayoutParams(lp); view.setText(largeLabels.get(i).first); view.setBackgroundResource(largeLabels.get(i).second.resourceId); parent.addView(view); } } private void updateBadgeInfo(int requiredOnScreen, int answeredOnScreen) { View badgeBorder = this.findViewById(R.id.nav_badge_border_drawer); TextView badge = (TextView)this.findViewById(R.id.nav_badge); //If we don't need this stuff, just bail if(requiredOnScreen <= 1) { //Hide all badge related items badgeBorder.setVisibility(View.INVISIBLE); badge.setVisibility(View.INVISIBLE); return; } //Otherwise, update badge stuff badgeBorder.setVisibility(View.VISIBLE); badge.setVisibility(View.VISIBLE); if(requiredOnScreen - answeredOnScreen == 0) { //Unicode checkmark badge.setText("\u2713"); badge.setBackgroundResource(R.drawable.badge_background_complete); } else { badge.setBackgroundResource(R.drawable.badge_background); badge.setText(String.valueOf(requiredOnScreen - answeredOnScreen)); } } /** * Takes in a form entry prompt that is obtained generically and if there * is already one on screen (which, for isntance, may have cached some of its data) * returns the object in use currently. * * @param prompt * @return */ private FormEntryPrompt getOnScreenPrompt(FormEntryPrompt prompt, ODKView view) { FormIndex index = prompt.getIndex(); for(QuestionWidget widget : view.getWidgets()) { if(widget.getFormId().equals(index)) { return widget.getPrompt(); } } return prompt; } /** * Refreshes the current view. the controller and the displayed view can get out of sync due to * dialogs and restarts caused by screen orientation changes, so they're resynchronized here. */ public void refreshCurrentView() { refreshCurrentView(true); } /** * Refreshes the current view. the controller and the displayed view can get out of sync due to * dialogs and restarts caused by screen orientation changes, so they're resynchronized here. */ public void refreshCurrentView(boolean animateLastView) { if(mFormController == null) { throw new RuntimeException("Form state is lost! Cannot refresh current view. This shouldn't happen, please submit a bug report."); } int event = mFormController.getEvent(); // When we refresh, repeat dialog state isn't maintained, so step back to the previous // question. // Also, if we're within a group labeled 'field list', step back to the beginning of that // group. // That is, skip backwards over repeat prompts, groups that are not field-lists, // repeat events, and indexes in field-lists that is not the containing group. while (event == FormEntryController.EVENT_PROMPT_NEW_REPEAT || (event == FormEntryController.EVENT_GROUP && !mFormController.indexIsInFieldList()) || event == FormEntryController.EVENT_REPEAT || (mFormController.indexIsInFieldList() && !(event == FormEntryController.EVENT_GROUP))) { event = mFormController.stepToPreviousEvent(); } //If we're at the beginning of form event, but don't show the screen for that, we need //to get the next valid screen if(event == FormEntryController.EVENT_BEGINNING_OF_FORM && !PreferencesActivity.showFirstScreen(this)) { this.showNextView(true); } else { View current = createView(event); showView(current, AnimationType.FADE, animateLastView); } } /* * (non-Javadoc) * @see android.app.Activity#onPrepareOptionsMenu(android.view.Menu) */ @Override public boolean onPrepareOptionsMenu(Menu menu) { menu.removeItem(MENU_LANGUAGES); menu.removeItem(MENU_HIERARCHY_VIEW); menu.removeItem(MENU_SAVE); menu.removeItem(MENU_PREFERENCES); if(mIncompleteEnabled) { menu.add(0, MENU_SAVE, 0, StringUtils.getStringRobust(this, R.string.save_all_answers)).setIcon( android.R.drawable.ic_menu_save); } menu.add(0, MENU_HIERARCHY_VIEW, 0, StringUtils.getStringRobust(this, R.string.view_hierarchy)).setIcon( R.drawable.ic_menu_goto); menu.add(0, MENU_LANGUAGES, 0, StringUtils.getStringRobust(this, R.string.change_language)) .setIcon(R.drawable.ic_menu_start_conversation) .setEnabled( (mFormController == null || mFormController.getLanguages() == null || mFormController.getLanguages().length == 1) ? false : true); menu.add(0, MENU_PREFERENCES, 0, StringUtils.getStringRobust(this, R.string.general_preferences)).setIcon( android.R.drawable.ic_menu_preferences); return true; } /* * (non-Javadoc) * @see android.app.Activity#onOptionsItemSelected(android.view.MenuItem) */ @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case MENU_LANGUAGES: createLanguageDialog(); return true; case MENU_SAVE: // don't exit saveDataToDisk(DO_NOT_EXIT, isInstanceComplete(false), null); return true; case MENU_HIERARCHY_VIEW: if (currentPromptIsQuestion()) { saveAnswersForCurrentScreen(DO_NOT_EVALUATE_CONSTRAINTS); } Intent i = new Intent(this, FormHierarchyActivity.class); startActivityForResult(i, HIERARCHY_ACTIVITY); return true; case MENU_PREFERENCES: Intent pref = new Intent(this, PreferencesActivity.class); startActivity(pref); return true; } return super.onOptionsItemSelected(item); } /** * @return true If the current index of the form controller contains questions */ private boolean currentPromptIsQuestion() { return (mFormController.getEvent() == FormEntryController.EVENT_QUESTION || mFormController .getEvent() == FormEntryController.EVENT_GROUP); } private boolean saveAnswersForCurrentScreen(boolean evaluateConstraints) { return saveAnswersForCurrentScreen(evaluateConstraints, true); } /** * Attempt to save the answer(s) in the current screen to into the data model. * * @param evaluateConstraints * @param failOnRequired Whether or not the constraint evaluation should return false if the question * is only required. (this is helpful for incomplete saves) * @return false if any error occurs while saving (constraint violated, etc...), true otherwise. */ private boolean saveAnswersForCurrentScreen(boolean evaluateConstraints, boolean failOnRequired) { // only try to save if the current event is a question or a field-list group boolean success = true; if (mFormController.getEvent() == FormEntryController.EVENT_QUESTION || (mFormController.getEvent() == FormEntryController.EVENT_GROUP && mFormController .indexIsInFieldList())) { if(mCurrentView instanceof ODKView) { HashMap<FormIndex, IAnswerData> answers = ((ODKView) mCurrentView).getAnswers(); // Sort the answers so if there are multiple errors, we can bring focus to the first one List<FormIndex> indexKeys = new ArrayList<FormIndex>(); indexKeys.addAll(answers.keySet()); Collections.sort(indexKeys, new Comparator<FormIndex>() { @Override public int compare(FormIndex arg0, FormIndex arg1) { return arg0.compareTo(arg1); } }); for (FormIndex index : indexKeys) { // Within a group, you can only save for question events if (mFormController.getEvent(index) == FormEntryController.EVENT_QUESTION) { int saveStatus = saveAnswer(answers.get(index), index, evaluateConstraints); if (evaluateConstraints && (saveStatus != FormEntryController.ANSWER_OK && (failOnRequired || saveStatus != FormEntryController.ANSWER_REQUIRED_BUT_EMPTY))) { createConstraintToast(index, mFormController.getQuestionPrompt(index) .getConstraintText(), saveStatus, success); success = false; } } else { Log.w(t, "Attempted to save an index referencing something other than a question: " + index.getReference()); } } } else { Log.w(t, "Unknown view type rendered while current event was question or group! View type: " + mCurrentView == null ? "null" : mCurrentView.getClass().toString()); } } return success; } /** * Clears the answer on the screen. */ private void clearAnswer(QuestionWidget qw) { qw.clearAnswer(); } /* * (non-Javadoc) * @see android.app.Activity#onCreateContextMenu(android.view.ContextMenu, android.view.View, android.view.ContextMenu.ContextMenuInfo) */ @Override public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { super.onCreateContextMenu(menu, v, menuInfo); menu.add(0, v.getId(), 0, StringUtils.getStringRobust(this, R.string.clear_answer)); if (mFormController.indexContainsRepeatableGroup()) { menu.add(0, DELETE_REPEAT, 0, StringUtils.getStringRobust(this, R.string.delete_repeat)); } menu.setHeaderTitle(StringUtils.getStringRobust(this, R.string.edit_prompt)); } /* * (non-Javadoc) * @see android.app.Activity#onContextItemSelected(android.view.MenuItem) */ @Override public boolean onContextItemSelected(MenuItem item) { /* * We don't have the right view here, so we store the View's ID as the item ID and loop * through the possible views to find the one the user clicked on. */ for (QuestionWidget qw : ((ODKView) mCurrentView).getWidgets()) { if (item.getItemId() == qw.getId()) { createClearDialog(qw); } } if (item.getItemId() == DELETE_REPEAT) { createDeleteRepeatConfirmDialog(); } return super.onContextItemSelected(item); } /* * (non-Javadoc) * @see android.support.v4.app.FragmentActivity#onRetainCustomNonConfigurationInstance() * * If we're loading, then we pass the loading thread to our next instance. */ @Override public Object onRetainCustomNonConfigurationInstance() { // if a form is loading, pass the loader task if (mFormLoaderTask != null && mFormLoaderTask.getStatus() != AsyncTask.Status.FINISHED) return mFormLoaderTask; // if a form is writing to disk, pass the save to disk task if (mSaveToDiskTask != null && mSaveToDiskTask.getStatus() != AsyncTask.Status.FINISHED) return mSaveToDiskTask; // mFormEntryController is static so we don't need to pass it. if (mFormController != null && currentPromptIsQuestion()) { saveAnswersForCurrentScreen(DO_NOT_EVALUATE_CONSTRAINTS); } return null; } private String getHeaderString() { if(mHeaderString != null) { //Localization? return mHeaderString; } else { return StringUtils.getStringRobust(this, R.string.app_name) + " > " + mFormController.getFormTitle(); } } @TargetApi(Build.VERSION_CODES.HONEYCOMB) public void setTitle(CharSequence title) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { if(this.getSupportFragmentManager().findFragmentByTag(TITLE_FRAGMENT_TAG) != null) { return; } } super.setTitle(title); } /** * Creates a view given the View type and an event * * @param event * @return newly created View */ private View createView(int event) { boolean isGroup = false; setTitle(getHeaderString()); switch (event) { case FormEntryController.EVENT_BEGINNING_OF_FORM: View startView = View.inflate(this, R.layout.form_entry_start, null); setTitle(getHeaderString()); ((TextView) startView.findViewById(R.id.description)).setText(StringUtils.getStringRobust(this, R.string.enter_data_description, mFormController.getFormTitle())); ((CheckBox) startView.findViewById(R.id.screen_form_entry_start_cbx_dismiss)).setText(StringUtils.getStringRobust(this, R.string.form_entry_start_hide)); Drawable image = null; String[] projection = { FormsColumns.FORM_MEDIA_PATH }; String selection = FormsColumns.FORM_FILE_PATH + "=?"; String[] selectionArgs = { mFormPath }; Cursor c = managedQuery(formProviderContentURI, projection, selection, selectionArgs, null); String mediaDir = null; if (c.getCount() < 1) { createErrorDialog("form Doesn't exist", true); return new View(this); } else { c.moveToFirst(); mediaDir = c.getString(c.getColumnIndex(FormsColumns.FORM_MEDIA_PATH)); } BitmapDrawable bitImage = null; // attempt to load the form-specific logo... // this is arbitrarily silly bitImage = new BitmapDrawable(mediaDir + "/form_logo.png"); if (bitImage != null && bitImage.getBitmap() != null && bitImage.getIntrinsicHeight() > 0 && bitImage.getIntrinsicWidth() > 0) { image = bitImage; } if (image == null) { // show the opendatakit zig... // image = getResources().getDrawable(R.drawable.opendatakit_zig); ((ImageView) startView.findViewById(R.id.form_start_bling)) .setVisibility(View.GONE); } else { ((ImageView) startView.findViewById(R.id.form_start_bling)) .setImageDrawable(image); } return startView; case FormEntryController.EVENT_END_OF_FORM: View endView = View.inflate(this, R.layout.form_entry_end, null); ((TextView) endView.findViewById(R.id.description)).setText(StringUtils.getStringRobust(this, R.string.save_enter_data_description, mFormController.getFormTitle())); // checkbox for if finished or ready to send final CheckBox instanceComplete = ((CheckBox) endView.findViewById(R.id.mark_finished)); instanceComplete.setText(StringUtils.getStringRobust(this, R.string.mark_finished)); //If incomplete is not enabled, make sure this box is checked. instanceComplete.setChecked(!mIncompleteEnabled || isInstanceComplete(true)); if(mFormController.isFormReadOnly() || !mIncompleteEnabled) { instanceComplete.setVisibility(View.GONE); } // edittext to change the displayed name of the instance final EditText saveAs = (EditText) endView.findViewById(R.id.save_name); //TODO: Figure this out based on the content provider or some part of the context saveAs.setVisibility(View.GONE); endView.findViewById(R.id.save_form_as).setVisibility(View.GONE); // disallow carriage returns in the name InputFilter returnFilter = new InputFilter() { public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) { for (int i = start; i < end; i++) { if (Character.getType((source.charAt(i))) == Character.CONTROL) { return ""; } } return null; } }; saveAs.setFilters(new InputFilter[] { returnFilter }); String saveName = getDefaultFormTitle(); saveAs.setText(saveName); // Create 'save' button Button button = (Button) endView.findViewById(R.id.save_exit_button); if(mFormController.isFormReadOnly()) { button.setText(StringUtils.getStringRobust(this, R.string.exit)); button.setOnClickListener(new OnClickListener() { /* * (non-Javadoc) * @see android.view.View.OnClickListener#onClick(android.view.View) */ @Override public void onClick(View v) { finishReturnInstance(); } }); } else { button.setText(StringUtils.getStringRobust(this, R.string.quit_entry)); button.setOnClickListener(new OnClickListener() { /* * (non-Javadoc) * @see android.view.View.OnClickListener#onClick(android.view.View) */ @Override public void onClick(View v) { // Form is marked as 'saved' here. if (saveAs.getText().length() < 1) { Toast.makeText(FormEntryActivity.this, StringUtils.getStringRobust(FormEntryActivity.this, R.string.save_as_error), Toast.LENGTH_SHORT).show(); } else { saveDataToDisk(EXIT, instanceComplete.isChecked(), saveAs .getText().toString()); } } }); } return endView; case FormEntryController.EVENT_GROUP: isGroup = true; case FormEntryController.EVENT_QUESTION: ODKView odkv = null; // should only be a group here if the event_group is a field-list try { odkv = new ODKView(this, mFormController.getQuestionPrompts(), mFormController.getGroupsForCurrentIndex(), mFormController.getWidgetFactory(), this, isGroup); Log.i(t, "created view for group"); } catch (RuntimeException e) { createErrorDialog(e.getMessage(), EXIT); e.printStackTrace(); // this is badness to avoid a crash. // really a next view should increment the formcontroller, create the view // if the view is null, then keep the current view and pop an error. return new View(this); } // Makes a "clear answer" menu pop up on long-click for (QuestionWidget qw : odkv.getWidgets()) { if (!qw.getPrompt().isReadOnly() && !mFormController.isFormReadOnly()) { registerForContextMenu(qw); } } updateNavigationCues(odkv); return odkv; default: Log.e(t, "Attempted to create a view that does not exist."); return null; } } /* * (non-Javadoc) * @see android.app.Activity#dispatchTouchEvent(android.view.MotionEvent) */ @SuppressLint("NewApi") @Override public boolean dispatchTouchEvent(MotionEvent mv) { //We need to ignore this even if it's processed by the action //bar (if one exists) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { View customView = getActionBar().getCustomView(); if(customView != null) { if(customView.dispatchTouchEvent(mv)) { return true; } } } boolean handled = mGestureDetector.onTouchEvent(mv); if (!handled) { return super.dispatchTouchEvent(mv); } return handled; // this is always true } /** * Determines what should be displayed on the screen. Possible options are: a question, an ask * repeat dialog, or the submit screen. Also saves answers to the data model after checking * constraints. */ private void showNextView() { showNextView(false); } private void showNextView(boolean resuming) { if(!resuming && mFormController.getEvent() == FormEntryController.EVENT_BEGINNING_OF_FORM) { //See if we should stop displaying the start screen CheckBox stopShowingIntroScreen = (CheckBox)mCurrentView.findViewById(R.id.screen_form_entry_start_cbx_dismiss); //Not sure why it would, but maybe timing issues? if(stopShowingIntroScreen != null) { if(stopShowingIntroScreen.isChecked()) { //set it! SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this); sharedPreferences.edit().putBoolean(PreferencesActivity.KEY_SHOW_START_SCREEN, false).commit(); } } } if (currentPromptIsQuestion()) { if (!saveAnswersForCurrentScreen(EVALUATE_CONSTRAINTS)) { // A constraint was violated so a dialog should be showing. return; } } if (mFormController.getEvent() != FormEntryController.EVENT_END_OF_FORM) { int event; try{ group_skip: do { event = mFormController.stepToNextEvent(FormController.STEP_OVER_GROUP); switch (event) { case FormEntryController.EVENT_QUESTION: case FormEntryController.EVENT_END_OF_FORM: View next = createView(event); if(!resuming) { showView(next, AnimationType.RIGHT); } else { showView(next, AnimationType.FADE, false); } break group_skip; case FormEntryController.EVENT_PROMPT_NEW_REPEAT: createRepeatDialog(); break group_skip; case FormEntryController.EVENT_GROUP: //We only hit this event if we're at the _opening_ of a field //list, so it seems totally fine to do it this way, technically //though this should test whether the index is the field list //host. if (mFormController.indexIsInFieldList() && mFormController.getQuestionPrompts().length != 0) { View nextGroupView = createView(event); if(!resuming) { showView(nextGroupView, AnimationType.RIGHT); } else { showView(nextGroupView, AnimationType.FADE, false); } break group_skip; } // otherwise it's not a field-list group, so just skip it break; case FormEntryController.EVENT_REPEAT: Log.i(t, "repeat: " + mFormController.getFormIndex().getReference()); // skip repeats break; case FormEntryController.EVENT_REPEAT_JUNCTURE: Log.i(t, "repeat juncture: " + mFormController.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); }catch(XPathTypeMismatchException e){ FormEntryActivity.this.createErrorDialog(e.getMessage(), EXIT); } } else { mBeenSwiped = false; } } /** * Determines what should be displayed between a question, or the start screen and displays the * appropriate view. Also saves answers to the data model without checking constraints. */ private void showPreviousView() { // The answer is saved on a back swipe, but question constraints are ignored. if (currentPromptIsQuestion()) { saveAnswersForCurrentScreen(DO_NOT_EVALUATE_CONSTRAINTS); } FormIndex startIndex = mFormController.getFormIndex(); FormIndex lastValidIndex = startIndex; if (mFormController.getEvent() != FormEntryController.EVENT_BEGINNING_OF_FORM) { int event = mFormController.stepToPreviousEvent(); //Step backwards until we either find a question, the beginning of the form, //or a field list with valid questions inside while (event != FormEntryController.EVENT_BEGINNING_OF_FORM && event != FormEntryController.EVENT_QUESTION && !(event == FormEntryController.EVENT_GROUP && mFormController.indexIsInFieldList() && mFormController .getQuestionPrompts().length != 0)) { event = mFormController.stepToPreviousEvent(); lastValidIndex = mFormController.getFormIndex(); } //check if we're at the beginning and not doing the whole "First screen" thing if(event == FormEntryController.EVENT_BEGINNING_OF_FORM && !PreferencesActivity.showFirstScreen(this)) { //If so, we can't go all the way back here, so we've gotta hit the last index that was valid mFormController.jumpToIndex(lastValidIndex); //Did we jump at all? (not sure how we could have, but there might be a mismatch) if(lastValidIndex.equals(startIndex)) { //If not, don't even bother changing the view. //NOTE: This needs to be the same as the //exit condition below, in case either changes mBeenSwiped = false; return; } //We might have walked all the way back still, which isn't great, //so keep moving forward again until we find it if(lastValidIndex.isBeginningOfFormIndex()) { //there must be a repeat between where we started and the beginning of hte form, walk back up to it this.showNextView(true); return; } } View next = createView(event); showView(next, AnimationType.LEFT); } else { //NOTE: this needs to match the exist condition above //when there is no start screen mBeenSwiped = false; } } /** * Displays the View specified by the parameter 'next', animating both the current view and next * appropriately given the AnimationType. Also updates the progress bar. */ public void showView(View next, AnimationType from) { showView(next, from, true); } public void showView(View next, AnimationType from, boolean animateLastView) { switch (from) { case RIGHT: mInAnimation = AnimationUtils.loadAnimation(this, R.anim.push_left_in); mOutAnimation = AnimationUtils.loadAnimation(this, R.anim.push_left_out); break; case LEFT: mInAnimation = AnimationUtils.loadAnimation(this, R.anim.push_right_in); mOutAnimation = AnimationUtils.loadAnimation(this, R.anim.push_right_out); break; case FADE: mInAnimation = AnimationUtils.loadAnimation(this, R.anim.fade_in); mOutAnimation = AnimationUtils.loadAnimation(this, R.anim.fade_out); break; } if (mCurrentView != null) { if(animateLastView) { mCurrentView.startAnimation(mOutAnimation); } mViewPane.removeView(mCurrentView); } mInAnimation.setAnimationListener(this); RelativeLayout.LayoutParams lp = new RelativeLayout.LayoutParams(LayoutParams.FILL_PARENT, LayoutParams.FILL_PARENT); mCurrentView = next; mViewPane.addView(mCurrentView, lp); mCurrentView.startAnimation(mInAnimation); if (mCurrentView instanceof ODKView) ((ODKView) mCurrentView).setFocus(this); else { InputMethodManager inputManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); inputManager.hideSoftInputFromWindow(mCurrentView.getWindowToken(), 0); } } // Hopefully someday we can use managed dialogs when the bugs are fixed /* * Ideally, we'd like to use Android to manage dialogs with onCreateDialog() and * onPrepareDialog(), but dialogs with dynamic content are broken in 1.5 (cupcake). We do use * managed dialogs for our static loading ProgressDialog. The main issue we noticed and are * waiting to see fixed is: onPrepareDialog() is not called after a screen orientation change. * http://code.google.com/p/android/issues/detail?id=1639 */ // /** * Creates and displays a dialog displaying the violated constraint. */ private void createConstraintToast(FormIndex index, String constraintText, int saveStatus, boolean requestFocus) { switch (saveStatus) { case FormEntryController.ANSWER_CONSTRAINT_VIOLATED: if (constraintText == null) { constraintText = StringUtils.getStringRobust(this, R.string.invalid_answer_error); } break; case FormEntryController.ANSWER_REQUIRED_BUT_EMPTY: constraintText = StringUtils.getStringRobust(this, R.string.required_answer_error); break; } boolean displayed = false; //We need to see if question in violation is on the screen, so we can show this cleanly. for(QuestionWidget q : ((ODKView)mCurrentView).getWidgets()) { if(index.equals(q.getFormId())) { q.notifyInvalid(constraintText, requestFocus); displayed = true; break; } } if(!displayed) { showCustomToast(constraintText, Toast.LENGTH_SHORT); } mBeenSwiped = false; } /** * Creates a toast with the specified message. * * @param message */ private void showCustomToast(String message, int duration) { LayoutInflater inflater = (LayoutInflater) getSystemService(Context.LAYOUT_INFLATER_SERVICE); View view = inflater.inflate(R.layout.toast_view, null); // set the text in the view TextView tv = (TextView) view.findViewById(R.id.message); tv.setText(message); Toast t = new Toast(this); t.setView(view); t.setDuration(duration); t.setGravity(Gravity.CENTER, 0, 0); t.show(); } /** * Creates and displays a dialog asking the user if they'd like to create a repeat of the * current group. */ private void createRepeatDialog() { ContextThemeWrapper wrapper = new ContextThemeWrapper(this, R.style.DialogBaseTheme); View view = LayoutInflater.from(wrapper).inflate(R.layout.component_repeat_new_dialog, null); mRepeatDialog = new AlertDialog.Builder(wrapper).create(); final AlertDialog theDialog = mRepeatDialog; mRepeatDialog.setView(view); mRepeatDialog.setIcon(android.R.drawable.ic_dialog_info); boolean navBar = PreferencesActivity.getProgressBarMode(this).useNavigationBar(); //this is super gross... NavigationDetails details = null; if(navBar) { details = calculateNavigationStatus(); } final boolean backExitsForm = navBar && !details.relevantBeforeCurrentScreen; final boolean nextExitsForm = navBar && details.relevantAfterCurrentScreen == 0; Button back = (Button)view.findViewById(R.id.component_repeat_back); back.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { if(backExitsForm) { FormEntryActivity.this.triggerUserQuitInput(); } else { theDialog.dismiss(); FormEntryActivity.this.refreshCurrentView(false); } } }); Button newButton = (Button)view.findViewById(R.id.component_repeat_new); newButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { theDialog.dismiss(); try { mFormController.newRepeat(); } catch (XPathTypeMismatchException e) { FormEntryActivity.this.createErrorDialog(e.getMessage(), EXIT); return; } showNextView(); } }); Button skip = (Button)view.findViewById(R.id.component_repeat_skip); skip.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { theDialog.dismiss(); if(!nextExitsForm) { showNextView(); } else { FormEntryActivity.this.triggerUserFormComplete(); } } }); back.setText(StringUtils.getStringRobust(this, R.string.repeat_go_back)); //Load up our icons Drawable exitIcon = getResources().getDrawable(R.drawable.icon_exit); exitIcon.setBounds(0, 0, exitIcon.getIntrinsicWidth(), exitIcon.getIntrinsicHeight()); Drawable doneIcon = getResources().getDrawable(R.drawable.icon_done); doneIcon.setBounds(0, 0, doneIcon.getIntrinsicWidth(), doneIcon.getIntrinsicHeight()); if (mFormController.getLastRepeatCount() > 0) { mRepeatDialog.setTitle(StringUtils.getStringRobust(this, R.string.leaving_repeat_ask)); mRepeatDialog.setMessage(StringUtils.getStringRobust(this, R.string.add_another_repeat, mFormController.getLastGroupText())); newButton.setText(StringUtils.getStringRobust(this, R.string.add_another)); if(!nextExitsForm) { skip.setText(StringUtils.getStringRobust(this, R.string.leave_repeat_yes)); } else { skip.setText(StringUtils.getStringRobust(this, R.string.leave_repeat_yes_exits)); } } else { mRepeatDialog.setTitle(StringUtils.getStringRobust(this, R.string.entering_repeat_ask)); mRepeatDialog.setMessage(StringUtils.getStringRobust(this, R.string.add_repeat, mFormController.getLastGroupText())); newButton.setText(StringUtils.getStringRobust(this, R.string.entering_repeat)); if(!nextExitsForm) { skip.setText(StringUtils.getStringRobust(this, R.string.add_repeat_no)); } else { skip.setText(StringUtils.getStringRobust(this, R.string.add_repeat_no_exits)); } } mRepeatDialog.setCancelable(false); mRepeatDialog.show(); if(nextExitsForm) { skip.setCompoundDrawables(null, doneIcon, null, null); } if(backExitsForm) { back.setCompoundDrawables(null, exitIcon, null, null); } mBeenSwiped = false; } /** * Creates and displays dialog with the given errorMsg. */ private void createErrorDialog(String errorMsg, final boolean shouldExit) { mErrorMessage = errorMsg; mAlertDialog = new AlertDialog.Builder(this).create(); mAlertDialog.setIcon(android.R.drawable.ic_dialog_info); mAlertDialog.setTitle(StringUtils.getStringRobust(this, R.string.error_occured)); mAlertDialog.setMessage(errorMsg); DialogInterface.OnClickListener errorListener = new DialogInterface.OnClickListener() { /* * (non-Javadoc) * @see android.content.DialogInterface.OnClickListener#onClick(android.content.DialogInterface, int) */ @Override public void onClick(DialogInterface dialog, int i) { switch (i) { case DialogInterface.BUTTON1: if (shouldExit) { finish(); } break; } } }; mAlertDialog.setCancelable(false); mAlertDialog.setButton(StringUtils.getStringRobust(this, R.string.ok), errorListener); mAlertDialog.show(); } /** * Creates a confirm/cancel dialog for deleting repeats. */ private void createDeleteRepeatConfirmDialog() { mAlertDialog = new AlertDialog.Builder(this).create(); mAlertDialog.setIcon(android.R.drawable.ic_dialog_info); String name = mFormController.getLastRepeatedGroupName(); int repeatcount = mFormController.getLastRepeatedGroupRepeatCount(); if (repeatcount != -1) { name += " (" + (repeatcount + 1) + ")"; } mAlertDialog.setTitle(StringUtils.getStringRobust(this, R.string.delete_repeat_ask)); mAlertDialog.setMessage(StringUtils.getStringRobust(this, R.string.delete_repeat_confirm, name)); DialogInterface.OnClickListener quitListener = new DialogInterface.OnClickListener() { /* * (non-Javadoc) * @see android.content.DialogInterface.OnClickListener#onClick(android.content.DialogInterface, int) */ @Override public void onClick(DialogInterface dialog, int i) { switch (i) { case DialogInterface.BUTTON1: // yes mFormController.deleteRepeat(); showPreviousView(); break; case DialogInterface.BUTTON2: // no break; } } }; mAlertDialog.setCancelable(false); mAlertDialog.setButton(StringUtils.getStringRobust(this, R.string.discard_group), quitListener); mAlertDialog.setButton2(StringUtils.getStringRobust(this, R.string.delete_repeat_no), quitListener); mAlertDialog.show(); } /** * Saves data and writes it to disk. If exit is set, program will exit after save completes. * Complete indicates whether the user has marked the isntancs as complete. If updatedSaveName * is non-null, the instances content provider is updated with the new name */ private boolean saveDataToDisk(boolean exit, boolean complete, String updatedSaveName) { // save current answer if (!saveAnswersForCurrentScreen(EVALUATE_CONSTRAINTS, complete)) { Toast.makeText(this, StringUtils.getStringRobust(this, R.string.data_saved_error), Toast.LENGTH_SHORT).show(); return false; } mSaveToDiskTask = new SaveToDiskTask(getIntent().getData(), exit, complete, updatedSaveName, this, instanceProviderContentURI, symetricKey); mSaveToDiskTask.setFormSavedListener(this); mSaveToDiskTask.execute(); showDialog(SAVING_DIALOG); return true; } /** * Create a dialog with options to save and exit, save, or quit without saving */ private void createQuitDialog() { final String[] items = mIncompleteEnabled ? new String[] {StringUtils.getStringRobust(this, R.string.keep_changes), StringUtils.getStringRobust(this, R.string.do_not_save)} : new String[] {StringUtils.getStringRobust(this, R.string.do_not_save)}; mAlertDialog = new AlertDialog.Builder(this) .setIcon(android.R.drawable.ic_dialog_info) .setTitle(StringUtils.getStringRobust(this, R.string.quit_application, mFormController.getFormTitle())) .setNeutralButton(StringUtils.getStringRobust(this, R.string.do_not_exit), new DialogInterface.OnClickListener() { /* * (non-Javadoc) * @see android.content.DialogInterface.OnClickListener#onClick(android.content.DialogInterface, int) */ @Override public void onClick(DialogInterface dialog, int id) { dialog.cancel(); } }).setItems(items, new DialogInterface.OnClickListener() { /* * (non-Javadoc) * @see android.content.DialogInterface.OnClickListener#onClick(android.content.DialogInterface, int) */ @Override public void onClick(DialogInterface dialog, int which) { switch (which) { case 0: // save and exit if(items.length == 1) { discardChangesAndExit(); } else { saveDataToDisk(EXIT, isInstanceComplete(false), null); } break; case 1: // discard changes and exit discardChangesAndExit(); break; case 2:// do nothing break; } } }).create(); mAlertDialog.getListView().setSelector(R.drawable.selector); mAlertDialog.show(); } private void discardChangesAndExit() { String selection = InstanceColumns.INSTANCE_FILE_PATH + " like '" + mInstancePath + "'"; Cursor c = FormEntryActivity.this.managedQuery( instanceProviderContentURI, null, selection, null, null); // if it's not already saved, erase everything if (c.getCount() < 1) { int images = 0; int audio = 0; int video = 0; // delete media first String instanceFolder = mInstancePath.substring(0, mInstancePath.lastIndexOf("/") + 1); Log.i(t, "attempting to delete: " + instanceFolder); String where = Images.Media.DATA + " like '" + instanceFolder + "%'"; String[] projection = { Images.ImageColumns._ID }; // images Cursor imageCursor = null; try { imageCursor = getContentResolver().query( android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, where, null, null); if (imageCursor.getCount() > 0) { imageCursor.moveToFirst(); String id = imageCursor.getString(imageCursor .getColumnIndex(Images.ImageColumns._ID)); Log.i( t, "attempting to delete: " + Uri.withAppendedPath( android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)); images = getContentResolver() .delete( Uri.withAppendedPath( android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id), null, null); } } finally { if ( imageCursor != null ) { imageCursor.close(); } } // audio Cursor audioCursor = null; try { audioCursor = getContentResolver().query( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection, where, null, null); if (audioCursor.getCount() > 0) { audioCursor.moveToFirst(); String id = audioCursor.getString(imageCursor .getColumnIndex(Images.ImageColumns._ID)); Log.i( t, "attempting to delete: " + Uri.withAppendedPath( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id)); audio = getContentResolver() .delete( Uri.withAppendedPath( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id), null, null); } } finally { if ( audioCursor != null ) { audioCursor.close(); } } // video Cursor videoCursor = null; try { videoCursor = getContentResolver().query( MediaStore.Video.Media.EXTERNAL_CONTENT_URI, projection, where, null, null); if (videoCursor.getCount() > 0) { videoCursor.moveToFirst(); String id = videoCursor.getString(imageCursor .getColumnIndex(Images.ImageColumns._ID)); Log.i( t, "attempting to delete: " + Uri.withAppendedPath( MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)); video = getContentResolver() .delete( Uri.withAppendedPath( MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id), null, null); } } finally { if ( videoCursor != null ) { videoCursor.close(); } } Log.i(t, "removed from content providers: " + images + " image files, " + audio + " audio files," + " and " + video + " video files."); File f = new File(instanceFolder); if (f.exists() && f.isDirectory()) { for (File del : f.listFiles()) { Log.i(t, "deleting file: " + del.getAbsolutePath()); del.delete(); } f.delete(); } } finishReturnInstance(false); } /** * Confirm clear answer dialog */ private void createClearDialog(final QuestionWidget qw) { mAlertDialog = new AlertDialog.Builder(this).create(); mAlertDialog.setIcon(android.R.drawable.ic_dialog_info); mAlertDialog.setTitle(StringUtils.getStringRobust(this, R.string.clear_answer_ask)); String question = qw.getPrompt().getLongText(); if (question.length() > 50) { question = question.substring(0, 50) + "..."; } mAlertDialog.setMessage(StringUtils.getStringRobust(this, R.string.clearanswer_confirm, question)); DialogInterface.OnClickListener quitListener = new DialogInterface.OnClickListener() { /* * (non-Javadoc) * @see android.content.DialogInterface.OnClickListener#onClick(android.content.DialogInterface, int) */ @Override public void onClick(DialogInterface dialog, int i) { switch (i) { case DialogInterface.BUTTON1: // yes clearAnswer(qw); saveAnswersForCurrentScreen(DO_NOT_EVALUATE_CONSTRAINTS); break; case DialogInterface.BUTTON2: // no break; } } }; mAlertDialog.setCancelable(false); mAlertDialog.setButton(StringUtils.getStringRobust(this, R.string.discard_answer), quitListener); mAlertDialog.setButton2(StringUtils.getStringRobust(this, R.string.clear_answer_no), quitListener); mAlertDialog.show(); } /** * Creates and displays a dialog allowing the user to set the language for the form. */ private void createLanguageDialog() { final String[] languages = mFormController.getLanguages(); int selected = -1; if (languages != null) { String language = mFormController.getLanguage(); for (int i = 0; i < languages.length; i++) { if (language.equals(languages[i])) { selected = i; } } } mAlertDialog = new AlertDialog.Builder(this) .setSingleChoiceItems(languages, selected, new DialogInterface.OnClickListener() { /* * (non-Javadoc) * @see android.content.DialogInterface.OnClickListener#onClick(android.content.DialogInterface, int) */ @Override public void onClick(DialogInterface dialog, int whichButton) { // Update the language in the content provider when selecting a new // language ContentValues values = new ContentValues(); values.put(FormsColumns.LANGUAGE, languages[whichButton]); String selection = FormsColumns.FORM_FILE_PATH + "=?"; String selectArgs[] = { mFormPath }; int updated = getContentResolver().update(formProviderContentURI, values, selection, selectArgs); Log.i(t, "Updated language to: " + languages[whichButton] + " in " + updated + " rows"); mFormController.setLanguage(languages[whichButton]); dialog.dismiss(); if (currentPromptIsQuestion()) { saveAnswersForCurrentScreen(DO_NOT_EVALUATE_CONSTRAINTS); } refreshCurrentView(); } }) .setTitle(StringUtils.getStringRobust(this, R.string.change_language)) .setNegativeButton(StringUtils.getStringRobust(this, R.string.do_not_change), new DialogInterface.OnClickListener() { /* * (non-Javadoc) * @see android.content.DialogInterface.OnClickListener#onClick(android.content.DialogInterface, int) */ @Override public void onClick(DialogInterface dialog, int whichButton) { } }).create(); mAlertDialog.show(); } /* * (non-Javadoc) * @see android.app.Activity#onCreateDialog(int) * * We use Android's dialog management for loading/saving progress dialogs */ @Override protected Dialog onCreateDialog(int id) { switch (id) { case PROGRESS_DIALOG: mProgressDialog = new ProgressDialog(this); DialogInterface.OnClickListener loadingButtonListener = new DialogInterface.OnClickListener() { /* * (non-Javadoc) * @see android.content.DialogInterface.OnClickListener#onClick(android.content.DialogInterface, int) */ @Override public void onClick(DialogInterface dialog, int which) { dialog.dismiss(); mFormLoaderTask.setFormLoaderListener(null); mFormLoaderTask.cancel(true); finish(); } }; mProgressDialog.setIcon(android.R.drawable.ic_dialog_info); mProgressDialog.setTitle(StringUtils.getStringRobust(this, R.string.loading_form)); mProgressDialog.setMessage(StringUtils.getStringRobust(this, R.string.please_wait)); mProgressDialog.setIndeterminate(true); mProgressDialog.setCancelable(false); mProgressDialog.setButton(StringUtils.getStringRobust(this, R.string.cancel_loading_form), loadingButtonListener); return mProgressDialog; case SAVING_DIALOG: mProgressDialog = new ProgressDialog(this); DialogInterface.OnClickListener savingButtonListener = new DialogInterface.OnClickListener() { /* * (non-Javadoc) * @see android.content.DialogInterface.OnClickListener#onClick(android.content.DialogInterface, int) */ @Override public void onClick(DialogInterface dialog, int which) { dialog.dismiss(); mSaveToDiskTask.setFormSavedListener(null); mSaveToDiskTask.cancel(true); } }; mProgressDialog.setIcon(android.R.drawable.ic_dialog_info); mProgressDialog.setTitle(StringUtils.getStringRobust(this, R.string.saving_form)); mProgressDialog.setMessage(StringUtils.getStringRobust(this, R.string.please_wait)); mProgressDialog.setIndeterminate(true); mProgressDialog.setCancelable(false); mProgressDialog.setButton(StringUtils.getStringRobust(this, R.string.cancel), savingButtonListener); mProgressDialog.setButton(StringUtils.getStringRobust(this, R.string.cancel_saving_form), savingButtonListener); return mProgressDialog; } return null; } /** * Dismiss any showing dialogs that we manually manage. */ private void dismissDialogs() { if (mAlertDialog != null && mAlertDialog.isShowing()) { mAlertDialog.dismiss(); } if(mRepeatDialog != null && mRepeatDialog.isShowing()) { mRepeatDialog.dismiss(); } } /* * (non-Javadoc) * @see android.support.v4.app.FragmentActivity#onPause() */ @Override protected void onPause() { dismissDialogs(); if (mCurrentView != null && currentPromptIsQuestion()) { saveAnswersForCurrentScreen(DO_NOT_EVALUATE_CONSTRAINTS); } super.onPause(); } /* * (non-Javadoc) * @see android.support.v4.app.FragmentActivity#onResume() */ @Override protected void onResume() { super.onResume(); if (mFormLoaderTask != null) { mFormLoaderTask.setFormLoaderListener(this); if (mFormController != null && mFormLoaderTask.getStatus() == AsyncTask.Status.FINISHED) { dismissDialog(PROGRESS_DIALOG); refreshCurrentView(); } } if (mSaveToDiskTask != null) { mSaveToDiskTask.setFormSavedListener(this); } if (mErrorMessage != null && (mAlertDialog != null && !mAlertDialog.isShowing())) { createErrorDialog(mErrorMessage, EXIT); return; } //csims@dimagi.com - 22/08/2012 - For release only, fix immediately. //There is a _horribly obnoxious_ bug in TimePickers that messes up how they work //on screen rotation. We need to re-do any setAnswers that we perform on them after //onResume. try { if(mCurrentView instanceof ODKView) { ODKView ov = ((ODKView) mCurrentView); if(ov.getWidgets() != null) { for(QuestionWidget qw : ov.getWidgets()) { if(qw instanceof DateTimeWidget) { ((DateTimeWidget)qw).setAnswer(); } else if(qw instanceof TimeWidget) { ((TimeWidget)qw).setAnswer(); } } } } } catch(Exception e) { //if this fails, we _really_ don't want to mess anything up. this is a last minute //fix } } /** * Call when the user provides input that they want to quit the form */ private void triggerUserQuitInput() { //If we're just reviewing a read only form, don't worry about saving //or what not, just quit if(mFormController.isFormReadOnly()) { //It's possible we just want to "finish" here, but //I don't really wanna break any c compatibility finishReturnInstance(); } else { createQuitDialog(); } } /** * Get the default title for ODK's "Form title" field * * @return */ private String getDefaultFormTitle() { String saveName = mFormController.getFormTitle(); if (getContentResolver().getType(getIntent().getData()) == InstanceColumns.CONTENT_ITEM_TYPE) { Uri instanceUri = getIntent().getData(); Cursor instance = managedQuery(instanceUri, null, null, null, null); if (instance.getCount() == 1) { instance.moveToFirst(); saveName = instance.getString(instance .getColumnIndex(InstanceColumns.DISPLAY_NAME)); } } return saveName; } /** * Call when the user is ready to save and return the current form as complete */ private void triggerUserFormComplete() { saveDataToDisk(EXIT, true, getDefaultFormTitle()); } @Override public boolean onKeyDown(int keyCode, KeyEvent event) { switch (keyCode) { case KeyEvent.KEYCODE_BACK: triggerUserQuitInput(); return true; case KeyEvent.KEYCODE_DPAD_RIGHT: if (event.isAltPressed() && !mBeenSwiped) { mBeenSwiped = true; showNextView(); return true; } break; case KeyEvent.KEYCODE_DPAD_LEFT: if (event.isAltPressed() && !mBeenSwiped) { mBeenSwiped = true; showPreviousView(); return true; } break; } return super.onKeyDown(keyCode, event); } /* * (non-Javadoc) * @see android.support.v4.app.FragmentActivity#onDestroy() */ @Override protected void onDestroy() { if (mFormLoaderTask != null) { mFormLoaderTask.setFormLoaderListener(null); // We have to call cancel to terminate the thread, otherwise it // lives on and retains the FEC in memory. // but only if it's done, otherwise the thread never returns if (mFormLoaderTask.getStatus() == AsyncTask.Status.FINISHED) { mFormLoaderTask.cancel(true); mFormLoaderTask.destroy(); } } if (mSaveToDiskTask != null) { mSaveToDiskTask.setFormSavedListener(null); // We have to call cancel to terminate the thread, otherwise it // lives on and retains the FEC in memory. if (mSaveToDiskTask.getStatus() == AsyncTask.Status.FINISHED) { mSaveToDiskTask.cancel(false); } } if (mNoGPSReceiver != null) { unregisterReceiver(mNoGPSReceiver); } super.onDestroy(); } /* * (non-Javadoc) * @see android.view.animation.Animation.AnimationListener#onAnimationEnd(android.view.animation.Animation) */ @Override public void onAnimationEnd(Animation arg0) { mBeenSwiped = false; } /* * (non-Javadoc) * @see android.view.animation.Animation.AnimationListener#onAnimationRepeat(android.view.animation.Animation) */ @Override public void onAnimationRepeat(Animation animation) { // Added by AnimationListener interface. } /* * (non-Javadoc) * @see android.view.animation.Animation.AnimationListener#onAnimationStart(android.view.animation.Animation) */ @Override public void onAnimationStart(Animation animation) { // Added by AnimationListener interface. } /* * (non-Javadoc) * @see org.odk.collect.android.listeners.FormLoaderListener#loadingComplete(org.odk.collect.android.logic.FormController) * * loadingComplete() is called by FormLoaderTask once it has finished loading a form. */ @SuppressLint("NewApi") @Override public void loadingComplete(FormController fc) { dismissDialog(PROGRESS_DIALOG); mFormController = fc; if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB){ // Newer menus may have already built the menu, before all data was ready invalidateOptionsMenu(); } Localizer mLocalizer = Localization.getGlobalLocalizerAdvanced(); if(mLocalizer != null){ String mLocale = mLocalizer.getLocale(); if (mLocale != null && fc.getLanguages() != null && Arrays.asList(fc.getLanguages()).contains(mLocale)){ fc.setLanguage(mLocale); } else{ Logger.log("formloader", "The current locale is not set"); } } else{ Logger.log("formloader", "Could not get the localizer"); } // Set saved answer path if (mInstancePath == null) { // Create new answer folder. String time = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss") .format(Calendar.getInstance().getTime()); String file = mFormPath.substring(mFormPath.lastIndexOf('/') + 1, mFormPath.lastIndexOf('.')); String path = mInstanceDestination + "/" + file + "_" + time; if (FileUtils.createFolder(path)) { mInstancePath = path + "/" + file + "_" + time + ".xml"; } } else { // we've just loaded a saved form, so start in the hierarchy view Intent i = new Intent(this, FormHierarchyActivity.class); startActivityForResult(i, HIERARCHY_ACTIVITY_FIRST_START); return; // so we don't show the intro screen before jumping to the hierarchy } //mFormController.setLanguage(mFormController.getLanguage()); /* here was code that loaded cached language preferences fin the * collect code. we've overridden that to use our language * from the shared preferences */ refreshCurrentView(); updateNavigationCues(this.mCurrentView); } /* * (non-Javadoc) * @see org.odk.collect.android.listeners.FormLoaderListener#loadingError(java.lang.String) * * called by the FormLoaderTask if something goes wrong. */ @Override public void loadingError(String errorMsg) { dismissDialog(PROGRESS_DIALOG); if (errorMsg != null) { createErrorDialog(errorMsg, EXIT); } else { createErrorDialog(StringUtils.getStringRobust(this, R.string.parse_error), EXIT); } } /* * (non-Javadoc) * @see org.odk.collect.android.listeners.FormSavedListener#savingComplete(int) * * Called by SavetoDiskTask if everything saves correctly. */ @Override public void savingComplete(int saveStatus) { dismissDialog(SAVING_DIALOG); switch (saveStatus) { case SaveToDiskTask.SAVED: Toast.makeText(this, StringUtils.getStringRobust(this, R.string.data_saved_ok), Toast.LENGTH_SHORT).show(); hasSaved = true; break; case SaveToDiskTask.SAVED_AND_EXIT: Toast.makeText(this, StringUtils.getStringRobust(this, R.string.data_saved_ok), Toast.LENGTH_SHORT).show(); hasSaved = true; finishReturnInstance(); break; case SaveToDiskTask.SAVE_ERROR: Toast.makeText(this, StringUtils.getStringRobust(this, R.string.data_saved_error), Toast.LENGTH_LONG) .show(); break; case FormEntryController.ANSWER_CONSTRAINT_VIOLATED: case FormEntryController.ANSWER_REQUIRED_BUT_EMPTY: refreshCurrentView(); // an answer constraint was violated, so do a 'swipe' to the next // question to display the proper toast(s) next(); break; } } /** * Attempts to save an answer to the specified index. * * @param answer * @param index * @param evaluateConstraints * @return status as determined in FormEntryController */ public int saveAnswer(IAnswerData answer, FormIndex index, boolean evaluateConstraints) { try { if (evaluateConstraints) { return mFormController.answerQuestion(index, answer); } else { mFormController.saveAnswer(index, answer); return FormEntryController.ANSWER_OK; } } catch(XPathException e) { //this is where runtime exceptions get triggered after the form has loaded createErrorDialog("There is a bug in one of your form's XPath Expressions \n" + e.getMessage(), EXIT); //We're exiting anyway return FormEntryController.ANSWER_OK; } } /** * Checks the database to determine if the current instance being edited has already been * 'marked completed'. A form can be 'unmarked' complete and then resaved. * * @return true if form has been marked completed, false otherwise. */ private boolean isInstanceComplete(boolean end) { // default to false if we're mid form boolean complete = false; // if we're at the end of the form, then check the preferences if (end) { // First get the value from the preferences SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this); complete = sharedPreferences.getBoolean(PreferencesActivity.KEY_COMPLETED_DEFAULT, true); } // Then see if we've already marked this form as complete before String selection = InstanceColumns.INSTANCE_FILE_PATH + "=?"; String[] selectionArgs = { mInstancePath }; Cursor c = getContentResolver().query(instanceProviderContentURI, null, selection, selectionArgs, null); startManagingCursor(c); if (c != null && c.getCount() > 0) { c.moveToFirst(); String status = c.getString(c.getColumnIndex(InstanceColumns.STATUS)); if (InstanceProviderAPI.STATUS_COMPLETE.compareTo(status) == 0) { complete = true; } } return complete; } public void next() { if (!mBeenSwiped) { mBeenSwiped = true; showNextView(); } } private void finishReturnInstance() { finishReturnInstance(true); } /** * Returns the instance that was just filled out to the calling activity, if requested. */ private void finishReturnInstance(boolean reportSaved) { String action = getIntent().getAction(); if (Intent.ACTION_PICK.equals(action) || Intent.ACTION_EDIT.equals(action)) { // caller is waiting on a picked form String selection = InstanceColumns.INSTANCE_FILE_PATH + "=?"; String[] selectionArgs = { mInstancePath }; Cursor c = managedQuery(instanceProviderContentURI, null, selection, selectionArgs, null); if (c.getCount() > 0) { // should only be one... c.moveToFirst(); String id = c.getString(c.getColumnIndex(InstanceColumns._ID)); Uri instance = Uri.withAppendedPath(instanceProviderContentURI, id); if(reportSaved || hasSaved) { setResult(RESULT_OK, new Intent().setData(instance)); } else { setResult(RESULT_CANCELED, new Intent().setData(instance)); } } } this.dismissDialogs(); finish(); } /* * (non-Javadoc) * @see android.view.GestureDetector.OnGestureListener#onDown(android.view.MotionEvent) */ @Override public boolean onDown(MotionEvent e) { return false; } /* * (non-Javadoc) * @see android.view.GestureDetector.OnGestureListener#onFling(android.view.MotionEvent, android.view.MotionEvent, float, float) */ @Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { // Looks for user swipes. If the user has swiped, move to the appropriate screen. DisplayMetrics dm = new DisplayMetrics(); getWindowManager().getDefaultDisplay().getMetrics(dm); //screen width and height in inches. double sw = dm.xdpi * dm.widthPixels; double sh = dm.ydpi * dm.heightPixels; //relative metrics for what constitutes a swipe (to adjust per screen size) double swipeX = 0.25; double swipeY = 0.25; //details of the motion itself float xMov = Math.abs(e1.getX() - e2.getX()); float yMov = Math.abs(e1.getY() - e2.getY()); double angleOfMotion = ((Math.atan(yMov / xMov) / Math.PI) * 180); //large screen (tablet style if( sw > 5 || sh > 5) { swipeX = 0.5; } // for all screens a swipe is left/right of at least .25" and at an angle of no more than 30 //degrees int xPixelLimit = (int) (dm.xdpi * .25); //int yPixelLimit = (int) (dm.ydpi * .25); if ((xMov > xPixelLimit && angleOfMotion < 30)) { if (velocityX > 0) { mBeenSwiped = true; showPreviousView(); return true; } else { mBeenSwiped = true; showNextView(); return true; } } return false; } /* * (non-Javadoc) * @see android.view.GestureDetector.OnGestureListener#onLongPress(android.view.MotionEvent) */ @Override public void onLongPress(MotionEvent e) { } /* * (non-Javadoc) * @see android.view.GestureDetector.OnGestureListener#onScroll(android.view.MotionEvent, android.view.MotionEvent, float, float) */ @Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { // The onFling() captures the 'up' event so our view thinks it gets long pressed. // We don't wnat that, so cancel it. mCurrentView.cancelLongPress(); return false; } /* * (non-Javadoc) * @see android.view.GestureDetector.OnGestureListener#onShowPress(android.view.MotionEvent) */ @Override public void onShowPress(MotionEvent e) { } /* * (non-Javadoc) * @see android.view.GestureDetector.OnGestureListener#onSingleTapUp(android.view.MotionEvent) */ @Override public boolean onSingleTapUp(MotionEvent e) { return false; } /* * (non-Javadoc) * @see org.odk.collect.android.listeners.AdvanceToNextListener#advance() */ @Override public void advance() { next(); } /* * (non-Javadoc) * @see org.odk.collect.android.listeners.WidgetChangedListener#widgetEntryChanged() */ @Override public void widgetEntryChanged() { updateFormRelevencies(); updateNavigationCues(this.mCurrentView); } }