/**
Copyright 2015 Tim Engler, Rareventure LLC
This file is part of Tiny Travel Tracker.
Tiny Travel Tracker is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Tiny Travel Tracker is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Tiny Travel Tracker. If not, see <http://www.gnu.org/licenses/>.
*/
package com.rareventure.gps2.gpx;
import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Date;
import java.util.TimeZone;
import java.util.TreeSet;
import java.util.regex.Pattern;
import java.util.zip.DataFormatException;
import java.util.zip.ZipEntry;
import java.util.zip.ZipException;
import java.util.zip.ZipInputStream;
import org.xml.sax.SAXException;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.content.DialogInterface.OnClickListener;
import android.content.Intent;
import android.os.Bundle;
import android.os.Environment;
import android.util.Log;
import android.view.View;
import android.view.WindowManager;
import android.widget.Button;
import android.widget.EditText;
import com.lamerman.FileDialog;
import com.lamerman.SelectionMode;
import com.rareventure.android.DbUtil;
import com.rareventure.android.ProgressDialogActivity;
import com.rareventure.android.database.DbDatastoreAccessor;
import com.rareventure.android.database.timmy.TimmyDatabase;
import com.rareventure.gps2.GTG;
import com.rareventure.gps2.GpsTrailerDb;
import com.rareventure.gps2.GpsTrailerDbProvider;
import com.rareventure.gps2.GpsTrailerService;
import com.rareventure.gps2.R;
import com.rareventure.gps2.GTG.Requirement;
import com.rareventure.gps2.database.GpsLocationRow;
import com.rareventure.gps2.database.TimeZoneTimeRow;
import com.rareventure.gps2.gpx.RestoreGpxBackup.ValidateAndRestoreGpxBackupTask.TaskList;
import com.rareventure.gps2.reviewer.PasswordDialogFragment;
import com.rareventure.gps2.reviewer.SettingsActivity;
import com.rareventure.util.ByteCounterInputStream;
import de.idyl.winzipaes.AesZipFileDecrypter;
import de.idyl.winzipaes.CrcIgnoringZipInputStream;
import de.idyl.winzipaes.impl.AESDecrypterBC;
import de.idyl.winzipaes.impl.ExtZipEntry;
public class RestoreGpxBackup extends ProgressDialogActivity {
public class FileInfo {
private File file;
public CharSequence errorStr;
private boolean isZip;
private AesZipFileDecrypter azfd;
private ExtZipEntry entry;
protected CharSequence password;
private byte [] fileZippedData;
protected long entrySize;
public FileInfo(File file) {
this.file = file;
if(file.toString().matches(".*\\.(?i:zip)"))
{
isZip = true;
}
else if(file.toString().matches(".*\\.(?i:gpx)"))
{
}
else
errorStr = getText(R.string.restore_gps_bad_file_ext);
try {
if(isZip)
{
azfd = new AesZipFileDecrypter(file, new AESDecrypterBC());
entry = findZipEntry(".*\\.(?i:gpx)");
if(entry == null)
{
errorStr = getText(R.string.restore_gpx_no_gpx_file_in_zip);
return;
}
}
}
catch(IOException e)
{
Log.e(GTG.TAG,"Can't read zip file", e);
errorStr = getText(R.string.restore_gpx_cannot_read_zip_file);
return;
}
}
private ExtZipEntry findZipEntry(String regex) throws IOException {
for(ExtZipEntry ze : azfd.getEntryList())
{
if(ze.getName().matches(regex))
return ze;
}
return null;
}
public boolean needsPassword() {
return isZip && entry.isEncrypted();
}
public boolean hasError() {
return errorStr != null;
}
/**
* Task and tasknumber are only used if we haven't already loaded the data in memory
* @param validateAndRestoreGpxBackupTask
* @param t
* @param taskNumber
* @return
* @throws ZipException
* @throws IOException
* @throws DataFormatException
*/
public InputStream getBufferedInputStream(final ValidateAndRestoreGpxBackupTask task, final TaskList tl) throws ZipException, IOException, DataFormatException {
if(!isZip)
{
entrySize = file.length();
return new BufferedInputStream(new FileInputStream(file));
}
else
{
entrySize = entry.getSize();
if(entry.isEncrypted())
{
if(fileZippedData == null)
fileZippedData = azfd.extractEntryToZippedByteArray(entry, password.toString(),
new AesZipFileDecrypter.ProgressListener() {
@Override
public void notifyProgress(int count, int total) {
tl.updateCurrTaskProgress(count, total);
}
@Override
public boolean isCanceled() {
return task.isCanceled;
}
});
//if is canceled
if(fileZippedData == null)
return null;
CrcIgnoringZipInputStream is = new CrcIgnoringZipInputStream(new ByteArrayInputStream(fileZippedData));
is.getNextEntry();
return is;
}
else
{
ZipInputStream zis = new ZipInputStream(new BufferedInputStream(new FileInputStream(file)));
for(;;)
{
ZipEntry ze = zis.getNextEntry();
if(ze.getName().equals(entry.getName()))
return zis;
}
}
}
}
}
private EditText filenameView;
private Pattern xmlPattern;
private Button restoreButton;
private FileInfo fileInfo;
private ValidateAndRestoreGpxBackupTask restoreTask;
private boolean isPaused = true;
@Override
public void doOnCreate(Bundle b) {
super.doOnCreate(b);
setContentView(R.layout.restore_gpx_backup);
filenameView = (EditText) findViewById(R.id.filename);
filenameView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
onFileBrowserButton(filenameView);
}
});
//co: we no longer bother letting the user type in a filename, it's such a weird thing to try and do
// filenameView.setOnFocusChangeListener(new View.OnFocusChangeListener() {
//
// @Override
// public void onFocusChange(View v, boolean hasFocus) {
// if (!hasFocus) {
// checkChosenFile();
// }
// }
//
// });
restoreButton = (Button) findViewById(R.id.restoreButton);
checkChosenFile();
}
private void checkChosenFile() {
if (filenameView.getText().toString().trim().isEmpty())
restoreButton.setEnabled(false);
else
restoreButton.setEnabled(true);
}
public void onRestoreButton(View view) {
String filename = filenameView.getText().toString().trim();
if (!filename.startsWith("/"))
filename = Environment.getExternalStorageDirectory() + "/"
+ filename;
final File f = new File(filename);
fileInfo = new FileInfo(f);
if (fileInfo.hasError()) {
new AlertDialog.Builder(this)
.setCancelable(false)
.setTitle(R.string.error)
.setMessage(fileInfo.errorStr)
.setNeutralButton(R.string.ok, null).show();
return;
}
new AlertDialog.Builder(this)
.setCancelable(false)
.setTitle(R.string.restore)
.setMessage(R.string.restore_confirmation_dialog_msg)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.perform_restore,
new OnClickListener() {
@Override
public void onClick(DialogInterface dialog,
int which) {
onRestore2();
}
}).show();
}
private void onRestore2()
{
if(fileInfo.needsPassword())
{
new PasswordDialogFragment.Builder(this)
.setTitle("Enter Password")
.setMessage(String.format(getString(R.string.restore_dialog_enter_password_for_filename_fmt),
fileInfo.entry.getName()))
.setOnOk(new PasswordDialogFragment.OnOkListener() {
@Override
public void onOk(CharSequence password) {
fileInfo.password = password;
performValidateAndThenCallRestore3InLongTask();
}
}).show();
}
else
performValidateAndThenCallRestore3InLongTask();
}
private void performValidateAndThenCallRestore3InLongTask() {
//we don't want the thing timing out, because leaving this page will cause the restore to quit
//(cleared in doAfterFinish() of the task
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
GTG.setIsInRestore(true);
runLongTask(restoreTask = new ValidateAndRestoreGpxBackupTask()
, true, false, R.string.restore_backup_dialog_title, R.string.clearing_in_memory_cache);
}
private static final int REQUEST_BROWSE = 13;
@Override
public void doOnResume() {
super.doOnResume();
isPaused = false;
showRestoreCompleteMessage();
}
@Override
public void doOnPause(boolean doOnResumeCalled) {
super.doOnPause(doOnResumeCalled);
isPaused = true;
}
@Override
public void finish()
{
//if they try and enter a password and hit cancel, GTGActivityHelper has no choice but to back up through all the activities on the stack
//so we have to cancel the backup, because our activity is about to finished.
//Note that using singleTask doesn't help here because we are finishing the task regardless.
if(restoreTask != null)
restoreTask.cancel();
restoreTask = null;
super.finish();
}
@Override
protected void onDestroy() {
super.onDestroy();
if(restoreTask != null)
restoreTask.cancel();
restoreTask = null;
}
public void onFileBrowserButton(View v) {
Intent intent = new Intent(getBaseContext(), FileDialog.class);
intent.putExtra(FileDialog.START_PATH, Environment
.getExternalStorageDirectory().toString());
// can user select directories or not
intent.putExtra(FileDialog.CAN_SELECT_DIR, false);
// alternatively you can set file filter
intent.putExtra(FileDialog.FORMAT_FILTER, new String[] { "zip", "gpx", "ZIP", "GPX" });
intent.putExtra(FileDialog.SELECTION_MODE, SelectionMode.MODE_OPEN);
startInternalActivityForResult(intent, REQUEST_BROWSE);
}
@Override
public void onActivityResult(final int requestCode, int resultCode,
final Intent data) {
if (resultCode == Activity.RESULT_OK) {
if (requestCode == REQUEST_BROWSE) {
File chosenFile = new File(data
.getStringExtra(FileDialog.RESULT_PATH));
File sdcardPath = Environment.getExternalStorageDirectory();
try {
String canonicalSdcardPath = sdcardPath.getCanonicalPath();
String canonicalChosenFile = chosenFile.getCanonicalPath();
if(canonicalChosenFile.startsWith(canonicalSdcardPath))
//set canonicalChosenFile to be relative to the external storage directory, +1 for '/
canonicalChosenFile = canonicalChosenFile.substring(canonicalSdcardPath.length()+1);
filenameView.setText(canonicalChosenFile);
}
catch(IOException e)
{
Log.e(GTG.TAG,"Error finding canonical paths", e);
filenameView.setText(chosenFile.toString());
}
checkChosenFile();
}
}
// else if (resultCode == Activity.RESULT_CANCELED) {
// Logger.getLogger(AccelerationChartRun.class.getName()).log(
// Level.WARNING, "file not selected");
// }
}
public void onCancelButton(View v) {
finish();
}
//TODO 2.1 make text not dark gray on select in file browse
private String errorMessage;
private boolean isRestoreCompletedAndNeedsMessage;
/**
* This task validates that we can read the gpx file and finds the
* time spans of the TrkSeg's inside of it. Since we need to write
* out the gps points in time order, we need to know this before
* we can actually perform the restore.
*
*/
public class ValidateAndRestoreGpxBackupTask extends ProgressDialogActivity.Task {
public static final int NUM_TASKS = 5;
protected static final int UPDATE_PROGRESS_BAR_INCREMENT = 100;
private int taskNumber = 0;
private TaskList tl;
public class TaskList
{
private final int [] [] tasks = {
{R.string.clearing_in_memory_cache, 1000},
{R.string.restore_dialog_reading_backup_file, 1000},
{R.string.restore_dialog_moving_old_gps_data, 500},
{R.string.restore_dialog_restoring, 4000},
{R.string.restore_dialog_cleaning_previous_points, 1000}
};
private int tasksTotal;
private int taskIndex = -1;
private int currPos;
public TaskList() {
tasksTotal = 0;
for(int [] task : tasks)
{
tasksTotal += task[1];
}
}
public void advanceToNextTask(int expectedTaskId)
{
if(taskIndex > 0)
currPos += tasks[taskIndex][1];
taskIndex++;
int [] task = tasks[taskIndex];
if(task[0] != expectedTaskId)
{
throw new IllegalStateException("Why you ask for "+expectedTaskId+", when we have "+task[0]+", hmm what?");
}
updateProgress(0, tasksTotal, currPos);
setMessage(task[0]);
}
public void updateCurrTaskProgress(double v, double t)
{
updateProgress(0, tasksTotal, (int)(currPos + v * (tasks[taskIndex][1]) / t));
}
}
@Override
public void doIt() {
final ByteCounterInputStream is;
tl = new TaskList();
tl.advanceToNextTask(R.string.clearing_in_memory_cache);
GTG.shutdownTimmyDb();
tl.advanceToNextTask(R.string.restore_dialog_reading_backup_file);
try {
InputStream bis = fileInfo.getBufferedInputStream(this, tl);
if(bis == null) //if canceled
return;
is = new ByteCounterInputStream(bis);
} catch (Exception e) {
//TODO 2.5: i18nize
Log.e(GTG.TAG, "Error opening file", e);
errorMessage = e.getMessage();
return ;
}
taskNumber++;
stopService(new Intent(RestoreGpxBackup.this, GpsTrailerService.class));
//first lock all the caches
// so nothing can alter the gps related database tables or
// timmy cache
tl.advanceToNextTask(R.string.restore_dialog_moving_old_gps_data);
GpsTrailerDb.moveToBakAndRecreateTable(GpsLocationRow.TABLE_NAME);
GpsTrailerDb.moveToBakAndRecreateTable(TimeZoneTimeRow.TABLE_NAME);
tl.advanceToNextTask(R.string.restore_dialog_restoring);
GTG.db.beginTransaction();
final DbDatastoreAccessor<GpsLocationRow> da = new DbDatastoreAccessor<GpsLocationRow>(GpsLocationRow.TABLE_INFO);
final DbDatastoreAccessor<TimeZoneTimeRow> tztDa =
new DbDatastoreAccessor<TimeZoneTimeRow>(TimeZoneTimeRow.TABLE_INFO);
final TimeZoneTimeRow tztr = new TimeZoneTimeRow();
tztr.id = 0;
GpxReader gpxr = new GpxReader(new GpxReader.GpxReaderCallback() {
private int currIndex=1;
private int count = 0;
private long lastMs = 0;
private GpsLocationRow gpr = new GpsLocationRow();
private TimeZone lastTz;
@Override
public void readTrkSeg() {
}
@Override
public void readTrkPt(double lon, double lat, double elevation, long timeMs,
java.util.TimeZone tz) {
if(GpxReader.isValidLocation(lon, lat) && timeMs > lastMs)
{
gpr.setData(timeMs, (int)(lat*1000000), (int)(lon*1000000), elevation);
gpr.id = currIndex;
da.insertRow(gpr);
currIndex++;
lastMs = timeMs;
}
else
{
Log.e(GTG.TAG,"Invalid point lon "+lon+", lat "+lat+", timeMs "+timeMs+", lastMs "+lastMs);
}
if(++count % UPDATE_PROGRESS_BAR_INCREMENT == 0)
tl.updateCurrTaskProgress(is.counter, fileInfo.entrySize);
if(tz != lastTz)
{
tztr.id++;
tztr.setData(timeMs, tz);
tztDa.insertRow(tztr);
lastTz = tz;
}
// if(count > 100)
// throw new IllegalStateException("HACK DELETE ME");
if(isCanceled)
{
throw new CanceledException();
}
}
@Override
public void readTrk() {
}
@Override
public void readGpx() {
}
});
try {
gpxr.doIt(is);
}
catch(CanceledException e)
{
setMessage("Canceling...");
cancelDoIt();
return;
}
catch(SAXException e)
{
e.printStackTrace();
cancelDoIt();
//TODO 2.5: i18nize
errorMessage = e.getMessage();
return;
}
catch(Exception e)
{
e.printStackTrace();
cancelDoIt();
//TODO 2.5: i18nize
errorMessage = gpxr.createErrorMessage(e.getMessage());
return;
}
//we might as well delete the user data keys that we are no longer using
GTG.db.execSQL("delete from USER_DATA_KEY where _id != "+GTG.crypt.userDataKeyId);
GTG.db.setTransactionSuccessful();
//note that we delete the cache just before we drop the GPS_LOCATION_ROW table. This should
//ensure that there is never a cache when the points themselves do not exist
//TODO 2.5: PERF: We can save the medial loc time cache and just set isTempLoc to true for all rows
GpsTrailerDbProvider.deleteUnopenedCache();
GTG.db.endTransaction();
tl.advanceToNextTask(R.string.restore_dialog_cleaning_previous_points);
GpsTrailerDb.dropBakTable(GpsLocationRow.TABLE_NAME);
GpsTrailerDb.dropBakTable(TimeZoneTimeRow.TABLE_NAME);
GTG.gpsLocCache.clear();
GTG.userLocationCache.clear();
GTG.tztSet.loadSet();
}
private void cancelDoIt() {
GTG.db.endTransaction();
GpsTrailerDb.dropTable(GpsLocationRow.TABLE_NAME);
GpsTrailerDb.dropTable(TimeZoneTimeRow.TABLE_NAME);
GpsTrailerDb.replaceTableWithBakIfNecessary(GTG.db, GpsLocationRow.TABLE_NAME);
GpsTrailerDb.replaceTableWithBakIfNecessary(GTG.db, TimeZoneTimeRow.TABLE_NAME);
//we've got to clear the statements since the database has changed
DbUtil.clearStatements();
GTG.gpsLocCache.clear();
GTG.userLocationCache.clear();
GTG.tztSet.loadSet();
}
@Override
public void doAfterFinish() {
GTG.setIsInRestore(false);
//restart the gps service now that we're no longer restoring
startService(new Intent(RestoreGpxBackup.this,
GpsTrailerService.class));
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
if(isCanceled)
return;
isRestoreCompletedAndNeedsMessage = true;
//since we keep the restore thread running even if we paused, we can't display
// an alert dialog (doing so causes the restore gpx backup window to freeze
// on reentry). So we set a flag and then in onResume, them message is shown
if(isPaused)
{
return;
}
else
showRestoreCompleteMessage();
return;
}
}
private void showRestoreCompleteMessage() {
if(!isRestoreCompletedAndNeedsMessage)
return;
if(errorMessage != null)
{
new AlertDialog.Builder(RestoreGpxBackup.this)
.setCancelable(false)
.setTitle(R.string.error)
.setMessage(errorMessage)
.setNeutralButton(R.string.ok, new OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
dialog.dismiss();
}
}).show();
}
else
new AlertDialog.Builder(RestoreGpxBackup.this)
.setCancelable(false)
.setTitle(R.string.finished)
.setMessage(R.string.restore_finished)
.setNeutralButton(R.string.ok, new OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
dialog.dismiss();
finish();
}
}).show();
isRestoreCompletedAndNeedsMessage = false;
}
private static class CanceledException extends RuntimeException
{
}
@Override
public int getRequirements() {
return GTG.REQUIREMENTS_RESTORE;
}
}