diff --git a/MyTracks/res/values/strings.xml b/MyTracks/res/values/strings.xml index 80a182bf6..bc9a9181b 100644 --- a/MyTracks/res/values/strings.xml +++ b/MyTracks/res/values/strings.xml @@ -165,9 +165,10 @@ limitations under the License. Recording Sharing + Not available in your version of Android + Announcement frequency Minimum time between two voice announcements - Text-to-speech announcements are not available in your version of Android Min distance between points Smallest distance between two recordings of your position @@ -204,6 +205,24 @@ limitations under the License. Maximum time or distance after which a new split marker will be inserted Pace + Backup + SD Card Backup + Backup now + Write a full backup of all your tracks to the SD card + Restore now + Restore a backup from the SD card + Cloud Backup + Enable cloud backup + Your data will be automatically copied to servers + Your data will NOT be copied to servers + + + Restoring a backup from the SD card will OVERWRITE all your current tracks. Are you sure? + Select the backup to restore + No backups found. + Writing all tracks to the SD card. This may take a while... + Reading all tracks from the SD card. This may take a while... + GPS Satellites @@ -364,8 +383,10 @@ limitations under the License. No external storage was found. Make sure that a SD card is inserted and that your phone is not mounted to a computer. Unable to write to file. + Unable to read from file. Unable to create directory on SD card. - Finished writing the track to SD card. + Finished writing to SD card. + Finished reading from SD card. Upgrade Check diff --git a/MyTracks/res/xml/preferences.xml b/MyTracks/res/xml/preferences.xml index 77f1abcf7..419ccbd7c 100644 --- a/MyTracks/res/xml/preferences.xml +++ b/MyTracks/res/xml/preferences.xml @@ -109,4 +109,28 @@ limitations under the License. android:summaryOff="@string/settings_share_url_only_summary_off" android:defaultValue="false" /> + + + + + + + + + + + diff --git a/MyTracks/src/com/google/android/apps/mytracks/MyTracksConstants.java b/MyTracks/src/com/google/android/apps/mytracks/MyTracksConstants.java index 79d93f26b..32350c145 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/MyTracksConstants.java +++ b/MyTracks/src/com/google/android/apps/mytracks/MyTracksConstants.java @@ -15,6 +15,8 @@ */ package com.google.android.apps.mytracks; +import android.os.Build; + /** * Constants used by the MyTracks application. * @@ -38,6 +40,11 @@ public abstract class MyTracksConstants { */ public static final Object SDCARD_TOP_DIR = "MyTracks"; + /** + * The API level of the Android version we're being run under. + */ + public static final int ANDROID_API_LEVEL = Integer.parseInt(Build.VERSION.SDK); + /* * onActivityResult request codes: */ diff --git a/MyTracks/src/com/google/android/apps/mytracks/MyTracksSettings.java b/MyTracks/src/com/google/android/apps/mytracks/MyTracksSettings.java index 776b5612f..9e97487e7 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/MyTracksSettings.java +++ b/MyTracks/src/com/google/android/apps/mytracks/MyTracksSettings.java @@ -15,6 +15,7 @@ */ package com.google.android.apps.mytracks; +import com.google.android.apps.mytracks.io.backup.ExternalFileBackup; import com.google.android.apps.mytracks.services.SafeStatusAnnouncerTask; import com.google.android.maps.mytracks.R; @@ -23,6 +24,7 @@ import android.preference.CheckBoxPreference; import android.preference.ListPreference; import android.preference.Preference; import android.preference.Preference.OnPreferenceChangeListener; +import android.preference.Preference.OnPreferenceClickListener; import android.preference.PreferenceActivity; import android.preference.PreferenceManager; import android.util.Log; @@ -118,6 +120,35 @@ public class MyTracksSettings extends PreferenceActivity { announcementFrequency.setSummary( R.string.settings_announcement_not_available_summary); } + + // Disable cloud backup options if not supported + if (MyTracksConstants.ANDROID_API_LEVEL < 8) { + // TODO: Merge by moving pref names to XML + CheckBoxPreference cloudBackupPreference = (CheckBoxPreference) findPreference("backupToCloud"); + cloudBackupPreference.setEnabled(false); + cloudBackupPreference.setChecked(false); + cloudBackupPreference.setSummaryOff(R.string.settings_not_available_summary); + } + + Preference backupNowPreference = findPreference("backupToSd"); + backupNowPreference.setOnPreferenceClickListener(new OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + ExternalFileBackup helper = new ExternalFileBackup(MyTracksSettings.this); + helper.writeToDefaultFile(); + return true; + } + }); + + Preference restoreNowPreference = findPreference("restoreFromSd"); + restoreNowPreference.setOnPreferenceClickListener(new OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + ExternalFileBackup helper = new ExternalFileBackup(MyTracksSettings.this); + helper.restoreFromFileList(); + return true; + } + }); } /** diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/backup/ExternalFileBackup.java b/MyTracks/src/com/google/android/apps/mytracks/io/backup/ExternalFileBackup.java new file mode 100644 index 000000000..8faf93348 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/backup/ExternalFileBackup.java @@ -0,0 +1,376 @@ +/* + * Copyright 2010 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 com.google.android.apps.mytracks.io.backup; + +import com.google.android.apps.mytracks.MyTracksConstants; +import com.google.android.apps.mytracks.MyTracksSettings; +import com.google.android.apps.mytracks.content.TrackPointsColumns; +import com.google.android.apps.mytracks.content.TracksColumns; +import com.google.android.apps.mytracks.content.WaypointsColumns; +import com.google.android.apps.mytracks.util.FileUtils; +import com.google.android.maps.mytracks.R; + +import android.app.AlertDialog; +import android.app.AlertDialog.Builder; +import android.app.Dialog; +import android.app.ProgressDialog; +import android.content.ContentResolver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.os.Handler; +import android.util.Log; +import android.widget.Toast; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FilenameFilter; +import java.io.IOException; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Date; + +/** + * Handler for writing or reading single-file backups. + * + * @author Rodrigo Damazio + */ +public class ExternalFileBackup { + // Since the user sees this format, we use the local timezone + private static final SimpleDateFormat BACKUP_FILENAME_FORMAT = + new SimpleDateFormat("'backup-'yyyy-MM-dd_HH-mm-ss"); + private static final SimpleDateFormat DISPLAY_BACKUP_FORMAT = + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + private static final String BACKUPS_SUBDIR = "backups"; + private static final int BACKUP_FILE_VERSION = 1; + + private final Context context; + private final FileUtils fileUtils; + private final Handler uiThreadHandler = new Handler(); + + public ExternalFileBackup(Context context) { + this.context = context; + this.fileUtils = new FileUtils(); + } + + /** + * Writes a full backup to the default file. + * This shows the results to the user. + */ + public void writeToDefaultFile() { + if (!fileUtils.isSdCardAvailable()) { + showToast(R.string.io_no_external_storage_found); + return; + } + + String dirName = fileUtils.buildExternalDirectoryPath(BACKUPS_SUBDIR); + final File dir = new File(dirName); + if (!fileUtils.ensureDirectoryExists(dir)) { + showToast(R.string.io_create_dir_failed); + return; + } + + final ProgressDialog progressDialog = ProgressDialog.show( + context, + context.getString(R.string.progress_title), + context.getString(R.string.backup_write_progress_message), + true); + + // Do the writing in another thread + new Thread() { + @Override + public void run() { + try { + final String filename = BACKUP_FILENAME_FORMAT.format(new Date()); + final File outputFile = new File(dir, filename); + + Log.d(MyTracksConstants.TAG, "Writing backup to file " + filename); + writeToFile(outputFile); + showToast(R.string.io_write_finished); + } catch (IOException e) { + showToast(R.string.io_write_failed); + return; + } finally { + dismissDialog(progressDialog); + } + } + }.start(); + } + + /** + * Restores a full backup from the SD card. + * The user will be given a choice of which backup to restore as well as a + * confirmation dialog. + */ + public void restoreFromFileList() { + // Get the list of existing backups + if (!fileUtils.isSdCardAvailable()) { + showToast(R.string.io_no_external_storage_found); + return; + } + + String dirName = fileUtils.buildExternalDirectoryPath(BACKUPS_SUBDIR); + final File backupDir = new File(dirName); + if (!backupDir.isDirectory()) { + showToast(R.string.no_backups); + return; + } + + final String[] backupFiles = getAvailableBackups(backupDir); + + if (backupFiles == null || backupFiles.length == 0) { + showToast(R.string.no_backups); + return; + } + + // Show a confirmation dialog + Builder confirmationDialogBuilder = new AlertDialog.Builder(context); + confirmationDialogBuilder.setMessage(R.string.restore_overwrites_warning); + confirmationDialogBuilder.setCancelable(false); + confirmationDialogBuilder.setNegativeButton(android.R.string.no, null); + confirmationDialogBuilder.setPositiveButton(android.R.string.yes, new OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + restoreFromFileListConfirmed(backupDir, backupFiles); + } + }); + confirmationDialogBuilder.create().show(); + } + + /** + * Shows a backup list for the user to pick, then restores it. + * + * @param backupDir the backup directory + * @param backupFiles the list of available backup files + */ + private void restoreFromFileListConfirmed(final File backupDir, final String[] backupFiles) { + if (backupFiles.length == 1) { + // Only one choice, don't bother showing the list + File inputFile = new File(backupDir, backupFiles[0]); + restoreFromFileAsync(inputFile); + return; + } + + // Make a user-visible version of the backup filenames + final String backupFileDates[] = new String[backupFiles.length]; + for (int i = 0; i < backupFiles.length; i++) { + try { + Date backupDate = BACKUP_FILENAME_FORMAT.parse(backupFiles[i]); + backupFileDates[i] = DISPLAY_BACKUP_FORMAT.format(backupDate); + } catch (ParseException e) { + throw new IllegalStateException("All filenames should be good here"); + } + } + // TODO: Invert order + Arrays.sort(backupFileDates); + + // Show a dialog for the user to pick which backup to restore + Builder dialogBuilder = new AlertDialog.Builder(context); + dialogBuilder.setCancelable(true); + dialogBuilder.setTitle(R.string.select_backup_to_restore); + dialogBuilder.setItems(backupFileDates, new OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + // User picked to restore this one + final String fileName = backupFiles[which]; + final File inputFile = new File(backupDir, fileName); + + restoreFromFileAsync(inputFile); + } + }); + dialogBuilder.create().show(); + } + + /** + * Shows a progress dialog, then starts restoring the backup osynchronously. + * + * @param inputFile the file to restore from + */ + private void restoreFromFileAsync(final File inputFile) { + // Show a progress dialog + final ProgressDialog progressDialog = ProgressDialog.show( + context, + context.getString(R.string.progress_title), + context.getString(R.string.backup_import_progress_message), + true); + + // Do the actual importing in another thread (don't block the UI) + new Thread() { + @Override + public void run() { + try { + Log.d(MyTracksConstants.TAG, "Restoring from file " + inputFile.getAbsolutePath()); + restoreFromFile(inputFile); + showToast(R.string.io_read_finished); + } catch (IOException e) { + showToast(R.string.io_read_failed); + } finally { + dismissDialog(progressDialog); + } + } + }.start(); + } + + /** + * Returns a list of available backups to be restored. + */ + public String[] getAvailableBackups(File dir) { + return dir.list(new FilenameFilter() { + @Override + public boolean accept(File dir, String filename) { + try { + BACKUP_FILENAME_FORMAT.parse(filename); + return true; + } catch (ParseException e) { + return false; + } + } + }); + } + + /** + * Shows a toast with the given contents. + */ + private void showToast(final int resId) { + uiThreadHandler.post(new Runnable() { + @Override + public void run() { + Toast.makeText(context, resId, Toast.LENGTH_LONG).show(); + } + }); + } + + /** + * Safely dismisses the given dialog. + */ + private void dismissDialog(final Dialog dialog) { + uiThreadHandler.post(new Runnable() { + @Override + public void run() { + dialog.dismiss(); + } + }); + } + + /** + * Synchronously writes a backup to the given file. + */ + private void writeToFile(File outputFile) throws IOException { + // Create all the auxiliary classes that will do the writing + PreferenceBackupHelper preferencesHelper = new PreferenceBackupHelper(); + DatabaseDumper trackDumper = new DatabaseDumper( + TracksColumns.BACKUP_COLUMNS, + TracksColumns.BACKUP_COLUMN_TYPES, + false); + DatabaseDumper waypointDumper = new DatabaseDumper( + WaypointsColumns.BACKUP_COLUMNS, + WaypointsColumns.BACKUP_COLUMN_TYPES, + false); + DatabaseDumper pointDumper = new DatabaseDumper( + TrackPointsColumns.BACKUP_COLUMNS, + TrackPointsColumns.BACKUP_COLUMN_TYPES, + false); + + // Open the target for writing + FileOutputStream outputStream = new FileOutputStream(outputFile); + DataOutputStream outWriter = new DataOutputStream(outputStream); + + try { + // Output a version header + outWriter.writeInt(BACKUP_FILE_VERSION); + + // Dump preferences + SharedPreferences preferences = + context.getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + preferencesHelper.exportPreferences(preferences, outWriter); + + // Dump the entire contents of each table + ContentResolver contentResolver = context.getContentResolver(); + Cursor tracksCursor = contentResolver.query( + TracksColumns.CONTENT_URI, null, null, null, null); + try { + trackDumper.writeAllRows(tracksCursor, outWriter); + } finally { + tracksCursor.close(); + } + + Cursor waypointsCursor = contentResolver.query( + WaypointsColumns.CONTENT_URI, null, null, null, null); + try { + waypointDumper.writeAllRows(waypointsCursor, outWriter); + } finally { + waypointsCursor.close(); + } + + Cursor pointsCursor = contentResolver.query( + TrackPointsColumns.CONTENT_URI, null, null, null, null); + try { + pointDumper.writeAllRows(pointsCursor, outWriter); + } finally { + pointsCursor.close(); + } + } finally { + outputStream.flush(); + outputStream.close(); + } + } + + /** + * Synchronously restores the backup from the given file. + */ + private void restoreFromFile(File inputFile) throws IOException { + // TODO: At this point we should stop recording if we were + + PreferenceBackupHelper preferencesHelper = new PreferenceBackupHelper(); + ContentResolver resolver = context.getContentResolver(); + DatabaseImporter trackImporter = new DatabaseImporter(TracksColumns.CONTENT_URI, resolver, false); + DatabaseImporter waypointImporter = new DatabaseImporter(WaypointsColumns.CONTENT_URI, resolver, false); + DatabaseImporter pointImporter = new DatabaseImporter(TrackPointsColumns.CONTENT_URI, resolver, false); + + FileInputStream inputStream = new FileInputStream(inputFile); + DataInputStream reader = new DataInputStream(inputStream); + + try { + int backupVersion = reader.readInt(); + if (backupVersion != BACKUP_FILE_VERSION) { + throw new IOException("Unknown backup file version " + backupVersion); + } + + // Restore preferences + SharedPreferences preferences = + context.getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + preferencesHelper.importPreferences(reader, preferences); + + // Delete all previous contents of the tables. + resolver.delete(TracksColumns.CONTENT_URI, null, null); + resolver.delete(TrackPointsColumns.CONTENT_URI, null, null); + resolver.delete(WaypointsColumns.CONTENT_URI, null, null); + + // Import the new contents of each table + trackImporter.importAllRows(reader); + waypointImporter.importAllRows(reader); + pointImporter.importAllRows(reader); + } finally { + inputStream.close(); + } + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/backup/MyTracksBackupAgent.java b/MyTracks/src/com/google/android/apps/mytracks/io/backup/MyTracksBackupAgent.java index 5c54f1557..2776bdc53 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/io/backup/MyTracksBackupAgent.java +++ b/MyTracks/src/com/google/android/apps/mytracks/io/backup/MyTracksBackupAgent.java @@ -84,12 +84,19 @@ public class MyTracksBackupAgent extends BackupAgent { @Override public void onBackup(ParcelFileDescriptor oldState, BackupDataOutput data, ParcelFileDescriptor newState) throws IOException { + SharedPreferences preferences = this.getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + // TODO: Pref name to XML + if (!preferences.getBoolean("backupToCloud", true)) { + Log.i(MyTracksConstants.TAG, "Cloud backup disabled - not doing it."); + return; + } + // TODO: Trigger backup on: track stop, preference change, track edit // TODO: How to handle backup while recording? (track may still change) BackupStateManager backupStateManager = createStateManager(oldState, newState); BackupStateManager stateManager = backupStateManager; - backupPreferences(data); + backupPreferences(data, preferences); backupTracks(data, stateManager); stateManager.flushNewState(); @@ -100,8 +107,7 @@ public class MyTracksBackupAgent extends BackupAgent { return new BackupStateManager(oldState, newState); } - private void backupPreferences(BackupDataOutput data) throws IOException { - SharedPreferences preferences = this.getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + private void backupPreferences(BackupDataOutput data, SharedPreferences preferences) throws IOException { PreferenceBackupHelper preferenceDumper = createPreferenceBackupHelper(); byte[] dumpedContents = preferenceDumper.exportPreferences(preferences); data.writeEntityHeader(PREFERENCES_ENTITY, dumpedContents.length); diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/backup/PreferenceBackupHelper.java b/MyTracks/src/com/google/android/apps/mytracks/io/backup/PreferenceBackupHelper.java index 9fd61f58c..d6fc86bf8 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/io/backup/PreferenceBackupHelper.java +++ b/MyTracks/src/com/google/android/apps/mytracks/io/backup/PreferenceBackupHelper.java @@ -25,6 +25,7 @@ import java.io.ByteArrayOutputStream; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; +import java.io.OutputStream; import java.util.Map; /** @@ -43,9 +44,26 @@ class PreferenceBackupHelper { * @return the corresponding byte array * @throws IOException if there are any errors while writing to the byte array */ - public byte[] exportPreferences(SharedPreferences preferences) throws IOException { + public byte[] exportPreferences(SharedPreferences preferences) + throws IOException { ByteArrayOutputStream bufStream = new ByteArrayOutputStream(BUFFER_SIZE); DataOutputStream outWriter = new DataOutputStream(bufStream); + exportPreferences(preferences, outWriter); + + return bufStream.toByteArray(); + } + + /** + * Exports all shared preferences from the given object into the given output + * stream. + * + * @param preferences the preferences to export + * @param bufStream the stream to write them to + * @throws IOException if there are any errors while writing the output + */ + public void exportPreferences( + SharedPreferences preferences, + DataOutputStream outWriter) throws IOException { Map values = preferences.getAll(); outWriter.writeInt(values.size()); @@ -53,8 +71,6 @@ class PreferenceBackupHelper { writePreference(entry.getKey(), entry.getValue(), outWriter); } outWriter.flush(); - - return bufStream.toByteArray(); } /** @@ -66,14 +82,27 @@ class PreferenceBackupHelper { * @throws IOException if there are any errors while reading */ public boolean importPreferences(byte[] data, SharedPreferences preferences) throws IOException { - Editor editor = preferences.edit(); - editor.clear(); - ByteArrayInputStream bufStream = new ByteArrayInputStream(data); DataInputStream reader = new DataInputStream(bufStream); - int numPreferneces = reader.readInt(); - for (int i = 0; i < numPreferneces; i++) { + return importPreferences(reader, preferences); + } + + /** + * Imports all preferences from the given stream. + * + * @param reader the stream to read from + * @param preferences the shared preferences to edit + * @return whether the preference change was successful + * @throws IOException if there are any errors while reading + */ + public boolean importPreferences(DataInputStream reader, + SharedPreferences preferences) throws IOException { + Editor editor = preferences.edit(); + editor.clear(); + + int numPreferences = reader.readInt(); + for (int i = 0; i < numPreferences; i++) { String name = reader.readUTF(); byte typeId = reader.readByte(); readAndSetPreference(name, typeId, reader, editor);