com.murrayc.galaxyzoo.app.QuestionFragment.java Source code

Java tutorial

Introduction

Here is the source code for com.murrayc.galaxyzoo.app.QuestionFragment.java

Source

/*
 * Copyright (C) 2014 Murray Cumming
 *
 * This file is part of android-galaxyzoo.
 *
 * android-galaxyzoo is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * android-galaxyzoo is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with android-galaxyzoo.  If not, see <http://www.gnu.org/licenses/>.
 */

package com.murrayc.galaxyzoo.app;

import android.app.Activity;
import android.content.ContentProviderOperation;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.OperationApplicationException;
import android.content.res.Configuration;
import android.database.Cursor;
import android.graphics.drawable.BitmapDrawable;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.os.RemoteException;
import android.support.annotation.NonNull;
import android.support.v4.app.FragmentActivity;
import android.support.v4.app.LoaderManager;
import android.support.v4.content.CursorLoader;
import android.support.v4.content.Loader;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.TableLayout;
import android.widget.TableRow;
import android.widget.TextView;
import android.widget.ToggleButton;

import com.murrayc.galaxyzoo.app.provider.ClassificationAnswer;
import com.murrayc.galaxyzoo.app.provider.ClassificationCheckbox;
import com.murrayc.galaxyzoo.app.provider.Item;
import com.murrayc.galaxyzoo.app.provider.ItemsContentProvider;

import java.lang.ref.WeakReference;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;

/**
 * A fragment representing a single subject.
 * This fragment is either contained in a {@link com.murrayc.galaxyzoo.app.ListActivity}
 * in two-pane mode (on tablets) or a {@link com.murrayc.galaxyzoo.app.ClassifyActivity}
 * on handsets.
 */
public class QuestionFragment extends BaseQuestionFragment implements LoaderManager.LoaderCallbacks<Cursor> {

    private static final String ARG_QUESTION_CLASSIFICATION_IN_PROGRESS = "classification-in-progress";

    private static final int URL_LOADER = 0;

    // We have to hard-code the indices - we can't use getColumnIndex because the Cursor
    // (actually a SQliteDatabase cursor returned
    // from our ContentProvider) only knows about the underlying SQLite database column names,
    // not our ContentProvider's column names. That seems like a design error in the Android API.
    //TODO: Use org.apache.commons.lang.ArrayUtils.indexOf() instead?
    /* private static final int COLUMN_INDEX_ID = 0; */
    private static final int COLUMN_INDEX_ZOONIVERSE_ID = 1;
    private static final int COLUMN_INDEX_GROUP_ID = 2;

    /**
     * A dummy implementation of the {@link com.murrayc.galaxyzoo.app.ListFragment.Callbacks} interface that does
     * nothing. Used only when this fragment is not attached to an activity.
     */
    private static final Callbacks sDummyCallbacks = new Callbacks() {
        public void onClassificationFinished() {
        }
    };

    /**
     * The fragment's current callback object, which is notified of list item
     * clicks.
     */
    private Callbacks mCallbacks = sDummyCallbacks;
    private final String[] mColumns = { Item.Columns._ID, Item.Columns.ZOONIVERSE_ID, Item.Columns.GROUP_ID };

    // A map of checkbox IDs to buttons.
    private final Map<String, ToggleButton> mCheckboxButtons = new HashMap<>();
    private Cursor mCursor = null;
    private boolean mLoaderFinished = false;

    private ClassificationInProgress mClassificationInProgress = new ClassificationInProgress();
    private QuestionLinearLayout mRootView = null;

    /**
     * Mandatory empty constructor for the fragment manager to instantiate the
     * fragment (e.g. upon screen orientation changes).
     */
    public QuestionFragment() {
    }

    @Override
    public void onCreate(final Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        //The item ID in savedInstanceState (from onSaveInstanceState())
        //overrules the item ID in the intent's arguments,
        //because the fragment may have been created with the virtual "next" ID,
        //but we replace that with the actual ID,
        //and we don't want to lost that actual ID when the fragment is recreated after
        //rotation.
        if (savedInstanceState != null) {
            setQuestionId(savedInstanceState.getString(ARG_QUESTION_ID));

            //We don't need to get the Group ID (or the Zooniverse ID),
            //because the base class's setItemId() will always get them from the database.

            //Get the classification in progress too,
            //instead of losing it when we rotate:
            mClassificationInProgress = savedInstanceState.getParcelable(ARG_QUESTION_CLASSIFICATION_IN_PROGRESS);
        } else {
            final Bundle bundle = getArguments();
            if (bundle != null) {
                setQuestionId(bundle.getString(ARG_QUESTION_ID));

                //We don't need to get the Group ID (or the Zooniverse ID),
                //because the base class's setItemId() will always get them from the database.
            }
        }

        setHasOptionsMenu(true);
    }

    @Override
    protected void setItemId(final String itemId) {
        super.setItemId(itemId);

        /*
         * Initializes the CursorLoader. The URL_LOADER value is eventually passed
         * to onCreateLoader().
         * This lets us get the Zooniverse ID and Group ID for the item.
         * We use restartLoader(), instead of initLoader(),
         * so we can refresh this fragment to show a different subject,
         * even when using the same query ("next") to do that.
         */
        mLoaderFinished = false; //Don't update() until this is ready.
        getLoaderManager().restartLoader(URL_LOADER, null, this);
    }

    @Override
    public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
            final Bundle savedInstanceState) {
        mRootView = (QuestionLinearLayout) inflater.inflate(R.layout.fragment_question, container, false);
        assert mRootView != null;

        setHasOptionsMenu(true);

        initializeSingleton();

        //This will be called later by updateIfReady(): update();

        return mRootView;
    }

    @Override
    void onSingletonInitialized() {
        super.onSingletonInitialized();

        updateIfReady();
    }

    @Override
    public void onSaveInstanceState(final Bundle outState) {
        //Save state to be used later by onCreate().
        //If we don't do this then we we will lose the current question ID that we are using.
        //This way we can get the current question ID back again in onCreate().
        //Otherwise, on rotation, onCreateView() will just get the first question ID, if any, that was first used
        //to create the fragment.
        outState.putString(ARG_QUESTION_ID, getQuestionId());
        outState.putParcelable(ARG_QUESTION_CLASSIFICATION_IN_PROGRESS, mClassificationInProgress);

        //We don't need to store the Group ID (or the Zooniverse ID),
        //because the base class's setItemId() will always get them from the database.

        super.onSaveInstanceState(outState);
    }

    @Override
    public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
        // Inflate the menu items for use in the action bar
        inflater.inflate(R.menu.actionbar_menu_question, menu);

        super.onCreateOptionsMenu(menu, inflater);
    }

    @Override
    public void onPrepareOptionsMenu(final Menu menu) {
        super.onPrepareOptionsMenu(menu);

        //Before the menu item is displayed,
        //make sure that the checked state is correct:
        final MenuItem item = menu.findItem(R.id.option_menu_item_favorite);
        if (item != null) {
            boolean checked = false;
            if (mClassificationInProgress != null) {
                checked = mClassificationInProgress.isFavorite();
            }
            item.setChecked(checked);
        }
    }

    @Override
    public boolean onOptionsItemSelected(final MenuItem item) {
        // handle item selection
        switch (item.getItemId()) {
        case R.id.option_menu_item_examples:
            doExamples();
            return true;
        case R.id.option_menu_item_favorite:
            final boolean checked = !item.isChecked();

            //Note:
            //"Icon menu" (TODO: What is that?) items don't actually show a checked state,
            //but it seems to work in the menu though not as an action in the action bar.
            //See http://developer.android.com/guide/topics/ui/menus.html#checkable
            item.setChecked(checked);

            //TODO: Use pretty icons instead:
            /*
            //Show an icon to indicate checkedness instead:
            //See http://developer.android.com/guide/topics/ui/menus.html#checkable
            if (checked) {
                item.setIcon(android.R.drawable.ic_menu_save); //A silly example.
            } else {
                item.setIcon(android.R.drawable.ic_menu_add); //A silly example.
            }
            */

            mClassificationInProgress.setFavorite(checked);
            return true;
        case R.id.option_menu_item_restart:
            restartClassification();
            return true;
        case R.id.option_menu_item_discuss:
            UiUtils.openDiscussionPage(getActivity(), getZooniverseId());
            return true;
        default:
            return super.onOptionsItemSelected(item);
        }
    }

    private void doExamples() {
        final Intent intent = new Intent(getActivity(), QuestionHelpActivity.class);
        intent.putExtra(ARG_QUESTION_ID, getQuestionId());
        intent.putExtra(QuestionHelpFragment.ARG_GROUP_ID, getGroupId());
        startActivity(intent);
    }

    @Override
    public void onAttach(final Context context) {
        super.onAttach(context);

        final Activity activity = getActivity();

        // Activities containing this fragment must implement its callbacks.
        if (!(activity instanceof Callbacks)) {
            throw new IllegalStateException("Activity must implement fragment's callbacks.");
        }

        mCallbacks = (Callbacks) activity;
    }

    @Override
    public void onDetach() {
        super.onDetach();

        // Reset the active callbacks interface to the dummy implementation.
        mCallbacks = sDummyCallbacks;
    }

    private void update() {
        final FragmentActivity activity = getActivity();
        if (activity == null)
            return;

        if (mRootView == null) {
            //This can happen when update() is called by the parent fragment
            //after this fragment has been instantiated after an orientation change,
            //but before onCreateView() has been called. It's not a problem
            //because onCreateView() will call this method again after setting mRootView.
            //Log.error("QuestionFragment.update(): mRootView is null.");
            return;
        }

        //Wipe the question details,
        //to ensure that we don't have old question details if somethng goes wrong when we
        //try to get and show the correct question details.
        final TextView textViewTitle = (TextView) mRootView.findViewById(R.id.textViewTitle);
        if (textViewTitle == null) {
            Log.error("update(): textViewTitle is null.");
            return;
        }
        textViewTitle.setText("");

        //Show the text:
        final TextView textViewText = (TextView) mRootView.findViewById(R.id.textViewText);
        if (textViewText == null) {
            Log.error("update(): textViewText is null.");
            return;
        }
        textViewText.setText("");

        final TableLayout layoutAnswers = (TableLayout) mRootView.findViewById(R.id.layoutAnswers);
        if (layoutAnswers == null) {
            Log.error("update(): layoutAnswers is null.");
            return;
        }
        layoutAnswers.removeAllViews();

        if (getSingleton() == null) {
            //The parent fragment's onSingletonInitialized has been called
            //but this fragment's onSingletonInitialized hasn't been called yet.
            //That's OK. update() will be called, indirectly, later by this fragment's onSingletonInitialized().
            return;
        }

        final DecisionTree.Question question = getQuestion();
        if (question == null) {
            Log.error("update(): question is null.");
            return;
        }

        //Show the title:
        textViewTitle.setText(question.getTitle());

        //Show the text:
        textViewText.setText(question.getText());

        layoutAnswers.setShrinkAllColumns(true);
        layoutAnswers.setStretchAllColumns(true);

        //Checkboxes:
        mCheckboxButtons.clear();
        final int COL_COUNT = 4;
        int col = 1;
        int rows = 0;
        TableRow row = null;
        final LayoutInflater inflater = LayoutInflater.from(activity);
        for (final DecisionTree.Checkbox checkbox : question.getCheckboxes()) {
            //Start a new row if necessary:
            if (row == null) {
                row = addRowToTable(activity, layoutAnswers);
                rows++;
            }

            final ToggleButton button = (ToggleButton) inflater.inflate(R.layout.question_answer_checkbox, null);

            //Use just the highlighting (line, color, etc) to show that it's selected,
            //instead of On/Off, so we don't need a separate label.
            //TODO: Use the icon. See http://stackoverflow.com/questions/18598255/android-create-a-toggle-button-with-image-and-no-text
            //TODO: Avoid the highlight bar thing at the bottom being drawn over the text.
            final String text = checkbox.getText();
            button.setText(text);
            button.setTextOn(text);
            button.setTextOff(text);

            insertButtonInRow(activity, row, button);

            final BitmapDrawable icon = getIcon(activity, checkbox);
            button.setCompoundDrawables(null, icon, null, null);

            mCheckboxButtons.put(checkbox.getId(), button);

            if (col < COL_COUNT) {
                col++;
            } else {
                col = 1;
                row = null;
            }
        }

        //Answers:
        for (final DecisionTree.Answer answer : question.getAnswers()) {
            //Start a new row if necessary:
            if (row == null) {
                row = addRowToTable(activity, layoutAnswers);
                rows++;
            }

            final Button button = createAnswerButton(activity, answer);
            insertButtonInRow(activity, row, button);

            final String questionId = question.getId();
            final String answerId = answer.getId();
            button.setOnClickListener(new View.OnClickListener() {
                public void onClick(final View v) {
                    // Perform action on click
                    onAnswerButtonClicked(questionId, answerId);
                }
            });

            if (col < COL_COUNT) {
                col++;
            } else {
                col = 1;
                row = null;
            }
        }

        //Add empty remaining cells, to avoid the other cells from expanding to fill the space,
        //because we want them to line up with the same cells above and below.
        if ((row != null) && (rows > 1)) {
            final int remaining_in_row = COL_COUNT - col + 1;
            for (int i = 0; i < remaining_in_row; i++) {
                //TODO: We could use Space instead of FrameLayout when using API>14.
                final FrameLayout placeholder = new FrameLayout(activity);
                insertButtonInRow(activity, row, placeholder);
            }
        }

        if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT) {

            /* This wastes even more space to be even more consistent:
            //Make sure there are always at least 2 rows,
            //so we request roughly the same amount of space each time:
            if (rows < 2) {
            row = addRowToTable(activity, layoutAnswers);
                
            final DecisionTree.Answer answer = new DecisionTree.Answer("bogus ID", "bogus title", getArbitraryIconId(), null, 0);
            final Button button = createAnswerButton(activity, answer);
            button.setVisibility(View.INVISIBLE); //It won't be seen, but it's size will be used.
            insertButtonInRow(activity, row, button);
            }
            */

            //This will be used in a later onLayout(),
            //so we will know the correct height at least during the second classification,
            mRootView.setRowsCountForMaxHeightExperienced(rows);

            //Try to keep the height consistent, to avoid the user seeing everything moving about.
            final int min = mRootView.getMaximumHeightExperienced(rows);
            if (min > 0) {
                mRootView.setMinimumHeight(min);
            }
        } else {
            //Ignore any previously-set minimum height,
            //to stop the portrait-mode's layout from affecting the layout-mode's layout:
            mRootView.setMinimumHeight(0);
        }
    }

    private static TableRow addRowToTable(final Activity activity, final TableLayout layoutAnswers) {
        final TableRow row = new TableRow(activity);

        final TableLayout.LayoutParams params = new TableLayout.LayoutParams(TableLayout.LayoutParams.MATCH_PARENT,
                TableLayout.LayoutParams.MATCH_PARENT);

        //Add a top margin between this row and any row above it:
        if (layoutAnswers.getChildCount() > 0) {
            final int margin = UiUtils.getPxForDpResource(activity, R.dimen.tiny_gap);
            params.setMargins(0, margin, 0, 0);
        }

        layoutAnswers.addView(row, params);
        return row;
    }

    private static void insertButtonInRow(final Context context, final TableRow row, final View button) {
        final TableRow.LayoutParams params = new TableRow.LayoutParams(0, TableRow.LayoutParams.MATCH_PARENT,
                1f /* weight */);
        //Use as little padding as possible at the left and right because the button
        //will usually get extra space from the TableLayout anyway,
        //but we want to avoid ugly line-breaks when the text is long (such as in translations).

        //Space around the inside of the buttons:
        //When we use smaller dp values, there seems to be no padding at the sides at all,
        //probably because the edges of the button are actually dependent on the standard background
        //image for buttons.
        //2 * standard_margin is nicer, but there is usually more, because the buttons expand
        //and a too-small margin is better than splitting a word across lines.
        final int padding = UiUtils.getPxForDpResource(context, R.dimen.small_margin);
        button.setPadding(padding, button.getPaddingTop(), padding, padding);

        if (row.getChildCount() > 0) {
            //Space between the buttons:
            final int margin = UiUtils.getPxForDpResource(context, R.dimen.tiny_gap);
            params.setMargins(margin, 0, 0, 0);
            // When using the standard background drawable (not our custom background color
            // which replaces it) This reduces the space caused by the standard background drawable,
            // but negative margins are unmaintainable voodoo:
            // params.setMargins(-4, 0, -4, 0);
        }

        row.addView(button, params);
    }

    private Button createAnswerButton(final Activity activity, final DecisionTree.Answer answer) {
        final LayoutInflater inflater = LayoutInflater.from(activity);
        final Button button = (Button) inflater.inflate(R.layout.question_answer_button, null);
        button.setText(answer.getText());

        final BitmapDrawable icon = getIcon(activity, answer);
        button.setCompoundDrawables(null, icon, null, null);
        //There is still some padding: button.setCompoundDrawablePadding(0); //UiUtils.getPxForDpResource(activity, R.dimen.standard_margin));
        return button;
    }

    private void onAnswerButtonClicked(@NonNull final String questionId, @NonNull final String answerId) {
        if (questionId == null) {
            Log.error("onAnswerButtonClicked: questionId was null.");
            return;
        }

        if (!TextUtils.equals(questionId, getQuestionId())) {
            Log.error("onAnswerButtonClicked: Unexpected questionId received: " + questionId + ", expected: "
                    + getQuestionId());
            return;
        }

        //TODO: Move this logic to the parent ClassifyFragment?

        //Save the answer so we can upload it when the classification is finished.
        storeAnswer(questionId, answerId);

        //Open the discussion page if the user chose that.
        final DecisionTree tree = getDecisionTree();
        if (tree.isDiscussQuestion(questionId)
                && TextUtils.equals(answerId, tree.getDiscussQuestionYesAnswerId())) {
            //Open a link to the discussion page.
            UiUtils.openDiscussionPage(getActivity(), getZooniverseId());
        }

        //Show the next question.
        showNextQuestion(questionId, answerId);
    }

    /**
     * Show the next question,
     * saving the whole classification and showing a new subject if necessary.
     *
     * @param questionId
     * @param answerId
     */
    private void showNextQuestion(final String questionId, final String answerId) {
        final View parentLayout = mRootView.findViewById(R.id.parentLayout);
        if (parentLayout == null) {
            Log.error("showNextQuestion(): parentLayout is null.");
            return;
        }

        final DecisionTree tree = getDecisionTree();
        final DecisionTree.Question nextQuestion = tree.getNextQuestionForAnswer(questionId, answerId);
        if (nextQuestion == null) {
            //Hide the question buttons to be sure that no interaction is possible until the next
            //subject is shown:
            parentLayout.setVisibility(View.INVISIBLE);

            //Try to prevent an invalid classification from being uploaded,
            //though we are not quite sure why that might happen.
            //See https://github.com/murraycu/android-galaxyzoo/issues/22
            if (!mClassificationInProgress.hasEnoughAnswers()) {
                Log.error("showNextQuestion(): Abandoning classification that doesn't have enough answers.");
                abandonItem();
                return;
            }

            //The classification is finished.
            //We save it to the ContentProvider, which will upload it.
            //
            //Set the questionID to null to prevent us from starting to save this again
            //if the user presses the "Done" button again while we are waiting for our AsyncTask.
            //(see our check for a null questionId at the start of this method.)
            //We give a _copy_ of the ClassificationInProgress to the AsyncTask,
            //to be really sure of avoiding concurrent access to it.
            final SaveClassificationTask task = new SaveClassificationTask(this,
                    new ClassificationInProgress(mClassificationInProgress));
            task.execute();
            return;
        }

        //Make sure the question is visible. Maybe the fragment was hidden while we were
        //saving the classification and getting the next subject:
        parentLayout.setVisibility(View.VISIBLE);

        final String nextQuestionId = nextQuestion.getId();

        //Skip the "Discuss" question, depending on the setting:
        if (!TextUtils.isEmpty(nextQuestionId) && !Utils.getShowDiscussQuestionFromSharedPrefs(getActivity())) {
            if (tree.isDiscussQuestion(nextQuestionId)) {

                //Add a "No" for the Discuss question without even showing the question:
                final String noAnswerId = tree.getDiscussQuestionNoAnswerId();
                storeAnswer(nextQuestionId, noAnswerId);

                showNextQuestion(nextQuestionId, noAnswerId);
                return;
            }
        }

        setQuestionId(nextQuestionId);
        update();
    }

    private void storeAnswer(final String questionId, final String answerId) {
        List<String> checkboxes = null;

        //Get the selected checkboxes too:
        final DecisionTree tree = getDecisionTree();
        final DecisionTree.Question question = tree.getQuestion(questionId);
        if ((question != null) && question.hasCheckboxes()) {
            checkboxes = new ArrayList<>();
            for (final DecisionTree.Checkbox checkbox : question.getCheckboxes()) {
                final String checkboxId = checkbox.getId();
                final ToggleButton button = mCheckboxButtons.get(checkboxId);
                if ((button != null) && button.isChecked()) {
                    checkboxes.add(checkboxId);
                }
            }
        }

        //Remember the answer:
        mClassificationInProgress.add(questionId, answerId, checkboxes);
    }

    private void wipeClassification() {
        mClassificationInProgress = new ClassificationInProgress();
        setQuestionId(null);
    }

    private void restartClassification() {
        wipeClassification();
        update();
    }

    private static class SaveClassificationTask extends AsyncTask<Void, Void, Void> {

        private final WeakReference<QuestionFragment> fragmentReference;
        private final ClassificationInProgress classificationInProgress;

        SaveClassificationTask(final QuestionFragment fragment,
                final ClassificationInProgress classificationInProgress) {
            this.fragmentReference = new WeakReference<>(fragment);
            this.classificationInProgress = classificationInProgress; //The caller gives us a copy.
        }

        @Override
        protected Void doInBackground(final Void... params) {

            if (fragmentReference == null) {
                return null;
            }

            final QuestionFragment fragment = fragmentReference.get();
            if (fragment == null) {
                return null;
            }

            if (isCancelled()) {
                return null;
            }

            fragment.saveClassificationSync(classificationInProgress);

            return null;
        }

        @Override
        protected void onPostExecute(final Void result) {
            if (fragmentReference == null) {
                return;
            }

            final QuestionFragment fragment = fragmentReference.get();
            if (fragment == null) {
                return;
            }

            //Finish the classification:
            fragment.wipeClassification();

            //TODO: Do something else for tablet UIs that share the activity.
            fragment.mCallbacks.onClassificationFinished();

        }

    }

    /**
     * Avoid calling this from the main (UI) thread - StrictMode doesn't like it on at least API 15
     * and API 16.
     *
     * @param classificationInProgress
     */
    private void saveClassificationSync(final ClassificationInProgress classificationInProgress) {
        final String itemId = getItemId();
        if (TextUtils.equals(itemId, ItemsContentProvider.URI_PART_ITEM_ID_NEXT)) {
            Log.error("QuestionFragment.saveClassification(): Attempting to save with the 'next' ID.");
            return;
        }

        final Activity activity = getActivity();
        if (activity == null)
            return;

        final ContentResolver resolver = activity.getContentResolver();

        // Add the related Classification Answers:
        // Use a ContentProvider operation to perform operations together,
        // either completely or not at all, as a transaction.
        // This should prevent an incomplete classification from being uploaded
        // before we have finished adding it.
        //
        // We use the specific ArrayList<> subtype instead of List<> because
        // ContentResolver.applyBatch() takes an ArrayList for some reason.
        final ArrayList<ContentProviderOperation> ops = new ArrayList<>();

        int sequence = 0;
        final List<ClassificationInProgress.QuestionAnswer> answers = classificationInProgress.getAnswers();
        if (answers != null) {
            for (final ClassificationInProgress.QuestionAnswer answer : answers) {
                ContentProviderOperation.Builder builder = ContentProviderOperation
                        .newInsert(ClassificationAnswer.CLASSIFICATION_ANSWERS_URI);
                final ContentValues valuesAnswers = new ContentValues();
                valuesAnswers.put(ClassificationAnswer.Columns.ITEM_ID, itemId);
                valuesAnswers.put(ClassificationAnswer.Columns.SEQUENCE, sequence);
                valuesAnswers.put(ClassificationAnswer.Columns.QUESTION_ID, answer.getQuestionId());
                valuesAnswers.put(ClassificationAnswer.Columns.ANSWER_ID, answer.getAnswerId());
                builder.withValues(valuesAnswers);
                ops.add(builder.build());

                //For instance, if the question has multiple-choice checkboxes to select before clicking
                //the "Done" answer:
                final List<String> checkboxIds = answer.getCheckboxIds();
                if (checkboxIds != null) {
                    for (final String checkboxId : checkboxIds) {
                        builder = ContentProviderOperation
                                .newInsert(ClassificationCheckbox.CLASSIFICATION_CHECKBOXES_URI);
                        final ContentValues valuesCheckbox = new ContentValues();
                        valuesCheckbox.put(ClassificationCheckbox.Columns.ITEM_ID, itemId);
                        valuesCheckbox.put(ClassificationCheckbox.Columns.SEQUENCE, sequence);
                        valuesCheckbox.put(ClassificationCheckbox.Columns.QUESTION_ID, answer.getQuestionId());
                        valuesCheckbox.put(ClassificationCheckbox.Columns.CHECKBOX_ID, checkboxId);
                        builder.withValues(valuesCheckbox);
                        ops.add(builder.build());
                    }
                }

                sequence++;
            }
        }

        //Mark the Item (Subject) as done:
        final Uri.Builder uriBuilder = Item.ITEMS_URI.buildUpon();
        uriBuilder.appendPath(getItemId());
        final ContentProviderOperation.Builder builder = ContentProviderOperation.newUpdate(uriBuilder.build());
        final ContentValues values = new ContentValues();
        values.put(Item.Columns.DONE, true);
        values.put(Item.Columns.DATETIME_DONE, getCurrentDateTimeAsIso8601());
        values.put(Item.Columns.FAVORITE, classificationInProgress.isFavorite());
        builder.withValues(values);
        ops.add(builder.build());

        try {
            resolver.applyBatch(ClassificationAnswer.AUTHORITY, ops);
        } catch (final RemoteException | OperationApplicationException e) {
            //This should never happen, and would mean a loss of the current classification,
            //so let it crash the app and generate a report with a stacktrace,
            //because that's (slightly) better than just ignoring it.
            //
            //I guess that OperationApplicationException is not an unchecked exception,
            //because it could be caused by not just pure programmer error,
            //for instance if our data did not fulfill a Sqlite database constraint.
            Log.error("QuestionFragment. saveClassification(): Exception from applyBatch()", e);
            throw new RuntimeException("ContentResolver.applyBatch() failed.", e);
        }

        //The ItemsContentProvider will upload the classification later.
    }

    private static String getCurrentDateTimeAsIso8601() {
        final Date now = new Date();
        //TODO: Is there a simpler way of getting an ISO-8601-formatted date,
        //or at least a way to avoid writing the format out manually here?
        final DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US);
        dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
        return dateFormat.format(now);
    }

    private void updateFromCursor() {
        if (mCursor == null) {
            Log.error("mCursor is null.");
            return;
        }

        final Activity activity = getActivity();
        if (activity == null)
            return;

        if (mCursor.getCount() <= 0) { //In case the query returned no rows.
            Log.error("QuestionFragment.updateFromCursor(): The ContentProvider query returned no rows.");
            UiUtils.warnAboutMissingNetwork(activity, mRootView);

            return;
        }

        if (mCursor.getColumnCount() <= 0) { //In case the query returned no columns.
            Log.error("The ContentProvider query returned no columns.");
            return;
        }

        mCursor.moveToFirst(); //There should only be one anyway.

        if (mRootView == null) {
            Log.error("QuestionFragment.updateFromCursor(): mRootView is null.");
            return;
        }

        final String zooniverseId = mCursor.getString(COLUMN_INDEX_ZOONIVERSE_ID);
        setZooniverseId(zooniverseId);

        String groupId = mCursor.getString(COLUMN_INDEX_GROUP_ID);
        if (TextUtils.isEmpty(groupId)) {
            Log.error("updateFromCursor(): We don't have a group ID. Using a default.");
            //Assume that this is an old cached item from before we added the group ID
            //database field, when we only use the sloan group:
            groupId = com.murrayc.galaxyzoo.app.provider.Config.SUBJECT_GROUP_ID_SDSS_LOST_SET;
        }
        setGroupId(groupId);
    }

    @Override
    public Loader<Cursor> onCreateLoader(final int loaderId, final Bundle bundle) {
        if (loaderId != URL_LOADER) {
            return null;
        }

        final String itemId = getItemId();
        if (TextUtils.isEmpty(itemId)) {
            return null;
        }

        final Activity activity = getActivity();

        final Uri.Builder builder = Item.CONTENT_URI.buildUpon();
        builder.appendPath(itemId);

        return new CursorLoader(activity, builder.build(), mColumns, null, // No where clause, return all records. We already specify just one via the itemId in the URI
                null, // No where clause, therefore no where column values.
                null // Use the default sort order.
        );
    }

    @Override
    public void onLoadFinished(final Loader<Cursor> cursorLoader, final Cursor cursor) {
        mCursor = cursor;
        updateFromCursor();

        mLoaderFinished = true;
        updateIfReady();

        // Avoid this being called twice (actually multiple times), which seems to be an Android bug:
        // See http://stackoverflow.com/questions/14719814/onloadfinished-called-twice
        // and https://code.google.com/p/android/issues/detail?id=63179
        getLoaderManager().destroyLoader(URL_LOADER);
    }

    private void updateIfReady() {
        if (mLoaderFinished && (getSingleton() != null)) {
            update();
        }
    }

    @Override
    public void onLoaderReset(final Loader<Cursor> cursorLoader) {
        /*
         * Clears out our reference to the Cursor.
         * This prevents memory leaks.
         */
        mCursor = null;
    }

    /**
     * A callback interface that all activities containing some fragments must
     * implement. This mechanism allows activities to be notified of
     * navigation selections.
     * <p/>
     * This is the recommended way for activities and fragments to communicate,
     * presumably because, unlike a direct function call, it still keeps the
     * fragment and activity implementations separate.
     * http://developer.android.com/guide/components/fragments.html#CommunicatingWithActivity
     */
    interface Callbacks {

        /**
         * We call this when the classification has been finished and saved.
         */
        void onClassificationFinished();
    }

    //This is just public so we can test it.
    /**
     * This lets us store the classification's answers
     * during the classification. Alternatively,
     * we could insert the answers into the ContentProvider along the
     * way, but this lets us avoid having half-complete classifications
     * in the content provider.
     */
    public static final class ClassificationInProgress implements Parcelable {
        public static final Parcelable.Creator<ClassificationInProgress> CREATOR = new Parcelable.Creator<ClassificationInProgress>() {
            public ClassificationInProgress createFromParcel(final Parcel in) {
                return new ClassificationInProgress(in);
            }

            public ClassificationInProgress[] newArray(final int size) {
                return new ClassificationInProgress[size];
            }
        };
        private final List<QuestionAnswer> answers;
        private boolean favorite = false;

        public ClassificationInProgress() {
            answers = new ArrayList<>();
        }

        public ClassificationInProgress(final ClassificationInProgress in) {
            this.answers = in.getAnswers(); //getAnswers() returns a copy.
            favorite = in.favorite;
        }

        public ClassificationInProgress(final Parcel in) {
            this.answers = in.createTypedArrayList(QuestionAnswer.CREATOR);

            favorite = (in.readInt() == 1);
        }

        @Override
        public boolean equals(final Object o) {
            if (this == o)
                return true;

            if (o == null || getClass() != o.getClass())
                return false;

            final ClassificationInProgress that = (ClassificationInProgress) o;

            if (favorite != that.favorite)
                return false;

            if (answers != null ? !answers.equals(that.answers) : that.answers != null)
                return false;

            return true;
        }

        @Override
        public int hashCode() {
            int result = answers != null ? answers.hashCode() : 0;
            result = 31 * result + (favorite ? 1 : 0);
            return result;
        }

        public void add(final String questionId, final String answerId, final List<String> checkboxIds) {
            answers.add(new QuestionAnswer(questionId, answerId, checkboxIds));
        }

        @Override
        public int describeContents() {
            return 0;
        }

        @Override
        public void writeToParcel(final Parcel dest, final int flags) {
            dest.writeTypedList(answers);
            dest.writeInt(favorite ? 1 : 0);
        }

        /** Returns a deep copy of the list of answers,
         * to avoid any chance of concurrent use.
         *
         * @return
         */
        List<QuestionAnswer> getAnswers() {
            final List<QuestionAnswer> result = new ArrayList<>();
            for (final QuestionAnswer answer : answers) {
                result.add(new QuestionAnswer(answer));
            }

            return result;
        }

        /**
         * This assumes that we always add the discuss question/answer by default even
         * when the user doesn't want to be asked.
         * @return
         */
        boolean hasEnoughAnswers() {
            return (answers.size() > 1);
        }

        boolean isFavorite() {
            return favorite;
        }

        public void setFavorite(final boolean favorite) {
            this.favorite = favorite;
        }

        private static class QuestionAnswer implements Parcelable {
            public static final Parcelable.Creator<QuestionAnswer> CREATOR = new Parcelable.Creator<QuestionAnswer>() {
                public QuestionAnswer createFromParcel(final Parcel in) {
                    return new QuestionAnswer(in);
                }

                public QuestionAnswer[] newArray(final int size) {
                    return new QuestionAnswer[size];
                }
            };

            // The question that was answered.
            private final String questionId;

            // The Answer that was chosen.
            private final String answerId;

            // Any checkboxes that were selected before the answer (usually "Done") was chosen.
            private final List<String> checkboxIds;

            public QuestionAnswer(final String questionId, final String answerId, final List<String> checkboxIds) {
                //Strings are immutable so we don't need to copy them:
                this.questionId = questionId;
                this.answerId = answerId;

                this.checkboxIds = deepCopyCheckBoxIds(checkboxIds);
            }

            private static List<String> deepCopyCheckBoxIds(@NonNull final List<String> strList) {
                if (strList == null) {
                    return null;
                }

                final List<String> result = new ArrayList<>();

                //Strings are immutable so we don't need to copy them:
                result.addAll(strList);

                return result;
            }

            private QuestionAnswer(final Parcel in) {
                //Keep this in sync with writeToParcel().
                this.questionId = in.readString();
                this.answerId = in.readString();

                this.checkboxIds = deepCopyCheckBoxIds(in.createStringArrayList());
            }

            public QuestionAnswer(final QuestionAnswer answer) {
                //Strings are immutable so we don't need to copy them:
                this.questionId = answer.getQuestionId();
                this.answerId = answer.getAnswerId();

                this.checkboxIds = deepCopyCheckBoxIds(answer.checkboxIds);
            }

            @Override
            public boolean equals(final Object o) {
                if (this == o)
                    return true;

                if (o == null || getClass() != o.getClass())
                    return false;

                final QuestionAnswer that = (QuestionAnswer) o;

                if (answerId != null ? !answerId.equals(that.answerId) : that.answerId != null)
                    return false;

                if (checkboxIds != null ? !checkboxIds.equals(that.checkboxIds) : that.checkboxIds != null)
                    return false;

                if (questionId != null ? !questionId.equals(that.questionId) : that.questionId != null)
                    return false;

                return true;
            }

            @Override
            public int hashCode() {
                int result = questionId != null ? questionId.hashCode() : 0;
                result = 31 * result + (answerId != null ? answerId.hashCode() : 0);
                result = 31 * result + (checkboxIds != null ? checkboxIds.hashCode() : 0);
                return result;
            }

            public String getQuestionId() {
                return questionId;
            }

            public String getAnswerId() {
                return answerId;
            }

            /** Returns a deep copy of the list of checkbox IDs,
             * to avoid any chance of concurrent use.
             *
             * @return
             */
            public List<String> getCheckboxIds() {
                return deepCopyCheckBoxIds(checkboxIds);
            }

            @Override
            public int describeContents() {
                return 0;
            }

            @Override
            public void writeToParcel(final Parcel dest, final int flags) {
                dest.writeString(getQuestionId());
                dest.writeString(getAnswerId());

                dest.writeStringList(checkboxIds);
            }
        }
    }
}