package me.guillaumin.android.osmtracker.gpx;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.Writer;
import java.net.URLEncoder;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.TimeZone;
import java.util.regex.Pattern;
import me.guillaumin.android.osmtracker.OSMTracker;
import me.guillaumin.android.osmtracker.R;
import me.guillaumin.android.osmtracker.db.DataHelper;
import me.guillaumin.android.osmtracker.db.TrackContentProvider;
import me.guillaumin.android.osmtracker.db.TrackContentProvider.Schema;
import me.guillaumin.android.osmtracker.exception.ExportTrackException;
import me.guillaumin.android.osmtracker.util.FileSystemUtils;
import android.app.AlertDialog;
import android.app.ProgressDialog;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.content.DialogInterface;
import android.content.DialogInterface.OnClickListener;
import android.content.Intent;
import android.database.Cursor;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Environment;
import android.preference.PreferenceManager;
import android.util.Log;
/**
* Base class to writes a GPX file and export
* track media (Photos, Sounds)
*
* @author Nicolas Guillaumin
*
*/
public abstract class ExportTrackTask extends AsyncTask<Void, Long, Boolean> {
private static final String TAG = ExportTrackTask.class.getSimpleName();
/**
* Characters to replace in track filename, for use by {@link #buildGPXFilename(Cursor)}. <BR>
* The characters are: (space) ' " / \ * ? ~ @ < > <BR>
* In addition, ':' will be replaced by ';', before calling this pattern.
*/
private final static Pattern FILENAME_CHARS_BLACKLIST_PATTERN =
Pattern.compile("[ '\"/\\\\*?~@<>]"); // must double-escape \
/**
* XML header.
*/
private static final String XML_HEADER = "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>";
private static final String CDATA_START = "<![CDATA[";
private static final String CDATA_END = "]]>";
/**
* GPX opening tag
*/
private static final String TAG_GPX = "<gpx"
+ " xmlns=\"http://www.topografix.com/GPX/1/1\""
+ " version=\"1.1\""
+ " creator=\"OSMTracker for Androidâ„¢ - https://github.com/nguillaumin/osmtracker-android\""
+ " xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\""
+ " xsi:schemaLocation=\"http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd \">";
/**
* Date format for a point timestamp.
*/
private SimpleDateFormat pointDateFormatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
/**
* {@link Context} to get resources
*/
protected Context context;
/**
* Track IDs to export
*/
protected long[] trackIds;
/**
* Dialog to display while exporting
*/
protected ProgressDialog dialog;
/**
* Message in case of an error
*/
private String errorMsg = null;
/**
* @param startDate
* @return The directory in which the track file should be created
* @throws ExportTrackException
*/
protected abstract File getExportDirectory(Date startDate) throws ExportTrackException;
/**
* Whereas to export the media files or not
* @return
*/
protected abstract boolean exportMediaFiles();
/**
* Whereas to update the track export date in the database at the end or not
* @return
*/
protected abstract boolean updateExportDate();
public ExportTrackTask(Context context, long... trackIds) {
this.context = context;
this.trackIds = trackIds;
pointDateFormatter.setTimeZone(TimeZone.getTimeZone("UTC"));
}
@Override
protected void onPreExecute() {
// Display dialog
dialog = new ProgressDialog(context);
dialog.setProgressStyle(ProgressDialog.STYLE_SPINNER);
dialog.setIndeterminate(true);
dialog.setCancelable(false);
dialog.setMessage(context.getResources().getString(R.string.trackmgr_exporting_prepare));
dialog.show();
}
@Override
protected Boolean doInBackground(Void... params) {
try {
for (int i=0; i<trackIds.length; i++) {
exportTrackAsGpx(trackIds[i]);
}
} catch (ExportTrackException ete) {
errorMsg = ete.getMessage();
return false;
}
return true;
}
@Override
protected void onProgressUpdate(Long... values) {
if (values.length == 1) {
// Standard progress update
dialog.incrementProgressBy(values[0].intValue());
} else if (values.length == 3) {
// To initialise the dialog, 3 values are passed to onProgressUpdate()
// trackId, number of track points, number of waypoints
dialog.dismiss();
dialog = new ProgressDialog(context);
dialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
dialog.setIndeterminate(false);
dialog.setCancelable(false);
dialog.setProgress(0);
dialog.setMax(values[1].intValue() + values[2].intValue());
dialog.setTitle(
context.getResources().getString(R.string.trackmgr_exporting)
.replace("{0}", Long.toString(values[0])));
dialog.show();
}
}
@Override
protected void onPostExecute(Boolean success) {
dialog.dismiss();
if (!success) {
new AlertDialog.Builder(context)
.setTitle(android.R.string.dialog_alert_title)
.setMessage(context.getResources()
.getString(R.string.trackmgr_export_error)
.replace("{0}", errorMsg))
.setIcon(android.R.drawable.ic_dialog_alert)
.setNeutralButton(android.R.string.ok, new OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
dialog.dismiss();
}
})
.show();
}
}
private void exportTrackAsGpx(long trackId) throws ExportTrackException {
File sdRoot = Environment.getExternalStorageDirectory();
if (sdRoot.canWrite()) {
ContentResolver cr = context.getContentResolver();
Cursor c = context.getContentResolver().query(ContentUris.withAppendedId(
TrackContentProvider.CONTENT_URI_TRACK, trackId), null, null,
null, null);
// Get the startDate of this track
// TODO: Maybe we should be pulling the track name instead?
// We'd need to consider the possibility that two tracks were given the same name
// We could possibly disambiguate by including the track ID in the Folder Name
// to avoid overwriting another track on one hand or needlessly creating additional
// directories to avoid overwriting.
Date startDate = new Date();
if (null != c && 1 <= c.getCount()) {
c.moveToFirst();
long startDateInMilliseconds = c.getLong(c.getColumnIndex(Schema.COL_START_DATE));
startDate.setTime(startDateInMilliseconds);
}
File trackGPXExportDirectory = getExportDirectory(startDate);
String filenameBase = buildGPXFilename(c);
c.close();
File trackFile = new File(trackGPXExportDirectory, filenameBase);
Cursor cTrackPoints = cr.query(TrackContentProvider.trackPointsUri(trackId), null,
null, null, Schema.COL_TIMESTAMP + " asc");
Cursor cWayPoints = cr.query(TrackContentProvider.waypointsUri(trackId), null, null,
null, Schema.COL_TIMESTAMP + " asc");
if (null != cTrackPoints && null != cWayPoints) {
publishProgress(new Long[] { trackId, (long) cTrackPoints.getCount(), (long) cWayPoints.getCount() });
try {
writeGpxFile(cTrackPoints, cWayPoints, trackFile);
if (exportMediaFiles()) {
copyWaypointFiles(trackId, trackGPXExportDirectory);
}
if (updateExportDate()) {
DataHelper.setTrackExportDate(trackId, System.currentTimeMillis(), cr);
}
} catch (IOException ioe) {
throw new ExportTrackException(ioe.getMessage());
} finally {
cTrackPoints.close();
cWayPoints.close();
}
// Force rescan of directory
ArrayList<String> files = new ArrayList<String>();
for (File file: trackGPXExportDirectory.listFiles()) {
files.add(file.getAbsolutePath());
}
MediaScannerConnection.scanFile(context, files.toArray(new String[0]), null, null);
}
} else {
throw new ExportTrackException(context.getResources().getString(R.string.error_externalstorage_not_writable));
}
}
/**
* Writes the GPX file
* @param cTrackPoints Cursor to track points.
* @param cWayPoints Cursor to way points.
* @param target Target GPX file
* @throws IOException
*/
private void writeGpxFile(Cursor cTrackPoints, Cursor cWayPoints, File target) throws IOException {
String accuracyOutput = PreferenceManager.getDefaultSharedPreferences(context).getString(
OSMTracker.Preferences.KEY_OUTPUT_ACCURACY,
OSMTracker.Preferences.VAL_OUTPUT_ACCURACY);
boolean fillHDOP = PreferenceManager.getDefaultSharedPreferences(context).getBoolean(
OSMTracker.Preferences.KEY_OUTPUT_GPX_HDOP_APPROXIMATION,
OSMTracker.Preferences.VAL_OUTPUT_GPX_HDOP_APPROXIMATION);
String compassOutput = PreferenceManager.getDefaultSharedPreferences(context).getString(
OSMTracker.Preferences.KEY_OUTPUT_COMPASS,
OSMTracker.Preferences.VAL_OUTPUT_COMPASS);
Log.v(TAG, "write preferences: compass:" + compassOutput);
Writer writer = null;
try {
writer = new BufferedWriter(new FileWriter(target));
writer.write(XML_HEADER + "\n");
writer.write(TAG_GPX + "\n");
writeWayPoints(writer, cWayPoints, accuracyOutput, fillHDOP, compassOutput);
writeTrackPoints(context.getResources().getString(R.string.gpx_track_name), writer, cTrackPoints, fillHDOP, compassOutput);
writer.write("</gpx>");
} finally {
if (writer != null) {
writer.close();
}
}
}
/**
* Iterates on track points and write them.
* @param trackName Name of the track (metadata).
* @param fw Writer to the target file.
* @param c Cursor to track points.
* @param fillHDOP Indicates whether fill <hdop> tag with approximation from location accuracy.
* @param compass Indicates if and how to write compass heading to the GPX ('none', 'comment', 'extension')
* @throws IOException
*/
private void writeTrackPoints(String trackName, Writer fw, Cursor c, boolean fillHDOP, String compass) throws IOException {
// Update dialog every 1%
int dialogUpdateThreshold = c.getCount() / 100;
if (dialogUpdateThreshold == 0) {
dialogUpdateThreshold++;
}
fw.write("\t" + "<trk>" + "\n");
fw.write("\t\t" + "<name>" + CDATA_START + trackName + CDATA_END + "</name>" + "\n");
if (fillHDOP) {
fw.write("\t\t" + "<cmt>"
+ CDATA_START
+ context.getResources().getString(R.string.gpx_hdop_approximation_cmt)
+ CDATA_END
+ "</cmt>" + "\n");
}
fw.write("\t\t" + "<trkseg>" + "\n");
int i=0;
for(c.moveToFirst(); !c.isAfterLast(); c.moveToNext(),i++) {
StringBuffer out = new StringBuffer();
out.append("\t\t\t" + "<trkpt lat=\""
+ c.getDouble(c.getColumnIndex(Schema.COL_LATITUDE)) + "\" "
+ "lon=\"" + c.getDouble(c.getColumnIndex(Schema.COL_LONGITUDE)) + "\">" + "\n");
if (! c.isNull(c.getColumnIndex(Schema.COL_ELEVATION))) {
out.append("\t\t\t\t" + "<ele>" + c.getDouble(c.getColumnIndex(Schema.COL_ELEVATION)) + "</ele>" + "\n");
}
out.append("\t\t\t\t" + "<time>" + pointDateFormatter.format(new Date(c.getLong(c.getColumnIndex(Schema.COL_TIMESTAMP)))) + "</time>" + "\n");
if(fillHDOP && ! c.isNull(c.getColumnIndex(Schema.COL_ACCURACY))) {
out.append("\t\t\t\t" + "<hdop>" + (c.getDouble(c.getColumnIndex(Schema.COL_ACCURACY)) / OSMTracker.HDOP_APPROXIMATION_FACTOR) + "</hdop>" + "\n");
}
if(OSMTracker.Preferences.VAL_OUTPUT_COMPASS_COMMENT.equals(compass) && !c.isNull(c.getColumnIndex(Schema.COL_COMPASS))) {
out.append("\t\t\t\t" + "<cmt>"+CDATA_START+"compass: " +
c.getDouble(c.getColumnIndex(Schema.COL_COMPASS))+
"\n\t\t\t\t\tcompAccuracy: " +
c.getLong(c.getColumnIndex(Schema.COL_COMPASS_ACCURACY))+
CDATA_END+"</cmt>"+"\n");
}
String buff = "";
if(! c.isNull(c.getColumnIndex(Schema.COL_SPEED))) {
buff += "\t\t\t\t\t" + "<speed>" + c.getDouble(c.getColumnIndex(Schema.COL_SPEED)) + "</speed>" + "\n";
}
if(OSMTracker.Preferences.VAL_OUTPUT_COMPASS_EXTENSION.equals(compass) && !c.isNull(c.getColumnIndex(Schema.COL_COMPASS))) {
buff += "\t\t\t\t\t" + "<compass>" + c.getDouble(c.getColumnIndex(Schema.COL_COMPASS)) + "</compass>" + "\n";
buff += "\t\t\t\t\t" + "<compass_accuracy>" + c.getDouble(c.getColumnIndex(Schema.COL_COMPASS_ACCURACY)) + "</compass_accuracy>" + "\n";
}
if(! buff.equals("")) {
out.append("\t\t\t\t" + "<extensions>\n");
out.append(buff);
out.append("\t\t\t\t" + "</extensions>\n");
}
out.append("\t\t\t" + "</trkpt>" + "\n");
fw.write(out.toString());
if (i % dialogUpdateThreshold == 0) {
publishProgress((long) dialogUpdateThreshold);
}
}
fw.write("\t\t" + "</trkseg>" + "\n");
fw.write("\t" + "</trk>" + "\n");
}
/**
* Iterates on way points and write them.
* @param fw Writer to the target file.
* @param c Cursor to way points.
* @param accuracyInfo Constant describing how to include (or not) accuracy info for way points.
* @param fillHDOP Indicates whether fill <hdop> tag with approximation from location accuracy.
* @param compass Indicates if and how to write compass heading to the GPX ('none', 'comment', 'extension')
* @throws IOException
*/
private void writeWayPoints(Writer fw, Cursor c, String accuracyInfo, boolean fillHDOP, String compass) throws IOException {
// Update dialog every 1%
int dialogUpdateThreshold = c.getCount() / 100;
if (dialogUpdateThreshold == 0) {
dialogUpdateThreshold++;
}
// Label for meter unit
String meterUnit = context.getResources().getString(R.string.various_unit_meters);
// Word "accuracy"
String accuracy = context.getResources().getString(R.string.various_accuracy);
int i=0;
for(c.moveToFirst(); !c.isAfterLast(); c.moveToNext(), i++) {
StringBuffer out = new StringBuffer();
out.append("\t" + "<wpt lat=\""
+ c.getDouble(c.getColumnIndex(Schema.COL_LATITUDE)) + "\" "
+ "lon=\"" + c.getDouble(c.getColumnIndex(Schema.COL_LONGITUDE)) + "\">" + "\n");
if (! c.isNull(c.getColumnIndex(Schema.COL_ELEVATION))) {
out.append("\t\t" + "<ele>" + c.getDouble(c.getColumnIndex(Schema.COL_ELEVATION)) + "</ele>" + "\n");
}
out.append("\t\t" + "<time>" + pointDateFormatter.format(new Date(c.getLong(c.getColumnIndex(Schema.COL_TIMESTAMP)))) + "</time>" + "\n");
String name = c.getString(c.getColumnIndex(Schema.COL_NAME));
if (! OSMTracker.Preferences.VAL_OUTPUT_ACCURACY_NONE.equals(accuracyInfo) && ! c.isNull(c.getColumnIndex(Schema.COL_ACCURACY))) {
// Outputs accuracy info for way point
if (OSMTracker.Preferences.VAL_OUTPUT_ACCURACY_WPT_NAME.equals(accuracyInfo)) {
// Output accuracy with name
out.append("\t\t" + "<name>"
+ CDATA_START
+ name
+ " (" + c.getDouble(c.getColumnIndex(Schema.COL_ACCURACY)) + meterUnit + ")"
+ CDATA_END
+ "</name>" + "\n");
if (OSMTracker.Preferences.VAL_OUTPUT_COMPASS_COMMENT.equals(compass) &&
! c.isNull(c.getColumnIndex(Schema.COL_COMPASS))) {
out.append("\t\t"+ "<cmt>" + CDATA_START + "compass: " + c.getDouble(c.getColumnIndex(Schema.COL_COMPASS)) +
"\n\t\t\tcompass accuracy: " + c.getInt(c.getColumnIndex(Schema.COL_COMPASS_ACCURACY)) + CDATA_END + "</cmt>\n");
}
} else if (OSMTracker.Preferences.VAL_OUTPUT_ACCURACY_WPT_CMT.equals(accuracyInfo)) {
// Output accuracy in separate tag
out.append("\t\t" + "<name>" + CDATA_START + name + CDATA_END + "</name>" + "\n");
if (OSMTracker.Preferences.VAL_OUTPUT_COMPASS_COMMENT.equals(compass) &&
! c.isNull(c.getColumnIndex(Schema.COL_COMPASS))) {
out.append("\t\t" + "<cmt>" + CDATA_START + accuracy + ": " + c.getDouble(c.getColumnIndex(Schema.COL_ACCURACY)) + meterUnit +
"\n\t\t\t compass heading: " + c.getDouble(c.getColumnIndex(Schema.COL_COMPASS)) +
"deg\n\t\t\t compass accuracy: " + c.getDouble(c.getColumnIndex(Schema.COL_COMPASS_ACCURACY)) +CDATA_END + "</cmt>" + "\n");
} else {
out.append("\t\t" + "<cmt>" + CDATA_START + accuracy + ": " + c.getDouble(c.getColumnIndex(Schema.COL_ACCURACY)) + meterUnit + CDATA_END + "</cmt>" + "\n");
}
} else {
// Unknown value for accuracy info, shouldn't occur but who knows ?
// See issue #68. Output at least the name just in case.
out.append("\t\t" + "<name>" + CDATA_START + name + CDATA_END + "</name>" + "\n");
}
} else {
// No accuracy info requested, or available
out.append("\t\t" + "<name>" + CDATA_START + name + CDATA_END + "</name>" + "\n");
if (OSMTracker.Preferences.VAL_OUTPUT_COMPASS_COMMENT.equals(compass) &&
! c.isNull(c.getColumnIndex(Schema.COL_COMPASS))) {
out.append("\t\t"+ "<cmt>" + CDATA_START + "compass: " + c.getDouble(c.getColumnIndex(Schema.COL_COMPASS)) +
"\n\t\t\tcompass accuracy: " + c.getInt(c.getColumnIndex(Schema.COL_COMPASS_ACCURACY)) + CDATA_END + "</cmt>\n");
}
}
String link = c.getString(c.getColumnIndex(Schema.COL_LINK));
if (link != null) {
out.append("\t\t" + "<link href=\"" + URLEncoder.encode(link) + "\">" + "\n");
out.append("\t\t\t" + "<text>" + link +"</text>\n");
out.append("\t\t" + "</link>" + "\n");
}
if (! c.isNull(c.getColumnIndex(Schema.COL_NBSATELLITES))) {
out.append("\t\t" + "<sat>" + c.getInt(c.getColumnIndex(Schema.COL_NBSATELLITES)) + "</sat>" + "\n");
}
if(fillHDOP && ! c.isNull(c.getColumnIndex(Schema.COL_ACCURACY))) {
out.append("\t\t" + "<hdop>" + (c.getDouble(c.getColumnIndex(Schema.COL_ACCURACY)) / OSMTracker.HDOP_APPROXIMATION_FACTOR) + "</hdop>" + "\n");
}
if (OSMTracker.Preferences.VAL_OUTPUT_COMPASS_EXTENSION.equals(compass) &&
! c.isNull(c.getColumnIndex(Schema.COL_COMPASS))) {
out.append("\t\t<extensions>\n");
out.append("\t\t\t"+ "<compass>" + c.getDouble(c.getColumnIndex(Schema.COL_COMPASS)) + "</compass>\n");
out.append("\t\t\t" + "<compass_accuracy>" + c.getInt(c.getColumnIndex(Schema.COL_COMPASS_ACCURACY)) + "</compass_accuracy>" + "\n");
out.append("\t\t</extensions>\n");
}
out.append("\t" + "</wpt>" + "\n");
fw.write(out.toString());
if (i % dialogUpdateThreshold == 0) {
publishProgress((long) dialogUpdateThreshold);
}
}
}
/**
* Copy all files from the OSMTracker external storage location to gpxOutputDirectory
* @param gpxOutputDirectory The directory to which the track is being exported
*/
private void copyWaypointFiles(long trackId, File gpxOutputDirectory) {
// Get the new location where files related to these waypoints are/should be stored
File trackDir = DataHelper.getTrackDirectory(trackId);
if(trackDir != null){
Log.v(TAG, "Copying files from the standard TrackDir ["+trackDir+"] to the export directory ["+gpxOutputDirectory+"]");
FileSystemUtils.copyDirectoryContents(gpxOutputDirectory, trackDir);
}
}
/**
* Build GPX filename from track info, based on preferences.
* The filename will have the start date, and/or the track name if available.
* If no name is available, fall back to the start date and time.
* Track name characters will be sanitized using {@link #FILENAME_CHARS_BLACKLIST_PATTERN}.
* @param c Track info: {@link Schema#COL_NAME}, {@link Schema#COL_START_DATE}
* @return GPX filename, not including the path
*/
protected String buildGPXFilename(Cursor c) {
// Build GPX filename from track info & preferences
final String filenameOutput = PreferenceManager.getDefaultSharedPreferences(context).getString(
OSMTracker.Preferences.KEY_OUTPUT_FILENAME,
OSMTracker.Preferences.VAL_OUTPUT_FILENAME);
StringBuffer filenameBase = new StringBuffer();
final int colName = c.getColumnIndexOrThrow(Schema.COL_NAME);
if ((! c.isNull(colName))
&& (! filenameOutput.equals(OSMTracker.Preferences.VAL_OUTPUT_FILENAME_DATE)))
{
final String tname_raw =
c.getString(colName).trim().replace(':', ';');
final String sanitized =
FILENAME_CHARS_BLACKLIST_PATTERN.matcher(tname_raw).replaceAll("_");
filenameBase.append(sanitized);
}
if ((filenameBase.length() == 0)
|| ! filenameOutput.equals(OSMTracker.Preferences.VAL_OUTPUT_FILENAME_NAME))
{
final long startDate = c.getLong(c.getColumnIndex(Schema.COL_START_DATE));
if (filenameBase.length() > 0)
filenameBase.append('_');
filenameBase.append(DataHelper.FILENAME_FORMATTER.format(new Date(startDate)));
}
filenameBase.append(DataHelper.EXTENSION_GPX);
return filenameBase.toString();
}
}