diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/GpxImporter.java b/MyTracks/src/com/google/android/apps/mytracks/io/GpxImporter.java index 4fbba6150..e8903cd37 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/io/GpxImporter.java +++ b/MyTracks/src/com/google/android/apps/mytracks/io/GpxImporter.java @@ -20,6 +20,7 @@ import com.google.android.apps.mytracks.content.MyTracksProviderUtils; import com.google.android.apps.mytracks.content.Track; import com.google.android.apps.mytracks.stats.TripStatisticsBuilder; import com.google.android.apps.mytracks.util.MyTracksUtils; +import com.google.android.apps.mytracks.util.StringUtils; import android.location.Location; import android.location.LocationManager; @@ -28,11 +29,8 @@ import android.util.Log; import java.io.IOException; import java.io.InputStream; -import java.text.ParseException; -import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.List; -import java.util.SimpleTimeZone; import javax.xml.parsers.ParserConfigurationException; import javax.xml.parsers.SAXParser; @@ -54,22 +52,6 @@ import org.xml.sax.helpers.DefaultHandler; */ public class GpxImporter extends DefaultHandler { - /* - * Different date formats used in GPX files - */ - static final SimpleDateFormat DATE_FORMAT1 = new SimpleDateFormat( - "yyyy-MM-dd'T'HH:mm:ssZ"); - static final SimpleDateFormat DATE_FORMAT2 = new SimpleDateFormat( - "yyyy-MM-dd'T'HH:mm:ss'Z'"); - static final SimpleDateFormat DATE_FORMAT3 = new SimpleDateFormat( - "yyyy-MM-dd'T'HH:mm:ss.SSSZ"); - static final SimpleTimeZone UTC_TIMEZONE = new SimpleTimeZone(0, "UTC"); - static { - DATE_FORMAT1.setTimeZone(UTC_TIMEZONE); - DATE_FORMAT2.setTimeZone(UTC_TIMEZONE); - DATE_FORMAT3.setTimeZone(UTC_TIMEZONE); - } - /* * GPX-XML tag names and attributes. */ @@ -211,7 +193,7 @@ public class GpxImporter extends DefaultHandler { this.providerUtils = providerUtils; tracksWritten = new ArrayList(); } - + @Override public void characters(char[] ch, int start, int length) throws SAXException { String newContent = new String(ch, start, length); @@ -453,7 +435,16 @@ public class GpxImporter extends DefaultHandler { private void onTimeElementEnd() throws SAXException { if (location == null) { return; } - long time = parseTimeForAllFormats(content); + // Parse the time + long time; + try { + time = StringUtils.parseXmlDateTime(content.trim()); + } catch (IllegalArgumentException e) { + String msg = createErrorMessage("Unable to parse time: " + content); + throw new SAXException(msg, e); + } + + // Calculate derived attributes from previous point if (lastSegmentLocation != null) { long timeDifference = time - lastSegmentLocation.getTime(); @@ -473,7 +464,9 @@ public class GpxImporter extends DefaultHandler { location.setBearing(lastSegmentLocation.bearingTo(location)); } + // Fill in the time location.setTime(time); + // initialize start time with time of first track point if (statsBuilder == null) { statsBuilder = new TripStatisticsBuilder(); @@ -511,44 +504,6 @@ public class GpxImporter extends DefaultHandler { return result; } - /** - * Parse time trying different formats used in GPX files. - * - * @param timeContents string with time information - * @return time as long - * @throws SAXException on time parsing errors - */ - private long parseTimeForAllFormats(String timeContents) throws SAXException { - long time; - timeContents = timeContents.trim(); - - // 1st try with time zone at end a la "+0000" - time = parseTime(timeContents, DATE_FORMAT1); - if (time > -1) { return time; } - - // if that fails, try with a literal "Z" at the end - // (this is not according to xml standard, but some gpx files are like - // that): - time = parseTime(timeContents, DATE_FORMAT2); - if (time > -1) { return time; } - - // some gpx timestamps have 3 additional digits at the end. - time = parseTime(timeContents, DATE_FORMAT3); - if (time > -1) { return time; } - - // everything failed - abort the import - String msg = createErrorMessage("Invalid time format: " + timeContents); - throw new SAXException(msg); - } - - private long parseTime(String timeContents, SimpleDateFormat format) { - try { - return format.parse(timeContents).getTime(); - } catch (ParseException ex) { - return -1; - } - } - /** * Returns the ID of the last point inserted into the database. */ diff --git a/MyTracks/src/com/google/android/apps/mytracks/util/StringUtils.java b/MyTracks/src/com/google/android/apps/mytracks/util/StringUtils.java index c4c0b2317..ebcfb37f7 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/util/StringUtils.java +++ b/MyTracks/src/com/google/android/apps/mytracks/util/StringUtils.java @@ -16,17 +16,22 @@ package com.google.android.apps.mytracks.util; import com.google.android.apps.mytracks.MyTracksSettings; +import com.google.android.apps.mytracks.content.DescriptionGenerator; import com.google.android.apps.mytracks.content.Track; import com.google.android.apps.mytracks.content.Waypoint; -import com.google.android.apps.mytracks.content.DescriptionGenerator; import com.google.android.apps.mytracks.stats.TripStatistics; import com.google.android.maps.mytracks.R; import android.content.Context; import android.content.SharedPreferences; +import java.text.ParsePosition; +import java.text.SimpleDateFormat; import java.util.Date; +import java.util.SimpleTimeZone; import java.util.Vector; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Various string manipulation methods. @@ -76,6 +81,75 @@ public class StringUtils implements DescriptionGenerator { return ""; } + private static final SimpleDateFormat BASE_XML_DATE_FORMAT = + new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss"); + static { + BASE_XML_DATE_FORMAT.setTimeZone(new SimpleTimeZone(0, "UTC")); + } + private static final Pattern XML_DATE_EXTRAS_PATTERN = + Pattern.compile("^(\\.\\d+)?(?:Z|([+-])(\\d{2}):(\\d{2}))?$"); + + /** + * Parses an XML dateTime element as defined by + * {@link http://www.w3.org/TR/xmlschema-2/#dateTime}. + */ + public static long parseXmlDateTime(String xmlTime) { + // Parse the base date (fixed format) + ParsePosition position = new ParsePosition(0); + Date date = BASE_XML_DATE_FORMAT.parse(xmlTime, position); + if (date == null) { + throw new IllegalArgumentException("Invalid XML dateTime value: '" + xmlTime + + "' (at position " + position.getErrorIndex() + ")"); + } + + // Parse the extras + Matcher matcher = + XML_DATE_EXTRAS_PATTERN.matcher(xmlTime.substring(position.getIndex())); + if (!matcher.matches()) { + // This will match even an empty string as all groups are optional, + // so a non-match means some other garbage was there + throw new IllegalArgumentException("Invalid XML dateTime value: " + xmlTime); + } + + long time = date.getTime(); + + // Account for fractional seconds + String fractional = matcher.group(1); + if (fractional != null) { + // Regex ensures fractional part is in (0,1( + float fractionalSeconds = Float.parseFloat(fractional); + long fractionalMillis = (long) (fractionalSeconds * 1000.0f); + time += fractionalMillis; + } + + // Account for timezones + String sign = matcher.group(2); + String offsetHoursStr = matcher.group(3); + String offsetMinsStr = matcher.group(4); + if (sign != null && offsetHoursStr != null && offsetMinsStr != null) { + // Regex ensures sign is + or - + boolean plusSign = sign.equals("+"); + int offsetHours = Integer.parseInt(offsetHoursStr); + int offsetMins = Integer.parseInt(offsetMinsStr); + + // Regex ensures values are >= 0 + if (offsetHours > 14 || offsetMins > 59) { + throw new IllegalArgumentException("Bad timezone in " + xmlTime); + } + + long totalOffsetMillis = (offsetMins + offsetHours * 60L) * 60000L; + + // Make time go back to UTC + if (plusSign) { + time -= totalOffsetMillis; + } else { + time += totalOffsetMillis; + } + } + + return time; + } + /** * Formats a number of milliseconds as a string. * diff --git a/MyTracksTest/src/com/google/android/apps/mytracks/io/GpxImporterTest.java b/MyTracksTest/src/com/google/android/apps/mytracks/io/GpxImporterTest.java index 091d2b0f0..8da162036 100644 --- a/MyTracksTest/src/com/google/android/apps/mytracks/io/GpxImporterTest.java +++ b/MyTracksTest/src/com/google/android/apps/mytracks/io/GpxImporterTest.java @@ -62,7 +62,12 @@ public class GpxImporterTest extends AndroidTestCase { private static final String TRACK_LAT_2 = "48.768374"; private static final String TRACK_LON_2 = "9.177816"; private static final String TRACK_ELE_2 = "333.0"; - private static final String TRACK_TIME_2 = "2010-04-22T18:21:50Z"; + private static final String TRACK_TIME_2 = "2010-04-22T18:21:50.12"; + + private static final SimpleDateFormat DATE_FORMAT1 = + new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"); + private static final SimpleDateFormat DATE_FORMAT2 = + new SimpleDateFormat("yyyy-MM-dd'T'hh:mm:ss'Z'"); // TODO use real files from different sources with more track points private static final String VALID_TEST_GPX = " trackParam = new Capture(); - SimpleDateFormat format = GpxImporter.DATE_FORMAT2; Location loc1 = new Location(LocationManager.GPS_PROVIDER); - loc1.setTime(format.parse(TRACK_TIME_1).getTime()); + loc1.setTime(DATE_FORMAT2.parse(TRACK_TIME_1).getTime()); loc1.setLatitude(Double.parseDouble(TRACK_LAT_1)); loc1.setLongitude(Double.parseDouble(TRACK_LON_1)); loc1.setAltitude(Double.parseDouble(TRACK_ELE_1)); Location loc2 = new Location(LocationManager.GPS_PROVIDER); - loc2.setTime(format.parse(TRACK_TIME_2).getTime()); + loc2.setTime(DATE_FORMAT1.parse(TRACK_TIME_2).getTime()); loc2.setLatitude(Double.parseDouble(TRACK_LAT_2)); loc2.setLongitude(Double.parseDouble(TRACK_LON_2)); loc2.setAltitude(Double.parseDouble(TRACK_ELE_2)); @@ -152,7 +156,7 @@ public class GpxImporterTest extends AndroidTestCase { Track track = trackParam.getValue(); assertEquals(TRACK_NAME, track.getName()); assertEquals(TRACK_DESC, track.getDescription()); - assertEquals(format.parse(TRACK_TIME_1).getTime(), track.getStatistics() + assertEquals(DATE_FORMAT2.parse(TRACK_TIME_1).getTime(), track.getStatistics() .getStartTime()); assertNotSame(-1, track.getStartId()); assertNotSame(-1, track.getStopId()); diff --git a/MyTracksTest/src/com/google/android/apps/mytracks/util/StringUtilsTest.java b/MyTracksTest/src/com/google/android/apps/mytracks/util/StringUtilsTest.java new file mode 100644 index 000000000..adbe6bbf4 --- /dev/null +++ b/MyTracksTest/src/com/google/android/apps/mytracks/util/StringUtilsTest.java @@ -0,0 +1,82 @@ +/* + * 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.util; + +import java.util.GregorianCalendar; +import java.util.TimeZone; + +import junit.framework.TestCase; + +/** + * Tests for {@link StringUtils}. + * + * @author Rodrigo Damazio + */ +public class StringUtilsTest extends TestCase { + public void testParseXmlDateTime() { + assertParseXmlDateTime("2010-05-04T03:02:01", + 2010, 5, 4, 3, 2, 1, 0); + } + + public void testParseXmlDateTime_fractional() { + assertParseXmlDateTime("2010-05-04T03:02:01.3", + 2010, 5, 4, 3, 2, 1, 300); + assertParseXmlDateTime("2010-05-04T03:02:01.35", + 2010, 5, 4, 3, 2, 1, 350); + assertParseXmlDateTime("2010-05-04T03:02:01.352", + 2010, 5, 4, 3, 2, 1, 352); + assertParseXmlDateTime("2010-05-04T03:02:01.3525", + 2010, 5, 4, 3, 2, 1, 352); + } + + public void testParseXmlDateTime_timezone() { + assertParseXmlDateTime("2010-05-04T03:02:01Z", + 2010, 5, 4, 3, 2, 1, 0); + assertParseXmlDateTime("2010-05-04T03:02:01+00:00", + 2010, 5, 4, 3, 2, 1, 0); + assertParseXmlDateTime("2010-05-04T03:02:01-00:00", + 2010, 5, 4, 3, 2, 1, 0); + assertParseXmlDateTime("2010-05-04T03:02:01+01:00", + 2010, 5, 4, 2, 2, 1, 0); + assertParseXmlDateTime("2010-05-04T03:02:01+10:30", + 2010, 5, 3, 16, 32, 1, 0); + assertParseXmlDateTime("2010-05-04T03:02:01-09:30", + 2010, 5, 4, 12, 32, 1, 0); + assertParseXmlDateTime("2010-05-04T03:02:01-05:00", + 2010, 5, 4, 8, 2, 1, 0); + } + + public void testParseXmlDateTime_fractionalAndTimezone() { + assertParseXmlDateTime("2010-05-04T03:02:01.352Z", + 2010, 5, 4, 3, 2, 1, 352); + assertParseXmlDateTime("2010-05-04T03:02:01.47+00:00", + 2010, 5, 4, 3, 2, 1, 470); + assertParseXmlDateTime("2010-05-04T03:02:01.5791+03:00", + 2010, 5, 4, 0, 2, 1, 579); + assertParseXmlDateTime("2010-05-04T03:02:01.8-05:30", + 2010, 5, 4, 8, 32, 1, 800); + } + + private void assertParseXmlDateTime(String dateTime, + int year, int month, int day, int hour, int min, int second, int millis) { + long timestamp = StringUtils.parseXmlDateTime(dateTime); + GregorianCalendar calendar = + new GregorianCalendar(TimeZone.getTimeZone("UTC")); + calendar.set(year, month - 1, day, hour, min, second); + calendar.set(GregorianCalendar.MILLISECOND, millis); + assertEquals(calendar.getTimeInMillis(), timestamp); + } +}