com.coincollection.MainActivity.java Source code

Java tutorial

Introduction

Here is the source code for com.coincollection.MainActivity.java

Source

/*
 * Coin Collection, an Android app that helps users track the coins that they've collected
 * Copyright (C) 2010-2016 Andrew Williams
 *
 * This file is part of Coin Collection.
 *
 * Coin Collection 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.
 *
 * Coin Collection 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 Coin Collection.  If not, see <http://www.gnu.org/licenses/>.
 */

/***
 This file pulls in the AsyncTask code from cw-android
 Copyright (c) 2008-2012 CommonsWare, LLC
 Modified by Andrew Williams
 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.
    
 From _The Busy Coder's Guide to Android Development_
 http://commonsware.com/Android
    
 https://raw.github.com/commonsguy/cw-android/master/Rotation/RotationAsync/src/com/commonsware/android/rotation/async/RotationAsync.java
    
 */

package com.coincollection;

import android.Manifest;
import android.app.AlertDialog;
import android.app.ProgressDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.database.Cursor;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Environment;
import android.support.v4.app.ActivityCompat;
import android.support.v4.app.FragmentManager;
import android.support.v4.app.FragmentTransaction;
import android.support.v4.content.ContextCompat;
import android.support.v7.app.AppCompatActivity;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.ListView;
import android.widget.TextView;
import android.widget.Toast;
import android.widget.AdapterView.OnItemClickListener;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;

import com.opencsv.CSVReader;
import com.opencsv.CSVWriter;
import com.spencerpages.BuildConfig;
import com.spencerpages.MainApplication;
import com.spencerpages.R;

import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;

/**
 * The main Activity for the app.  Implements a ListView which lets the user view a previously
 * created collection or add/delete/reorder/export/import collections
 */
public class MainActivity extends AppCompatActivity {

    private final ArrayList<CollectionListInfo> mCollectionListEntries = new ArrayList<>();
    private final Context mContext = this;
    private FrontAdapter mListAdapter;
    private DatabaseAdapter mDbAdapter;
    private Resources mRes;

    // The number of actual collections in mCollectionListEntries
    private int mNumberOfCollections;

    // To be used with a simple cancel-able alert.  For more complicated alerts use a different one
    private AlertDialog.Builder mBuilder = null;

    // Used for the Update Database functionality
    private ProgressDialog mProgressDialog = null;
    private InitTask mTask = null;

    private boolean mDatabaseHasBeenOpened = false;
    private boolean mIsImportingCollection = false;

    // See notes in onCreate below.  Used to handle the case where we are importing collections and
    // the screen orientation changes
    private boolean mShouldFinishViewSetupToo = false;

    // These are used to support importing the collection data.  After we have read everything in,
    // we save the info here while we ask the user whether they really want to delete all of their
    // existing collections.
    // TODO Rename this to indicated that they are associated with importing
    private ArrayList<CollectionListInfo> mImportedCollectionListInfos = null;
    private ArrayList<String[][]> mCollectionContents = null;
    private int mDatabaseVersion = -1;

    // Export directory path
    private final static String EXPORT_FOLDER_NAME = "/coin-collection-app-files";

    // Default list item view positions
    //  0. Add Collection
    //  1. Remove Collection
    //  2. Import Collections
    //  3. Export Collections
    //  4. Re-order Collections
    //  5. About
    // Note: Using constants instead of an enum based on this:
    // https://developer.android.com/training/articles/memory.html#Overhead
    // - Enums often require more than twice as much memory as static constants.
    public final static int ADD_COLLECTION = 0;
    public final static int REMOVE_COLLECTION = 1;
    public final static int IMPORT_COLLECTIONS = 2;
    public final static int EXPORT_COLLECTIONS = 3;
    public final static int REORDER_COLLECTIONS = 4;
    public final static int ABOUT = 5;
    // As a hack to get the static strings at the bottom of the list, we add spacers into
    // mCollectionListEntries.  This tracks the number of those spacers, which we use in several
    // places.
    private final static int NUMBER_OF_COLLECTION_LIST_SPACERS = 6;

    // App permission requests
    private final static int IMPORT_PERMISSIONS_REQUEST = 0;
    private final static int EXPORT_PERMISSIONS_REQUEST = 1;

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

        setContentView(R.layout.main_activity_layout);

        mBuilder = new AlertDialog.Builder(this);
        mRes = getResources();

        // Check whether it is the users first time using the app
        final SharedPreferences mainPreferences = getSharedPreferences(MainApplication.PREFS, MODE_PRIVATE);

        // In legacy code we used first_Time_screen2 here so that the message would be displayed
        // until they made it to the create collection screen.  That isn't necessary anymore, but
        // if they are upgrading from that don't show them the help screen if first_Time_screen1
        // isn't set
        if (mainPreferences.getBoolean("first_Time_screen1", true)
                && mainPreferences.getBoolean("first_Time_screen2", true)) {
            // Show the user how to do everything
            AlertDialog.Builder builder = new AlertDialog.Builder(this);
            builder.setMessage(mRes.getString(R.string.intro_message)).setCancelable(false)
                    .setPositiveButton(mRes.getString(R.string.okay), new DialogInterface.OnClickListener() {
                        public void onClick(DialogInterface dialog, int id) {
                            dialog.cancel();
                            SharedPreferences.Editor editor = mainPreferences.edit();
                            editor.putBoolean("first_Time_screen1", false);
                            editor.commit(); // .apply() in later APIs
                        }
                    });
            AlertDialog alert = builder.create();
            alert.show();
        }

        InitTask check = (InitTask) getLastCustomNonConfigurationInstance();

        // TODO If there is a screen orientation change, it looks like a ProgressDialog gets leaked. :(
        if (check == null) {

            if (BuildConfig.DEBUG) {
                Log.d(MainApplication.APP_NAME, "No previous state so kicking off InitTask to doOpen");
            }

            // Kick off the InitTask to open the database.  This will likely be the first open,
            // so we want it in the AsyncTask in case we have to go into onUpgrade and it takes
            // a long time.

            mTask = new InitTask();

            mTask.doOpen = true;
            mTask.activity = this;

            mTask.execute();

            // The InitTask will call finishViewSetup once the database has been opened
            // for the first time

        } else {

            if (BuildConfig.DEBUG) {
                Log.d(MainApplication.APP_NAME, "Taking over existing mTask");
            }

            // an InitTask is running, make a new dialog to show it
            mTask = check;
            mTask.activity = this;

            // There's two possible InitTask's that could be running:
            //     - The one to open the database for the first time
            //     - The one to import collections
            // In the case of the former, we just want to show the dialog that the user had on the
            // screen.  For the latter case, we still need something to call finishViewSetup, and
            // we don't want to call it here bc it will try to use the database too early.  Instead,
            // set a flag that will have that InitTask call finishViewSetup for us as well.

            if (mProgressDialog != null && mProgressDialog.isShowing()) {
                mProgressDialog.dismiss();
            }

            String message = mTask.openMessage;

            if (mTask.doImport) {
                message = mTask.importMessage;

                mDatabaseHasBeenOpened = true; // This has to have happened at this point
                mIsImportingCollection = true;
                mShouldFinishViewSetupToo = true;
            }
            // Make a new dialog

            mProgressDialog = new ProgressDialog(mContext);
            mProgressDialog.setCancelable(false);
            mProgressDialog.setMessage(message);
            mProgressDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER);
            mProgressDialog.setProgress(0);
            mProgressDialog.show();

        }

        // HISTORIC - no longer the case:
        //
        // We need to open the database since we are the first activity to run
        // We only want to open the database once, though, because it is a hassle
        // in that it is very expensive and must be opened in an asynchronous mTask
        // background thread as to avoid getting application not responding errors.
        // So, here is what we do:
        // - Instantiate a Service that will hold the database adapter
        //    + This will be around for the lifetime of the application
        // - Once the service has been created, open up the database in an async mTask
        // - Once the database is open, finish setting up the UI from the data pulled

        // After we open it, it must have at least one activity bound to it at all times
        // for it to stay alive.  So, each activity must bind to it on onCreate and
        // unbind in onDestroy.  Once the app is terminating, all the activity onDestroy's
        // will have been called and the service's onDestroy will then get called, where
        // we close the databse

        // Actually instantiate the database service
        //Intent mServiceIntent = new Intent(this, DatabaseService.class);
        // and bind to it
        //bindService(mServiceIntent, mConnection, Context.BIND_AUTO_CREATE);
        //
        // Having the database open for a long time didn't seem to work out well, though - after
        // having the app open for a while, database errors would start popping up.  Now, we just
        // do the first DB open in an AsyncTask to ensure we don't get an ANR if a database upgrade
        // is required, but just open and close the database regularly as needed after that.
    }

    /**
     * Finishes setting up the view once the database has been opened.
     */
    private void finishViewSetup() {

        // Called from the InitTask after the database has been successfully
        // opened for the first time.  Now we can be fairly sure that future
        // open's won't trigger an ANR issue.

        if (BuildConfig.DEBUG) {
            Log.v("mainActivity", "finishViewSetup");
        }

        // Instantiate the DatabaseAdapter
        mDbAdapter = new DatabaseAdapter(this);

        // Populate mCollectionListEntries with the data from the database
        updateCollectionListFromDatabase();

        // Instantiate the FrontAdapter
        mListAdapter = new FrontAdapter(mContext, mCollectionListEntries, mNumberOfCollections);

        ListView lv = (ListView) findViewById(R.id.main_activity_listview);

        lv.setAdapter(mListAdapter);

        // TODO Not sure what this does?
        lv.setTextFilterEnabled(true); // Typing narrows down the list

        // For when we use fragments, listen to the backstack so we can transition back here from
        // the fragment

        getSupportFragmentManager().addOnBackStackChangedListener(new FragmentManager.OnBackStackChangedListener() {
            public void onBackStackChanged() {

                if (0 == getSupportFragmentManager().getBackStackEntryCount()) {

                    // We are back at this activity, so restore the ActionBar
                    getSupportActionBar().setTitle(mRes.getString(R.string.app_name));
                    getSupportActionBar().setDisplayHomeAsUpEnabled(false);
                    getSupportActionBar().setHomeButtonEnabled(false);

                    // The collections may have been re-ordered, so update them here.
                    updateCollectionListFromDatabase();

                    // Change it out with the new list
                    mListAdapter.items = mCollectionListEntries;
                    mListAdapter.numberOfCollections = mNumberOfCollections;
                    mListAdapter.notifyDataSetChanged();
                }
            }
        });

        // Now set the onItemClickListener to perform a certain action based on what's clicked
        lv.setOnItemClickListener(new OnItemClickListener() {
            public void onItemClick(AdapterView<?> parent, View view, int position, long id) {

                // See whether it was one of the special list entries (Add collection, delete
                // collection, etc.)
                if (position >= mNumberOfCollections) {

                    int newPosition = position - mNumberOfCollections;
                    Intent intent;

                    switch (newPosition) {
                    case ADD_COLLECTION:
                        intent = new Intent(mContext, CoinPageCreator.class);
                        startActivity(intent);
                        break;
                    case REMOVE_COLLECTION:

                        if (mNumberOfCollections == 0) {
                            Toast.makeText(mContext, mRes.getString(R.string.no_collections), Toast.LENGTH_SHORT)
                                    .show();
                            break;
                        }
                        // Thanks!
                        // http://stackoverflow.com/questions/2397106/listview-in-alertdialog
                        CharSequence[] names = new CharSequence[mNumberOfCollections];
                        for (int i = 0; i < mNumberOfCollections; i++) {
                            names[i] = mCollectionListEntries.get(i).getName();
                        }

                        AlertDialog.Builder delete_builder = new AlertDialog.Builder(mContext);
                        delete_builder.setTitle(mRes.getString(R.string.select_collection_delete));
                        delete_builder.setItems(names, new DialogInterface.OnClickListener() {
                            public void onClick(DialogInterface dialog, int item) {
                                showDeleteConfirmation(mCollectionListEntries.get(item).getName());
                            }
                        });
                        AlertDialog alert = delete_builder.create();
                        alert.show();
                        break;
                    case IMPORT_COLLECTIONS:
                        handleImportCollectionsPart1();
                        break;
                    case EXPORT_COLLECTIONS:
                        handleExportCollectionsPart1();
                        break;
                    case REORDER_COLLECTIONS:

                        if (mNumberOfCollections == 0) {
                            Toast.makeText(mContext, mRes.getString(R.string.no_collections), Toast.LENGTH_SHORT)
                                    .show();
                            break;
                        }

                        // Get a list that excludes the spacers
                        List<CollectionListInfo> tmp = mCollectionListEntries.subList(0, mNumberOfCollections);
                        ArrayList<CollectionListInfo> collections = new ArrayList<>(tmp);

                        ReorderCollections fragment = new ReorderCollections();
                        fragment.setCollectionList(collections);

                        // Show the fragment used for reordering collections
                        getSupportFragmentManager().beginTransaction()
                                .add(R.id.main_activity_frame, fragment, "ReorderFragment").addToBackStack(null)
                                .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN).commit();

                        // Setup the actionbar for the reorder page
                        // TODO Check for NULL
                        getSupportActionBar().setTitle(mRes.getString(R.string.reorder_collection));
                        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
                        getSupportActionBar().setHomeButtonEnabled(true);

                        break;
                    case ABOUT:

                        LayoutInflater inflater = (LayoutInflater) mContext
                                .getSystemService(LAYOUT_INFLATER_SERVICE);
                        View layout = inflater.inflate(R.layout.info_popup,
                                (ViewGroup) findViewById(R.id.info_layout_root));

                        AlertDialog.Builder info_builder = new AlertDialog.Builder(mContext);
                        info_builder.setView(layout);

                        TextView tv = (TextView) layout.findViewById(R.id.info_textview);
                        tv.setText(buildInfoText());

                        AlertDialog alertDialog = info_builder.create();
                        alertDialog.show();
                        break;
                    }

                    return;
                }
                // If it gets here, the user has selected a collection

                Intent intent = new Intent(mContext, CollectionPage.class);

                CollectionListInfo listEntry = mCollectionListEntries.get(position);

                intent.putExtra(CollectionPage.COLLECTION_NAME, listEntry.getName());
                intent.putExtra(CollectionPage.COLLECTION_TYPE_INDEX, listEntry.getCollectionTypeIndex());

                startActivity(intent);
            }
        });
    }

    /**
     * Kicks off the import process by reading in the import files into our internal representation.
     * Once this is complete, it kicks off an AsyncTask to actually store the data in the database.
     */
    private void handleImportCollectionsPart1() {

        // Check for READ_EXTERNAL_STORAGE permissions (must request starting in API Level 23)
        // hasPermissions() will kick off the permissions request and the handler will re-call
        // this method after prompting the user.
        if (!hasPermissions(Manifest.permission.READ_EXTERNAL_STORAGE, IMPORT_PERMISSIONS_REQUEST)) {
            return;
        }

        // See whether we can read from the external storage
        String state = Environment.getExternalStorageState();
        if (Environment.MEDIA_MOUNTED.equals(state)) {
            // Should be able to read from it without issue
        } else if (Environment.MEDIA_SHARED.equals(state)) {
            // Shared with PC so can't write to it
            showCancelableAlert(mRes.getString(R.string.cannot_rd_ext_media_shared));
            return;
        } else {
            // Doesn't exist, so notify user
            showCancelableAlert(mRes.getString(R.string.cannot_rd_ext_media_state, state));
            return;
        }

        //http://stackoverflow.com/questions/3551821/android-write-to-sd-card-folder
        File sdCard = Environment.getExternalStorageDirectory();
        String path = sdCard.getAbsolutePath() + EXPORT_FOLDER_NAME;
        File dir = new File(path);

        if (!dir.isDirectory()) {
            // The directory doesn't exist, notify the user
            showCancelableAlert(mRes.getString(R.string.cannot_find_export_dir, path));
            return;
        }

        boolean errorOccurred = false;

        // Read the database version
        File inputFile = new File(dir, "database_version.txt");
        CSVReader in = openFileForReading(inputFile);
        if (in == null)
            return;

        try {
            String[] values = in.readNext();
            if (values == null || values.length != 1) {
                throw new Exception();
            }
            mDatabaseVersion = Integer.parseInt(values[0]);
        } catch (Exception e) {
            showCancelableAlert(mRes.getString(R.string.error_reading_file, inputFile.getAbsolutePath()));
            return;
        }

        if (-1 == mDatabaseVersion) {
            showCancelableAlert(mRes.getString(R.string.error_db_version));
            return;
        }

        if (!closeInputFile(in, inputFile)) {
            return;
        }

        // Read the collection_info table
        inputFile = new File(dir, "list-of-collections.csv");
        in = openFileForReading(inputFile);
        if (in == null)
            return;

        ArrayList<CollectionListInfo> collectionInfo = new ArrayList<>();

        try {
            String[] items;

            // TODO Raise an error if in.readNext returns null
            while (null != (items = in.readNext())) {

                // Perform some sanity checks here
                int numberOfColumns = 5;

                if (items.length != numberOfColumns) {
                    errorOccurred = true;
                    showCancelableAlert(mRes.getString(R.string.error_invalid_backup_file, 1));
                    break;
                }

                String name = items[0];
                String type = items[1];
                int totalCollected = Integer.valueOf(items[2]);
                int total = Integer.valueOf(items[3]);
                int displayType = Integer.valueOf(items[4]);

                // Strip out all bad characters.  They shouldn't be there anyway ;)
                name = name.replace('[', ' ');
                name = name.replace(']', ' ');

                // Must be a valid coin type
                int i;
                for (i = 0; i < MainApplication.COLLECTION_TYPES.length; i++) {

                    if (MainApplication.COLLECTION_TYPES[i].getCoinType().equals(type)) {
                        break;
                    } else if (MainApplication.COLLECTION_TYPES.length == i + 1) {
                        errorOccurred = true;
                        showCancelableAlert(mRes.getString(R.string.error_invalid_backup_file, 2));
                        break;
                    }
                }

                int coinTypeIndex = i;

                if (errorOccurred) {
                    break;
                }

                // Must have a positive number collected and a positive max
                if (totalCollected < 0 || total < 0) {
                    errorOccurred = true;
                    showCancelableAlert(mRes.getString(R.string.error_invalid_backup_file, 3));
                    break;
                }

                // Must not have a name that is the same as a previous one
                for (i = 0; i < collectionInfo.size(); i++) {

                    CollectionListInfo previousCollectionListInfo = collectionInfo.get(i);
                    if (name.equals(previousCollectionListInfo.getName())) {
                        errorOccurred = true;
                        showCancelableAlert(mRes.getString(R.string.error_invalid_backup_file, 4));
                        break;
                    }
                }

                if (displayType != CollectionPage.SIMPLE_DISPLAY
                        && displayType != CollectionPage.ADVANCED_DISPLAY) {
                    errorOccurred = true;
                    showCancelableAlert(mRes.getString(R.string.error_invalid_backup_file, 5));
                    break;
                }

                if (errorOccurred) {
                    break;
                }

                // Everything checks out, so create a new CollectionListInfo
                // for this
                CollectionListInfo info = new CollectionListInfo(name, total, totalCollected, coinTypeIndex,
                        displayType);

                // Good to go, add it to the list
                collectionInfo.add(info);
            }
        } catch (Exception e) {
            errorOccurred = true;
            showCancelableAlert(mRes.getString(R.string.error_unknown_read, inputFile.getAbsolutePath()));
        }

        // Close the input file.  If we've already shown an error then no
        // need to show another one if the close fails
        if (!closeInputFile(in, inputFile, errorOccurred)) {
            return;
        }

        if (errorOccurred) {
            // Don't continue on
            return;
        }

        // The ArrayList will be indexed by collection, the outer String[] will be indexed
        // by line number, and the inner String[] will be each cell in the row
        ArrayList<String[][]> collectionContents = new ArrayList<>();

        // We loaded in the collection "metadata" table, so now load in each collection
        for (int i = 0; i < collectionInfo.size(); i++) {

            CollectionListInfo collectionData = collectionInfo.get(i);

            // If any '/''s exist in the collection name, change them to "_SL_" to match
            // the export logic (used to prevent slashes from being confused as path
            // delimiters when opening the file.)
            String collectionFileName = collectionData.getName().replaceAll("/", "_SL_");

            inputFile = new File(dir, collectionFileName + ".csv");

            if (!inputFile.isFile()) {
                showCancelableAlert(mRes.getString(R.string.cannot_find_input_file, inputFile.getAbsolutePath()));
                return;
            }

            in = openFileForReading(inputFile);
            if (in == null)
                return;

            ArrayList<String[]> collectionContent = new ArrayList<>();

            try {
                String[] items;
                while (null != (items = in.readNext())) {

                    // Perform some sanity checks and clean-up here
                    int numberOfColumns = 6;

                    if (items.length < numberOfColumns) {
                        errorOccurred = true;
                        showCancelableAlert(mRes.getString(R.string.error_invalid_backup_file, 11) + " "
                                + String.valueOf(items.length));
                        break;
                    }

                    // TODO Maybe add more checks
                    collectionContent.add(items);
                }

            } catch (Exception e) {
                errorOccurred = true;
                showCancelableAlert(mRes.getString(R.string.error_unknown_read, inputFile.getAbsolutePath()));
            }

            if (errorOccurred) {
                // Don't continue on
                closeInputFile(in, inputFile, true);
                return;
            }

            // Verify that we read in the correct number of records
            if (collectionContent.size() != collectionData.getMax()) {
                errorOccurred = true;
                showCancelableAlert(mRes.getString(R.string.error_invalid_backup_file, 12));
            }

            // TODO Can this happen? ClassCastException Object[] cannot be cast to String[][]
            collectionContents.add(collectionContent.toArray(new String[0][]));

            if (!closeInputFile(in, inputFile)) {
                return;
            }

            if (errorOccurred) {
                // Don't continue on
                return;
            }
        }

        // Cool, at this point we've read in the data successfully and we've passed all of
        // the sanity checks.  We should put this data aside, show the user a message to
        // have them confirm that they want to do this... Although if they don't have any
        // collections we can optimize this step out
        mImportedCollectionListInfos = collectionInfo;
        mCollectionContents = collectionContents;

        if (0 == mNumberOfCollections) {
            // Finish the import by kicking off an AsyncTask to do the heavy lifting          
            mTask = new InitTask();

            mTask.doImport = true;
            mTask.activity = this;

            mTask.execute();

        } else {
            showImportConfirmation();
        }
    }

    private void handleImportCollectionsCancel() {
        // Release the memory associated with the collection info we read in
        this.mImportedCollectionListInfos = null;
        this.mCollectionContents = null;
        this.mDatabaseVersion = -1;
    }

    /**
     * Finishes strong with some heavy lifting (putting the imported data into the database.)  This
     * should be done from an AsyncTask, since it could cause an ANR error if done on the main
     * thread!
     */
    private void handleImportCollectionsPart2() {

        // Take the data we've stored and replace what's in the database with it

        // NOTE We can't use the showCancelableAlert here because this doesn't get
        // executed on the main thread.

        mDbAdapter.open();

        // TODO Consider how to make this more robust to failures
        for (int i = 0; i < mNumberOfCollections; i++) {

            CollectionListInfo info = mCollectionListEntries.get(i);
            mDbAdapter.dropTable(info.getName());
        }

        mDbAdapter.dropCollectionInfoTable();

        mDbAdapter.createCollectionInfoTable();

        for (int i = 0; i < mImportedCollectionListInfos.size(); i++) {
            CollectionListInfo collectionListInfo = mImportedCollectionListInfos.get(i);
            String[][] collectionContents = mCollectionContents.get(i);

            String name = collectionListInfo.getName();
            String coinType = collectionListInfo.getCollectionObj().getCoinType();
            int total = collectionListInfo.getMax();
            int displayType = collectionListInfo.getDisplayType();

            mDbAdapter.createNewTable(name, coinType, total, displayType, i, collectionContents);
        }

        // Release the memory associated with the collection info we read in
        mImportedCollectionListInfos = null;
        mCollectionContents = null;

        // Update any imported tables, if necessary
        if (mDatabaseVersion != MainApplication.DATABASE_VERSION) {
            mDbAdapter.upgradeCollections(mDatabaseVersion);
        }
        mDatabaseVersion = -1;

        mDbAdapter.close();

        // Looks like the view gets reloaded automatically... Hooray!
    }

    /**
     * Begins the collection export process by doing some preliminary external media checks and
     * prompts the user if an export will overwrite previous backup files.
     */
    private void handleExportCollectionsPart1() {
        // TODO Move this function to be more resistant to ANR, if reports show that it is a
        // problem

        // Check for WRITE_EXTERNAL_STORAGE permissions (must request starting in API Level 23)
        // hasPermissions() will kick off the permissions request and the handler will re-call
        // this method after prompting the user.
        if (!hasPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE, EXPORT_PERMISSIONS_REQUEST)) {
            return;
        }

        // See whether we can write to the external storage
        String state = Environment.getExternalStorageState();
        if (Environment.MEDIA_MOUNTED.equals(state)) {
            // Should be able to write to it without issue
        } else if (Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
            // Can't write to it, so notify user
            showCancelableAlert(mRes.getString(R.string.cannot_wr_ext_media_ro));
            return;
        } else if (Environment.MEDIA_SHARED.equals(state)) {
            // Shared with PC so can't write to it
            showCancelableAlert(mRes.getString(R.string.cannot_wr_ext_media_shared));
            return;
        } else {
            // Doesn't exist, so notify user
            showCancelableAlert(mRes.getString(R.string.cannot_wr_ext_media_state, state));
            return;
        }

        //http://stackoverflow.com/questions/3551821/android-write-to-sd-card-folder
        File sdCard = Environment.getExternalStorageDirectory();
        String path = sdCard.getAbsolutePath() + EXPORT_FOLDER_NAME;
        File dir = new File(path);

        if (dir.isDirectory() || dir.exists()) {
            // Let the user decide whether they want to delete this
            showExportConfirmation();
        } else {
            // Proceed with exporting directly
            handleExportCollectionsPart2();
        }
    }

    /**
     * Finishes the collection export process (after giving the user a chance to cancel if this will
     * cause an existing backup to be deleted.)
     */
    private void handleExportCollectionsPart2() {

        // At this point we know we can write to storage and the user is ok
        // if we blow away existing imported files

        // TODO Move this function to be more resistant to ANR, if necessary

        //http://stackoverflow.com/questions/3551821/android-write-to-sd-card-folder
        File sdCard = Environment.getExternalStorageDirectory();
        String path = sdCard.getAbsolutePath() + EXPORT_FOLDER_NAME;
        File dir = new File(path);

        if (!dir.isDirectory() && !dir.mkdir()) {
            // The directory doesn't exist, notify the user
            showCancelableAlert(mRes.getString(R.string.failed_mk_dir, path));
            return;
        }

        boolean errorOccurred = false;

        // Write out the collection_info table
        File outputFile = new File(dir, "list-of-collections.csv");
        CSVWriter out = openFileForWriting(outputFile);
        if (out == null)
            return;

        mDbAdapter.open();

        // Iterate through the list of collections and write the files
        for (int i = 0; i < mNumberOfCollections; i++) {
            // name, coinType, total, max, display

            CollectionListInfo item = mCollectionListEntries.get(i);

            String name = item.getName();
            String type = item.getType();
            String totalCollected = String.valueOf(item.getCollected());
            String total = String.valueOf(item.getMax());

            // NOTE For display, don't use item.getDisplayType bc I don't
            // think we populate that value except when importing...
            // TODO Update the code so that this value is used instead
            // of the separate fetchTableDisplay calls
            String display = String.valueOf(mDbAdapter.fetchTableDisplay(name));

            String[] values = new String[] { name, type, totalCollected, total, display };

            out.writeNext(values);
        }

        if (!closeOutputFile(out, outputFile)) {
            return;
        }

        if (errorOccurred) {
            return;
        }

        // Write out the database version
        outputFile = new File(dir, "database_version.txt");
        out = openFileForWriting(outputFile);
        if (out == null)
            return;

        String[] version = new String[] { String.valueOf(MainApplication.DATABASE_VERSION) };
        out.writeNext(version);

        if (!closeOutputFile(out, outputFile)) {
            return;
        }

        // Write out all of the other tables
        for (int i = 0; i < mNumberOfCollections; i++) {
            CollectionListInfo item = mCollectionListEntries.get(i);
            String name = item.getName();

            // Handle '/''s in the file names (otherwise importing will fail, because the OS will
            // think the '/' characters are folder delimiters.)  This will be undone when we import.
            String cleanName = name.replaceAll("/", "_SL_");

            outputFile = new File(dir, cleanName + ".csv");
            out = openFileForWriting(outputFile);
            if (out == null)
                return;

            // coinIdentifier, coinMint, inCollection, advGradeIndex, advQuantityIndex, advNotes
            Cursor resultCursor = mDbAdapter.getAllCollectionInfo(name);
            if (resultCursor.moveToFirst()) {
                do {
                    String coinIdentifier = resultCursor.getString(resultCursor.getColumnIndex("coinIdentifier"));
                    String coinMint = resultCursor.getString(resultCursor.getColumnIndex("coinMint"));
                    String inCollection = String
                            .valueOf(resultCursor.getInt(resultCursor.getColumnIndex("inCollection")));
                    String advGradeIndex = String
                            .valueOf(resultCursor.getInt(resultCursor.getColumnIndex("advGradeIndex")));
                    String advQuantityIndex = String
                            .valueOf(resultCursor.getInt(resultCursor.getColumnIndex("advQuantityIndex")));
                    String advNotes = resultCursor.getString(resultCursor.getColumnIndex("advNotes"));

                    String[] values = new String[] { coinIdentifier, coinMint, inCollection, advGradeIndex,
                            advQuantityIndex, advNotes };

                    out.writeNext(values);

                } while (resultCursor.moveToNext());
            }
            resultCursor.close();

            if (!closeOutputFile(out, outputFile)) {
                return;
            }
        }

        mDbAdapter.close();

        showCancelableAlert(mRes.getString(R.string.success_export, EXPORT_FOLDER_NAME));
    }

    private void showCancelableAlert(String text) {
        mBuilder.setMessage(text).setCancelable(true);
        AlertDialog alert = mBuilder.create();
        alert.show();
    }

    private CSVReader openFileForReading(File file) {

        try {
            // Tell the CSVReader to use the NULL character as the escape
            // character to effectively allow no escape characters
            // (otherwise, '\' is the escape character, and it can be
            // typed by users!)
            CSVReader reader = new CSVReader(new FileReader(file), CSVWriter.DEFAULT_SEPARATOR,
                    CSVWriter.DEFAULT_QUOTE_CHARACTER, '\0');
            return reader;
        } catch (Exception e) {
            Log.e(MainApplication.APP_NAME, e.toString());
            showCancelableAlert(mRes.getString(R.string.error_open_file_reading, file.getAbsolutePath()));
            return null;
        }
    }

    private boolean closeInputFile(CSVReader in, File file) {
        return closeInputFile(in, file, false);
    }

    private boolean closeInputFile(CSVReader in, File file, boolean silent) {
        try {
            in.close();
        } catch (IOException e) {
            if (!silent) {
                showCancelableAlert(mRes.getString(R.string.error_closing_input_file, file.getAbsolutePath()));
            }
            return false;
        }
        return true;
    }

    private CSVWriter openFileForWriting(File file) {

        try {
            CSVWriter writer = new CSVWriter(new FileWriter(file));
            return writer;
        } catch (Exception e) {
            Log.e(MainApplication.APP_NAME, e.toString());
            showCancelableAlert(mRes.getString(R.string.error_open_file_writing, file.getAbsolutePath()));
            return null;
        }
    }

    private boolean closeOutputFile(CSVWriter out, File file) {
        try {
            out.close();
        } catch (IOException e) {
            showCancelableAlert(mRes.getString(R.string.error_closing_output_file, file.getAbsolutePath()));
            return false;
        }
        return true;
    }

    // https://developer.android.com/training/permissions/requesting.html
    // Expected: Manifest.permission.{READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE}
    private boolean hasPermissions(String permission, int callbackTag) {

        int permissionState = ContextCompat.checkSelfPermission(this, permission);
        if (permissionState != PackageManager.PERMISSION_GRANTED) {

            // Not providing an explanation but the user should know what this is for
            // This will prompt the user to grant/deny permissions, and the result will
            // be delivered via a callback.
            ActivityCompat.requestPermissions(this, new String[] { permission }, callbackTag);

            return false;
        }
        return true;
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults) {

        if ((grantResults.length > 0) && (grantResults[0] == PackageManager.PERMISSION_GRANTED)) {
            // Request Granted!
            switch (requestCode) {
            case IMPORT_PERMISSIONS_REQUEST: {
                // Retry import, now with permissions granted
                handleImportCollectionsPart1();
                break;
            }
            case EXPORT_PERMISSIONS_REQUEST: {
                // Retry export, now with permissions granted
                handleExportCollectionsPart1();
                break;
            }
            }
        } else {
            // Request Denied!
            switch (requestCode) {
            case IMPORT_PERMISSIONS_REQUEST: {
                showCancelableAlert(mRes.getString(R.string.import_canceled));
                break;
            }
            case EXPORT_PERMISSIONS_REQUEST: {
                showCancelableAlert(mRes.getString(R.string.export_canceled));
                break;
            }
            }
        }
    }

    // Need to make our own Array Adapter to handle the special list (list of collections + entries
    // for 'Create Collections', 'Reorder Collections', etc.)
    // Thanks! http://www.softwarepassion.com/android-series-custom-listview-items-and-adapters/
    private class FrontAdapter extends ArrayAdapter<CollectionListInfo> {

        public ArrayList<CollectionListInfo> items;
        public int numberOfCollections;
        private Resources mRes;

        public FrontAdapter(Context context, ArrayList<CollectionListInfo> items, int numberOfCollections) {
            super(context, R.layout.list_element, R.id.textView1, items);
            this.items = items;
            this.numberOfCollections = numberOfCollections;
            mRes = context.getResources();
        }

        @Override
        public int getViewTypeCount() {
            return 2;
        }

        @Override
        public int getItemViewType(int position) {
            if (position >= this.numberOfCollections) {
                return 1;
            } else {
                return 0;
            }
        }

        @Override
        public View getView(int position, View convertView, ViewGroup parent) {
            View v = convertView;
            int viewType = getItemViewType(position);
            if (v == null) {
                LayoutInflater vi = (LayoutInflater) getSystemService(Context.LAYOUT_INFLATER_SERVICE);

                if (0 == viewType) {
                    v = vi.inflate(R.layout.list_element, parent, false);
                } else {
                    v = vi.inflate(R.layout.list_element_navigation, parent, false);
                }
            }

            if (1 == viewType) {
                // Set up the non-collection views
                ImageView image = (ImageView) v.findViewById(R.id.navImageView);
                TextView text = (TextView) v.findViewById(R.id.navTextView);

                int newPosition = position - this.numberOfCollections;

                switch (newPosition) {
                case ADD_COLLECTION:
                    image.setBackgroundResource(R.drawable.icon_circle_add);
                    text.setText(mRes.getString(R.string.create_new_collection));
                    break;
                case REMOVE_COLLECTION:
                    image.setBackgroundResource(R.drawable.icon_minus);
                    text.setText(mRes.getString(R.string.delete_collection));
                    break;
                case IMPORT_COLLECTIONS:
                    image.setBackgroundResource(R.drawable.icon_cloud_upload);
                    text.setText(mRes.getString(R.string.import_collection));

                    break;
                case EXPORT_COLLECTIONS:
                    image.setBackgroundResource(R.drawable.icon_cloud_download);
                    text.setText(mRes.getString(R.string.export_collection));

                    break;
                case REORDER_COLLECTIONS:
                    image.setBackgroundResource(R.drawable.icon_sort);
                    text.setText(mRes.getString(R.string.reorder_collection));

                    break;
                case ABOUT:
                    image.setBackgroundResource(R.drawable.icon_info);
                    text.setText(mRes.getString(R.string.app_info));

                    break;
                }

                return v;
            }

            // If it gets here, we need to set up a view for a collection

            CollectionListInfo item = items.get(position);

            String tableName = item.getName();

            int total = item.getCollected();
            if (tableName != null) {

                ImageView image = (ImageView) v.findViewById(R.id.imageView1);
                if (image != null) {
                    image.setBackgroundResource(item.getCoinImageIdentifier());
                }

                TextView tt = (TextView) v.findViewById(R.id.textView1);
                if (tt != null) {
                    tt.setText(tableName);
                }

                TextView mt = (TextView) v.findViewById(R.id.textView2);
                if (mt != null) {
                    mt.setText(total + "/" + item.getMax());
                }

                TextView bt = (TextView) v.findViewById(R.id.textView3);
                if (total >= item.getMax()) {
                    // The collection is complete
                    if (bt != null) {
                        bt.setText(mRes.getString(R.string.collection_complete));
                    }
                } else {
                    bt.setText("");
                }
            }
            return v;
        }
    }

    @Override
    public void onWindowFocusChanged(boolean hasFocus) {
        super.onWindowFocusChanged(hasFocus);

        // Note that this provides information about global focus state, which is managed
        // independently of activity lifecycles. As such, while focus changes will generally have
        // some relation to lifecycle changes (an activity that is stopped will not generally get
        // window focus), you should not rely on any particular order between the callbacks here
        // and those in the other lifecycle methods such as onResume().

        // We use this function as a convenience for updating the database once the list gets focus
        // after returning from the add/delete/reorder views.

        if (hasFocus && mDatabaseHasBeenOpened && !mIsImportingCollection) {

            // Only do this if the database has been opened with the AsyncTask first
            // and we aren't modifying the database like crazy (importing)
            // We need this so that new collections that are added/removed get shown

            updateCollectionListFromDatabase();

            // Change it out with the new list
            mListAdapter.items = mCollectionListEntries;
            mListAdapter.numberOfCollections = mNumberOfCollections;
            mListAdapter.notifyDataSetChanged();

        }
    }

    /**
     * Reloads the collection list from the database.  This is useful after changes have been made
     * (collections reordered, deleted, etc.)
     */
    private void updateCollectionListFromDatabase() {

        // Get rid of the other items in the list (if any)
        mCollectionListEntries.clear();

        // Open the database.  The "big" open should have been called already.
        mDbAdapter.open();

        //Get a list of all the database tables
        Cursor resultCursor = mDbAdapter.getAllTables();

        if (resultCursor.moveToFirst()) {
            do {
                CollectionListInfo listEntry = new CollectionListInfo();

                String name = resultCursor.getString(resultCursor.getColumnIndex("name"));
                String coinType = resultCursor.getString(resultCursor.getColumnIndex("coinType"));
                int total = resultCursor.getInt(resultCursor.getColumnIndex("total"));

                // Figure out what collection type maps to this
                // TODO Not the best way to do this, find a better one
                int index;
                for (index = 0; index < MainApplication.COLLECTION_TYPES.length; index++) {
                    if (MainApplication.COLLECTION_TYPES[index].getCoinType().equals(coinType)) {
                        break;
                    }
                }
                // TODO Consider adding error check in case we didn't find the name in typeOfCoins...
                // This is pretty unlikely, though
                listEntry.setName(name);
                listEntry.setMax(total);
                listEntry.setCollected(mDbAdapter.fetchTotalCollected(name));
                listEntry.setCollectionTypeIndex(index);

                // Add it to the list of collections
                mCollectionListEntries.add(listEntry);

            } while (resultCursor.moveToNext());
        }
        resultCursor.close();

        mDbAdapter.close();

        // Record the actual number of collections before spacers are added
        mNumberOfCollections = mCollectionListEntries.size();

        // We use an ArrayAdapter to power the ListView, but since we want to add in somethings that
        // don't have items in the list, we add in some blank entries to account for them.  Pretty
        // hacked together but it should work.
        for (int i = 0; i < NUMBER_OF_COLLECTION_LIST_SPACERS; i++) {
            mCollectionListEntries.add(null);
        }
    }

    private void showExportConfirmation() {

        AlertDialog.Builder builder = new AlertDialog.Builder(this);
        builder.setMessage(mRes.getString(R.string.export_warning)).setCancelable(false)
                .setPositiveButton(mRes.getString(R.string.yes), new DialogInterface.OnClickListener() {
                    public void onClick(DialogInterface dialog, int id) {
                        // TODO Maybe use AsyncTask, if necessary
                        handleExportCollectionsPart2();
                    }
                }).setNegativeButton(mRes.getString(R.string.no), new DialogInterface.OnClickListener() {
                    public void onClick(DialogInterface dialog, int id) {
                        dialog.cancel();
                    }
                });
        AlertDialog alert = builder.create();
        alert.show();
    }

    private void showImportConfirmation() {

        AlertDialog.Builder builder = new AlertDialog.Builder(this);
        builder.setMessage(mRes.getString(R.string.import_warning)).setCancelable(false)
                .setPositiveButton(mRes.getString(R.string.yes), new DialogInterface.OnClickListener() {
                    public void onClick(DialogInterface dialog, int id) {
                        // Finish the import by kicking off an AsyncTask to do the heavy lifting
                        mTask = new InitTask();

                        mTask.doImport = true;
                        mTask.activity = MainActivity.this;

                        MainActivity.this.mIsImportingCollection = true;

                        mTask.execute();
                    }
                }).setNegativeButton(mRes.getString(R.string.no), new DialogInterface.OnClickListener() {
                    public void onClick(DialogInterface dialog, int id) {
                        dialog.cancel();
                        handleImportCollectionsCancel();
                    }
                });
        AlertDialog alert = builder.create();
        alert.show();
    }

    private void showDeleteConfirmation(String name) {

        // TODO Not sure why we have to do this????  Take out and ensure no breakage.  Maybe when I
        // originally wrote this my Java skills were just poor ;)
        final String name2 = name;

        AlertDialog.Builder builder = new AlertDialog.Builder(this);
        builder.setMessage(mRes.getString(R.string.delete_warning, name2)).setCancelable(false)
                .setPositiveButton(mRes.getString(R.string.yes), new DialogInterface.OnClickListener() {
                    public void onClick(DialogInterface dialog, int id) {
                        //Do the deleting
                        mDbAdapter.open();
                        mDbAdapter.dropTable(name2);

                        //Get a list of all the database tables
                        Cursor resultCursor = mDbAdapter.getAllCollectionNames();
                        int i = 0;
                        if (resultCursor.moveToFirst()) {
                            do {
                                String name = resultCursor.getString(resultCursor.getColumnIndex("name"));
                                // Fix up the displayOrder
                                mDbAdapter.updateDisplayOrder(name, i);
                                i++;
                            } while (resultCursor.moveToNext());
                        }
                        resultCursor.close();

                        mDbAdapter.close();
                    }
                }).setNegativeButton(mRes.getString(R.string.no), new DialogInterface.OnClickListener() {
                    public void onClick(DialogInterface dialog, int id) {
                        dialog.cancel();
                    }
                });
        AlertDialog alert = builder.create();
        alert.show();
    }

    /**
     * Used by the ReorderCollections fragment to get our context
     * TODO Better way for it to get 'this'?
     * @return our Activity context
     */
    public Context getContext() {
        return this;
    }

    // https://raw.github.com/commonsguy/cw-android/master/Rotation/RotationAsync/src/com/commonsware/android/rotation/async/RotationAsync.java
    // TODO Consider only using one of onSaveInstanceState and onRetainNonConfigurationInstanceState
    // TODO Also, read the notes on this better and make sure we are using it correctly
    @Override
    public Object onRetainCustomNonConfigurationInstance() {

        if (mProgressDialog != null && mProgressDialog.isShowing()) {
            mProgressDialog.dismiss();
            return mTask;
        } else {
            // No dialog showing, do nothing
            return null;
        }
    }

    @Override
    public void onDestroy() {

        // TODO Not a perfect solution, but assuming this gets called, we should cut down on the
        // race condition inherent in how we do our AsyncTask
        if (mTask != null) {
            mTask.activity = null;
        }
        super.onDestroy();
    }

    /**
     * sub-class of AsyncTask
     * Example from http://code.google.com/p/makemachine/source/browse/trunk/android/examples/async_task/src/makemachine/android/examples/async/AsyncTaskExample.java
     */

    // We need to use this AsyncTask for two purposes - opening the database and also
    // importing a collection.

    // TODO For passing the AsyncTask between Activity instances, see this post:
    // http://www.androiddesignpatterns.com/2013/04/retaining-objects-across-config-changes.html
    // Our method is subject to the race conditions described therein :O

    class InitTask extends AsyncTask<Void, Void, Void> {
        // Use these to know whether we are opening the database or importing new ones
        // TODO Make these strings come from the strings resource file
        boolean doImport = false;
        final String importMessage = "Importing Collections...";

        boolean doOpen = false;
        final String openMessage = "Opening Databases...";

        MainActivity activity;

        @Override
        protected Void doInBackground(Void... params) {
            if (activity == null) {
                return null;
            }

            if (doOpen) {

                DatabaseAdapter dbAdapter = new DatabaseAdapter(activity);
                dbAdapter.open();
                // Now just close it, since the database will have updated and
                // that's really all we need this AsyncTask for
                dbAdapter.close();

            } else if (doImport) {

                // Start doing all of the file reading and database importing
                activity.handleImportCollectionsPart2();
            }

            return null;
        }

        // -- gets called just before thread begins
        @Override
        protected void onPreExecute() {
            super.onPreExecute();

            String message = openMessage;

            if (doImport) {
                message = importMessage;
            }

            if (activity == null) {
                return;
            }

            activity.mProgressDialog = new ProgressDialog(activity);
            activity.mProgressDialog.setCancelable(false);
            activity.mProgressDialog.setMessage(message);
            activity.mProgressDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER);
            activity.mProgressDialog.setProgress(0);
            activity.mProgressDialog.show();

        }

        @Override
        protected void onProgressUpdate(Void... values) {
            super.onProgressUpdate(values);
        }

        @Override
        protected void onPostExecute(Void result) {
            super.onPostExecute(result);

            if (activity == null) {
                return;
            }

            if (activity.mProgressDialog.isShowing()) {
                activity.mProgressDialog.dismiss();
            }
            activity.mProgressDialog = null;

            if (doOpen) {

                activity.mDatabaseHasBeenOpened = true;

                activity.finishViewSetup();

            } else if (doImport) {

                activity.mIsImportingCollection = false;

                // If we've rotated and need to finish setting up the view, do it
                if (activity.mShouldFinishViewSetupToo) {
                    activity.finishViewSetup();
                    activity.mShouldFinishViewSetupToo = false;
                }

                // Good to go!
            }
            doOpen = false;
            doImport = false;
            activity = null;
        }
    }

    /**
     * Takes the reordered list of collections in from the ReorderCollections fragment and updates
     * the ordering in the database.
     *
     * @param reorderedList The reordered list of collections
     */
    public void handleCollectionsReordered(ArrayList<CollectionListInfo> reorderedList) {

        mDbAdapter.open();
        for (int i = 0; i < reorderedList.size(); i++) {
            CollectionListInfo info = reorderedList.get(i);
            mDbAdapter.updateDisplayOrder(info.getName(), i);
            mCollectionListEntries.set(i, info);
        }
        mDbAdapter.close();
    }

    private String buildInfoText() {
        HashSet<String> attributions = new HashSet<>();
        for (CollectionInfo collection : MainApplication.COLLECTION_TYPES) {
            String attribution = collection.getAttributionString();

            if (attribution.equals("")) {
                continue;
            }

            attributions.add(attribution);
        }

        StringBuilder builder = new StringBuilder();

        builder.append(getResources().getString(R.string.info_overview));
        builder.append("\n\n");
        for (String attribution : attributions) {
            builder.append(attribution);
            builder.append("\n\n");
        }

        return builder.toString();
    }

}