package de.blau.android.osm;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.TimeZone;
import java.util.concurrent.locks.ReentrantLock;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;
import org.xml.sax.Attributes;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlPullParserFactory;
import org.xmlpull.v1.XmlSerializer;
import android.content.Context;
import android.location.Location;
import android.os.AsyncTask;
import android.util.Log;
import de.blau.android.osm.GeoPoint.InterruptibleGeoPoint;
import de.blau.android.util.SavingHelper;
/**
* GPS track data class. Only one instance allowed.
* Automatically saves and loads content.
* Content saving happens continuously to avoid large delays when closing.
* A BufferedOutputStream is used to prevent large amounts of flash wear.
*/
public class Track extends DefaultHandler {
private static final String TAG = "Track";
private final ArrayList<TrackPoint> track;
private final String SAVEFILE = "track.dat";
private final Context ctx;
/**
* For conversion from UNIX epoch time and back
*/
private static final String DATE_PATTERN_ISO8601_UTC = "yyyy-MM-dd'T'HH:mm:ss'Z'";
private static final SimpleDateFormat ISO8601FORMAT;
private static final Calendar calendarInstance = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
static {
// Hardcode 'Z' timezone marker as otherwise '+0000' will be used, which is invalid in GPX
ISO8601FORMAT = new SimpleDateFormat(DATE_PATTERN_ISO8601_UTC, Locale.US);
ISO8601FORMAT.setTimeZone(TimeZone.getTimeZone("UTC"));
}
/**
* if loadingFinished is true, indicates how many records the save file contains
*/
private int savedTrackPoints = 0;
/**
* Set to true as soon as loading is finished. If true, it is guaranteed that either:
* a) the save file does not exist, savedTrackPoints is 0 and memory does not contain any significant amount of data
* b) the save file does exist, is valid and contains exactly savedTrackPoints records
*/
private Boolean loadingFinished = false;
/**
* Everything except loading happens on the UI thread.
* {@link #close()} may be called on the UI thread to close the track.
* After this, the track file must not be touched.
* This can be easily achieved using {@link #savingDisabled} for the UI thread.
* However, an asynchronous load could still be running.
* To prevent closing the file while such a load is running,
* the loadingLock is used.
*/
private ReentrantLock loadingLock = new ReentrantLock();
/**
* Set to true when an unrecoverable error prevents track saving, to avoid crashing the app.
*/
private boolean savingDisabled = false;
private DataOutputStream saveFileStream = null;
/**
* Ensure only one instance may be open at a time
*/
private static volatile boolean isOpen = false;
/** set by {@link #markNewSegment()} - indicates that the next track point will have the isNewSegment flag set */
private boolean nextIsNewSegment = false;
/**
* Indicates how many of the track points are already in the save file.
*/
public Track(Context context) {
track = new ArrayList<TrackPoint>();
ctx = context;
if (isOpen) {
markSavingBroken("Attempted to open multiple instances of Track - saving disabled", null);
} else {
isOpen = true;
Log.i(TAG, "Opened track");
asyncLoad();
}
}
public void reset() {
deleteSaveFile();
track.clear();
}
public void addTrackPoint(final Location location) {
if (location != null) {
track.add(new TrackPoint(location, nextIsNewSegment));
nextIsNewSegment = false;
save();
}
}
public List<TrackPoint> getTrackPoints() {
return new ArrayList<TrackPoint>(track); // need a shallow copy here
}
@Override
public String toString() {
String str = "";
for (TrackPoint loc : track) {
str += loc.toString() + '\n';
}
return str;
}
public void save() {
if (savingDisabled) {
Log.e(TAG, "Saving disabled but tried to save");
return;
}
if (!loadingFinished) return;
if (savedTrackPoints == track.size()) return;
// There are records to be saved
ensureFileOpen();
while (savedTrackPoints < track.size()) {
try {
track.get(savedTrackPoints).toStream(saveFileStream);
} catch (IOException e) {
markSavingBroken("Failed to save track point", e);
return;
}
savedTrackPoints++;
}
}
/**
* Opens the saveFileStream if necessary
*/
private void ensureFileOpen() {
if (savingDisabled) {
Log.e(TAG, "Saving disabled but tried to ensureFileOpen");
return;
}
if (saveFileStream != null) return;
File saveFile = new File(ctx.getFilesDir(), SAVEFILE);
try {
FileOutputStream fileOutput = null;
DataOutputStream out = null;
if (saveFile.exists()) {
// append to existing save file
fileOutput = ctx.openFileOutput(SAVEFILE, Context.MODE_APPEND);
out = new DataOutputStream(new BufferedOutputStream(fileOutput));
} else {
// no save file, create one
fileOutput = ctx.openFileOutput(SAVEFILE, Context.MODE_PRIVATE);
out = new DataOutputStream(new BufferedOutputStream(fileOutput));
out.writeInt(TrackPoint.FORMAT_VERSION);
savedTrackPoints = 0;
}
saveFileStream = out;
} catch (Exception e) {
markSavingBroken("Failed to open track save file", e);
}
}
private void deleteSaveFile() {
if (savingDisabled) {
Log.e(TAG, "Saving disabled but tried to deleteSaveFile");
return;
}
if (saveFileStream != null) {
SavingHelper.close(saveFileStream);
saveFileStream = null;
}
savedTrackPoints = 0;
File saveFile = new File(ctx.getFilesDir(), SAVEFILE);
//noinspection ResultOfMethodCallIgnored
saveFile.delete();
if (saveFile.exists()) {
markSavingBroken("Failed to delete undesired track file", null);
}
}
/**
* If something terrible happens, use this to log an error and disable saving
* @param message
* @param exception
*/
private void markSavingBroken(String message, Throwable exception) {
savingDisabled = true;
Log.e(TAG, "Saving broken - " + message, exception);
}
private void asyncLoad() {
new AsyncTask<Void, Void, Void>() {
private ArrayList<TrackPoint> loaded = new ArrayList<Track.TrackPoint>();
@Override
protected Void doInBackground(Void... params) {
loadingLock.lock();
try {
if (!isOpen) {
return null; // if this has been closed by close() in the meantime, STOP
}
File saveFile = new File(ctx.getFilesDir(), SAVEFILE);
boolean success = load();
if (!success || loaded.isEmpty()) {
Log.i(TAG, "Deleting broken or empty save file");
deleteSaveFile();
}
// If the save file exists, it contains exactly the elements in loaded
if (!loaded.isEmpty() && !saveFile.exists()) {
// A broken save file was partially recovered. Rewrite it now.
Log.i(TAG, "Rewriting partially recovered save file");
rewriteSaveFile(loaded);
}
savedTrackPoints = loaded.size();
// There are only two possible situations now:
// - save file does not exist, savedTrackPoints is 0 and memory does not contain any significant amount of data
// - save file does exist, is valid and contains exactly savedTrackPoints records
return null;
} finally {
loadingLock.unlock();
}
}
@Override
protected void onPostExecute(Void result) {
track.addAll(0, loaded);
loadingFinished = true;
// See end of doInBackground for possible states
Log.i(TAG, "Track loading finished, loaded entries: " + loaded.size());
if (track.size() > savedTrackPoints) save();
}
/**
* Loads a track from the file to the "loaded" ArrayList.
* @return true if the file was loaded without problems, false if some problem occurred and the file needs to be rewritten
*/
private boolean load() {
FileInputStream fileInput = null;
DataInputStream in = null;
try {
fileInput = ctx.openFileInput(SAVEFILE);
in = new DataInputStream(new BufferedInputStream(fileInput));
long size = fileInput.getChannel().size();
// if you manage to record over 32 GB of track data (in RAM) on a mobile device,
// which means non-stop recording over many many years,
// you deserve the problem you are going to get when the integer overflows in the next line.
int records = (int)((size - 4) / TrackPoint.RECORD_SIZE);
loaded.ensureCapacity(records);
if (in.readInt() != TrackPoint.FORMAT_VERSION) {
Log.e(TAG, "cannot load track, incompatible data format");
return false;
}
for (int i = 0; i < records; i++) {
loaded.add(TrackPoint.fromStream(in));
}
if ( (size - 4) % TrackPoint.RECORD_SIZE != 0) {
Log.e(TAG, "track file contains partial record");
return false;
}
return true;
} catch (FileNotFoundException e) {
Log.i(TAG, "No saved track");
return false;
} catch (Exception e) {
Log.e(TAG, "failed to (completely) load track" , e);
return false;
} finally {
SavingHelper.close(in);
}
}
/**
* Saves the given data to disk, overwriting anything already saved
*/
private void rewriteSaveFile(Iterable<TrackPoint> data) {
FileOutputStream fileOutput = null;
DataOutputStream out = null;
try {
fileOutput = ctx.openFileOutput(SAVEFILE, Context.MODE_PRIVATE);
out = new DataOutputStream(new BufferedOutputStream(fileOutput));
out.writeInt(TrackPoint.FORMAT_VERSION);
for (TrackPoint point : data) {
point.toStream(out);
}
} catch (Exception e) {
markSavingBroken("Failed to rewrite broken save file", e);
} finally {
SavingHelper.close(out);
}
}
}.execute();
}
/**
* Saves and closes the track. The object should not be used afterwards, as saving will be disabled.
* The save file will never be accessed by this object again, and isOpen will be set to false.
* This will allow to open the track again.
*/
public void close() {
if (!isOpen) return;
Log.d(TAG,"Trying to close track");
loadingLock.lock();
try {
save();
if (saveFileStream != null) {
SavingHelper.close(saveFileStream);
saveFileStream = null;
}
savingDisabled = true;
isOpen = false;
Log.i(TAG,"Track closed");
} finally {
loadingLock.unlock();
}
}
/**
* Call each time a new segment should be created.
*/
public void markNewSegment() {
nextIsNewSegment = true;
}
/**
* Writes GPX data to the output stream.
* @throws XmlPullParserException
* @throws IOException
* @throws IllegalStateException
* @throws IllegalArgumentException
*/
public void exportToGPX(OutputStream outputStream) throws XmlPullParserException, IllegalArgumentException, IllegalStateException, IOException {
XmlSerializer serializer = XmlPullParserFactory.newInstance().newSerializer();
serializer.setOutput(outputStream, "UTF-8");
serializer.startDocument("UTF-8", null);
serializer.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true);
serializer.startTag(null, "gpx");
serializer.attribute(null, "xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance");
serializer.attribute(null, "xmlns", "http://www.topografix.com/GPX/1/0");
serializer.attribute(null, "xsi:schemaLocation", "http://www.topografix.com/GPX/1/0 http://www.topografix.com/GPX/1/0/gpx.xsd");
serializer.attribute(null, "version", "1.0");
serializer.attribute(null, "creator", "Vespucci");
serializer.startTag(null, "trk");
serializer.startTag(null, "trkseg");
boolean hasPoints = false;
for (TrackPoint pt : getTrackPoints()) {
if (hasPoints && pt.isNewSegment()) {
// start new segment
serializer.endTag(null, "trkseg");
serializer.startTag(null, "trkseg");
}
hasPoints = true;
pt.toXml(serializer);
}
serializer.endTag(null, "trkseg");
serializer.endTag(null, "trk");
serializer.endTag(null, "gpx");
serializer.endDocument();
}
/**
* Reads GPX data from the output stream.
*/
public void importFromGPX(InputStream is) {
try {
start(is);
} catch (Exception e) {
Log.e("Track", "importFromGPX failed " + e);
e.printStackTrace();
}
}
/**
* start parsing a GPX file
* @param in
* @throws SAXException
* @throws IOException
* @throws ParserConfigurationException
*/
private void start(final InputStream in) throws SAXException, IOException, ParserConfigurationException {
SAXParserFactory factory = SAXParserFactory.newInstance();
SAXParser saxParser = factory.newSAXParser();
saxParser.parse(in, this);
}
/**
* minimalistic GPX file parser
*/
private boolean newSegment = false;
private double parsedLat;
private double parsedLon;
private double parsedEle = Double.NaN;
private long parsedTime = 0L;
private enum State {
NONE, TIME, ELE
}
private State state = State.NONE;
@Override
public void startElement(final String uri, final String element, final String qName, final Attributes atts) {
try {
if (element.equals("gpx")) {
state = State.NONE;
Log.d("Track","parsing gpx");
} else if (element.equals("trk")) {
Log.d("Track","parsing trk");
} else if (element.equals("trkseg")) {
Log.d("Track","parsing trkseg");
newSegment = true;
} else if (element.equals("trkpt")) {
parsedLat = Double.parseDouble(atts.getValue("lat"));
parsedLon = Double.parseDouble(atts.getValue("lon"));
} else if (element.equals("time")) {
state = State.TIME;
} else if (element.equals("ele")) {
state = State.ELE;
}
} catch (Exception e) {
Log.e("Profil", "Parse Exception", e);
}
}
@Override
public void characters(char[] ch, int start, int length) {
switch(state) {
case NONE:
return;
case ELE:
parsedEle = Double.parseDouble(new String(ch,start,length));
return;
case TIME:
try {
parsedTime = parseTime(new String(ch,start,length));
} catch (ParseException e) {
parsedTime = 0L;
}
return;
}
}
/**
* Synchronized method to avoid potential problem with static DateFormat
* @param t
* @return
* @throws ParseException
*/
private synchronized long parseTime(String t) throws ParseException {
return ISO8601FORMAT.parse(new String(t)).getTime();
}
@Override
public void endElement(final String uri, final String element, final String qName) {
if (element.equals("gpx")) {
} else if (element.equals("trk")) {
} else if (element.equals("trkseg")) {
} else if (element.equals("trkpt")) {
track.add(new TrackPoint(newSegment?TrackPoint.FLAG_NEWSEGMENT:0, parsedLat, parsedLon, parsedEle, parsedTime));
newSegment = false;
parsedEle = Double.NaN;
parsedTime = 0L;
} else if (element.equals("time")) {
state = State.NONE;
} else if (element.equals("ele")) {
state = State.NONE;
}
}
/**
* This is a class to store location points and provide storing/serialization for them.
* Everything considered less relevant is commented out to save space.
* If you chose that this should be included in the GPX, uncomment it,
* increment {@link #FORMAT_VERSION}, set the correct {@link #RECORD_SIZE}
* and rewrite {@link #fromStream(DataInputStream)}, {@link #toStream(DataOutputStream)}
* and {@link #getGPXString()}.
*
* @author Jan
*/
public static class TrackPoint implements InterruptibleGeoPoint {
// private static final SimpleDateFormat ISO8601FORMAT;
// private static final Calendar calendarInstance = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
// static {
// // Hardcode 'Z' timezone marker as otherwise '+0000' will be used, which is invalid in GPX
// ISO8601FORMAT = new SimpleDateFormat(DATE_PATTERN_ISO8601_UTC);
// ISO8601FORMAT.setTimeZone(TimeZone.getTimeZone("UTC"));
// }
public static final int FORMAT_VERSION = 2;
public static final int RECORD_SIZE = 1+4*8;
public static final byte FLAG_NEWSEGMENT = 1;
public final byte flags;
public final double latitude;
public final double longitude;
public final double altitude;
public final long time;
// public final Float accuracy;
// public final Float bearing;
// public final Float speed;
public TrackPoint(Location original, boolean isNewSegment) {
flags = encodeFlags(isNewSegment);
latitude = original.getLatitude();
longitude = original.getLongitude();
altitude = original.hasAltitude() ? original.getAltitude() : Double.NaN;
time = original.getTime();
// accuracy = original.hasAccuracy() ? original.getAccuracy() : null;
// bearing = original.hasBearing() ? original.getBearing() : null;
// speed = original.hasSpeed() ? original.getSpeed() : null;
}
private TrackPoint(byte flags, double latitude, double longitude, double altitude, long time) {
// Log.d("Track","new trkpt " + flags + " " + latitude+ " " + longitude+ " " + altitude+ " " + time);
this.flags = flags;
this.latitude = latitude;
this.longitude = longitude;
this.altitude = altitude;
this.time = time;
}
/**
* Loads a track point from a {@link DataInputStream}
* @param stream the stream from which to load
* @return the loaded data point
* @throws IOException if anything goes wrong
*/
public static TrackPoint fromStream(DataInputStream stream) throws IOException {
return new TrackPoint(
stream.readByte(), // flags
stream.readDouble(), // lat
stream.readDouble(), // lon
stream.readDouble(), // alt
stream.readLong() // time
);
}
/**
* Writes the current track point to the data output stream
* @param stream target stream
* @throws IOException
*/
public void toStream(DataOutputStream stream) throws IOException {
stream.writeByte(flags);
stream.writeDouble(latitude);
stream.writeDouble(longitude);
stream.writeDouble(altitude);
stream.writeLong(time);
}
@Override
public int getLat() { return (int) (latitude * 1E7); }
@Override
public int getLon() { return (int) (longitude * 1E7); }
public double getLatitude() { return latitude; }
public double getLongitude() { return longitude; }
public long getTime() { return time; }
public boolean hasAltitude() { return !Double.isNaN(altitude); }
// public boolean hasAccuracy() { return accuracy != null; }
// public boolean hasBearing() { return bearing != null; }
// public boolean hasSpeed() { return speed != null; }
public double getAltitude() { return !Double.isNaN(altitude) ? altitude : 0d; }
// public float getAccuracy() { return accuracy != null ? accuracy : 0f; }
// public float getBearing() { return bearing != null ? bearing : 0f; }
// public float getSpeed() { return speed != null ? speed : 0f; }
private byte encodeFlags(boolean isNewSegment) {
byte result = 0;
if (isNewSegment) result += FLAG_NEWSEGMENT;
return result;
}
public boolean isNewSegment() {
return (flags & FLAG_NEWSEGMENT) > 0;
}
/**
* Adds a GPX trkpt (track point) tag to the given serializer (synchronized due to use of calendarInstance)
* @param serializer the xml serializer to use for output
* @throws IOException
*/
public synchronized void toXml(XmlSerializer serializer) throws IOException {
serializer.startTag(null, "trkpt");
serializer.attribute(null, "lat", String.format(Locale.US, "%f", latitude));
serializer.attribute(null, "lon", String.format(Locale.US, "%f", longitude));
if (hasAltitude()) {
serializer.startTag(null, "ele").text(String.format(Locale.US, "%f", altitude)).endTag(null, "ele");
}
calendarInstance.setTimeInMillis(time);
String timestamp = ISO8601FORMAT.format(new Date(time));
serializer.startTag(null, "time").text(timestamp).endTag(null, "time");
serializer.endTag(null, "trkpt");
}
@Override
public String toString() {
return String.format(Locale.US, "%f, %f", latitude, longitude);
}
@Override
public boolean isInterrupted() {
return isNewSegment();
}
}
}