Build a nice UX to backup and sync your app data on Google Drive (3/3)

In my previous posts I’ve explained how to export/import a Realm database from your app and how to eventually backup it on Google Drive. If you’ve read the latter in particular, you’ll know we created a simple activity that used Drive’s Picker to pick a destination folder for the backup file and again Drive’s Picker to select a backup to restore. Even Google this week shared a case study about WhatsApp automatic backups with Google Drive (well, a less technical one, hah) so this is an hot topic.

[Read all the article for a bonus video]


Some weeks ago, Satyajit Sahoo shared with me some interesting mockups for our Backup activity in Glucosio.

The idea is replace Drive’s default Pickers with something more user friendly: instead of picking each time the destination folder, ask it just the first time and then store it. Instead of asking each time which file to restore, show a list of saved backups and let the user pick the right one.

Pretty cool, eh? So, let’s start.

Layouts

This should be relatively easy for us, since we just have to copy from the mockups. First we build the main activity layout:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:background="#ffffff"
android:layout_width="match_parent"
android:layout_height="match_parent">

<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/activity_backup_drive_desc"
android:textColor="@color/glucosio_text"
android:layout_marginBottom="16dp"/>
<Button
android:id="@+id/activity_backup_drive_button_backup"
android:padding="16dp"
android:layout_margin="16dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/glucosio_accent"
android:textColor="#80000000"
android:text="@string/activity_backup_drive_button_backup"/>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="16dp"
android:text="@string/activity_backup_drive_last"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:text="N/A"/>
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="1px"
android:layout_marginTop="8dp"
android:background="@color/glucosio_separator"/>
<LinearLayout
android:id="@+id/activity_backup_drive_button_folder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:paddingTop="8dp"
android:orientation="horizontal">
<TextView fontPath="fonts/lato-bold.ttf"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:layout_marginLeft="16dp"
android:textColor="@color/glucosio_text"
android:textStyle="bold"
android:text="@string/activity_backup_drive_folder"
/>
<TextView
android:id="@+id/activity_backup_drive_textview_folder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:paddingRight="16dp"
android:gravity="right"
android:text="N/A"/>
</LinearLayout>
<TextView
android:id="@+id/activity_backup_drive_button_manage_drive"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
android:layout_marginLeft="12dp"
android:padding="4dp"
android:background="?android:attr/selectableItemBackground"
android:text="@string/activity_backup_drive_button_open_drive"
android:textAllCaps="true"
android:textSize="12sp"
android:textColor="@color/glucosio_text_light"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="1px"
android:layout_marginBottom="8dp"
android:layout_marginTop="8dp"
android:background="@color/glucosio_separator"/>
<TextView fontPath="fonts/lato-bold.ttf"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:textAllCaps="true"
android:textColor="@color/glucosio_text"
android:textStyle="bold"
android:text="@string/activity_backup_drive_recent"
/>
<com.github.paolorotolo.expandableheightlistview.ExpandableHeightListView
android:id="@+id/activity_backup_drive_listview_restore"
android:layout_width="fill_parent"
android:layout_height="wrap_content">
</com.github.paolorotolo.expandableheightlistview.ExpandableHeightListView>
</LinearLayout>
</LinearLayout>
</ScrollView>
</LinearLayout>

Note that I’m using my own library ExpandableHeightListView to build the ListView that will contain the list of available backups. You can read more about it on GitHub.

The final result should be this (the blue one is our ListView):

Second, we need a layout for the ListView items. A simple CardView should do the work:

<android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/item_history"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
android:foreground="?android:attr/selectableItemBackground"
android:orientation="vertical"
app:cardElevation="1dp">

<LinearLayout
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:background="#ffffff"
android:orientation="vertical"
tools:ignore="MissingPrefix">

<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">

<TextView
android:id="@+id/item_history_time"
fontPath="fonts/lato-bold.ttf"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textStyle="bold"
android:text="2:30 am, Monday, 22 December 1997"
android:textColor="@color/glucosio_text_light" />

<TextView
android:id="@+id/item_history_type"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Before breakfast"
android:textColor="@color/glucosio_text" />
</LinearLayout>
</LinearLayout>
</android.support.v7.widget.CardView>

Finally, a simple dialog that will be prompted when the user picks a backup to restore.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_margin="16dp"
android:weightSum="1">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="20sp"
android:textColor="@color/glucosio_text_dark"
android:text="@string/activity_backup_drive_dialog_restore_title"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/activity_backup_drive_dialog_restore_desc"/>

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textStyle="bold"
android:textColor="@color/glucosio_text_dark"
android:textAllCaps="true"
android:text="@string/activity_backup_drive_dialog_restore_details"/>

<TextView
android:id="@+id/dialog_backup_restore_created"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="22/12/1997"/>
<TextView
android:id="@+id/dialog_backup_restore_size"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="1.2MB"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingBottom="16dp"
android:layout_gravity="right"
android:gravity="right">

<android.support.v7.widget.AppCompatButton
android:id="@+id/dialog_backup_restore_button_cancel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="?android:attr/selectableItemBackground"
android:padding="8dp"
android:text="@string/activity_backup_drive_dialog_restore_button_cancel"
android:textColor="@color/glucosio_pink"
android:textStyle="bold" />
<android.support.v7.widget.AppCompatButton
android:id="@+id/dialog_backup_restore_button_restore"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="?android:attr/selectableItemBackground"
android:padding="8dp"
android:text="@string/activity_backup_drive_dialog_restore_button_restore"
android:textColor="@color/glucosio_pink"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>

Logic

And here we are at the interesting part. We have to save the backup folder the first time so we can use it later, remember? To save it we can use Android’s SharedPreferences.

private void saveBackupFolder(String folderPath) {
SharedPreferences.Editor editor = sharedPref.edit();
editor.putString(BACKUP_FOLDER_KEY, folderPath);
editor.apply();
}

Pretty easy. Now when the Backup button is pressed, we check if a value is already stored in SharedPreferences. If yes, we just run the backup method (see my precedent post for the complete code). If not, we show Drive’s picker and save the picked folder.

String backupFolder = backupFolder = sharedPref.getString(BACKUP_FOLDER_KEY, "");
// First we check if a backup folder is set
if ("".equals(backupFolder)) {
try {
if (mGoogleApiClient != null && mGoogleApiClient.isConnected()) {
if (intentPicker == null)
intentPicker = buildIntent();
//Start the picker to choose a folder
startIntentSenderForResult(
intentPicker, REQUEST_CODE_PICKER, null, 0, 0, 0);
}
} catch (IntentSender.SendIntentException e) {
Log.e(TAG, "Unable to send intent", e);
showErrorDialog();
}
} else {
uploadToDrive(DriveId.decodeFromString(backupFolder));
}

As you can see, if a backup folder is not defined, we open the picker and collect the result in onActivityResult:

@Override
protected void onActivityResult(final int requestCode, final int resultCode, final Intent data) {
switch (requestCode) {
// [...]
case REQUEST_CODE_PICKER:
intentPicker = null;

if (resultCode == RESULT_OK) {
//Get the folder drive id
DriveId mFolderDriveId = data.getParcelableExtra(
OpenFileActivityBuilder.EXTRA_RESPONSE_DRIVE_ID);

saveBackupFolder(mFolderDriveId.encodeToString());

uploadToDrive(mFolderDriveId);
}
break;

// [...]
}
}

Here we used the method saveBackupFolder (that I’ve written before) to save to SharedPreferences a String that contains an encoded DriveId. The id can be decoded later using DriveId.decodeFromString().

The method uploadToDrive() handles the uploading process and saves the backup file in the folder with an hardcoded name (“glucosio.realm”, you’ll see why later).

private void uploadToDrive(DriveId mFolderDriveId) {
if (mFolderDriveId != null) {
//Create the file on GDrive
final DriveFolder folder = mFolderDriveId.asDriveFolder();
Drive.DriveApi.newDriveContents(mGoogleApiClient)
.setResultCallback(new ResultCallback<DriveApi.DriveContentsResult>() {
@Override
public void onResult(DriveApi.DriveContentsResult result) {
if (!result.getStatus().isSuccess()) {
Log.e(TAG, "Error while trying to create new file contents");
showErrorDialog();
return;
}
final DriveContents driveContents = result.getDriveContents();

// Perform I/O off the UI thread.
new Thread() {
@Override
public void run() {
// write content to DriveContents
OutputStream outputStream = driveContents.getOutputStream();

FileInputStream inputStream = null;
try {
inputStream = new FileInputStream(new File(realm.getPath()));
} catch (FileNotFoundException e) {
reportToFirebase(e, "Error uploading backup from drive, file not found");
showErrorDialog();
e.printStackTrace();
}

byte[] buf = new byte[1024];
int bytesRead;
try {
if (inputStream != null) {
while ((bytesRead = inputStream.read(buf)) > 0) {
outputStream.write(buf, 0, bytesRead);
}
}
} catch (IOException e) {

showErrorDialog();
e.printStackTrace();
}


MetadataChangeSet changeSet = new MetadataChangeSet.Builder()
.setTitle("glucosio.realm")
.setMimeType("text/plain")
.build();

// create a file in selected folder
folder.createFile(mGoogleApiClient, changeSet, driveContents)
.setResultCallback(new ResultCallback<DriveFolder.DriveFileResult>() {
@Override
public void onResult(DriveFolder.DriveFileResult result) {
if (!result.getStatus().isSuccess()) {
Log.d(TAG, "Error while trying to create the file");
showErrorDialog();
finish();
return;
}
showSuccessDialog();
finish();
}
});
}
}.start();
}
});
}
}

Now that we handled the upload, we need to show the saved backups in the ListView. I think the easier method is to perform a search on Google Drive of all the files in the backup folder and pick the ones named “glucosio.realm”.

First I’ve defined a GlucoseBackup class to store all relevant data for each backup file.

public class GlucosioBackup {

private DriveId driveId;
private Date modifiedDate;
private long backupSize;

public GlucosioBackup(DriveId driveId, Date modifiedDate, long backupSize){
this.driveId = driveId;
this.modifiedDate = modifiedDate;
this.backupSize = backupSize;
}

public DriveId getDriveId() {
return driveId;
}

public void setDriveId(DriveId driveId) {
this.driveId = driveId;
}

public Date getModifiedDate() {
return modifiedDate;
}

public void setModifiedDate(Date modifiedDate) {
this.modifiedDate = modifiedDate;
}

public long getBackupSize() {
return backupSize;
}

public void setBackupSize(long backupSize) {
this.backupSize = backupSize;
}
}

Then I’ve written the method to do the search and return and ArrayList of GlucosioBackup.

private void getBackupsFromDrive(DriveFolder folder){
final Activity activity = this;
SortOrder sortOrder = new SortOrder.Builder()
.addSortDescending(SortableField.MODIFIED_DATE).build();
Query query = new Query.Builder()
.addFilter(Filters.eq(SearchableField.TITLE, "glucosio.realm"))
.addFilter(Filters.eq(SearchableField.TRASHED, false))

.setSortOrder(sortOrder)
.build();
folder.queryChildren(mGoogleApiClient, query)
.setResultCallback(new ResultCallback<DriveApi.MetadataBufferResult>() {

private ArrayList<GlucosioBackup> backupsArray = new ArrayList<GlucosioBackup>();

@Override
public void onResult(DriveApi.MetadataBufferResult result) {
MetadataBuffer buffer = result.getMetadataBuffer();
int size = buffer.getCount();
for (int i=0; i<size; i++){
Metadata metadata = buffer.get(i);
DriveId driveId = metadata.getDriveId();
Date modifiedDate = metadata.getModifiedDate();
long backupSize = metadata.getFileSize();
backupsArray.add(new GlucosioBackup(driveId, modifiedDate, backupSize));
backupListView.setAdapter(new BackupAdapter(activity, R.layout.preferences_backup, backupsArray));
}

}
});
}

We filtered our results and picked only the ones with “glucosio.realm” in the title, with the Trashed attribute set to false (for obvious reasons :P). Then we received the metadata buffer from Drive and extracted the id, modified date and size of each file. We also created an array of GlucoseReadings and passed the whole thing to an adapter.

Yeah, guess what? We need to build an adapter for the ListView now :)

Build the ArrayAdapter

We can simply use an ArrayAdapter. Read more about it here.

The constructor requires the activity context, the layout file that will populate each row of the ListView and the ArrayList of data.

BackupAdapter(Context context, int resource, List<GlucosioBackup> items)

We override the getView method of the adapter and set all values in our TextViews.

@Override
public View getView(int position, View convertView, ViewGroup parent) {
View v = convertView;

if (v == null) {
LayoutInflater vi;
vi = LayoutInflater.from(getContext());
v = vi.inflate(R.layout.activity_backup_drive_restore_item, null);
}

GlucosioBackup p = getItem(position);
final DriveId driveId= p.getDriveId();
final String modified = p.getModifiedDate();
final String size = humanReadableByteCount(p.getBackupSize(), true);

if (p != null) {
TextView modifiedTextView = (TextView) v.findViewById(R.id.item_history_time);
TextView typeTextView = (TextView) v.findViewById(R.id.item_history_type);
modifiedTextView.setText(modified);
typeTextView.setText(size);
}

v.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {

// Show custom dialog
final Dialog dialog = new Dialog(context);
dialog.setContentView(R.layout.dialog_backup_restore);
TextView createdTextView = (TextView) dialog.findViewById(R.id.dialog_backup_restore_created);
TextView sizeTextView = (TextView) dialog.findViewById(R.id.dialog_backup_restore_size);
Button restoreButton = (Button) dialog.findViewById(R.id.dialog_backup_restore_button_restore);
Button cancelButton = (Button) dialog.findViewById(R.id.dialog_backup_restore_button_cancel);

createdTextView.setText(modified);
sizeTextView.setText(size);

restoreButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
((BackupActivity)context).downloadFromDrive(driveId.asDriveFile());
}
});

cancelButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
dialog.dismiss();
}
});

dialog.show();
}
});

return v;
}

Ah, yes, I’m also converting raw file size to a human readable format. Here’s the method (thanks StackOverflow :P).

private static String humanReadableByteCount(long bytes, boolean si) {
int unit = si ? 1000 : 1024;
if (bytes < unit) return bytes + " B";
int exp = (int) (Math.log(bytes) / Math.log(unit));
String pre = (si ? "kMGTPE" : "KMGTPE").charAt(exp-1) + (si ? "" : "i");
return String.format("%.1f %sB", bytes / Math.pow(unit, exp), pre);
}

Notice that we set an OnClickListener to the whole row layout. Each time a row is pressed, it shows the restore dialog. And if the user taps on Restore, it calls the restore method from the main activity (again, read my previous post to find out how to restore a file).

And what if the user wants to open the backup directory directly on Drive?

Nothing more simple, we take Folder’s metadata from Drive, get the directly link for the folder (using Drive’s API metadata.getAlternateLink()) and call a new intent that will fire up Google Drive app and show the content of our folder.

private void openOnDrive(DriveId driveId){
driveId.asDriveFolder().getMetadata((mGoogleApiClient)).setResultCallback(
new ResultCallback<DriveResource.MetadataResult>() {
@Override
public void onResult(DriveResource.MetadataResult result) {
if (!result.getStatus().isSuccess()) {
showErrorDialog();
return;
}
Metadata metadata = result.getMetadata();
String url = metadata.getAlternateLink();
Intent i = new Intent(Intent.ACTION_VIEW);
i.setData(Uri.parse(url));
startActivity(i);
}
}
);
}

As always, the full code is available on GitHub. Fell free to improve it and send us a pull request ❤

This is the final result.

As a bonus, here’s our promotional video that shows Glucosio’s Backup feature in action (yeah, it’s not only backup. With it you can also sync each device logged with the same Google account).