From 3532a6360b2c844c744813785005ec581d8aae19 Mon Sep 17 00:00:00 2001 From: Rodrigo Damazio Date: Mon, 16 Aug 2010 22:22:14 -0300 Subject: [PATCH] Adding backup facilities. TODO: some tests, calling to trigger new automatic backup on Froyo, manual backup to SD option --- MyTracks/AndroidManifest.xml | 18 +- .../apps/mytracks/content/ContentTypeIds.java | 32 ++ .../content/MyTracksProviderUtils.java | 19 ++ .../content/MyTracksProviderUtilsImpl.java | 20 +- .../mytracks/content/TrackPointsColumns.java | 12 + .../apps/mytracks/content/TracksColumns.java | 14 + .../mytracks/content/WaypointsColumns.java | 17 + .../io/backup/BackupDataInputStream.java | 53 +++ .../io/backup/BackupStateManager.java | 215 ++++++++++++ .../mytracks/io/backup/DatabaseDumper.java | 227 +++++++++++++ .../mytracks/io/backup/DatabaseImporter.java | 178 ++++++++++ .../io/backup/MyTracksBackupAgent.java | 315 ++++++++++++++++++ .../io/backup/PreferenceBackupHelper.java | 145 ++++++++ .../io/backup/BackupStateManagerTest.java | 161 +++++++++ .../io/backup/DatabaseDumperTest.java | 212 ++++++++++++ .../io/backup/DatabaseImporterTest.java | 301 +++++++++++++++++ .../io/backup/MyTracksBackupAgentTest.java | 112 +++++++ .../io/backup/PreferenceBackupHelperTest.java | 197 +++++++++++ 18 files changed, 2223 insertions(+), 25 deletions(-) create mode 100644 MyTracks/src/com/google/android/apps/mytracks/content/ContentTypeIds.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/backup/BackupDataInputStream.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/backup/BackupStateManager.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/backup/DatabaseDumper.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/backup/DatabaseImporter.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/backup/MyTracksBackupAgent.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/backup/PreferenceBackupHelper.java create mode 100644 MyTracksTest/src/com/google/android/apps/mytracks/io/backup/BackupStateManagerTest.java create mode 100644 MyTracksTest/src/com/google/android/apps/mytracks/io/backup/DatabaseDumperTest.java create mode 100644 MyTracksTest/src/com/google/android/apps/mytracks/io/backup/DatabaseImporterTest.java create mode 100644 MyTracksTest/src/com/google/android/apps/mytracks/io/backup/MyTracksBackupAgentTest.java create mode 100644 MyTracksTest/src/com/google/android/apps/mytracks/io/backup/PreferenceBackupHelperTest.java diff --git a/MyTracks/AndroidManifest.xml b/MyTracks/AndroidManifest.xml index 48e6c5a89..826ed1b93 100755 --- a/MyTracks/AndroidManifest.xml +++ b/MyTracks/AndroidManifest.xml @@ -4,14 +4,17 @@ android:versionCode="16" android:versionName="1.0.15"> + android:label="@string/app_name" + android:backupAgent="com.google.android.apps.mytracks.io.backup.MyTracksBackupAgent"> + + - @@ -36,16 +39,13 @@ - + android:configChanges="mcc|mnc|locale|touchscreen|keyboard|keyboardHidden|navigation|orientation|fontScale" /> - + android:label="Recorded Tracks" /> - + android:label="Recorded Waypoints" /> @@ -63,7 +63,7 @@ android:label="@string/elevation_label"/> + android:label="@string/welcome"/> oldTracks; + private List newTrackState; + + public BackupStateManager(ParcelFileDescriptor oldState, ParcelFileDescriptor newState) { + this.oldState = oldState; + this.newState = newState; + } + + /** + * Reads the old track state. + * + * If any errors occur while reading, this will return a partial or empty map. + * + * @return a map of track ID to its state + */ + public Map getOldTrackState() { + if (oldTracks != null) { + return oldTracks; + } + + oldTracks = new TreeMap(); + if (!hasOldState()) { + return oldTracks; + } + + try { + InputStream stateInputStream = openStateInput(); + DataInputStream stateInput = new DataInputStream(stateInputStream); + + // Read the version from the header + if (stateInput.readByte() != BACKUP_VERSION) { + throw new IllegalStateException("Bad backup version"); + } + + int previousNumberOfTracks = stateInput.readInt(); + for (int i = 0; i < previousNumberOfTracks; i++) { + TrackState oldTrack = readTrackState(stateInput); + oldTracks.put(oldTrack.trackId, oldTrack); + } + } catch (IOException e) { + // If we fail to read, return whatever we got - this only means + // we'll be backing up more than we should. + Log.e(MyTracksConstants.TAG, "Unable to read old state", e); + } + return oldTracks; + } + + /** + * Returns whether we have a known old state. + */ + protected boolean hasOldState() { + return oldState != null; + } + + /** + * Opens the old state and returns a stream to read it. + */ + protected InputStream openStateInput() { + return new FileInputStream(oldState.getFileDescriptor()); + } + + /** + * Opens the new state and returns a stream to write to it. + */ + protected OutputStream openStateOutput() { + return new FileOutputStream(newState.getFileDescriptor()); + } + + /** + * Reads the state of a single track. + * + * @param stateInput the stream to read from + * @return the state read + * @throws IOException if reading fails + */ + private TrackState readTrackState(DataInputStream stateInput) throws IOException { + long trackId = stateInput.readLong(); + String name = stateInput.readUTF(); + String desc = stateInput.readUTF(); + String category = stateInput.readUTF(); + return new TrackState(trackId, name, desc, category); + } + + /** + * Adds a track to the current/new state. + * Please notice that the state is not actually written until + * {@link #flushNewState} is called. + */ + public void addTrackState(Track track) { + ensureTrackState(); + + newTrackState.add(new TrackState(track.getId(), track.getName(), + track.getDescription(), track.getCategory())); + } + + /** + * Actually writes out the new state to its output. + * + * @throws IOException if there are errors while writing + */ + public void flushNewState() throws IOException { + ensureTrackState(); + + OutputStream stateOutputStream = openStateOutput(); + DataOutputStream stateOutput = new DataOutputStream(stateOutputStream); + + stateOutput.writeByte(BACKUP_VERSION); + + stateOutput.writeInt(newTrackState.size()); + for (TrackState track : newTrackState) { + writeTrackState(stateOutput, track); + } + + stateOutput.flush(); + } + + /** + * Ensures the track state buffer exists. + */ + private void ensureTrackState() { + if (newTrackState == null) { + newTrackState = new ArrayList(); + } + } + + /** + * Writes the state of a single track. + * + * @param stateOutput the stream to write to + * @param track the track state to write + * @throws IOException if there are errors while writing + */ + private void writeTrackState(DataOutputStream stateOutput, TrackState track) throws IOException { + stateOutput.writeLong(track.trackId); + stateOutput.writeUTF(track.name); + stateOutput.writeUTF(track.description); + stateOutput.writeUTF(track.category); + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/backup/DatabaseDumper.java b/MyTracks/src/com/google/android/apps/mytracks/io/backup/DatabaseDumper.java new file mode 100644 index 000000000..9c70f2590 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/backup/DatabaseDumper.java @@ -0,0 +1,227 @@ +/* + * 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.content.ContentTypeIds; + +import android.database.Cursor; +import android.database.MergeCursor; + +import java.io.DataOutputStream; +import java.io.IOException; + +/** + * Database dumper which is able to write only part of the database + * according to some query. + * + * This dumper is symmetrical to {@link DatabaseImporter}. + * + * @author Rodrigo Damazio + */ +class DatabaseDumper { + + /** The names of the columns being dumped. */ + private final String[] columnNames; + /** The types of the columns being dumped. */ + private final byte[] columnTypes; + /** Whether to output null fields. */ + private final boolean outputNullFields; + + // Temporary state + private int[] columnIndices; + private boolean[] hasFields; + + public DatabaseDumper(String[] columnNames, byte[] columnTypes, + boolean outputNullFields) { + if (columnNames.length != columnTypes.length) { + throw new IllegalArgumentException("Names don't match types"); + } + + this.columnNames = columnNames; + this.columnTypes = columnTypes; + this.outputNullFields = outputNullFields; + } + + /** + * Writes the header plus all rows that can be read from the given cursor. + * This assumes the cursor will have the same column and column indices on + * every row (and thus may not work with a {@link MergeCursor}). + */ + public void writeAllRows(Cursor cursor, DataOutputStream writer) throws IOException { + writeHeaders(cursor, cursor.getCount(), writer); + + if (!cursor.moveToFirst()) { + return; + } + + do { + writeOneRow(cursor, writer); + } while (cursor.moveToNext()); + } + + /** + * Writes just the headers for the data that will come from the given cursor. + * The headers include column information and the number of rows that will be + * written. + * + * @param cursor the cursor to get columns from + * @param numRows the number of rows that will be later written + * @throws IOException if there are errors while writing + */ + public void writeHeaders(Cursor cursor, int numRows, DataOutputStream writer) throws IOException { + initializeCachedValues(cursor); + writeQueryMetadata(cursor, numRows, writer); + } + + /** + * Writes the current row from the cursor. The cursor is not advanced. + * This must be called after {@link #writeHeaders}. + * + * @param cursor the cursor to write data from + * @throws IOException if there are any errors while writing + */ + public void writeOneRow(Cursor cursor, DataOutputStream writer) throws IOException { + if (columnIndices == null) { + throw new IllegalStateException( + "Cannot write rows before writing the header"); + } + + if (columnIndices.length > Long.SIZE) { + throw new IllegalArgumentException("Too many fields"); + } + + // Build a bitmap of which fields are present + long fields = 0; + for (int i = 0; i < columnIndices.length; i++) { + hasFields[i] = !cursor.isNull(columnIndices[i]); + fields |= (hasFields[i] ? 1 : 0) << i; + } + writer.writeLong(fields); + + // Actually write the present fields + for (int i = 0; i < columnIndices.length; i++) { + if (hasFields[i]) { + writeCell(columnIndices[i], columnTypes[i], cursor, writer); + } else if (outputNullFields) { + writeDummyCell(columnTypes[i], writer); + } + } + } + + /** + * Initializes the column indices and other temporary state for reading from + * the given cursor. + */ + private void initializeCachedValues(Cursor cursor) { + // These indices are constant for every row (unless we're fed a MergeCursor) + if (cursor instanceof MergeCursor) { + throw new IllegalArgumentException("Cannot use a MergeCursor"); + } + + columnIndices = new int[columnNames.length]; + for (int i = 0; i < columnNames.length; i++) { + String columnName = columnNames[i]; + columnIndices[i] = cursor.getColumnIndexOrThrow(columnName); + } + + hasFields = new boolean[columnIndices.length]; + } + + /** + * Writes metadata about the query to be dumped. + * + * @param cursor the cursor that the data will come from + * @param numRows the number of rows that will be dumped + * @throws IOException if there are any errors while writing + */ + private void writeQueryMetadata(Cursor cursor, int numRows, DataOutputStream writer) throws IOException { + // Write column data + writer.writeInt(columnNames.length); + for (int i = 0; i < columnNames.length; i++) { + String columnName = columnNames[i]; + byte columnType = columnTypes[i]; + writer.writeUTF(columnName); + writer.writeByte(columnType); + } + + // Write the number of rows + writer.writeInt(numRows); + } + + /** + * Writes a single cell of the database to the output. + * + * @param columnIdx the column index to read from + * @param columnType the type of the column to be read + * @param cursor the cursor to read from + * @throws IOException if there are any errors while writing + */ + private void writeCell(int columnIdx, byte columnTypeId, Cursor cursor, DataOutputStream writer) throws IOException { + switch (columnTypeId) { + case ContentTypeIds.LONG_TYPE_ID: + writer.writeLong(cursor.getLong(columnIdx)); + return; + case ContentTypeIds.DOUBLE_TYPE_ID: + writer.writeDouble(cursor.getDouble(columnIdx)); + return; + case ContentTypeIds.FLOAT_TYPE_ID: + writer.writeFloat(cursor.getFloat(columnIdx)); + return; + case ContentTypeIds.BOOLEAN_TYPE_ID: + writer.writeBoolean(cursor.getInt(columnIdx) != 0); + return; + case ContentTypeIds.INT_TYPE_ID: + writer.writeInt(cursor.getInt(columnIdx)); + return; + case ContentTypeIds.STRING_TYPE_ID: + writer.writeUTF(cursor.getString(columnIdx)); + return; + default: + throw new IllegalArgumentException("Type " + columnTypeId + " not supported"); + } + } + + /** + * Writes a dummy cell value to the output. + * + * @param columnType the type of the value to write + * @throws IOException if there are any errors while writing + */ + private void writeDummyCell(byte columnTypeId, DataOutputStream writer) throws IOException { + switch (columnTypeId) { + case ContentTypeIds.LONG_TYPE_ID: + writer.writeLong(0L); + return; + case ContentTypeIds.DOUBLE_TYPE_ID: + writer.writeDouble(0.0); + return; + case ContentTypeIds.FLOAT_TYPE_ID: + writer.writeFloat(0.0f); + return; + case ContentTypeIds.BOOLEAN_TYPE_ID: + writer.writeBoolean(false); + return; + case ContentTypeIds.INT_TYPE_ID: + writer.writeInt(0); + return; + case ContentTypeIds.STRING_TYPE_ID: + writer.writeUTF(""); + return; + default: + throw new IllegalArgumentException("Type " + columnTypeId + " not supported"); + } + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/backup/DatabaseImporter.java b/MyTracks/src/com/google/android/apps/mytracks/io/backup/DatabaseImporter.java new file mode 100644 index 000000000..a7dbb0f11 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/backup/DatabaseImporter.java @@ -0,0 +1,178 @@ +/* + * 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.content.ContentTypeIds; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.net.Uri; + +import java.io.DataInputStream; +import java.io.IOException; + +/** + * Database importer which reads values written by {@link DatabaseDumper}. + * + * @author Rodrigo Damazio + */ +public class DatabaseImporter { + + /** Maximum number of entries in a bulk insertion */ + private static final int DEFAULT_BULK_SIZE = 1024; + + private final Uri destinationUri; + private final ContentResolver resolver; + private final boolean readNullFields; + private final int bulkSize; + + // Metadata read from the reader + private String[] columnNames; + private byte[] columnTypes; + + public DatabaseImporter(Uri destinationUri, ContentResolver resolver, + boolean readNullFields) { + this(destinationUri, resolver, readNullFields, DEFAULT_BULK_SIZE); + } + + protected DatabaseImporter(Uri destinationUri, ContentResolver resolver, + boolean readNullFields, int bulkSize) { + this.destinationUri = destinationUri; + this.resolver = resolver; + this.readNullFields = readNullFields; + this.bulkSize = bulkSize; + } + + /** + * Reads the header which includes metadata about the table being imported. + * + * @throws IOException if there are any problems while reading + */ + private void readHeaders(DataInputStream reader) throws IOException { + int numColumns = reader.readInt(); + columnNames = new String[numColumns]; + columnTypes = new byte[numColumns]; + for (int i = 0; i < numColumns; i++) { + columnNames[i] = reader.readUTF(); + columnTypes[i] = reader.readByte(); + } + } + + /** + * Imports all rows from the reader into the database. + * Insertion is done in bulks for efficiency. + * + * @throws IOException if there are any errors while reading + */ + public void importAllRows(DataInputStream reader) throws IOException { + readHeaders(reader); + + ContentValues[] valueBulk = new ContentValues[bulkSize]; + int numValues = 0; + + int numRows = reader.readInt(); + int numColumns = columnNames.length; + + // For each row + for (int r = 0; r < numRows; r++) { + if (valueBulk[numValues] == null) { + valueBulk[numValues] = new ContentValues(numColumns); + } else { + // Reuse values objects + valueBulk[numValues].clear(); + } + + // Read the fields bitmap + long fields = reader.readLong(); + for (int c = 0; c < numColumns; c++) { + if ((fields & 1) == 1) { + // Field is present, read into values + readOneCell(columnNames[c], columnTypes[c], valueBulk[numValues], reader); + } else if (readNullFields) { + // Field not present but still written, read and discard + readOneCell(columnNames[c], columnTypes[c], null, reader); + } + + fields >>= 1; + } + + numValues++; + + // If we have enough values, flush them as a bulk insertion + if (numValues >= bulkSize) { + doBulkInsert(valueBulk); + numValues = 0; + } + } + + // Do a final bulk insert with the leftovers + if (numValues > 0) { + ContentValues[] leftovers = new ContentValues[numValues]; + System.arraycopy(valueBulk, 0, leftovers, 0, numValues); + doBulkInsert(leftovers); + } + } + + protected void doBulkInsert(ContentValues[] values) { + resolver.bulkInsert(destinationUri, values); + } + + /** + * Reads a single cell from the reader. + * + * @param name the name of the column to be read + * @param typeId the type ID of the column to be read + * @param values the {@link ContentValues} object to put the read cell value + * in - if null, the value is just discarded + * @throws IOException if there are any problems while reading + */ + private void readOneCell(String name, byte typeId, ContentValues values, DataInputStream reader) throws IOException { + switch (typeId) { + case ContentTypeIds.BOOLEAN_TYPE_ID: { + boolean value = reader.readBoolean(); + if (values != null) { values.put(name, value); } + return; + } + case ContentTypeIds.LONG_TYPE_ID: { + long value = reader.readLong(); + if (values != null) { values.put(name, value); } + return; + } + case ContentTypeIds.DOUBLE_TYPE_ID: { + double value = reader.readDouble(); + if (values != null) { values.put(name, value); } + return; + } + case ContentTypeIds.FLOAT_TYPE_ID: { + Float value = reader.readFloat(); + if (values != null) { values.put(name, value); } + return; + } + case ContentTypeIds.INT_TYPE_ID: { + int value = reader.readInt(); + if (values != null) { values.put(name, value); } + return; + } + case ContentTypeIds.STRING_TYPE_ID: { + String value = reader.readUTF(); + if (values != null) { values.put(name, value); } + return; + } + default: + throw new IOException("Read unknown type " + typeId); + } + } +} 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 new file mode 100644 index 000000000..5c54f1557 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/backup/MyTracksBackupAgent.java @@ -0,0 +1,315 @@ +/* + * 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.MyTracksProviderUtils; +import com.google.android.apps.mytracks.content.Track; +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.io.backup.BackupStateManager.TrackState; + +import android.app.backup.BackupAgent; +import android.app.backup.BackupDataInput; +import android.app.backup.BackupDataOutput; +import android.content.ContentResolver; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.os.ParcelFileDescriptor; +import android.util.Log; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Backup agent used to backup and restore all preferences and tracks. + * + * @author Rodrigo Damazio + */ +public class MyTracksBackupAgent extends BackupAgent { + private static final String PREFERENCES_ENTITY = "prefs"; + + private static final String TRACK_ENTITY_PREFIX = "track_"; + + /** + * Initial size of the buffer used to back up a track. + */ + private static final int INITIAL_BUFFER_SIZE = 4096; + + /** + * Size of the buffer after which it's flushed into the backup. + */ + private static final int MAX_BUFFER_SIZE = 32768; + + /** + * Utilities for accessing the content provider. + */ + private MyTracksProviderUtils providerUtils; + + // Database dumpers + protected DatabaseDumper trackDumper; + protected DatabaseDumper waypointDumper; + protected DatabaseDumper pointDumper; + + // Database importers + protected DatabaseImporter trackImporter; + protected DatabaseImporter waypointImporter; + protected DatabaseImporter pointImporter; + + @Override + public void onCreate() { + providerUtils = MyTracksProviderUtils.Factory.get(this); + } + + @Override + public void onBackup(ParcelFileDescriptor oldState, BackupDataOutput data, + ParcelFileDescriptor newState) throws IOException { + // 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); + backupTracks(data, stateManager); + + stateManager.flushNewState(); + } + + protected BackupStateManager createStateManager(ParcelFileDescriptor oldState, + ParcelFileDescriptor newState) { + return new BackupStateManager(oldState, newState); + } + + private void backupPreferences(BackupDataOutput data) throws IOException { + SharedPreferences preferences = this.getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + PreferenceBackupHelper preferenceDumper = createPreferenceBackupHelper(); + byte[] dumpedContents = preferenceDumper.exportPreferences(preferences); + data.writeEntityHeader(PREFERENCES_ENTITY, dumpedContents.length); + data.writeEntityData(dumpedContents, dumpedContents.length); + } + + protected PreferenceBackupHelper createPreferenceBackupHelper() { + return new PreferenceBackupHelper(); + } + + /** + * Backs up all the tracks. + * + * @param data the backup data to read from + * @param stateManager the state manager to read from/write to + * @throws IOException if there are any errors while reading or writing + */ + private void backupTracks(BackupDataOutput data, BackupStateManager stateManager) throws IOException { + Cursor tracksCursor = providerUtils.getTracksCursor(null); + if (!tracksCursor.moveToFirst()) { + Log.w(MyTracksConstants.TAG, "Nothing to back up"); + return; + } + + ensureDumpers(); + + // For each existing track + Map oldTrackState = stateManager.getOldTrackState(); + Set currentTrackIds = new HashSet(tracksCursor.getCount()); + do { + // Check if not already backed up + Track track = providerUtils.createTrack(tracksCursor); + TrackState oldTrack = oldTrackState.get(track.getId()); + if (oldTrack != null && oldTrack.represents(track)) { + // Track is already backed up and hasn't been changed + continue; + } + + // Back it up + stateManager.addTrackState(track); + writeTrack(data, track, tracksCursor); + } while (tracksCursor.moveToNext()); + tracksCursor.close(); + + // For each previously-backed-up track, minus those that currently exist + // (=only those that no longer exist) + Set deletedTrackIds = new HashSet(oldTrackState.keySet()); + deletedTrackIds.removeAll(currentTrackIds); + for (Long trackId : deletedTrackIds) { + // Delete it from the backup + data.writeEntityHeader(TRACK_ENTITY_PREFIX + trackId, -1); + } + } + + protected void ensureDumpers() { + if (trackDumper == null) { + trackDumper = + new DatabaseDumper( + TracksColumns.BACKUP_COLUMNS, + TracksColumns.BACKUP_COLUMN_TYPES, + false); + waypointDumper = + new DatabaseDumper( + WaypointsColumns.BACKUP_COLUMNS, + WaypointsColumns.BACKUP_COLUMN_TYPES, + false); + pointDumper = + new DatabaseDumper( + TrackPointsColumns.BACKUP_COLUMNS, + TrackPointsColumns.BACKUP_COLUMN_TYPES, + true); // Keep the size of each entry constant + } + } + + /** + * Writes a single track to the backup. + * + * @param data the backup data to write to + * @param track the track to write + * @param tracksCursor the tracks database cursor pointing to the given track + * @throws IOException if there are any problems while writing + */ + private void writeTrack(BackupDataOutput data, Track track, Cursor tracksCursor) throws IOException { + // Write the header + ByteArrayOutputStream bufStream = new ByteArrayOutputStream(INITIAL_BUFFER_SIZE); + DataOutputStream outWriter = new DataOutputStream(bufStream); + + // Write track metadata + trackDumper.writeHeaders(tracksCursor, 1, outWriter); + trackDumper.writeOneRow(tracksCursor, outWriter); + + // Write waypoints + Cursor waypointsCursor = providerUtils.getWaypointsCursor(track.getId(), -1, -1); + waypointDumper.writeAllRows(waypointsCursor, outWriter); + waypointsCursor.close(); + + // Read points from the database + Cursor pointsCursor = providerUtils.getLocationsCursor(track.getId(), 0, -1, false); + + // Write the header for the points + int numPoints = pointsCursor.getCount(); + pointDumper.writeHeaders(pointsCursor, numPoints, outWriter); + + // Write the first point to the output and calculate its size + // Since all points have the same size in the output, we can finally predict + // the total size of the track, and write the backup header. + int oldSize = bufStream.size(); + pointDumper.writeOneRow(pointsCursor, outWriter); + int newSize = bufStream.size(); + int trackPointsSize = numPoints * (newSize - oldSize); + int totalSize = oldSize + trackPointsSize; + + // Write out the backup header + data.writeEntityHeader(TRACK_ENTITY_PREFIX + track.getId(), totalSize); + + // Write the rest of the points, in chunks + while (pointsCursor.moveToNext()) { + if (bufStream.size() >= MAX_BUFFER_SIZE) { + outWriter.flush(); + data.writeEntityData(bufStream.toByteArray(), bufStream.size()); + bufStream.reset(); + } + + pointDumper.writeOneRow(pointsCursor, outWriter); + } + pointsCursor.close(); + + // Do the last flushing of the buffer + outWriter.flush(); + data.writeEntityData(bufStream.toByteArray(), bufStream.size()); + } + + @Override + public void onRestore(BackupDataInput data, int appVersionCode, + ParcelFileDescriptor newState) throws IOException { + BackupStateManager stateManager = new BackupStateManager(null, newState); + providerUtils.deleteAllTracks(); + + ensureImporters(); + + while (data.readNextHeader()) { + String key = data.getKey(); + if (key.startsWith(TRACK_ENTITY_PREFIX)) { + Track restoredTrack = restoreTrack(data); + stateManager.addTrackState(restoredTrack); + } else if (key.startsWith(PREFERENCES_ENTITY)) { + restorePreferences(data); + } else { + Log.e(MyTracksConstants.TAG, "Found unknown backup entity: " + key); + data.skipEntityData(); + } + } + + stateManager.flushNewState(); + } + + protected void ensureImporters() { + if (trackImporter == null) { + ContentResolver resolver = this.getContentResolver(); + trackImporter = + new DatabaseImporter(TracksColumns.CONTENT_URI, resolver, false); + waypointImporter = + new DatabaseImporter(WaypointsColumns.CONTENT_URI, resolver, false); + pointImporter = + new DatabaseImporter(TrackPointsColumns.CONTENT_URI, resolver, true); + } + } + + /** + * Restores all preferences from the backup. + * + * @param data the backup data to read from + * @throws IOException if there are any errors while reading + */ + private void restorePreferences(BackupDataInput data) throws IOException { + int dataSize = data.getDataSize(); + byte[] dataBuffer = new byte[dataSize]; + int read = data.readEntityData(dataBuffer, 0, dataSize); + if (read != dataSize) { + throw new IOException("Failed to read all the preferences data"); + } + + SharedPreferences preferences = this.getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + PreferenceBackupHelper importer = createPreferenceBackupHelper(); + importer.importPreferences(dataBuffer, preferences); + } + + /** + * Restores a single track from the backup. + * + * @param data the data to restore from + * @return the restored track + * @throws IOException + */ + private Track restoreTrack(BackupDataInput data) throws IOException { + BackupDataInputStream inputStream = new BackupDataInputStream(data); + DataInputStream reader = new DataInputStream(inputStream); + + trackImporter.importAllRows(reader); + waypointImporter.importAllRows(reader); + pointImporter.importAllRows(reader); + + // Get the expected track ID from the entity key + String key = data.getKey(); + int splitPos = key.lastIndexOf('_'); + String trackIdStr = key.substring(splitPos + 1); + long trackId = Long.parseLong(trackIdStr); + + return providerUtils.getTrack(trackId); + } +} 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 new file mode 100644 index 000000000..9fd61f58c --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/backup/PreferenceBackupHelper.java @@ -0,0 +1,145 @@ +/* + * 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.content.ContentTypeIds; + +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.Map; + +/** + * Helper for backing up and restoring shared preferences. + * + * @author Rodrigo Damazio + */ +class PreferenceBackupHelper { + + private static final int BUFFER_SIZE = 2048; + + /** + * Exports all shared preferences from the given object as a byte array. + * + * @param preferences the preferences to export + * @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 { + ByteArrayOutputStream bufStream = new ByteArrayOutputStream(BUFFER_SIZE); + DataOutputStream outWriter = new DataOutputStream(bufStream); + Map values = preferences.getAll(); + + outWriter.writeInt(values.size()); + for (Map.Entry entry : values.entrySet()) { + writePreference(entry.getKey(), entry.getValue(), outWriter); + } + outWriter.flush(); + + return bufStream.toByteArray(); + } + + /** + * Imports all preferences from the given byte array. + * + * @param data the byte array to read preferences 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(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++) { + String name = reader.readUTF(); + byte typeId = reader.readByte(); + readAndSetPreference(name, typeId, reader, editor); + } + + return editor.commit(); + } + + /** + * Reads a single preference and sets it into the given editor. + * + * @param name the name of the preference to read + * @param typeId the type ID of the preference to read + * @param reader the reader to read from + * @param editor the editor to set the preference in + * @throws IOException if there are errors while reading + */ + private void readAndSetPreference(String name, byte typeId, + DataInputStream reader, Editor editor) throws IOException { + switch (typeId) { + case ContentTypeIds.BOOLEAN_TYPE_ID: + editor.putBoolean(name, reader.readBoolean()); + return; + case ContentTypeIds.LONG_TYPE_ID: + editor.putLong(name, reader.readLong()); + return; + case ContentTypeIds.FLOAT_TYPE_ID: + editor.putFloat(name, reader.readFloat()); + return; + case ContentTypeIds.INT_TYPE_ID: + editor.putInt(name, reader.readInt()); + return; + case ContentTypeIds.STRING_TYPE_ID: + editor.putString(name, reader.readUTF()); + return; + } + } + + /** + * Writes a single preference. + * + * @param name the name of the preference to write + * @param value the correctly-typed value of the preference + * @param writer the writer to write to + * @throws IOException if there are errors while writing + */ + private void writePreference(String name, Object value, DataOutputStream writer) throws IOException { + writer.writeUTF(name); + + if (value instanceof Boolean) { + writer.writeByte(ContentTypeIds.BOOLEAN_TYPE_ID); + writer.writeBoolean((Boolean) value); + } else if (value instanceof Integer) { + writer.writeByte(ContentTypeIds.INT_TYPE_ID); + writer.writeInt((Integer) value); + } else if (value instanceof Long) { + writer.writeByte(ContentTypeIds.LONG_TYPE_ID); + writer.writeLong((Long) value); + } else if (value instanceof Float) { + writer.writeByte(ContentTypeIds.FLOAT_TYPE_ID); + writer.writeFloat((Float) value); + } else if (value instanceof String) { + writer.writeByte(ContentTypeIds.STRING_TYPE_ID); + writer.writeUTF((String) value); + } else { + throw new IllegalArgumentException("Type " + value.getClass().getName() + " not supported"); + } + } +} diff --git a/MyTracksTest/src/com/google/android/apps/mytracks/io/backup/BackupStateManagerTest.java b/MyTracksTest/src/com/google/android/apps/mytracks/io/backup/BackupStateManagerTest.java new file mode 100644 index 000000000..b4649937f --- /dev/null +++ b/MyTracksTest/src/com/google/android/apps/mytracks/io/backup/BackupStateManagerTest.java @@ -0,0 +1,161 @@ +package com.google.android.apps.mytracks.io.backup; + +import com.google.android.apps.mytracks.content.Track; +import com.google.android.apps.mytracks.io.backup.BackupStateManager.TrackState; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Map; + +import junit.framework.TestCase; + +/** + * Tests for {@link BackupStateManager}. + * + * @author Rodrigo Damazio + */ +public class BackupStateManagerTest extends TestCase { + + /** + * Testable version of the state manager which doesn't use actual files. + */ + private static class TestableBackupStateManager extends BackupStateManager { + private final byte[] oldStateData; + private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + public TestableBackupStateManager(byte[] oldStateData) { + super(null, null); + + this.oldStateData = oldStateData; + } + + @Override + protected OutputStream openStateOutput() { + return outputStream; + } + + public ByteArrayOutputStream getOutputStream() { + return outputStream; + } + + @Override + protected InputStream openStateInput() { + if (!hasOldState()) { + throw new IllegalStateException("Don't have old state"); + } + + return new ByteArrayInputStream(oldStateData); + } + + @Override + protected boolean hasOldState() { + return oldStateData != null; + } + } + + public void testTrackStateRepresents() { + TrackState state = new TrackState(123L, "name", "desc", "cat"); + Track stateTrack = makeTrack(123L, "name", "desc", "cat"); + + assertTrue(state.represents(stateTrack)); + } + + public void testTrackStateRepresents_wrongId() { + TrackState state = new TrackState(123L, "name", "desc", "cat"); + Track stateTrack = makeTrack(456L, "name", "desc", "cat"); + + assertFalse(state.represents(stateTrack)); + } + + public void testTrackStateRepresents_wrongName() { + TrackState state = new TrackState(123L, "name", "desc", "cat"); + Track stateTrack = makeTrack(123L, "name2", "desc", "cat"); + + assertFalse(state.represents(stateTrack)); + } + + public void testTrackStateRepresents_wrongDescription() { + TrackState state = new TrackState(123L, "name", "desc", "cat"); + Track stateTrack = makeTrack(123L, "name", "desc2", "cat"); + + assertFalse(state.represents(stateTrack)); + } + + public void testTrackStateRepresents_wrongCategory() { + TrackState state = new TrackState(123L, "name", "desc", "cat"); + Track stateTrack = makeTrack(123L, "name", "desc", "cat2"); + + assertFalse(state.represents(stateTrack)); + } + + public void testGetOldTrackState_empty() { + TestableBackupStateManager stateManager = new TestableBackupStateManager(null); + Map oldState = stateManager.getOldTrackState(); + assertTrue(oldState.isEmpty()); + } + + public void testWriteReadState() throws Exception { + // Initially create a state manager which knows no state + TestableBackupStateManager stateManager = new TestableBackupStateManager(null); + + // Add some state to it + stateManager.addTrackState(makeTrack(123L, "Name1")); + stateManager.addTrackState(makeTrack(456L, "Name2")); + stateManager.flushNewState(); + + // Get the result + byte[] stateData = stateManager.getOutputStream().toByteArray(); + + // Now create a state manager with the previous serialized state + stateManager = new TestableBackupStateManager(stateData); + + // Read its old state and verify it + Map oldState = stateManager.getOldTrackState(); + assertEquals(oldState.toString(), 2, oldState.size()); + assertTrue(oldState.containsKey(123L)); + assertTrue(oldState.containsKey(456L)); + assertStateEquals(123L, "Name1", oldState.get(123L)); + assertStateEquals(456L, "Name2", oldState.get(456L)); + + // Write a new, different state + stateManager.addTrackState(makeTrack(123L, "Name1")); + stateManager.addTrackState(makeTrack(987L, "Name3")); + stateManager.flushNewState(); + + // Get the new result + stateData = stateManager.getOutputStream().toByteArray(); + + // Create another state manager to read this last state + stateManager = new TestableBackupStateManager(stateData); + + // Read and verify it + oldState = stateManager.getOldTrackState(); + assertEquals(oldState.toString(), 2, oldState.size()); + assertTrue(oldState.containsKey(123L)); + assertTrue(oldState.containsKey(987L)); + assertStateEquals(123L, "Name1", oldState.get(123L)); + assertStateEquals(987L, "Name3", oldState.get(987L)); + } + + private Track makeTrack(long id, String name) { + return makeTrack(id, name, "Description for " + name, "Category for " + name); + } + + private Track makeTrack(long id, String name, String description, String category) { + Track track = new Track(); + track.setId(id); + track.setName(name); + track.setDescription(description); + track.setCategory(category); + return track; + } + + private void assertStateEquals(long id, String name, TrackState state) { + assertEquals(id, state.trackId); + assertEquals(name, state.name); + assertEquals("Description for " + name, state.description); + assertEquals("Category for " + name, state.category); + } +} diff --git a/MyTracksTest/src/com/google/android/apps/mytracks/io/backup/DatabaseDumperTest.java b/MyTracksTest/src/com/google/android/apps/mytracks/io/backup/DatabaseDumperTest.java new file mode 100644 index 000000000..4316ff3b4 --- /dev/null +++ b/MyTracksTest/src/com/google/android/apps/mytracks/io/backup/DatabaseDumperTest.java @@ -0,0 +1,212 @@ +/* + * 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.content.ContentTypeIds; + +import android.database.MatrixCursor; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +import junit.framework.TestCase; + +/** + * Tests for {@link DatabaseDumper}. + * + * @author Rodrigo Damazio + */ +public class DatabaseDumperTest extends TestCase { + + private static final String[] COLUMN_NAMES = { + "intCol", "longCol", "floatCol", "doubleCol", "stringCol", "boolCol" + }; + private static final byte[] COLUMN_TYPES = { + ContentTypeIds.INT_TYPE_ID, ContentTypeIds.LONG_TYPE_ID, + ContentTypeIds.FLOAT_TYPE_ID, ContentTypeIds.DOUBLE_TYPE_ID, + ContentTypeIds.STRING_TYPE_ID, ContentTypeIds.BOOLEAN_TYPE_ID + }; + private static final String[][] FAKE_DATA = { + { "42", "123456789", "3.1415", "2.72", "lolcat", "1" }, + { null, "123456789", "3.1415", "2.72", "lolcat", "1" }, + { "42", null, "3.1415", "2.72", "lolcat", "1" }, + { "42", "123456789", null, "2.72", "lolcat", "1" }, + { "42", "123456789", "3.1415", null, "lolcat", "1" }, + { "42", "123456789", "3.1415", "2.72", null, "1" }, + { "42", "123456789", "3.1415", "2.72", "lolcat", null }, + }; + private static final long[] EXPECTED_FIELD_SETS = { + 0x3F, 0x3E, 0x3D, 0x3B, 0x37, 0x2F, 0x1F + }; + + private MatrixCursor cursor; + + @Override + protected void setUp() throws Exception { + super.setUp(); + + // Add fake data to the cursor + cursor = new MatrixCursor(COLUMN_NAMES); + for (String[] row : FAKE_DATA) { + cursor.addRow(row); + } + } + + public void testWriteAllRows_noNulls() throws Exception { + testWriteAllRows(false); + } + + public void testWriteAllRows_withNulls() throws Exception { + testWriteAllRows(true); + } + + private void testWriteAllRows(boolean hasNullFields) throws Exception { + // Dump it + DatabaseDumper dumper = new DatabaseDumper(COLUMN_NAMES, COLUMN_TYPES, hasNullFields); + ByteArrayOutputStream outStream = new ByteArrayOutputStream(1024); + DataOutputStream writer = new DataOutputStream(outStream ); + dumper.writeAllRows(cursor, writer); + + // Read the results + byte[] result = outStream.toByteArray(); + ByteArrayInputStream inputStream = new ByteArrayInputStream(result); + DataInputStream reader = new DataInputStream(inputStream); + + // Verify the header + assertHeader(reader); + + // Verify the number of rows + assertEquals(FAKE_DATA.length, reader.readInt()); + + // Verify the row contents + + // Row 0 + assertEquals(EXPECTED_FIELD_SETS[0], reader.readLong()); + assertEquals(42, reader.readInt()); + assertEquals(123456789L, reader.readLong()); + assertEquals(3.1415f, reader.readFloat()); + assertEquals(2.72, reader.readDouble()); + assertEquals("lolcat", reader.readUTF()); + assertTrue(reader.readBoolean()); + + // Row 1 + assertEquals(EXPECTED_FIELD_SETS[1], reader.readLong()); + if (hasNullFields) reader.readInt(); + assertEquals(123456789L, reader.readLong()); + assertEquals(3.1415f, reader.readFloat()); + assertEquals(2.72, reader.readDouble()); + assertEquals("lolcat", reader.readUTF()); + assertTrue(reader.readBoolean()); + + // Row 2 + assertEquals(EXPECTED_FIELD_SETS[2], reader.readLong()); + assertEquals(42, reader.readInt()); + if (hasNullFields) reader.readLong(); + assertEquals(3.1415f, reader.readFloat()); + assertEquals(2.72, reader.readDouble()); + assertEquals("lolcat", reader.readUTF()); + assertTrue(reader.readBoolean()); + + // Row 3 + assertEquals(EXPECTED_FIELD_SETS[3], reader.readLong()); + assertEquals(42, reader.readInt()); + assertEquals(123456789L, reader.readLong()); + if (hasNullFields) reader.readFloat(); + assertEquals(2.72, reader.readDouble()); + assertEquals("lolcat", reader.readUTF()); + assertTrue(reader.readBoolean()); + + // Row 4 + assertEquals(EXPECTED_FIELD_SETS[4], reader.readLong()); + assertEquals(42, reader.readInt()); + assertEquals(123456789L, reader.readLong()); + assertEquals(3.1415f, reader.readFloat()); + if (hasNullFields) reader.readDouble(); + assertEquals("lolcat", reader.readUTF()); + assertTrue(reader.readBoolean()); + + // Row 5 + assertEquals(EXPECTED_FIELD_SETS[5], reader.readLong()); + assertEquals(42, reader.readInt()); + assertEquals(123456789L, reader.readLong()); + assertEquals(3.1415f, reader.readFloat()); + assertEquals(2.72, reader.readDouble()); + if (hasNullFields) reader.readUTF(); + assertTrue(reader.readBoolean()); + + // Row 6 + assertEquals(EXPECTED_FIELD_SETS[6], reader.readLong()); + assertEquals(42, reader.readInt()); + assertEquals(123456789L, reader.readLong()); + assertEquals(3.1415f, reader.readFloat()); + assertEquals(2.72, reader.readDouble()); + assertEquals("lolcat", reader.readUTF()); + if (hasNullFields) reader.readBoolean(); + } + + public void testFewerRows() throws Exception { + // Dump only the first two rows + DatabaseDumper dumper = new DatabaseDumper(COLUMN_NAMES, COLUMN_TYPES, false); + ByteArrayOutputStream outStream = new ByteArrayOutputStream(1024); + DataOutputStream writer = new DataOutputStream(outStream); + + dumper.writeHeaders(cursor, 2, writer); + cursor.moveToFirst(); + dumper.writeOneRow(cursor, writer); + cursor.moveToNext(); + dumper.writeOneRow(cursor, writer); + + // Read the results + byte[] result = outStream.toByteArray(); + ByteArrayInputStream inputStream = new ByteArrayInputStream(result); + DataInputStream reader = new DataInputStream(inputStream); + + // Verify the header + assertHeader(reader); + + // Verify the number of rows + assertEquals(2, reader.readInt()); + + // Row 0 + assertEquals(EXPECTED_FIELD_SETS[0], reader.readLong()); + assertEquals(42, reader.readInt()); + assertEquals(123456789L, reader.readLong()); + assertEquals(3.1415f, reader.readFloat()); + assertEquals(2.72, reader.readDouble()); + assertEquals("lolcat", reader.readUTF()); + assertTrue(reader.readBoolean()); + + // Row 1 + assertEquals(EXPECTED_FIELD_SETS[1], reader.readLong()); + // Null field not read + assertEquals(123456789L, reader.readLong()); + assertEquals(3.1415f, reader.readFloat()); + assertEquals(2.72, reader.readDouble()); + assertEquals("lolcat", reader.readUTF()); + assertTrue(reader.readBoolean()); + } + + private void assertHeader(DataInputStream reader) throws IOException { + assertEquals(6, reader.readInt()); + for (int i = 0; i < COLUMN_NAMES.length; i++) { + assertEquals(COLUMN_NAMES[i], reader.readUTF()); + assertEquals(COLUMN_TYPES[i], reader.readByte()); + } + } +} diff --git a/MyTracksTest/src/com/google/android/apps/mytracks/io/backup/DatabaseImporterTest.java b/MyTracksTest/src/com/google/android/apps/mytracks/io/backup/DatabaseImporterTest.java new file mode 100644 index 000000000..05b13aeaa --- /dev/null +++ b/MyTracksTest/src/com/google/android/apps/mytracks/io/backup/DatabaseImporterTest.java @@ -0,0 +1,301 @@ +/* + * 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.content.ContentTypeIds; + +import android.content.ContentValues; +import android.net.Uri; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.util.ArrayList; + +import junit.framework.TestCase; + +/** + * Tests for {@link DatabaseImporter}. + * + * @author Rodrigo Damazio + */ +public class DatabaseImporterTest extends TestCase { + + private static final Uri DESTINATION_URI = Uri.parse("http://www.google.com/"); + private static final int TEST_BULK_SIZE = 10; + private ArrayList insertedValues; + + private class TestableDatabaseImporter extends DatabaseImporter { + public TestableDatabaseImporter(boolean readNullFields) { + super(DESTINATION_URI, null, readNullFields, TEST_BULK_SIZE); + } + + @Override + protected void doBulkInsert(ContentValues[] values) { + insertedValues.ensureCapacity(insertedValues.size() + values.length); + + // We need to make a copy of the values since the objects are re-used + for (ContentValues contentValues : values) { + insertedValues.add(new ContentValues(contentValues)); + } + } + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + + insertedValues = new ArrayList(); + } + + public void testImportAllRows() throws Exception { + testImportAllRows(false); + } + + public void testImportAllRows_readNullFields() throws Exception { + testImportAllRows(true); + } + + private void testImportAllRows(boolean readNullFields) throws Exception { + // Create a fake data stream to be read + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(1024); + DataOutputStream writer = new DataOutputStream(outputStream); + + // Add the header + writer.writeInt(6); + writer.writeUTF("col1"); + writer.writeByte(ContentTypeIds.INT_TYPE_ID); + writer.writeUTF("col2"); + writer.writeByte(ContentTypeIds.BOOLEAN_TYPE_ID); + writer.writeUTF("col3"); + writer.writeByte(ContentTypeIds.STRING_TYPE_ID); + writer.writeUTF("col4"); + writer.writeByte(ContentTypeIds.FLOAT_TYPE_ID); + writer.writeUTF("col5"); + writer.writeByte(ContentTypeIds.DOUBLE_TYPE_ID); + writer.writeUTF("col6"); + writer.writeByte(ContentTypeIds.LONG_TYPE_ID); + + // Add the number of rows + writer.writeInt(2); + + // Add a row with all fields present + writer.writeLong(0x3F); + writer.writeInt(42); + writer.writeBoolean(true); + writer.writeUTF("lolcat"); + writer.writeFloat(3.1415f); + writer.writeDouble(2.72); + writer.writeLong(123456789L); + + // Add a row with some missing fields + writer.writeLong(0x15); + writer.writeInt(42); + if (readNullFields) writer.writeBoolean(false); + writer.writeUTF("lolcat"); + if (readNullFields) writer.writeFloat(0.0f); + writer.writeDouble(2.72); + if (readNullFields) writer.writeLong(0L); + + writer.flush(); + + // Do the importing + DatabaseImporter importer = new TestableDatabaseImporter(readNullFields); + byte[] dataBytes = outputStream.toByteArray(); + importer.importAllRows(new DataInputStream(new ByteArrayInputStream(dataBytes))); + + assertEquals(2, insertedValues.size()); + + // Verify the first row + ContentValues value = insertedValues.get(0); + assertEquals(value.toString(), 6, value.size()); + + assertValue(42, "col1", value); + assertValue(true, "col2", value); + assertValue("lolcat", "col3", value); + assertValue(3.1415f, "col4", value); + assertValue(2.72, "col5", value); + assertValue(123456789L, "col6", value); + + // Verify the second row + value = insertedValues.get(1); + assertEquals(value.toString(), 3, value.size()); + + assertValue(42, "col1", value); + assertValue("lolcat", "col3", value); + assertValue(2.72, "col5", value); + } + + public void testImportAllRows_noRows() throws Exception { + // Create a fake data stream to be read + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(1024); + DataOutputStream writer = new DataOutputStream(outputStream); + + // Add the header + writer.writeInt(6); + writer.writeUTF("col1"); + writer.writeByte(ContentTypeIds.INT_TYPE_ID); + writer.writeUTF("col2"); + writer.writeByte(ContentTypeIds.BOOLEAN_TYPE_ID); + writer.writeUTF("col3"); + writer.writeByte(ContentTypeIds.STRING_TYPE_ID); + writer.writeUTF("col4"); + writer.writeByte(ContentTypeIds.FLOAT_TYPE_ID); + writer.writeUTF("col5"); + writer.writeByte(ContentTypeIds.DOUBLE_TYPE_ID); + writer.writeUTF("col6"); + writer.writeByte(ContentTypeIds.LONG_TYPE_ID); + + // Add the number of rows + writer.writeInt(0); + + writer.flush(); + + // Do the importing + DatabaseImporter importer = new TestableDatabaseImporter(false); + byte[] dataBytes = outputStream.toByteArray(); + importer.importAllRows(new DataInputStream(new ByteArrayInputStream(dataBytes))); + + assertTrue(insertedValues.isEmpty()); + } + + public void testImportAllRows_emptyRows() throws Exception { + testImportAllRowsWithEmptyRows(false); + } + + public void testImportAllRows_emptyRowsWithNulls() throws Exception { + testImportAllRowsWithEmptyRows(true); + } + + private void testImportAllRowsWithEmptyRows(boolean readNullFields) throws Exception { + // Create a fake data stream to be read + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(1024); + DataOutputStream writer = new DataOutputStream(outputStream); + + // Add the header + writer.writeInt(6); + writer.writeUTF("col1"); + writer.writeByte(ContentTypeIds.INT_TYPE_ID); + writer.writeUTF("col2"); + writer.writeByte(ContentTypeIds.BOOLEAN_TYPE_ID); + writer.writeUTF("col3"); + writer.writeByte(ContentTypeIds.STRING_TYPE_ID); + writer.writeUTF("col4"); + writer.writeByte(ContentTypeIds.FLOAT_TYPE_ID); + writer.writeUTF("col5"); + writer.writeByte(ContentTypeIds.DOUBLE_TYPE_ID); + writer.writeUTF("col6"); + writer.writeByte(ContentTypeIds.LONG_TYPE_ID); + + // Add the number of rows + writer.writeInt(3); + + // Add 2 rows with no fields + for (int i = 0; i < 2; i++) { + writer.writeLong(0); + if (readNullFields) { + writer.writeInt(0); + writer.writeBoolean(false); + writer.writeUTF(""); + writer.writeFloat(0.0f); + writer.writeDouble(0.0); + writer.writeLong(0L); + } + } + + // Add a row with some missing fields + writer.writeLong(0x15); + writer.writeInt(42); + if (readNullFields) writer.writeBoolean(false); + writer.writeUTF("lolcat"); + if (readNullFields) writer.writeFloat(0.0f); + writer.writeDouble(2.72); + if (readNullFields) writer.writeLong(0L); + + writer.flush(); + + // Do the importing + DatabaseImporter importer = new TestableDatabaseImporter(readNullFields); + byte[] dataBytes = outputStream.toByteArray(); + importer.importAllRows(new DataInputStream(new ByteArrayInputStream(dataBytes))); + + assertEquals(insertedValues.toString(), 3, insertedValues.size()); + + ContentValues value = insertedValues.get(0); + assertEquals(value.toString(), 0, value.size()); + + value = insertedValues.get(1); + assertEquals(value.toString(), 0, value.size()); + + // Verify the third row (only one with values) + value = insertedValues.get(2); + assertEquals(value.toString(), 3, value.size()); + + assertFalse(value.containsKey("col2")); + assertFalse(value.containsKey("col4")); + assertFalse(value.containsKey("col6")); + + assertValue(42, "col1", value); + assertValue("lolcat", "col3", value); + assertValue(2.72, "col5", value); + } + + public void testImportAllRows_bulks() throws Exception { + // Create a fake data stream to be read + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(1024); + DataOutputStream writer = new DataOutputStream(outputStream); + + // Add the header + writer.writeInt(2); + writer.writeUTF("col1"); + writer.writeByte(ContentTypeIds.INT_TYPE_ID); + writer.writeUTF("col2"); + writer.writeByte(ContentTypeIds.STRING_TYPE_ID); + + // Add lots of rows (so the insertions are split in multiple bulks) + int numRows = TEST_BULK_SIZE * 5 / 2; + writer.writeInt(numRows); + for (int i = 0; i < numRows; i++) { + writer.writeLong(3); + writer.writeInt(i); + writer.writeUTF(Integer.toString(i * 2)); + } + + writer.flush(); + + // Do the importing + DatabaseImporter importer = new TestableDatabaseImporter(false); + byte[] dataBytes = outputStream.toByteArray(); + importer.importAllRows(new DataInputStream(new ByteArrayInputStream(dataBytes))); + + // Verify the rows + assertEquals(numRows, insertedValues.size()); + for (int i = 0; i < numRows; i++) { + ContentValues value = insertedValues.get(i); + assertEquals(value.toString(), 2, value.size()); + assertValue(i, "col1", value); + assertValue(Integer.toString(i * 2), "col2", value); + } + } + + private void assertValue(T expectedValue, String name, ContentValues values) { + @SuppressWarnings("unchecked") + T value = (T) values.get(name); + assertNotNull(value); + assertEquals(expectedValue, value); + } +} diff --git a/MyTracksTest/src/com/google/android/apps/mytracks/io/backup/MyTracksBackupAgentTest.java b/MyTracksTest/src/com/google/android/apps/mytracks/io/backup/MyTracksBackupAgentTest.java new file mode 100644 index 000000000..cd1444d7a --- /dev/null +++ b/MyTracksTest/src/com/google/android/apps/mytracks/io/backup/MyTracksBackupAgentTest.java @@ -0,0 +1,112 @@ +/* + * 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.content.MyTracksProviderUtils; +import com.google.android.apps.mytracks.content.MyTracksProviderUtils.Factory; +import com.google.android.apps.mytracks.testing.TestingProviderUtilsFactory; +import com.google.android.testing.mocking.AndroidMock; +import com.google.android.testing.mocking.UsesMocks; + +import android.os.ParcelFileDescriptor; + +import junit.framework.TestCase; + +/** + * Tests for {@link MyTracksBackupAgent}. + * + * @author Rodrigo Damazio + */ +public class MyTracksBackupAgentTest extends TestCase { + // Database dumpers + private DatabaseDumper trackDumper; + private DatabaseDumper waypointDumper; + private DatabaseDumper pointDumper; + + // Database importers + private DatabaseImporter trackImporter; + private DatabaseImporter waypointImporter; + private DatabaseImporter pointImporter; + + // Other dependencies + private BackupStateManager stateManager; + private PreferenceBackupHelper preferencesHelper; + private MyTracksProviderUtils providerUtils; + private Factory oldProviderUtilsFactory; + + /** + * Testable version of the backup agent, with dependencies mocked out. + */ + private class TestableBackupAgent extends MyTracksBackupAgent { + @Override + protected BackupStateManager createStateManager( + ParcelFileDescriptor oldState, ParcelFileDescriptor newState) { + return stateManager; + } + + @Override + protected PreferenceBackupHelper createPreferenceBackupHelper() { + return preferencesHelper; + } + + @Override + protected void ensureDumpers() { + super.trackDumper = MyTracksBackupAgentTest.this.trackDumper; + super.waypointDumper = MyTracksBackupAgentTest.this.waypointDumper; + super.pointDumper = MyTracksBackupAgentTest.this.pointDumper; + } + + @Override + protected void ensureImporters() { + super.trackImporter = MyTracksBackupAgentTest.this.trackImporter; + super.waypointImporter = MyTracksBackupAgentTest.this.waypointImporter; + super.pointImporter = MyTracksBackupAgentTest.this.pointImporter; + } + } + + @UsesMocks({ DatabaseDumper.class, DatabaseImporter.class }) + @Override + protected void setUp() throws Exception { + super.setUp(); + + trackDumper = AndroidMock.createMock(DatabaseDumper.class); + waypointDumper = AndroidMock.createMock(DatabaseDumper.class); + pointDumper = AndroidMock.createMock(DatabaseDumper.class); + trackImporter = AndroidMock.createMock(DatabaseImporter.class); + waypointImporter = AndroidMock.createMock(DatabaseImporter.class); + pointImporter = AndroidMock.createMock(DatabaseImporter.class); + stateManager = AndroidMock.createMock(BackupStateManager.class); + preferencesHelper = AndroidMock.createMock(PreferenceBackupHelper.class); + providerUtils = AndroidMock.createMock(MyTracksProviderUtils.class); + oldProviderUtilsFactory = + TestingProviderUtilsFactory.installWithInstance(providerUtils); + } + + @Override + protected void tearDown() throws Exception { + TestingProviderUtilsFactory.restoreOldFactory(oldProviderUtilsFactory); + super.tearDown(); + } + + public void testOnBackup() { + fail("Not yet implemented"); + } + + public void testOnRestore() { + fail("Not yet implemented"); + } + +} diff --git a/MyTracksTest/src/com/google/android/apps/mytracks/io/backup/PreferenceBackupHelperTest.java b/MyTracksTest/src/com/google/android/apps/mytracks/io/backup/PreferenceBackupHelperTest.java new file mode 100644 index 000000000..0cdd7c626 --- /dev/null +++ b/MyTracksTest/src/com/google/android/apps/mytracks/io/backup/PreferenceBackupHelperTest.java @@ -0,0 +1,197 @@ +/* + * 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 android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; + +import java.util.HashMap; +import java.util.Map; + +import junit.framework.TestCase; + +/** + * Tests for {@link PreferenceBackupHelper}. + * + * @author Rodrigo Damazio + */ +public class PreferenceBackupHelperTest extends TestCase { + private Map preferenceValues; + private SharedPreferences preferences; + private PreferenceBackupHelper preferenceBackupHelper; + + /** + * Mock shared preferences editor which does not persist state. + */ + private class MockPreferenceEditor implements SharedPreferences.Editor { + private Map newPreferences = new HashMap(preferenceValues); + + @Override + public Editor clear() { + newPreferences.clear(); + return this; + } + + @Override + public boolean commit() { + preferenceValues = newPreferences; + return true; + } + + @Override + public Editor putBoolean(String key, boolean value) { + return put(key, value); + } + + @Override + public Editor putFloat(String key, float value) { + return put(key, value); + } + + @Override + public Editor putInt(String key, int value) { + return put(key, value); + } + + @Override + public Editor putLong(String key, long value) { + return put(key, value); + } + + @Override + public Editor putString(String key, String value) { + return put(key, value); + } + + private Editor put(String key, T value) { + newPreferences.put(key, value); + return this; + } + + @Override + public Editor remove(String key) { + newPreferences.remove(key); + return this; + } + } + + /** + * Mock shared preferences which does not persist state. + */ + private class MockPreferences implements SharedPreferences { + @Override + public boolean contains(String key) { + return preferenceValues.containsKey(key); + } + + @Override + public Editor edit() { + return new MockPreferenceEditor(); + } + + @Override + public Map getAll() { + return preferenceValues; + } + + @Override + public boolean getBoolean(String key, boolean defValue) { + return get(key, defValue); + } + + @Override + public float getFloat(String key, float defValue) { + return get(key, defValue); + } + + @Override + public int getInt(String key, int defValue) { + return get(key, defValue); + } + + @Override + public long getLong(String key, long defValue) { + return get(key, defValue); + } + + @Override + public String getString(String key, String defValue) { + return get(key, defValue); + } + + @Override + public void registerOnSharedPreferenceChangeListener( + OnSharedPreferenceChangeListener listener) { + throw new UnsupportedOperationException(); + } + + @Override + public void unregisterOnSharedPreferenceChangeListener( + OnSharedPreferenceChangeListener listener) { + throw new UnsupportedOperationException(); + } + + @SuppressWarnings("unchecked") + private T get(String key, T defValue) { + Object value = preferenceValues.get(key); + if (value == null) return defValue; + return (T) value; + } + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + + preferenceValues = new HashMap(); + preferences = new MockPreferences(); + preferenceBackupHelper = new PreferenceBackupHelper(); + } + + public void testExportImportPreferences() throws Exception { + // Populate with some initial values + Editor editor = preferences.edit(); + editor.clear(); + editor.putBoolean("bool1", true); + editor.putBoolean("bool2", false); + editor.putFloat("flt1", 3.14f); + editor.putInt("int1", 42); + editor.putLong("long1", 123456789L); + editor.putString("str1", "lolcat"); + editor.commit(); + + // Export it + byte[] exported = preferenceBackupHelper.exportPreferences(preferences); + + // Mess with the previous values + editor = preferences.edit(); + editor.clear(); + editor.putString("str2", "Shouldn't be there after restore"); + editor.putBoolean("bool2", true); + editor.commit(); + + // Import it back + preferenceBackupHelper.importPreferences(exported, preferences); + + assertFalse(preferences.contains("str2")); + assertTrue(preferences.getBoolean("bool1", false)); + assertFalse(preferences.getBoolean("bool2", true)); + assertEquals(3.14f, preferences.getFloat("flt1", 0.0f)); + assertEquals(42, preferences.getInt("int1", 0)); + assertEquals(123456789L, preferences.getLong("long1", 0)); + assertEquals("lolcat", preferences.getString("str1", "")); + } +}