net.tawacentral.roger.secrets.FileUtils.java Source code

Java tutorial

Introduction

Here is the source code for net.tawacentral.roger.secrets.FileUtils.java

Source

// Copyright (c) 2009, Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package net.tawacentral.roger.secrets;

import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.OutputStream;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;

import javax.crypto.Cipher;
import javax.crypto.CipherInputStream;
import javax.crypto.CipherOutputStream;

import net.tawacentral.roger.secrets.SecurityUtils.CipherInfo;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import android.app.backup.BackupAgentHelper;
import android.app.backup.BackupDataInput;
import android.app.backup.BackupDataOutput;
import android.app.backup.FileBackupHelper;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.ParcelFileDescriptor;
import android.util.Log;
import au.com.bytecode.opencsv.CSVReader;
import au.com.bytecode.opencsv.CSVWriter;

/**
 * Helper class to manage reading and writing the secrets file.  The file
 * is encrypted using the ciphers created by the SecurityUtils helper
 * functions.
 *
 * Methods that touch the main secrets files are thread safe.  This allows
 * the file to be saved in a background thread so that the UI is not blocked
 * in the most common use cases.  Note that stopping the app and restarting
 * it again may still cause the UI to block if the write take a long time.
 *
 * @author rogerta
 */
@SuppressWarnings("javadoc")
public class FileUtils {
    /** Return value for the getSaltAndRounds() function. */
    public static class SaltAndRounds {
        public SaltAndRounds(byte[] salt, int rounds) {
            this.salt = salt;
            this.rounds = rounds;
        }

        public byte[] salt;
        public int rounds;
    }

    /** Name of the preferences file for backup. */
    public static final String PREFS_FILE_NAME = "backup";

    /**
     * Name of last backup date preference. Long value as number or millis as
     * returned by System.currentTimeMillis().
     */
    public static final String PREF_LAST_BACKUP_DATE = "last_backup_date";

    /**
     * Name of last nag date preference. Long value as number or
     * millis as returned by System.currentTimeMillis().  This is the time at
     * which we last nagged the user to enable online backup.
     */
    public static final String PREF_LAST_NAG_DATE = "last_nag_date";

    /** Name of the secrets file. */
    public static final String SECRETS_FILE_NAME = "secrets";

    /** Name of the secrets backup file on the SD card. */
    public static final String SECRETS_FILE_NAME_SDCARD = "/sdcard/secrets";

    /** Name of the secrets CSV file on the SD card. */
    public static final String SECRETS_FILE_NAME_CSV = "/sdcard/secrets.csv";

    /** Name of the OI Safe CSV file on the SD card. */
    public static final String OI_SAFE_FILE_NAME_CSV = "/sdcard/oisafe.csv";

    private static final File SECRETS_FILE_CSV = new File(SECRETS_FILE_NAME_CSV);
    private static final File OI_SAFE_FILE_CSV = new File(OI_SAFE_FILE_NAME_CSV);

    /** Secrets CSV column names */
    public static final String COL_DESCRIPTION = "Description";
    public static final String COL_USERNAME = "Id";
    public static final String COL_PASSWORD = "PIN";
    public static final String COL_EMAIL = "Email";
    public static final String COL_NOTES = "Notes";

    private static final String EMPTY_STRING = "";
    private static final String INDENT = "   ";
    private static final String RP_PREFIX = "@";

    // secrets ID for JSON
    private static final String JSON_SECRETS_ID = "secrets";

    /** Tag for logging purposes. */
    public static final String LOG_TAG = "FileUtils";

    /** Lock for accessing main secrets file. */
    private static final Object lock = new Object();

    private static final byte[] SIGNATURE = { 0x22, 0x34, 0x56, 0x79 };

    /** Does the secrets file exist? */
    public static boolean secretsExist(Context context) {
        // Instead of just checking for the existence of the secrets file
        // explicitly, I will check for the existence of any file in the
        // application's data directory.  This check is valid because:
        //
        //  - when the user runs the app for the first time, an empty secrets file
        //    is always written
        //  - there is at least one file in existences even during the save
        //    operation
        //
        // The benefit of making this assumption is that I don't need to acquire
        // the file lock in order to test for the existence of the secrets file.
        // This speeds up leaving the secrets list activity since the test for
        // existence does not need to wait for the save to finish.  This also
        // speeds the wake time when secrets was active at the time the phone
        // went to sleep.
        String[] filenames = context.fileList();
        return filenames.length > 0;
    }

    /** Does the secrets restore file exist on the SD card? */
    public static boolean restoreFileExist() {
        File file = new File(SECRETS_FILE_NAME_SDCARD);
        return file.exists();
    }

    /**
     * Gets the time of the last online backup.
     *
     * @param ctx A context to get the preferences from.
     * @return The time of the last online backup, as millisecs since epoch.
     */
    public static long getTimeOfLastOnlineBackup(Context ctx) {
        return ctx.getSharedPreferences(PREFS_FILE_NAME, 0).getLong(PREF_LAST_BACKUP_DATE, 0);
    }

    /**
     * Is the online backup too old?
     *
     * @param ctx A context to get the preferences from.
     * @return True if the last backup is too old.
     */
    public static boolean isOnlineBackupTooOld(Context ctx) {
        long now = System.currentTimeMillis();
        long lastSaved = getTimeOfLastOnlineBackup(ctx);

        // If lastSaved is zero, this means an online backup has never been done.
        // Nag the user about it, but no more than once per week.
        if (lastSaved == 0) {
            SharedPreferences prefs = ctx.getSharedPreferences(PREFS_FILE_NAME, 0);
            long lastNag = prefs.getLong(PREF_LAST_NAG_DATE, 0);
            final long sixDays = 6 * 24 * 60 * 60 * 1000; // 6 days in millis.
            final long oneWeek = 7 * 24 * 60 * 60 * 1000; // One week in millis.

            // Don't warn the very first day the user runs the program.
            if (lastNag == 0) {
                prefs.edit().putLong(PREF_LAST_NAG_DATE, now - sixDays).apply();
            } else if ((now - lastNag) > oneWeek) {
                // In order not to nag users more than once a week about missing online
                // backup support, set the last nag time to now.
                prefs.edit().putLong(PREF_LAST_NAG_DATE, now).apply();
                return true;
            }
        }

        return false;
    }

    /** Is the restore point too old? */
    private static boolean isRestorePointTooOld(File file) {
        long lastModified = file.lastModified();
        long now = System.currentTimeMillis();
        long twoDays = 2 * 24 * 60 * 60 * 1000; // 2 days.

        return (now - lastModified) > twoDays;
    }

    /**
     * Get all existing restore points, including the restore file on the SD card
     * if it exists.
     *
     * @param context Activity context in which the save is called.
     * @return A list of all possible restore points.
     */
    public static List<String> getRestorePoints(Context context) {
        String[] filenames = context.fileList();
        ArrayList<String> list = new ArrayList<String>(filenames.length + 1);
        if (restoreFileExist())
            list.add(SECRETS_FILE_NAME_SDCARD);

        for (String filename : filenames) {
            if (filename.startsWith(RP_PREFIX))
                list.add(filename);
        }

        return list;
    }

    /**
     * Cleanup any residual data files from a previous bad run, if any.  The
     * algorithm is as follows:
     *
     * - delete any file with "new" in the name.  These are possibly partial
     *   writes, so their contents is undefined.
     * - if no secrets file exists, rename the most recent auto restore point
     *   file to secrets.
     * - if too many auto restore point files exist, delete the extra ones.
     *   However, don't delete any auto-backups younger than 48 hours.
     *
     * @param context Activity context in which the save is called.
     */
    public static void cleanupDataFiles(Context context) {
        Log.d(LOG_TAG, "FileUtils.cleanupDataFiles");
        synchronized (lock) {
            String[] filenames = context.fileList();
            int oldCount = filenames.length;
            boolean secretsFileExists = context.getFileStreamPath(SECRETS_FILE_NAME).exists();

            // Cleanup any partial saves and find the most recent auto-backup file.
            {
                File mostRecent = null;
                int mostRecentIndex = -1;
                for (int i = 0; i < filenames.length; ++i) {
                    String filename = filenames[i];
                    if (-1 != filename.indexOf("new")) {
                        context.deleteFile(filename);
                        --oldCount;
                        filenames[i] = null;
                    } else if (filename.startsWith(RP_PREFIX)) {
                        if (!secretsFileExists) {
                            File f = context.getFileStreamPath(filename);
                            if (null == mostRecent || f.lastModified() > mostRecent.lastModified()) {
                                mostRecent = f;
                                mostRecentIndex = i;
                            }
                        }
                    } else {
                        --oldCount;
                        filenames[i] = null;
                    }
                }

                // If we don't have a secrets file but found an auto-backup file,
                // rename the more recent auto-backup to secrets.
                if (null != mostRecent) {
                    mostRecent.renameTo(context.getFileStreamPath(SECRETS_FILE_NAME));
                    --oldCount;
                    filenames[mostRecentIndex] = null;
                }
            }

            // If there are too many old files, delete the oldest extra ones.
            while (oldCount > 10) {
                File oldest = null;
                int oldestIndex = -1;

                for (int i = 0; i < filenames.length; ++i) {
                    String filename = filenames[i];
                    if (null == filename)
                        continue;

                    File f = context.getFileStreamPath(filename);
                    if (null == oldest || f.lastModified() < oldest.lastModified()) {
                        oldest = f;
                        oldestIndex = i;
                    }
                }

                if (null != oldest) {
                    // If the oldest file is not too old, then just break out of the
                    // loop.  We don't want to delete any "old" files that are too
                    // recent.
                    if (!FileUtils.isRestorePointTooOld(oldest))
                        break;

                    oldest.delete();
                    --oldCount;
                    filenames[oldestIndex] = null;
                }
            }
        }
    }

    /**
     * Gets the salt and rounds already in use on this device, or null if none
     * exists.
     *
     * @param context Activity context in which the save is called.
     * @param path The file to read the salt and rounds from.  Can either be the
     *     string SECRETS_FILE_NAME_SDCARD, SECRETS_FILE_NAME, or the  name of a
     *     restore point.
     * @return the salt and rounds
     */
    public static SaltAndRounds getSaltAndRounds(Context context, String path) {
        // The salt is stored as a byte array at the start of the secrets file.
        FileInputStream input = null;
        try {
            input = SECRETS_FILE_NAME_SDCARD.equals(path) ? new FileInputStream(path) : context.openFileInput(path);
            return getSaltAndRounds(input);
        } catch (Exception ex) {
            Log.e(LOG_TAG, "getSaltAndRounds", ex);
        } finally {
            try {
                if (null != input)
                    input.close();
            } catch (IOException ex) {
            }
        }
        return new SaltAndRounds(null, 0);
    }

    /**
     * Gets the salt and rounds already in use on this device, or null if none
     * exists.
     *
     * @param input The stream to read the salt and rounds from.
     * @return the salt and rounds
     *
     * @throws IOException
     */
    public static SaltAndRounds getSaltAndRounds(InputStream input) throws IOException {
        // The salt is stored as a byte array at the start of the secrets file.
        byte[] signature = new byte[SIGNATURE.length];
        byte[] salt = null;
        int rounds = 0;
        input.read(signature);
        if (Arrays.equals(signature, SIGNATURE)) {
            int length = input.read();
            salt = new byte[length];
            input.read(salt);
            rounds = input.read();
            if (rounds < 4 || rounds > 31) {
                salt = null;
                rounds = 0;
            }
        }

        return new SaltAndRounds(salt, rounds);
    }

    /**
     * Saves the secrets to file using the password retrieved from the user.
     *
     * @param context Activity context in which the save is called.
     * @param existing The file to save into.
     * @param cipher The encryption cipher to use with the file.
     * @param salt The salt used to create the cipher.
     * @param rounds The number of rounds for bcrypt.
     * @param secrets The collection of secrets to save.
     * @return True if saved successfully.
     */
    public static int saveSecrets(Context context, File existing, Cipher cipher, byte[] salt, int rounds,
            ArrayList<Secret> secrets) {
        Log.d(LOG_TAG, "FileUtils.saveSecrets");
        synchronized (lock) {
            Log.d(LOG_TAG, "FileUtils.saveSecrets: got lock");

            // To be as safe as possible, for example to handle low space conditions,
            // we will save the secrets to a file using the following steps:
            //
            //  1- write the secrets to a new temporary file (tempn)
            //     on error: delete tempn
            //  2- rename the existing secrets file, if any (to tempo)
            //     on error: delete tempn
            //  3- rename the new temporary file to the official file name
            //     on error: rename tempo back to existing, delete tempn
            //
            // The old file we hang around for a while.  The cleanupDataFiles()
            // method, which is called whenever Secrets is re-launched, will make
            // sure that the old file don't accumulate indefinitely.
            String prefix = MessageFormat.format(RP_PREFIX + "{0,date,yy.MM.dd}-{0,time,HH:mm}", new Date(), null);
            File parent = existing.getParentFile();
            File tempn = new File(parent, "new");
            File tempo = new File(parent, prefix);
            for (int i = 0; tempn.exists() || tempo.exists(); ++i) {
                tempn = new File(parent, "new" + i);
                tempo = new File(parent, prefix + i);
            }
            // Step 1
            FileOutputStream fos = null;
            try {
                fos = new FileOutputStream(tempn);
                writeSecrets(fos, cipher, salt, rounds, secrets);
            } catch (Exception ex) {
                Log.d(LOG_TAG, "FileUtils.saveSecrets: could not write secrets file");
                // NOTE: this delete() works, even though the file is still open.
                tempn.delete();
                return R.string.error_save_secrets;
            } finally {
                try {
                    if (null != fos)
                        fos.close();
                } catch (IOException ex) {
                }
            }

            // Step 2
            if (existing.exists() && !existing.renameTo(tempo)) {
                Log.d(LOG_TAG, "FileUtils.saveSecrets: could not move existing file");
                tempn.delete();
                return R.string.error_cannot_move_existing;
            }

            // Step 3
            if (!tempn.renameTo(existing)) {
                Log.d(LOG_TAG, "FileUtils.saveSecrets: could not move new file");
                tempo.renameTo(existing);
                tempn.delete();
                return R.string.error_cannot_move_new;
            }

            Log.d(LOG_TAG, "FileUtils.saveSecrets: done");
            return 0;
        }
    }

    /**
     * Backup the secrets to SD card using the password retrieved from the user.
     *
     * @param context Activity context in which the backup is called.
     * @param cipher The encryption cipher to use with the file.
     * @param salt The salt used to create the cipher.
     * @param rounds The number of rounds for bcrypt.
     * @param secrets The list of secrets to save.
     * @return True if saved successfully
     */
    public static boolean backupSecrets(Context context, Cipher cipher, byte[] salt, int rounds,
            ArrayList<Secret> secrets) {
        Log.d(LOG_TAG, "FileUtils.backupSecrets");

        if (null == cipher)
            return false;

        FileOutputStream output = null;
        boolean success = false;

        try {
            output = new FileOutputStream(SECRETS_FILE_NAME_SDCARD);
            writeSecrets(output, cipher, salt, rounds, secrets);
            success = true;
        } catch (Exception ex) {
        } finally {
            try {
                if (null != output)
                    output.close();
            } catch (IOException ex) {
            }
        }

        return success;
    }

    /* start new load/restore methods */

    /*
     * Load methods.
     * These handle historic file and cipher formats for backward compatability
     * with old secrets and backup files. They differ in both the cipher used and
     * underlying data format.
     * 
     * V1 used a cipher with fixed salt and rounds values (C1), and Java serialized
     * object format (F1).
     * V2 used a cipher with variable salt and round values (C2), stored in the secrets
     * file header, and Java serialized object format. Because of the new
     * stored values, the file format (F2) differs from V1.
     * V3 used a modified version of the V2 cipher (password fix) (C3), and the same
     * file format as V2.
     * Current (V4): uses same V3 cipher mechanism, and JSON file format (F3)
     * 
     * Pictorially:
     *                 Cipher format
     *                        
     *             |  C1  |  C2  |  C3  
     *          ---|------|------|------
     *          F1 |  V1  |      |
     * File     ---|------|------|------
     * format   F2 |      |  V2  |  V3
     *          ---|------|------|------
     *          F3 |      |      |  V4
     */

    /**
     * Opens the secrets file using the password retrieved from the user.
     *
     * @param context Activity context in which the load is called.
     * @return A list of loaded secrets.
     */
    public static ArrayList<Secret> loadSecrets(Context context) {
        synchronized (lock) {
            Log.d(LOG_TAG, "FileUtils.loadSecrets: got lock");
            return loadSecrets(context, SECRETS_FILE_NAME, SecurityUtils.getCipherInfo());
        }
    }

    /**
     * Opens the secrets file using the password retrieved from the user.
     *
     * @param context Activity context in which the load is called.
     * @param fileName Name of file to be loaded
     * @param info CipherInfo
     * @return A list of loaded secrets.
     */
    public static ArrayList<Secret> loadSecrets(Context context, String fileName, CipherInfo info) {
        Log.d(LOG_TAG, "FileUtils.loadSecrets");

        if (null == info)
            return null;

        ArrayList<Secret> secrets = null;
        InputStream input = null;

        try {
            input = SECRETS_FILE_NAME_SDCARD.equals(fileName) ? new FileInputStream(fileName)
                    : context.openFileInput(fileName);
            secrets = readSecrets(input, info.decryptCipher, info.salt, info.rounds);
        } catch (Exception ex) {
            Log.e(LOG_TAG, "loadSecrets", ex);
        } finally {
            try {
                if (null != input)
                    input.close();
            } catch (IOException ex) {
            }
        }
        Log.d(LOG_TAG, "FileUtils.loadSecrets: done");
        return secrets;
    }

    /**
     * Opens the secrets file using the password retrieved from the user and
     * the old encryption cipher.  This function is called only for backward
     * compatibility purposes, when Secrets encounters an error trying to load
     * the secrets using the current encryption method.
     * 
     * V1 used fixed rounds and salt, not stored in the file.
     *
     * @param context Activity context in which the load is called.
     * @param cipher Decryption cipher for old encryption.
     * @return A list of loaded secrets.
     */
    public static ArrayList<Secret> loadSecretsV1(Context context, Cipher cipher) {
        synchronized (lock) {
            Log.d(LOG_TAG, "FileUtils.loadSecretsV1: got lock");
            return loadSecretsV1(context, cipher, SECRETS_FILE_NAME);
        }
    }

    /**
     * See previous method for description.
     * 
     * @param context Activity context in which the load is called.
     * @param cipher Decryption cipher for old encryption.
     * @param fileName Name of file to be loaded
     * @return A list of loaded secrets.
     */
    @SuppressWarnings("unchecked")
    public static ArrayList<Secret> loadSecretsV1(Context context, Cipher cipher, String fileName) {
        Log.d(LOG_TAG, "FileUtils.loadSecretsV1");
        if (null == cipher)
            return null;

        ArrayList<Secret> secrets = null;
        ObjectInputStream input = null;

        try {
            InputStream fis = SECRETS_FILE_NAME_SDCARD.equals(fileName) ? new FileInputStream(fileName)
                    : context.openFileInput(fileName);
            input = new ObjectInputStream(new CipherInputStream(fis, cipher));
            secrets = (ArrayList<Secret>) input.readObject();
        } catch (Exception ex) {
            Log.e(LOG_TAG, "loadSecretsV1", ex);
        } finally {
            try {
                if (null != input)
                    input.close();
            } catch (IOException ex) {
            }
        }
        Log.d(LOG_TAG, "FileUtils.loadSecretsV1: done");
        return secrets;
    }

    /**
     * Opens the secrets file using the password retrieved from the user and
     * the old encryption cipher.  This function is called only for backward
     * compatibility purposes, when Secrets encounters an error trying to load
     * the secrets using the current encryption method.
     * 
     * V2 used variable rounds and salt, stored in the file header.
     *
     * @param context Activity context in which the load is called.
     * @param cipher Decryption cipher for old encryption.
     * @param salt The salt to use when creating the encryption key.
     * @param rounds The number of rounds for bcrypt.
     * @return A list of loaded secrets.
     */
    public static ArrayList<Secret> loadSecretsV2(Context context, Cipher cipher, byte[] salt, int rounds) {
        synchronized (lock) {
            return loadSecretsV2(context, SECRETS_FILE_NAME, cipher, salt, rounds);
        }
    }

    /**
     * See previous method for description.
     *
     * @param context Activity context in which the load is called.
     * @param fileName Name of file to be loaded
     * @param cipher Decryption cipher for old encryption.
     * @param salt The salt to use when creating the encryption key.
     * @param rounds The number of rounds for bcrypt.
     * @return A list of loaded secrets.
     */
    public static ArrayList<Secret> loadSecretsV2(Context context, String fileName, Cipher cipher, byte[] salt,
            int rounds) {
        Log.d(LOG_TAG, "FileUtils.loadSecretsV2");
        if (null == cipher)
            return null;
        ArrayList<Secret> secrets = null;
        InputStream input = null;

        try {
            input = SECRETS_FILE_NAME_SDCARD.equals(fileName) ? new FileInputStream(fileName)
                    : context.openFileInput(fileName);
            secrets = readSecretsV2(input, cipher, salt, rounds);
        } catch (Exception ex) {
            Log.e(LOG_TAG, "loadSecretsV2", ex);
        } finally {
            try {
                if (null != input)
                    input.close();
            } catch (IOException ex) {
            }
        }

        return secrets;
    }

    /**
     * Opens the secrets file using the password retrieved from the user. This
     * function is called only for backward compatibility purposes, when Secrets
     * encounters an error trying to load the secrets using the current encryption
     * method.
     *
     * V3 used the current cipher mechanism and the old object file format.
     *
     * @param context
     *          Activity context in which the load is called.
     * @return A list of loaded secrets.
     */
    public static ArrayList<Secret> loadSecretsV3(Context context) {
        synchronized (lock) {
            Log.d(LOG_TAG, "FileUtils.loadSecretsV21: got lock");
            return loadSecretsV3(context, SecurityUtils.getCipherInfo(), SECRETS_FILE_NAME);
        }
    }

    /**
     * See previous method for description.
     *
     * @param context
     *          Activity context in which the load is called.
     * @param fileName Name of file to be loaded
     * @param info CipherInfo
     * @return A list of loaded secrets.
     */
    public static ArrayList<Secret> loadSecretsV3(Context context, CipherInfo info, String fileName) {
        Log.d(LOG_TAG, "FileUtils.loadSecretsV3");

        if (null == info)
            return null;

        ArrayList<Secret> secrets = null;
        InputStream input = null;

        try {
            input = SECRETS_FILE_NAME_SDCARD.equals(fileName) ? new FileInputStream(fileName)
                    : context.openFileInput(fileName);
            secrets = readSecretsV2(input, info.decryptCipher, info.salt, info.rounds);
        } catch (Exception ex) {
            Log.e(LOG_TAG, "loadSecretsV3", ex);
        } finally {
            try {
                if (null != input)
                    input.close();
            } catch (IOException ex) {
            }
        }
        Log.d(LOG_TAG, "FileUtils.loadSecretsv3: done");
        return secrets;
    }

    /* end new load/restore methods */

    /**
     * Writes the secrets to the given output stream encrypted with the given
     * cipher.
     *
     * The output stream is closed by the caller.
     *
     * @param output The output stream to write the secrets to.
     * @param cipher The cipher to encrypt the secrets with.
     * @param secrets The secrets to write.
     * @param rounds The number of rounds for bcrypt.
     * @throws IOException
     */
    private static void writeSecrets(OutputStream output, Cipher cipher, byte[] salt, int rounds,
            ArrayList<Secret> secrets) throws IOException {
        output.write(SIGNATURE);
        output.write(salt.length);
        output.write(salt);
        output.write(rounds);
        output.write(FileUtils.toEncryptedJSONSecretsStream(cipher, secrets));
        output.flush();
    }

    /**
     * Read the secrets from the given input stream, decrypting with the given
     * cipher.
     *
     * @param input
     *          The input stream to read the secrets from.
     * @param cipher
     *          The cipher to decrypt the secrets with.
     * @return The secrets read from the stream.
     * @throws IOException
     * @throws ClassNotFoundException
     */
    private static ArrayList<Secret> readSecrets(InputStream input, Cipher cipher, byte[] salt, int rounds)
            throws IOException, ClassNotFoundException {
        SaltAndRounds pair = getSaltAndRounds(input);
        if (!Arrays.equals(pair.salt, salt) || pair.rounds != rounds) {
            return null;
        }
        BufferedInputStream bis = new BufferedInputStream(input);
        ByteArrayOutputStream buffer = new ByteArrayOutputStream();
        try {
            // read the whole stream into the buffer
            int nRead;
            byte[] data = new byte[4096];
            while ((nRead = bis.read(data, 0, data.length)) != -1) {
                buffer.write(data, 0, nRead);
            }
            buffer.flush();
            return FileUtils.fromEncryptedJSONSecretsStream(cipher, buffer.toByteArray());
        } finally {
            try {
                if (null != bis)
                    bis.close();
            } catch (IOException ex) {
            }
        }
    }

    /**
     * Read the secrets from the given input stream, decrypting with the given
     * cipher. This uses the old object format and exists for compatibility.
     *
     * @param input The input stream to read the secrets from.
     * @param cipher The cipher to decrypt the secrets with.
     * @return The secrets read from the stream.
     * @throws IOException
     * @throws ClassNotFoundException
     */
    @SuppressWarnings("unchecked")
    private static ArrayList<Secret> readSecretsV2(InputStream input, Cipher cipher, byte[] salt, int rounds)
            throws IOException, ClassNotFoundException {
        SaltAndRounds pair = getSaltAndRounds(input);
        if (!Arrays.equals(pair.salt, salt) || pair.rounds != rounds) {
            return null;
        }
        ObjectInputStream oin = new ObjectInputStream(new CipherInputStream(input, cipher));
        try {
            return (ArrayList<Secret>) oin.readObject();
        } finally {
            try {
                if (null != oin)
                    oin.close();
            } catch (IOException ex) {
            }
        }
    }

    /**
     * Returns an json object representing the contained secrets.
     *
     * @param secrets
     *          The list of secrets.
     * @return String of secrets
     * @throws JSONException
     */
    public static JSONObject toJSONSecrets(ArrayList<Secret> secrets) throws JSONException {
        JSONObject jsonValues = new JSONObject();
        JSONArray jsonSecrets = new JSONArray();
        for (Secret secret : secrets) {
            jsonSecrets.put(secret.toJSON());
        }
        jsonValues.put(JSON_SECRETS_ID, jsonSecrets);

        return jsonValues;
    }

    /**
     * Constructs a secrets collection from the supplied JSON object
     *
     * @param jsonValues
     *          JSON object
     * @return list of secrets
     * @throws JSONException
     *           if error with JSON data
     */
    public static ArrayList<Secret> fromJSONSecrets(JSONObject jsonValues) throws JSONException {
        JSONArray jsonSecrets = jsonValues.getJSONArray(JSON_SECRETS_ID);
        ArrayList<Secret> secretList = new ArrayList<Secret>();
        for (int i = 0; i < jsonSecrets.length(); i++) {
            secretList.add(Secret.fromJSON((JSONObject) jsonSecrets.get(i)));
        }

        return secretList;
    }

    /**
     * Returns an encrypted json stream representing the user's secrets.
     *
     * @param cipher
     *          The encryption cipher to use with the file.
     * @param secrets
     *          The list of secrets.
     * @return byte array of secrets
     * @throws IOException
     *           if any error occurs
     */
    public static byte[] toEncryptedJSONSecretsStream(Cipher cipher, ArrayList<Secret> secrets) throws IOException {
        CipherOutputStream output = null;
        ByteArrayOutputStream baos = new ByteArrayOutputStream();

        try {
            output = new CipherOutputStream(baos, cipher);
            output.write(FileUtils.toJSONSecrets(secrets).toString().getBytes("UTF-8"));
        } catch (Exception e) {
            Log.e(LOG_TAG, "toEncryptedJSONSecretsStream", e);
            throw new IOException("toEncryptedJSONSecretsStream failed: " + e.getMessage());
        } finally {
            try {
                if (null != output)
                    output.close();
            } catch (IOException ex) {
            }
        }

        return baos.toByteArray();
    }

    /**
     * Constructs secrets from the supplied encrypted byte stream
     *
     * @param cipher
     *          cipher to use
     * @param secrets
     *          encrypted byte array
     * @return list of secrets
     * @throws IOException
     *           if any error occurs
     */
    public static ArrayList<Secret> fromEncryptedJSONSecretsStream(Cipher cipher, byte[] secrets)
            throws IOException {
        try {
            byte[] secretStrBytes = cipher.doFinal(secrets);
            JSONObject jsonValues = new JSONObject(new String(secretStrBytes, "UTF-8"));
            return FileUtils.fromJSONSecrets(jsonValues);
        } catch (Exception e) {
            Log.e(LOG_TAG, "fromEncryptedJSONSecretsStream", e);
            throw new IOException("fromEncryptedJSONSecretsStream failed: " + e.getMessage());
        }
    }

    /** Deletes all secrets from the phone.
     * @param context the current context
     * @return always true
     */
    public static boolean deleteSecrets(Context context) {
        Log.d(LOG_TAG, "FileUtils.deleteSecrets");
        synchronized (lock) {
            String filenames[] = context.fileList();
            for (String filename : filenames) {
                context.deleteFile(filename);
            }
        }

        return true;
    }

    /**
     * Export secrets to a CSV file on the SD card.  See the description of
     * the importSecrets() method for more details about the format written.
     * @param context the current context
     * @param secrets the secrets to export
     * @return true if successful, false otherwise
     */
    public static boolean exportSecrets(Context context, List<Secret> secrets) {
        // An array to hold the rows that will be written to the CSV file.
        String[] row = new String[] { COL_DESCRIPTION, COL_USERNAME, COL_PASSWORD, COL_EMAIL, COL_NOTES };
        CSVWriter writer = null;
        boolean success = false;

        try {
            writer = new CSVWriter(new FileWriter(SECRETS_FILE_NAME_CSV));

            // Write descriptive headers.
            writer.writeNext(row);

            // Write out each secret.
            for (Secret secret : secrets) {
                row[0] = secret.getDescription();
                row[1] = secret.getUsername();
                row[2] = secret.getPassword(true); // true: forExport
                row[3] = secret.getEmail();
                row[4] = secret.getNote();

                // NOTE: writeNext() handles nulls in row[] gracefully.
                writer.writeNext(row);
                success = true;
            }
        } catch (Exception ex) {
            Log.e(LOG_TAG, "exportSecrets", ex);
        } finally {
            try {
                if (null != writer)
                    writer.close();
            } catch (IOException ex) {
            }
        }

        return success;
    }

    /**
     * Returns the file that should be imported.  This method will look for a file
     * on the SD card whose name is either the secrets CSV file or the OI Safe
     * CSV file.  To support other formats, should add some code here to detect
     * those files.
     *
     * If more than one CSV file of exist, the one last modified is used.
     * @return the file to import
     */
    public static File getFileToImport() {
        boolean haveSecretsCsv = SECRETS_FILE_CSV.exists();
        boolean haveOiSafeCsv = OI_SAFE_FILE_CSV.exists();
        File file = null;

        if (haveSecretsCsv && haveOiSafeCsv) {
            if (SECRETS_FILE_CSV.lastModified() > OI_SAFE_FILE_CSV.lastModified()) {
                file = SECRETS_FILE_CSV;
            } else {
                file = OI_SAFE_FILE_CSV;
            }
        } else if (haveSecretsCsv) {
            file = SECRETS_FILE_CSV;
        } else if (haveOiSafeCsv) {
            file = OI_SAFE_FILE_CSV;
        }

        return file;
    }

    /**
     * Import secrets from a CSV file.  This function will first clear the secrets
     * list argument before adding anything read from the file. If there are no
     * errors while reading, true is returned.
     *
     * Note that its possible for this function to return false, and yet the
     * secrets list is not empty.  This means that some, but not all, secrets
     * were able to be read from the file.
     *
     * For the moment, this function assumes that the first line is a description
     * so it is not imported.  At least 5 columns are assumed for each line:
     * description, username, password, email, and notes, in that order (this is
     * the default format as written by exportSecrets()).
     *
     * This function will attempt to detect OI Safe CSV files and import them
     * accordingly.  It does this by reading the first line of file, and looking
     * for column descriptions as exported by OI Safe 1.1.0.
     *
     * @param context Activity context for global services
     * @param file File to import
     * @param secrets List to append secrets read from the file
     * @return True if all secrets read successfully, and false otherwise
     */
    public static boolean importSecrets(Context context, File file, ArrayList<Secret> secrets) {
        secrets.clear();

        boolean isOiSafeCsv = false;
        boolean isSecretsScv = false;
        boolean success = false;
        CSVReader reader = null;

        try {
            reader = new CSVReader(new FileReader(file));

            // Use the first line to determine the type of csv file.  Secrets will
            // output 5 columns, with the names as used in the exportSecrets()
            // function.  OI Safe 1.1.0 is also detected.
            String headers[] = reader.readNext();
            if (null != headers) {
                isSecretsScv = isSecretsCsv(headers);
                if (!isSecretsScv)
                    isOiSafeCsv = isOiSafeCsv(headers);
            }

            // Read all the rest of the lines as secrets.
            for (;;) {
                String[] row = reader.readNext();
                if (null == row)
                    break;

                Secret secret = new Secret();
                if (isOiSafeCsv) {
                    secret.setDescription(row[1]);
                    secret.setUsername(row[3]);
                    secret.setPassword(row[4], false);
                    secret.setEmail(EMPTY_STRING);

                    // I will combine the category, website, and notes columns into
                    // the notes field in secrets.
                    int approxMaxLength = row[0].length() + row[2].length() + row[5].length() + 32;
                    StringBuilder builder = new StringBuilder(approxMaxLength);
                    builder.append(row[5]).append("\n\n");
                    builder.append("Category: ").append(row[0]).append('\n');
                    if (null != row[2] && row[2].length() > 0)
                        builder.append("Website: ").append(row[2]).append('\n');

                    secret.setNote(builder.toString());
                } else {
                    // If we get here, then this may be an unknown format.  For better
                    // or for worse, this is a "best effort" to import that data.
                    secret.setDescription(row[0]);
                    secret.setUsername(row[1]);
                    secret.setPassword(row[2], false);
                    secret.setEmail(row[3]);
                    secret.setNote(row[4]);
                }

                secrets.add(secret);
            }

            // We'll only return complete success if we get here, and we detected
            // that we knew the file format.  This will give the user an indication
            // do look at the secrets if the format was not automatically detected.
            success = isOiSafeCsv || isSecretsScv;
        } catch (Exception ex) {
            Log.e(LOG_TAG, "importSecrets", ex);
        } finally {
            try {
                if (null != reader)
                    reader.close();
            } catch (IOException ex) {
            }
        }

        return success;
    }

    /** Is it likely that the CSV file is in OI Safe format? */
    private static boolean isOiSafeCsv(String[] headers) {
        if (headers[0].equalsIgnoreCase("Category") && headers[1].equalsIgnoreCase("Description")
                && headers[2].equalsIgnoreCase("Website") && headers[3].equalsIgnoreCase("Username")
                && headers[4].equalsIgnoreCase("Password") && headers[5].equalsIgnoreCase("Notes"))
            return true;

        return false;
    }

    /** Is it likely that the CSV file is in secrets format? */
    private static boolean isSecretsCsv(String[] headers) {
        if (headers[0].equalsIgnoreCase(COL_DESCRIPTION) && headers[1].equalsIgnoreCase(COL_USERNAME)
                && headers[2].equalsIgnoreCase(COL_PASSWORD) && headers[3].equalsIgnoreCase(COL_EMAIL)
                && headers[4].equalsIgnoreCase(COL_NOTES))
            return true;

        return false;
    }

    /** Returns a list of the supported CSV file names, newline separated. */
    public static String getCsvFileNames() {
        StringBuilder builder = new StringBuilder();
        builder.append(INDENT).append(SECRETS_FILE_CSV.getName()).append('\n');
        builder.append(INDENT).append(OI_SAFE_FILE_CSV.getName());

        return builder.toString();
    }

    static public class SecretsBackupAgent extends BackupAgentHelper {
        /** Tag for logging purposes. */
        public static final String LOG_TAG_AGENT = "SecretsBackupAgent";

        /** Key in backup set for file data. */
        private static final String KEY = "file";

        @Override
        public void onCreate() {
            Log.d(LOG_TAG_AGENT, "onCreate");

            FileBackupHelper helper = new FileBackupHelper(this, FileUtils.SECRETS_FILE_NAME);
            addHelper(KEY, helper);
        }

        @Override
        public void onBackup(ParcelFileDescriptor oldState, BackupDataOutput data, ParcelFileDescriptor newState)
                throws IOException {
            Log.d(LOG_TAG_AGENT, "onBackup");
            synchronized (lock) {
                super.onBackup(oldState, data, newState);
            }
            getSharedPreferences(PREFS_FILE_NAME, 0).edit()
                    .putLong(PREF_LAST_BACKUP_DATE, System.currentTimeMillis()).apply();
        }

        @Override
        public void onRestore(BackupDataInput data, int appVersionCode, ParcelFileDescriptor newState)
                throws IOException {
            Log.d(LOG_TAG_AGENT, "onRestore");
            synchronized (lock) {
                super.onRestore(data, appVersionCode, newState);
            }
        }

        @Override
        public void onDestroy() {
            Log.d(LOG_TAG_AGENT, "onDestroy");
            super.onDestroy();
        }
    }
}