DbTradeAlert for Android: Add Backup

First post in this series: Introduction to DbTradeAlert

Previous post: Add Settings and Finishing Touches


Adding backup requires just a few lines of code or even nothing at all. But it requires an astounding amount of explanations so lets start with explanations.

Using Android’s Built-In Backup

Starting with Marshmallow Android will back up your app’s data and settings automatically. The only requirement for that is the user’s consent during Google account creation. This will also backup their WiFi passwords which is a dealbreaker for many people. That said Auto Backup just works – no effort required by you or your app’s users.

If your app targets API levels below 23 (Marshmallow) or runs on a Pre-Marshmallow device it can still use Android’s built-in backup but you have to work for it:

  1. Register for the Android backup service and put the key it issued into the app’s manifest
  2. Extend BackupAgentHelper and mention it in the app’s manifest
  3. Use FileBackupHelper and SharedPreferencesBackupHelper to point to individual files for backup
  4. Notify Google of data changes by calling BackupManager.dataChanged()

No matter which way the app uses Android’s built-in backup: it just automagically works. That also means you and your app’s users have no access to individual files and cannot control when data is backed up. And Google only keeps the last version – tough luck if that is the wrong one.

I needed access to DbTradeAlert’s database file because of the somewhat special requirement to switch between a database for personal use and one for screenshots. And being able to copy an SQLite database back and forth between the phone and a computer means you can do for example bulk updates with a tool like DB Browser for SQLite. So I needed more of an import / export functionality than a backup mechanism.

DbTradeAlert.db in DB Browser for SQLite

DbTradeAlert.db in DB Browser for SQLite

Importing and Exporting a Database

Again thanks to Stack Overflow coding that was less work than explaining it. This time let’s start with the code changes.

First step is to extend the app’s menu:

It gets an additional entry with two sub menu items to export and import the database.

The next step is to handle these sub menu items in WatchlistListActivity:

public class WatchlistListActivity extends AppCompatActivity
        implements WatchlistFragment.OnListFragmentInteractionListener {
    // ...
    private long copyFile(File sourceFile, File targetFile) {
        final String methodName = "copyFile";
        long bytesTransferred = 0;
        FileChannel sourceChannel = null;
        FileChannel targetChannel = null;
        try {
            try {
                sourceChannel = new FileInputStream(sourceFile).getChannel();
                Log.v(CLASS_NAME, String.format(
                        "%s(): sourcefile = %s", methodName, sourceFile));
                targetChannel = new FileOutputStream(targetFile).getChannel();
                Log.v(CLASS_NAME, String.format(
                        "%s(): targetFile = %s", methodName, targetFile));
                bytesTransferred
                        = targetChannel.transferFrom(sourceChannel, 0, sourceChannel.size());
                Log.v(CLASS_NAME, String.format(
                        "%s(): bytesTransferred = %d", methodName, bytesTransferred));
            } finally {
                if (sourceChannel != null) {
                    sourceChannel.close();
                }
                if (targetChannel != null) {
                    targetChannel.close();
                }
            }
        } catch (IOException e) {
            Log.e(CLASS_NAME, "Exception caught", e);
            Toast.makeText(this, e.toString(), Toast.LENGTH_LONG).show();
        }
        return bytesTransferred;
    } // copyFile()

    // ...

    private void copyDatabase(boolean isExport) {
        File sourceDb;
        File targetDb;
        String message;
        File externalFilesDir = getExternalFilesDir(null);
        if (externalFilesDir != null) {
            if (isExport) {
                sourceDb = getDatabasePath(DbHelper.DB_NAME);
                targetDb = new File(externalFilesDir, DbHelper.DB_NAME);
            } else {
                sourceDb = new File(externalFilesDir, DbHelper.DB_NAME);
                targetDb = getDatabasePath(DbHelper.DB_NAME);
            }
            long bytesCopied = copyFile(sourceDb, targetDb);
            if (isExport) {
                MediaScannerConnection.scanFile(WatchlistListActivity.this,
                        new String[]{targetDb.getAbsolutePath()}, null, null);
            }
            message = (isExport ? "Exported to " : "Imported from ")
                    + targetDb + " (" + bytesCopied + " bytes copied)";
        } else {
            message = "External files directory not available. SD card unmounted?";
        }
        Log.d(CLASS_NAME, message);
        Toast.makeText(this, message, Toast.LENGTH_LONG).show();
    } // copyDatabase()

    // ...

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        // Handle action bar item clicks here. The action bar will
        // automatically handle clicks on the Home/Up button, so long
        // as you specify a parent activity in AndroidManifest.xml.
        Intent intent;
        int id = item.getItemId();
        switch (id) {
            case R.id.action_export_database: {
                boolean isExport = true;
                copyDatabase(isExport);
                return true;
            }
            case R.id.action_import_database: {
                // Have user confirm overwriting of DB by import
                new AlertDialog.Builder(this)
                        .setIcon(android.R.drawable.ic_dialog_alert)
                        .setMessage("Overwrite existing database? No undo!")
                        .setNegativeButton("Cancel", null)
                        .setPositiveButton(android.R.string.yes,
                                new DialogInterface.OnClickListener() {
                                    public void onClick(DialogInterface dialog, int whichButton) {
                                        boolean isExport = false;
                                        copyDatabase(isExport);
                                    }
                                })
                        .setTitle("Overwrite DB?")
                        .show();
                return true;
            }            
            // ...
            default:
                return super.onOptionsItemSelected(item);
        }
    } // onOptionsItemSelected()

    // ...
}

Exporting a database is straightforward as WatchlistListActivity.copyDatabase() can rely on built-in methods to determine the directory of the app’s database as well as the target directory. In this instance it’s “/data/user/0/de.dbremes.dbtradealert/databases/” and “/storage/emulated/0/Android/data/de.dbremes.dbtradealert/files/”.

In both cases “0” stands for the first user created on the device – Android is a multi-user environment since 4.2 (API 17). And as Android only permits to specify the database’s name in SQLiteOpenHelper’s constructor it’s no wonder it knows where to find the database. Context.getExternalFilesDir() is where things get interesting.

The most important point about Context.getExternalFilesDir() is that “External” means shared with the outside world – it doesn’t necessarily mean physically extern like an SD card. I’ll spare you the dirty details of internal vs. external vs. SD card storage and recommend CommonsWare’s 5 part blog post about that if you can take it. Oh and in addition to the details he mentioned in 2014 Android 6.0 / Marshmallow complicated things further with adopted vs. portable SD cards.

DbTradeAlert just needs to copy the database file to or from a directory that’s accessible from a computer – a.k.a. shared – and Context.getExternalFilesDir() points to that directory. Some points to note about Context.getExternalFilesDir():

  • The directory is app specific and starting with API level 19 an app doesn’t need permissions to read from or write to it.
  • The directory gets created automatically when copyFile() does its job.
  • You can pass values like Environment.DIRECTORY_PICTURES to get specialized directories.
  • The method returns null if the external storage it points to isn’t mounted. I don’t know the scenario in which the primary external storage would be on removable media though.
  • Its sibling getExternalFilesDirs() (note plural) returns the same as getExternalFilesDir() in its first entry. The other entries point to secondary external storage which will be on removable media. Check their state with Environment.getExternalStorageState() before accessing them.
  • The directory and everything in it gets deleted when a user uninstalls the app.

Another important point is the call to MediaScannerConnection.scanFile() – without it the new file wouldn’t be visible for a connected computer. That’s because Android uses MTP (Media Transfer Protocol) to share files with the world outside since version 3.1 / Honeycomb / API level 12. With MTP a connected computer has no direct acccess to the file system but asks a server on the device to read or write directories and files. And that server needs to be informed when a new file or directory is created outside special directories for pictures, movies and such.

The only point that’s left is to ask for the permission for writing to external storage on devices with Android prior to API level 19:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="de.dbremes.dbtradealert">

    <!-- uses-permission elements need to precede application element -->
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
    <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
    <uses-permission android:name="android.permission.WAKE_LOCK" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="18" />

    <!-- application element -->
</manifest>

To try it out:

  1. In this scenario it’s a given but a user has to make shure
    1. the device is connected to the computer
    2. the USB usage notification says “USB for file transfers”
  2. In DbTradeAlert’s overflow menu select Copy Database | Export Database
  3. In the computer’s explorer find the device and go to “Internal Storage\Android\data\de.dbremes.dbtradealert\files”
  4. Copy dbtradealert.db to change it for example with DB Browser for SQLite – it’s write-protected in its current directory
Importing a database

Importing a database

Importing a database will overwrite the app’s database and DbTradeAlert reminds the user of that. Note that an AlertDialog runs asynchronously and therefore cannot pass its result back but does its work in a callback.

And a note of caution: while SQLite is pretty robust importing the database during the app’s hourly update of quotes will probably corrupt it. So check the last update’s timestamp to avoid possible collisions.

To try the import:

  1. Make changes to the exported database on your computer
  2. Copy dbtradealert.db back to the device’s “Internal Storage\Android\data\de.dbremes.dbtradealert\files” folder
  3. In DbTradeAlert’s overflow menu select Copy Database | Import Database
  4. Accept the risk
  5. Do your changes show up in the respective Edit screen?

Next post: Enter the Play Store

Additional Resources

Advertisements
This entry was posted in Uncategorized and tagged , . Bookmark the permalink.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s