From 27dd5298f7c1377fd5a0e8efd4f3cb98e0ce36d3 Mon Sep 17 00:00:00 2001 From: Rodrigo Damazio Date: Wed, 19 May 2010 17:32:31 -0700 Subject: [PATCH] Importing all the source code :) --- .../google/android/apps/mymaps/GeoRect.java | 64 + .../android/apps/mymaps/MyMapsConstants.java | 29 + .../android/apps/mymaps/MyMapsFeature.java | 247 +++ .../apps/mymaps/MyMapsFeatureMetadata.java | 101 ++ .../apps/mymaps/MyMapsGDataConverter.java | 195 ++ .../apps/mymaps/MyMapsGDataWrapper.java | 190 ++ .../android/apps/mymaps/MyMapsList.java | 350 ++++ .../apps/mymaps/MyMapsMapMetadata.java | 66 + .../android/apps/mymaps/VersionChecker.java | 184 ++ .../android/apps/mytracks/AccountChooser.java | 128 ++ .../android/apps/mytracks/ChartActivity.java | 592 +++++++ .../apps/mytracks/ChartSettingsDialog.java | 87 + .../apps/mytracks/ChartValueSeries.java | 214 +++ .../android/apps/mytracks/ChartView.java | 798 +++++++++ .../android/apps/mytracks/CheckUnits.java | 90 + .../google/android/apps/mytracks/Eula.java | 91 + .../apps/mytracks/ExportAllTracks.java | 227 +++ .../apps/mytracks/IntegerListPreference.java | 95 + .../android/apps/mytracks/MyTracks.java | 1572 +++++++++++++++++ .../apps/mytracks/MyTracksConstants.java | 173 +- .../mytracks/MyTracksDeleteAllTracks.java | 82 + .../apps/mytracks/MyTracksDetails.java | 124 ++ .../android/apps/mytracks/MyTracksList.java | 269 +++ .../android/apps/mytracks/MyTracksMap.java | 978 ++++++++++ .../apps/mytracks/MyTracksOverlay.java | 383 ++++ .../apps/mytracks/MyTracksSettings.java | 152 ++ .../mytracks/MyTracksWaypointDetails.java | 172 ++ .../apps/mytracks/MyTracksWaypointsList.java | 261 +++ .../android/apps/mytracks/NavControls.java | 220 +++ .../apps/mytracks/ProgressIndicator.java | 30 + .../apps/mytracks/SendToGoogleDialog.java | 159 ++ .../android/apps/mytracks/StatsActivity.java | 474 +++++ .../android/apps/mytracks/StatsUtilities.java | 274 +++ .../apps/mytracks/WelcomeActivity.java | 76 + .../content/DescriptionGenerator.java | 12 +- .../mytracks/content/MyTracksProvider.java | 6 +- .../content/MyTracksProviderUtils.java | 10 +- .../content/MyTracksProviderUtilsImpl.java | 8 +- .../android/apps/mytracks/content/Track.java | 18 +- .../apps/mytracks/content/TrackBuffer.java | 12 +- .../mytracks/content/TrackPointsColumns.java | 6 +- .../apps/mytracks/content/TracksColumns.java | 6 +- .../apps/mytracks/content/Waypoint.java | 6 +- .../mytracks/content/WaypointsColumns.java | 6 +- .../android/apps/mytracks/io/AuthManager.java | 77 + .../apps/mytracks/io/AuthManagerFactory.java | 59 + .../apps/mytracks/io/AuthManagerOld.java | 198 +++ .../apps/mytracks/io/CsvTrackWriter.java | 176 ++ .../android/apps/mytracks/io/GpxImport.java | 237 +++ .../apps/mytracks/io/GpxTrackWriter.java | 162 ++ .../apps/mytracks/io/KmlTrackWriter.java | 239 +++ .../apps/mytracks/io/ModernAuthManager.java | 211 +++ .../android/apps/mytracks/io/SendToDocs.java | 525 ++++++ .../apps/mytracks/io/SendToMyMaps.java | 689 ++++++++ .../apps/mytracks/io/TrackFormatWriter.java | 121 ++ .../android/apps/mytracks/io/TrackWriter.java | 365 ++++ .../apps/mytracks/io/TrackWriterFactory.java | 107 ++ .../mytracks/io/gdata/AndroidGDataClient.java | 429 +++++ .../mytracks/io/gdata/GDataClientFactory.java | 50 + .../apps/mytracks/io/gdata/GDataWrapper.java | 224 +++ .../mytracks/io/gdata/QueryParamsImpl.java | 106 ++ .../AbsoluteLocationListenerPolicy.java | 59 + .../AdaptiveLocationListenerPolicy.java | 81 + .../services/ITrackRecordingService.aidl | 9 + .../services/LocationListenerPolicy.java | 48 + .../apps/mytracks/services/PeriodicTask.java | 39 + .../services/PeriodicTaskExecuter.java | 92 + .../services/SafeStatusAnnouncerTask.java | 62 + .../mytracks/services/SignalStrengthTask.java | 193 ++ .../services/SignalStrengthTaskModern.java | 155 ++ .../apps/mytracks/services/SplitManager.java | 146 ++ .../services/StatusAnnouncerTask.java | 180 ++ .../services/TaskExecuterManager.java | 80 + .../apps/mytracks/services/TimeSplitTask.java | 37 + .../services/TrackRecordingService.java | 964 ++++++++++ .../apps/mytracks/stats/DoubleBuffer.java | 143 ++ .../apps/mytracks/stats/ExtremityMonitor.java | 93 + .../apps/mytracks/stats/TripStatistics.java | 565 ++++++ .../mytracks/stats/TripStatisticsData.aidl | 2 + .../mytracks/stats/TripStatisticsData.java | 309 ++++ .../apps/mytracks/util/ChartURLGenerator.java | 173 ++ .../mytracks/util/ChartsExtendedEncoder.java | 51 + .../apps/mytracks/util/MyTracksUtils.java | 212 ++- .../apps/mytracks/util/ResourceUtils.java | 84 + .../apps/mytracks/util/StringUtils.java | 399 +++++ .../apps/mytracks/util/UnitConversions.java | 10 +- .../wireless/gdata/docs/DocumentsClient.java | 46 + .../gdata/docs/SpreadsheetsClient.java | 270 +++ .../wireless/gdata/maps/MapFeatureEntry.java | 50 + .../wireless/gdata/maps/MapsClient.java | 113 ++ .../gdata/maps/xml/XmlMapsGDataParser.java | 178 ++ .../maps/xml/XmlMapsGDataParserFactory.java | 110 ++ .../maps/xml/XmlMapsGDataSerializer.java | 277 +++ 93 files changed, 18381 insertions(+), 61 deletions(-) create mode 100644 MyTracks/src/com/google/android/apps/mymaps/GeoRect.java create mode 100644 MyTracks/src/com/google/android/apps/mymaps/MyMapsConstants.java create mode 100644 MyTracks/src/com/google/android/apps/mymaps/MyMapsFeature.java create mode 100644 MyTracks/src/com/google/android/apps/mymaps/MyMapsFeatureMetadata.java create mode 100644 MyTracks/src/com/google/android/apps/mymaps/MyMapsGDataConverter.java create mode 100644 MyTracks/src/com/google/android/apps/mymaps/MyMapsGDataWrapper.java create mode 100644 MyTracks/src/com/google/android/apps/mymaps/MyMapsList.java create mode 100644 MyTracks/src/com/google/android/apps/mymaps/MyMapsMapMetadata.java create mode 100644 MyTracks/src/com/google/android/apps/mymaps/VersionChecker.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/AccountChooser.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/ChartActivity.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/ChartSettingsDialog.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/ChartValueSeries.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/ChartView.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/CheckUnits.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/Eula.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/ExportAllTracks.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/IntegerListPreference.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/MyTracks.java create mode 100755 MyTracks/src/com/google/android/apps/mytracks/MyTracksDeleteAllTracks.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/MyTracksDetails.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/MyTracksList.java create mode 100755 MyTracks/src/com/google/android/apps/mytracks/MyTracksMap.java create mode 100755 MyTracks/src/com/google/android/apps/mytracks/MyTracksOverlay.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/MyTracksSettings.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/MyTracksWaypointDetails.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/MyTracksWaypointsList.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/NavControls.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/ProgressIndicator.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/SendToGoogleDialog.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/StatsActivity.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/StatsUtilities.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/WelcomeActivity.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/AuthManager.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/AuthManagerFactory.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/AuthManagerOld.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/CsvTrackWriter.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/GpxImport.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/GpxTrackWriter.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/KmlTrackWriter.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/ModernAuthManager.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/SendToDocs.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/SendToMyMaps.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/TrackFormatWriter.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/TrackWriter.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/TrackWriterFactory.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/gdata/AndroidGDataClient.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/gdata/GDataClientFactory.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/gdata/GDataWrapper.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/gdata/QueryParamsImpl.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/services/AbsoluteLocationListenerPolicy.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/services/AdaptiveLocationListenerPolicy.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/services/LocationListenerPolicy.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/services/PeriodicTask.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/services/PeriodicTaskExecuter.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/services/SafeStatusAnnouncerTask.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/services/SignalStrengthTask.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/services/SignalStrengthTaskModern.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/services/SplitManager.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/services/StatusAnnouncerTask.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/services/TaskExecuterManager.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/services/TimeSplitTask.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/services/TrackRecordingService.java create mode 100755 MyTracks/src/com/google/android/apps/mytracks/stats/DoubleBuffer.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/stats/ExtremityMonitor.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/stats/TripStatistics.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/stats/TripStatisticsData.aidl create mode 100644 MyTracks/src/com/google/android/apps/mytracks/stats/TripStatisticsData.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/util/ChartURLGenerator.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/util/ChartsExtendedEncoder.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/util/ResourceUtils.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/util/StringUtils.java create mode 100644 MyTracks/src/com/google/wireless/gdata/docs/DocumentsClient.java create mode 100755 MyTracks/src/com/google/wireless/gdata/docs/SpreadsheetsClient.java create mode 100644 MyTracks/src/com/google/wireless/gdata/maps/MapFeatureEntry.java create mode 100644 MyTracks/src/com/google/wireless/gdata/maps/MapsClient.java create mode 100644 MyTracks/src/com/google/wireless/gdata/maps/xml/XmlMapsGDataParser.java create mode 100644 MyTracks/src/com/google/wireless/gdata/maps/xml/XmlMapsGDataParserFactory.java create mode 100644 MyTracks/src/com/google/wireless/gdata/maps/xml/XmlMapsGDataSerializer.java diff --git a/MyTracks/src/com/google/android/apps/mymaps/GeoRect.java b/MyTracks/src/com/google/android/apps/mymaps/GeoRect.java new file mode 100644 index 000000000..f854b55bf --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mymaps/GeoRect.java @@ -0,0 +1,64 @@ +/* + * Copyright 2009 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.mymaps; + +import com.google.android.maps.GeoPoint; + +/** + * A rectangle in geographical space. + */ +public class GeoRect { + public int top; + public int left; + public int bottom; + public int right; + + public GeoRect() { + top = 0; + left = 0; + bottom = 0; + right = 0; + } + + public GeoRect(GeoPoint center, int latSpan, int longSpan) { + top = center.getLatitudeE6() - latSpan / 2; + left = center.getLongitudeE6() - longSpan / 2; + bottom = center.getLatitudeE6() + latSpan / 2; + right = center.getLongitudeE6() + longSpan / 2; + } + + public GeoPoint getCenter() { + return new GeoPoint(top / 2 + bottom / 2, left / 2 + right / 2); + } + + public int getLatSpan() { + return bottom - top; + } + + public int getLongSpan() { + return right - left; + } + + public boolean contains(GeoPoint geoPoint) { + if (geoPoint.getLatitudeE6() >= top + && geoPoint.getLatitudeE6() <= bottom + && geoPoint.getLongitudeE6() >= left + && geoPoint.getLongitudeE6() <= right) { + return true; + } + return false; + } +} diff --git a/MyTracks/src/com/google/android/apps/mymaps/MyMapsConstants.java b/MyTracks/src/com/google/android/apps/mymaps/MyMapsConstants.java new file mode 100644 index 000000000..8fa70a9c2 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mymaps/MyMapsConstants.java @@ -0,0 +1,29 @@ +/* + * Copyright 2009 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.mymaps; + +import com.google.wireless.gdata.maps.MapsClient; + +/** + * Constants for My Maps. + */ +public class MyMapsConstants { + public static final String MAPSHOP_BASE_URL = + "http://maps.google.com/maps/ms"; + public static final String MAPSHOP_SERVICE = MapsClient.SERVICE; + + private MyMapsConstants() { } +} diff --git a/MyTracks/src/com/google/android/apps/mymaps/MyMapsFeature.java b/MyTracks/src/com/google/android/apps/mymaps/MyMapsFeature.java new file mode 100644 index 000000000..dba928ac5 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mymaps/MyMapsFeature.java @@ -0,0 +1,247 @@ +/* + * Copyright 2009 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.mymaps; + +import com.google.android.maps.GeoPoint; + +import java.io.Serializable; +import java.util.Vector; + +/** + * MyMapsFeature contains all of the data associated with a feature in My Maps, + * where a feature is a marker, line, or shape. Some of the data is stored in a + * {@link MyMapsFeatureMetadata} object so that it can be more efficiently + * transmitted to other activities. + */ +public class MyMapsFeature implements Serializable { + + private static final long serialVersionUID = 8439035544430497236L; + + /** A marker feature displays an icon at a single point on the map. */ + public static final int MARKER = 0; + + /** + * A line feature displays a line connecting a set of points on the map. + */ + public static final int LINE = 1; + + /** + * A shape feature displays a border defined by connecting a set of points, + * including connecting the last to the first, and displays the area + * confined by this border. + */ + public static final int SHAPE = 2; + + /** The local feature id for this feature, if needed. */ + private String androidId; + + /** + * The latitudes of the points of this feature in order, specified in + * millionths of a degree north. + */ + private final Vector latitudeE6 = new Vector(); + + /** + * The longitudes of the points of this feature in order, specified in + * millionths of a degree east. + */ + private final Vector longitudeE6 = new Vector(); + + /** The metadata of this feature in a format efficient for transmission. */ + private MyMapsFeatureMetadata featureInfo = new MyMapsFeatureMetadata(); + + /** + * Initializes a valid but empty feature. It will default to a + * {@link #MARKER} with a blue placemark with a dot as an icon at the + * location (0, 0). + */ + public MyMapsFeature() { + } + + /** + * Adds a new point to the end of this feature. + * + * @param point The new point to add + */ + public synchronized void addPoint(GeoPoint point) { + latitudeE6.add(point.getLatitudeE6()); + longitudeE6.add(point.getLongitudeE6()); + } + + /** + * Generates a new local id for this feature based on the current time and + * a random number. + */ + public synchronized void generateAndroidId() { + Long time = System.currentTimeMillis(); + Integer random = (int) (Math.random() * 10000); + androidId = time.toString() + "." + random.toString(); + } + + /** + * Retrieves the current local id for this feature if one is available. + * + * @return The local id for this feature + */ + public String getAndroidId() { + return androidId; + } + + /** + * Retrieves the current (html) description of this feature. The description + * is stored in the feature metadata. + * + * @return The description of this feature + */ + public String getDescription() { + return featureInfo.getDescription(); + } + + /** + * Sets the description of this feature. That description is stored in the + * feature metadata. + * + * @param description The new description of this feature + */ + public synchronized void setDescription(String description) { + featureInfo.setDescription(description); + } + + /** + * Retrieves the point at the given index for this feature. + * + * @param index The index of the point desired + * @return A {@link GeoPoint} representing the point or null if that point + * doesn't exist + */ + public GeoPoint getPoint(int index) { + if (latitudeE6.size() <= index) { + return null; + } + return new GeoPoint(latitudeE6.get(index), longitudeE6.get(index)); + } + + /** + * Counts the number of points in this feature and return that count. + * + * @return The number of points in this feature + */ + public int getPointCount() { + return latitudeE6.size(); + } + + /** + * Retrieves the title of this feature. That title is stored in the feature + * metadata. + * + * @return the current title of this feature + */ + public String getTitle() { + return featureInfo.getTitle(); + } + + /** + * Retrieves the type of this feature. That type is stored in the feature + * metadata. + * + * @return One of {@link #MARKER}, {@link #LINE}, or {@link #SHAPE} + * identifying the type of this feature + */ + public int getType() { + return featureInfo.getType(); + } + + /** + * Retrieves the current color of this feature as an ARGB color integer. + * That color is stored in the feature metadata. + * + * @return The ARGB color of this feature + */ + public int getColor() { + return featureInfo.getColor(); + } + + /** + * Retrieves the current line width of this feature. That line width is + * stored in the feature metadata. + * + * @return The line width of this feature + */ + public int getLineWidth() { + return featureInfo.getLineWidth(); + } + + /** + * Retrieves the current fill color of this feature as an ARGB color + * integer. That color is stored in the feature metadata. + * + * @return The ARGB fill color of this feature + */ + public int getFillColor() { + return featureInfo.getFillColor(); + } + + /** + * Retrieves the current icon url of this feature. That icon url is stored + * in the feature metadata. + * + * @return The icon url for this feature + */ + public String getIconUrl() { + return featureInfo.getIconUrl(); + } + + /** + * Sets the title of this feature. That title is stored in the feature + * metadata. + * + * @param title The new title of this feature + */ + public synchronized void setTitle(String title) { + featureInfo.setTitle(title); + } + + /** + * Sets the type of this feature. That type is stored in the feature + * metadata. + * + * @param type The new type of the feature. That type must be one of + * {@link #MARKER}, {@link #LINE}, or {@link #SHAPE} + */ + public synchronized void setType(int type) { + featureInfo.setType(type); + } + + /** + * Sets the ARGB color of this feature. That color is stored in the feature + * metadata. + * + * @param color The new ARGB color of this feature + */ + public synchronized void setColor(int color) { + featureInfo.setColor(color); + } + + /** + * Sets the icon url of this feature. That icon url is stored in the feature + * metadata. + * + * @param url The new icon url of the feature + */ + public synchronized void setIconUrl(String url) { + featureInfo.setIconUrl(url); + } +} diff --git a/MyTracks/src/com/google/android/apps/mymaps/MyMapsFeatureMetadata.java b/MyTracks/src/com/google/android/apps/mymaps/MyMapsFeatureMetadata.java new file mode 100644 index 000000000..996f4c743 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mymaps/MyMapsFeatureMetadata.java @@ -0,0 +1,101 @@ +/* + * Copyright 2009 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.mymaps; + +/** + * Metadata about a maps feature. + */ +public class MyMapsFeatureMetadata { + + private static final String BLUE_DOT_URL = + "http://maps.google.com/mapfiles/ms/micons/blue-dot.png"; + private static final int DEFAULT_COLOR = 0x800000FF; + private static final int DEFAULT_FILL_COLOR = 0xC00000FF; + + private String title; + private String description; + private int type; + private int color; + private int lineWidth; + private int fillColor; + private String iconUrl; + + public MyMapsFeatureMetadata() { + title = ""; + description = ""; + type = MyMapsFeature.MARKER; + color = DEFAULT_COLOR; + lineWidth = 5; + fillColor = DEFAULT_FILL_COLOR; + iconUrl = BLUE_DOT_URL; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public int getType() { + return type; + } + + public void setType(int type) { + this.type = type; + } + + public int getColor() { + return color; + } + + public void setColor(int color) { + this.color = color; + } + + public int getLineWidth() { + return lineWidth; + } + + public void setLineWidth(int width) { + lineWidth = width; + } + + public int getFillColor() { + return fillColor; + } + + public void setFillColor(int color) { + fillColor = color; + } + + public String getIconUrl() { + return iconUrl; + } + + public void setIconUrl(String url) { + iconUrl = new String(url); + } +} diff --git a/MyTracks/src/com/google/android/apps/mymaps/MyMapsGDataConverter.java b/MyTracks/src/com/google/android/apps/mymaps/MyMapsGDataConverter.java new file mode 100644 index 000000000..0c726f4c5 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mymaps/MyMapsGDataConverter.java @@ -0,0 +1,195 @@ +/* + * Copyright 2009 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.mymaps; + +import com.google.wireless.gdata.data.Entry; +import com.google.wireless.gdata.data.StringUtils; +import com.google.wireless.gdata.maps.MapFeatureEntry; +import com.google.wireless.gdata.maps.MapsClient; + +import android.graphics.Color; +import android.util.Log; + +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlPullParserFactory; +import org.xmlpull.v1.XmlSerializer; + +import java.io.IOException; +import java.io.StringWriter; + +/** + * Converter from GData objects to MyMaps objects. + */ +public class MyMapsGDataConverter { + + private final XmlSerializer xmlSerializer; + + public MyMapsGDataConverter() throws XmlPullParserException { + xmlSerializer = XmlPullParserFactory.newInstance().newSerializer(); + } + + public static MyMapsMapMetadata getMapMetadataForEntry( + MapFeatureEntry entry) { + MyMapsMapMetadata metadata = new MyMapsMapMetadata(); + if ("public".equals(entry.getPrivacy())) { + metadata.setSearchable(true); + } else { + metadata.setSearchable(false); + } + metadata.setTitle(entry.getTitle()); + + String desc = entry.getContent(); + if (desc.length() > 12) { + metadata.setDescription(desc.substring(9, desc.length() - 3)); + } + metadata.setGDataEditUri(entry.getEditUri()); + + return metadata; + } + + public static String getMapidForEntry(Entry entry) { + return MapsClient.getMapIdFromMapEntryId(entry.getId()); + } + + public static Entry getMapEntryForMetadata(MyMapsMapMetadata metadata) { + MapFeatureEntry entry = new MapFeatureEntry(); + entry.setEditUri(metadata.getGDataEditUri()); + entry.setTitle(metadata.getTitle()); + entry.setContent(metadata.getDescription()); + entry.setPrivacy(metadata.getSearchable() ? "public" : "unlisted"); + entry.setAuthor("android"); + entry.setEmail("nobody@google.com"); + return entry; + } + + public MapFeatureEntry getEntryForFeature(MyMapsFeature feature) { + MapFeatureEntry entry = new MapFeatureEntry(); + entry.setTitle(feature.getTitle()); + entry.setAuthor("android"); + entry.setEmail("nobody@google.com"); + entry.setCategoryScheme("http://schemas.google.com/g/2005#kind"); + entry.setCategory("http://schemas.google.com/g/2008#mapfeature"); + entry.setEditUri(""); + if (!StringUtils.isEmpty(feature.getAndroidId())) { + entry.setAttribute("_androidId", feature.getAndroidId()); + } + try { + StringWriter writer = new StringWriter(); + xmlSerializer.setOutput(writer); + xmlSerializer.startTag(null, "Placemark"); + xmlSerializer.attribute(null, "xmlns", "http://earth.google.com/kml/2.2"); + xmlSerializer.startTag(null, "Style"); + if (feature.getType() == MyMapsFeature.MARKER) { + xmlSerializer.startTag(null, "IconStyle"); + xmlSerializer.startTag(null, "Icon"); + xmlSerializer.startTag(null, "href"); + xmlSerializer.text(feature.getIconUrl()); + xmlSerializer.endTag(null, "href"); + xmlSerializer.endTag(null, "Icon"); + xmlSerializer.endTag(null, "IconStyle"); + } else { + xmlSerializer.startTag(null, "LineStyle"); + xmlSerializer.startTag(null, "color"); + int color = feature.getColor(); + // Reverse the color because KML is ABGR and Android is ARGB + xmlSerializer.text(Integer.toHexString( + Color.argb(Color.alpha(color), Color.blue(color), + Color.green(color), Color.red(color)))); + xmlSerializer.endTag(null, "color"); + xmlSerializer.startTag(null, "width"); + xmlSerializer.text(Integer.toString(feature.getLineWidth())); + xmlSerializer.endTag(null, "width"); + xmlSerializer.endTag(null, "LineStyle"); + + if (feature.getType() == MyMapsFeature.SHAPE) { + xmlSerializer.startTag(null, "PolyStyle"); + xmlSerializer.startTag(null, "color"); + int fcolor = feature.getFillColor(); + // Reverse the color because KML is ABGR and Android is ARGB + xmlSerializer.text(Integer.toHexString(Color.argb(Color.alpha(fcolor), + Color.blue(fcolor), Color.green(fcolor), Color.red(fcolor)))); + xmlSerializer.endTag(null, "color"); + xmlSerializer.startTag(null, "fill"); + xmlSerializer.text("1"); + xmlSerializer.endTag(null, "fill"); + xmlSerializer.startTag(null, "outline"); + xmlSerializer.text("1"); + xmlSerializer.endTag(null, "outline"); + xmlSerializer.endTag(null, "PolyStyle"); + } + } + xmlSerializer.endTag(null, "Style"); + xmlSerializer.startTag(null, "name"); + xmlSerializer.text(feature.getTitle()); + xmlSerializer.endTag(null, "name"); + xmlSerializer.startTag(null, "description"); + xmlSerializer.cdsect(feature.getDescription()); + xmlSerializer.endTag(null, "description"); + StringBuilder pointBuilder = new StringBuilder(); + for (int i = 0; i < feature.getPointCount(); ++i) { + if (i > 0) { + pointBuilder.append('\n'); + } + pointBuilder.append(feature.getPoint(i).getLongitudeE6() / 1e6); + pointBuilder.append(','); + pointBuilder.append(feature.getPoint(i).getLatitudeE6() / 1e6); + pointBuilder.append(",0.000000"); + } + String pointString = pointBuilder.toString(); + if (feature.getType() == MyMapsFeature.MARKER) { + xmlSerializer.startTag(null, "Point"); + xmlSerializer.startTag(null, "coordinates"); + xmlSerializer.text(pointString); + xmlSerializer.endTag(null, "coordinates"); + xmlSerializer.endTag(null, "Point"); + } else if (feature.getType() == MyMapsFeature.LINE) { + xmlSerializer.startTag(null, "LineString"); + xmlSerializer.startTag(null, "tessellate"); + xmlSerializer.text("1"); + xmlSerializer.endTag(null, "tessellate"); + xmlSerializer.startTag(null, "coordinates"); + xmlSerializer.text(pointString); + xmlSerializer.endTag(null, "coordinates"); + xmlSerializer.endTag(null, "LineString"); + } else { + xmlSerializer.startTag(null, "Polygon"); + xmlSerializer.startTag(null, "outerBoundaryIs"); + xmlSerializer.startTag(null, "LinearRing"); + xmlSerializer.startTag(null, "tessellate"); + xmlSerializer.text("1"); + xmlSerializer.endTag(null, "tessellate"); + xmlSerializer.startTag(null, "coordinates"); + xmlSerializer.text(pointString + "\n" + + Double.toString(feature.getPoint(0).getLongitudeE6() / 1e6) + + "," + + Double.toString(feature.getPoint(0).getLatitudeE6() / 1e6) + + ",0.000000"); + xmlSerializer.endTag(null, "coordinates"); + xmlSerializer.endTag(null, "LinearRing"); + xmlSerializer.endTag(null, "outerBoundaryIs"); + xmlSerializer.endTag(null, "Polygon"); + } + xmlSerializer.endTag(null, "Placemark"); + xmlSerializer.flush(); + entry.setContent(writer.toString()); + Log.d("My Google Maps", "Generated kml:\n" + entry.getContent()); + Log.d("My Google Maps", "Edit URI: " + entry.getEditUri()); + } catch (IOException e) { + e.printStackTrace(); + } + return entry; + } +} diff --git a/MyTracks/src/com/google/android/apps/mymaps/MyMapsGDataWrapper.java b/MyTracks/src/com/google/android/apps/mymaps/MyMapsGDataWrapper.java new file mode 100644 index 000000000..de4e0f069 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mymaps/MyMapsGDataWrapper.java @@ -0,0 +1,190 @@ +/* + * 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.mymaps; + +import com.google.android.apps.mytracks.MyTracksConstants; +import com.google.android.apps.mytracks.io.AuthManager; +import com.google.android.apps.mytracks.io.gdata.GDataClientFactory; +import com.google.android.common.gdata.AndroidXmlParserFactory; +import com.google.wireless.gdata.client.GDataClient; +import com.google.wireless.gdata.client.HttpException; +import com.google.wireless.gdata.maps.MapsClient; +import com.google.wireless.gdata.maps.xml.XmlMapsGDataParserFactory; +import com.google.wireless.gdata.parser.xml.SimplePullParser.ParseException; +import com.google.wireless.gdata2.ConflictDetectedException; +import com.google.wireless.gdata2.client.AuthenticationException; + +import android.content.Context; +import android.util.Log; + +import java.io.IOException; + +/** + * MyMapsGDataWrapper provides a wrapper around GData operations that maintains + * the GData client, and provides a method to run gdata queries with proper + * error handling. After a query is run, the wrapper can be queried about the + * error that occurred. + */ +public class MyMapsGDataWrapper { + /** + * A QueryFunction is passed in when executing a query. The query function + * of the class is called with the GData client as a parameter. The + * function should execute whatever operations it desires on the client + * without concern for whether the client will throw an error. + */ + public interface QueryFunction { + public abstract void query(MapsClient client) + throws AuthenticationException, IOException, ParseException, + ConflictDetectedException, Exception; + } + + // The types of error that may be encountered + /** No error occurred. */ + public static final int ERROR_NO_ERROR = 0; + /** There was an authentication error, the auth token may be invalid. */ + public static final int ERROR_AUTH = 1; + /** There was an internal error on the server side. */ + public static final int ERROR_INTERNAL = 2; + /** There was an error connecting to the server. */ + public static final int ERROR_CONNECTION = 3; + /** The item queried did not exit. */ + public static final int ERROR_NOT_FOUND = 4; + /** There was an error parsing or serializing locally. */ + public static final int ERROR_LOCAL = 5; + /** There was a conflict, update the entry and try again. */ + public static final int ERROR_CONFLICT = 6; + /** + * A query was run after cleaning up the wrapper, so the client was invalid. + */ + public static final int ERROR_CLEANED_UP = 7; + /** An unknown error occurred. */ + public static final int ERROR_UNKNOWN = 100; + + private final GDataClient androidGdataClient; + private final MapsClient client; + + private String errorMessage; + private int errorType; + private AuthManager auth; + private boolean retryOnAuthFailure; + private int retriesPending; + private boolean cleanupCalled; + + public MyMapsGDataWrapper(Context context) { + androidGdataClient = GDataClientFactory.getGDataClient(context); + client = + new MapsClient(androidGdataClient, new XmlMapsGDataParserFactory( + new AndroidXmlParserFactory())); + errorType = ERROR_NO_ERROR; + errorMessage = null; + auth = null; + retryOnAuthFailure = false; + retriesPending = 0; + cleanupCalled = false; + } + + public boolean runQuery(final QueryFunction query) { + if (client == null) { + errorType = ERROR_CLEANED_UP; + errorMessage = "GData Wrapper has already been cleaned up!"; + return false; + } + try { + query.query(client); + errorType = ERROR_NO_ERROR; + errorMessage = null; + return true; + } catch (AuthenticationException e) { + Log.e(MyTracksConstants.TAG, "Exception", e); + errorType = ERROR_AUTH; + errorMessage = e.getMessage(); + } catch (HttpException e) { + Log.e(MyTracksConstants.TAG, "HttpException", e); + errorMessage = e.getMessage(); + if (errorMessage.contains("401")) { + errorType = ERROR_AUTH; + } else { + errorType = ERROR_CONNECTION; + } + } catch (IOException e) { + Log.e(MyTracksConstants.TAG, "Exception", e); + errorMessage = e.getMessage(); + if (errorMessage.contains("503")) { + errorType = ERROR_INTERNAL; + } else { + errorType = ERROR_CONNECTION; + } + } catch (ParseException e) { + Log.e(MyTracksConstants.TAG, "Exception", e); + errorType = ERROR_LOCAL; + errorMessage = e.getMessage(); + } catch (ConflictDetectedException e) { + Log.e(MyTracksConstants.TAG, "Exception", e); + errorType = ERROR_CONFLICT; + errorMessage = e.getMessage(); + } catch (Exception e) { + Log.e(MyTracksConstants.TAG, "Exception", e); + errorType = ERROR_UNKNOWN; + errorMessage = e.getMessage(); + e.printStackTrace(); + } + Log.d(MyTracksConstants.TAG, "GData error encountered: " + errorMessage); + if (errorType == ERROR_AUTH && auth != null) { + Runnable whenFinished = null; + if (retryOnAuthFailure) { + retriesPending++; + whenFinished = new Runnable() { + public void run() { + retriesPending--; + retryOnAuthFailure = false; + runQuery(query); + if (cleanupCalled && retriesPending == 0) { + cleanUp(); + } + } + }; + } + auth.invalidateAndRefresh(whenFinished); + } + return false; + } + + public int getErrorType() { + return errorType; + } + + public String getErrorMessage() { + return errorMessage; + } + + // cleanUp must be called when done using this wrapper to close the client. + // Note that the cleanup will be delayed if auth failure retries were + // requested and there is a pending retry. + public void cleanUp() { + if (retriesPending == 0 && !cleanupCalled) { + androidGdataClient.close(); + } + cleanupCalled = true; + } + + public void setAuthManager(AuthManager newAuth) { + this.auth = newAuth; + } + + public void setRetryOnAuthFailure(boolean retry) { + retryOnAuthFailure = retry; + } +} diff --git a/MyTracks/src/com/google/android/apps/mymaps/MyMapsList.java b/MyTracks/src/com/google/android/apps/mymaps/MyMapsList.java new file mode 100644 index 000000000..27c881efe --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mymaps/MyMapsList.java @@ -0,0 +1,350 @@ +/* + * Copyright 2009 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.mymaps; + +import com.google.android.accounts.Account; +import com.google.android.apps.mymaps.MyMapsGDataWrapper.QueryFunction; +import com.google.android.apps.mytracks.AccountChooser; +import com.google.android.apps.mytracks.MyTracks; +import com.google.android.apps.mytracks.io.AuthManager; +import com.google.android.apps.mytracks.io.AuthManagerFactory; +import com.google.android.maps.mytracks.R; +import com.google.wireless.gdata.maps.MapFeatureEntry; +import com.google.wireless.gdata.maps.MapsClient; +import com.google.wireless.gdata.parser.GDataParser; + +import android.app.Activity; +import android.content.Intent; +import android.database.DataSetObserver; +import android.graphics.Color; +import android.os.Bundle; +import android.os.Handler; +import android.view.ContextMenu; +import android.view.ContextMenu.ContextMenuInfo; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnCreateContextMenuListener; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.ListAdapter; +import android.widget.ListView; +import android.widget.TextView; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Set; +import java.util.Vector; + +/** + * Activity which displays the list of "my maps" maps. + */ +public class MyMapsList extends Activity implements ListAdapter { + + private static final int MENU_OPEN = 0; + private static final int MENU_SHARE = 2; + private static final int GET_LOGIN = 1; + + private LookupThread listLookup; + private Vector mapsList; + private Vector publicList; + private Set observerSet; + private Handler loadHandler; + private int contextPosition; + private AuthManager auth; + + /** + * Thread which looks up the maps list. + */ + private class LookupThread extends Thread { + private boolean finished = false; + private boolean success = true; + + private GDataParser listParser; + + @Override + public void run() { + finished = false; + listParser = null; + MyMapsGDataWrapper wrapper = new MyMapsGDataWrapper(MyMapsList.this); + wrapper.setAuthManager(auth); + wrapper.setRetryOnAuthFailure(true); + + success = wrapper.runQuery(new QueryFunction() { + public void query(MapsClient client) throws IOException, Exception { + listParser = client.getParserForFeed( + MapFeatureEntry.class, MapsClient.getMapsFeed(), + auth.getAuthToken()); + listParser.init(); + while (listParser.hasMoreData()) { + MapFeatureEntry entry = + (MapFeatureEntry) listParser.readNextEntry(null); + MyMapsMapMetadata metadata = + MyMapsGDataConverter.getMapMetadataForEntry(entry); + String mapid = MyMapsGDataConverter.getMapidForEntry(entry); + synchronized (mapsList) { + // Search through the maps list to see if it has the mapid and + // remove it if so, so that we can replace it with updated info + for (int i = 0; i < mapsList.size(); ++i) { + if (mapid.equals(mapsList.get(i)[0])) { + mapsList.remove(i); + publicList.remove(i); + --i; + } + } + mapsList.add(new String[] { + mapid, metadata.getTitle(), metadata.getDescription() }); + publicList.add(metadata.getSearchable()); + } + } + listParser.close(); + listParser = null; + success = true; + finished = true; + } + }); + + if (listParser != null) { + listParser.close(); + } + wrapper.cleanUp(); + if (success || wrapper.getErrorType() != MyMapsGDataWrapper.ERROR_AUTH) { + finished = true; + } + } + + public boolean isFinished() { + return finished; + } + + public boolean succeeded() { + return success; + } + } + + private final Runnable checkLoaded = new Runnable() { + @Override + public void run() { + if (listLookup.isFinished()) { + findViewById(R.id.loading).setVisibility(View.GONE); + if (!listLookup.succeeded()) { + findViewById(R.id.failed).setVisibility(View.VISIBLE); + } + TextView emptyView = (TextView) findViewById(R.id.mapslist_empty); + ListView list = (ListView) findViewById(R.id.maplist); + list.setEmptyView(emptyView); + } else { + loadHandler.postDelayed(checkLoaded, 500); + } + Iterator iter = observerSet.iterator(); + while (iter.hasNext()) { + iter.next().onChanged(); + } + } + }; + + private final OnItemClickListener clickListener = new OnItemClickListener() { + @Override + public void onItemClick(AdapterView parent, View v, int position, + long id) { + Intent result = new Intent(); + result.putExtra("mapid", (String) getItem(position)); + MyMapsList.this.setResult(RESULT_OK, result); + finish(); + } + }; + + private final OnCreateContextMenuListener contextMenuListener = + new OnCreateContextMenuListener() { + @Override + public void onCreateContextMenu(ContextMenu menu, View v, + ContextMenuInfo menuInfo) { + AdapterView.AdapterContextMenuInfo info = + (AdapterView.AdapterContextMenuInfo) menuInfo; + contextPosition = info.position; + menu.add(0, MENU_OPEN, 0, R.string.open_map); + menu.add(0, MENU_SHARE, 0, R.string.share_map); + } + }; + + /** Called when the activity is first created. */ + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + + auth = AuthManagerFactory.getAuthManager(this, GET_LOGIN, null, true, + MapsClient.SERVICE); + + setContentView(R.layout.list); + + ListView list = (ListView) findViewById(R.id.maplist); + list.setOnItemClickListener(clickListener); + list.setOnCreateContextMenuListener(contextMenuListener); + + mapsList = new Vector(); + publicList = new Vector(); + + listLookup = new LookupThread(); + // TODO fix this for non-froyo devices. + if (AuthManagerFactory.useModernAuthManager()) { + runOnUiThread(new Runnable() { + @Override + public void run() { + MyTracks.getInstance().getAccountChooser().chooseAccount( + MyMapsList.this, + new AccountChooser.AccountHandler() { + @Override + public void handleAccountSelected(Account account) { + if (account != null) { + // The user did not quit and there was a valid google + // account. + doLogin(account); + } + } + }); + } + }); + } else { + doLogin(null); + } + + observerSet = new HashSet(); + + list.setAdapter(this); + + loadHandler = new Handler(); + loadHandler.postDelayed(checkLoaded, 500); + } + + private void doLogin(final Account account) { + auth.doLogin(new Runnable() { + public void run() { + listLookup.start(); + } + }, account); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, + Intent data) { + if (requestCode == GET_LOGIN) { + auth.authResult(resultCode, data); + } + super.onActivityResult(requestCode, resultCode, data); + } + + @Override + public boolean onMenuItemSelected(int featureId, MenuItem item) { + switch (item.getItemId()) { + case MENU_OPEN: + clickListener.onItemClick(null, null, contextPosition, 0); + return true; + case MENU_SHARE: + Intent shareIntent = new Intent(Intent.ACTION_SEND); + shareIntent.setType("text/plain"); + shareIntent.putExtra(Intent.EXTRA_SUBJECT, + getText(R.string.share_map_subject)); + shareIntent.putExtra(Intent.EXTRA_TEXT, String.format( + getText(R.string.share_map_body_format).toString(), + mapsList.get(contextPosition)[1], + MyMapsConstants.MAPSHOP_BASE_URL + "?msa=0&msid=" + + mapsList.get(contextPosition)[0])); + startActivity(Intent.createChooser(shareIntent, + getText(R.string.share_map).toString())); + return true; + } + return false; + } + + @Override + public boolean areAllItemsEnabled() { + return true; + } + + @Override + public boolean isEnabled(int position) { + return true; + } + + @Override + public int getCount() { + return mapsList.size(); + } + + @Override + public Object getItem(int position) { + return mapsList.get(position)[0]; + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public int getItemViewType(int position) { + return 0; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + if (convertView == null) { + convertView = + getLayoutInflater().inflate(R.layout.listitem, parent, false); + } + + String[] map = mapsList.get(position); + ((TextView) convertView.findViewById(R.id.maplistitem)).setText(map[1]); + ((TextView) convertView.findViewById(R.id.maplistdesc)).setText(map[2]); + TextView publicUnlisted = + (TextView) convertView.findViewById(R.id.maplistpublic); + if (publicList.get(position)) { + publicUnlisted.setTextColor(Color.RED); + publicUnlisted.setText(R.string.public_map); + } else { + publicUnlisted.setTextColor(Color.GREEN); + publicUnlisted.setText(R.string.unlisted_map); + } + + return convertView; + } + + @Override + public int getViewTypeCount() { + return 1; + } + + @Override + public boolean hasStableIds() { + return false; + } + + @Override + public boolean isEmpty() { + return mapsList.isEmpty(); + } + + @Override + public void registerDataSetObserver(DataSetObserver observer) { + observerSet.add(observer); + } + + @Override + public void unregisterDataSetObserver(DataSetObserver observer) { + observerSet.remove(observer); + } +} diff --git a/MyTracks/src/com/google/android/apps/mymaps/MyMapsMapMetadata.java b/MyTracks/src/com/google/android/apps/mymaps/MyMapsMapMetadata.java new file mode 100644 index 000000000..d5531cf4a --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mymaps/MyMapsMapMetadata.java @@ -0,0 +1,66 @@ +/* + * Copyright 2009 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.mymaps; + +/** + * Metadata about a "my maps" map. + */ +public class MyMapsMapMetadata { + + private String title; + private String description; + private String gdataEditUri; + private boolean searchable; + + public MyMapsMapMetadata() { + title = ""; + description = ""; + gdataEditUri = ""; + searchable = false; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = new String(title); + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = new String(description); + } + + public boolean getSearchable() { + return searchable; + } + + public void setSearchable(boolean searchable) { + this.searchable = searchable; + } + + public String getGDataEditUri() { + return gdataEditUri; + } + + public void setGDataEditUri(String editUri) { + this.gdataEditUri = new String(editUri); + } +} diff --git a/MyTracks/src/com/google/android/apps/mymaps/VersionChecker.java b/MyTracks/src/com/google/android/apps/mymaps/VersionChecker.java new file mode 100644 index 000000000..6a47a7879 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mymaps/VersionChecker.java @@ -0,0 +1,184 @@ +/* + * Copyright 2009 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.mymaps; + +import com.google.android.apps.mymaps.MyMapsGDataWrapper.QueryFunction; +import com.google.android.apps.mytracks.MyTracksConstants; +import com.google.android.maps.mytracks.R; +import com.google.wireless.gdata.GDataException; +import com.google.wireless.gdata.client.HttpException; +import com.google.wireless.gdata.data.Entry; +import com.google.wireless.gdata.maps.MapsClient; +import com.google.wireless.gdata.parser.GDataParser; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.TabActivity; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.content.Intent; +import android.net.Uri; +import android.util.Log; + +import java.io.IOException; + +public class VersionChecker { + /** The User id for the account storing MyTracks versions. */ + private static final String VERSION_USER_ID = "116927647071651051525"; + private static final String VERSION_CLIENT = "AndroidMyTracks"; + + private static final String VERSION_STATUS_LATEST = "latest"; + private static final String VERSION_STATUS_OKAY = "okay"; + private static final String VERSION_STATUS_OLD = "old"; + private static final String VERSION_STATUS_BAD = "bad"; + + private final TabActivity parentActivity; + private Dialog dialog; + private boolean canceledAvailable; + private boolean canceledRecommended; + + public VersionChecker(TabActivity parent) { + parentActivity = parent; + canceledAvailable = false; + canceledRecommended = false; + + startMapsQueryThread(); + } + + private void startMapsQueryThread() { + new Thread() { + @Override + public void run() { + MyMapsGDataWrapper mapsGDataWrapper = + new MyMapsGDataWrapper(parentActivity); + mapsGDataWrapper.runQuery(new QueryFunction() { + @Override + public void query(MapsClient client) throws IOException, Exception { + doMapsQuery(client); + } + }); + mapsGDataWrapper.cleanUp(); + } + }.start(); + } + + private void doMapsQuery(MapsClient client) + throws GDataException, IOException, HttpException { + final boolean newVersion; // Returned new version available + final boolean newVersionRecommended; // Returned new version recommended + final boolean newVersionRequired; // Returned new version required + String currentVersion = parentActivity.getString(R.string.version); + + GDataParser parser = client.getParserForFeed( + Entry.class, + MapsClient.getVersionFeed( + VERSION_USER_ID, + VERSION_CLIENT, + currentVersion), + ""); + Entry en = parser.parseStandaloneEntry(); + if (currentVersion.equals(en.getTitle())) { + if (VERSION_STATUS_LATEST.equals(en.getContent())) { + newVersion = false; + newVersionRecommended = false; + newVersionRequired = false; + } else if (VERSION_STATUS_OKAY.equals(en.getContent())) { + newVersion = true; + newVersionRecommended = false; + newVersionRequired = false; + } else if (VERSION_STATUS_OLD.equals(en.getContent())) { + newVersion = true; + newVersionRecommended = true; + newVersionRequired = false; + } else if (VERSION_STATUS_BAD.equals(en.getContent())) { + newVersion = true; + newVersionRecommended = true; + newVersionRequired = true; + } else { + Log.e(MyTracksConstants.TAG, + "Got unknown version status: " + en.getContent()); + return; + } + } else { + Log.e(MyTracksConstants.TAG, + "Current version '" + currentVersion + "' doesn't match entry: " + + en.getTitle()); + return; + } + + if (!newVersion) { + Log.d(MyTracksConstants.TAG, "No new version available."); + return; + } + + final AlertDialog.Builder dialogBuilder = + new AlertDialog.Builder(parentActivity); + dialogBuilder.setTitle(R.string.version_check_title); + dialogBuilder.setPositiveButton(R.string.version_check_upgrade_button, + new OnClickListener() { + public void onClick(DialogInterface di, int number) { + Intent upgradeIntent = new Intent(); + upgradeIntent.setAction(Intent.ACTION_VIEW); + upgradeIntent.setData(Uri.parse(String.format("market://search?q=%s", + parentActivity.getResources().getString( + R.string.app_name).toString()))); + parentActivity.startActivity(upgradeIntent); + parentActivity.finish(); + } + }); + + if (newVersionRequired) { + dialogBuilder.setCancelable(false); + } else { + final boolean recommended = newVersionRecommended; + dialogBuilder.setNegativeButton(R.string.version_check_continue_button, + new OnClickListener() { + public void onClick(DialogInterface di, int number) { + dialog.dismiss(); + canceledAvailable = true; + if (recommended) { + canceledRecommended = true; + } + } + }); + } + + int messageResource; + if (newVersionRequired) { + messageResource = R.string.upgrade_required; + } else if (newVersionRecommended) { + messageResource = R.string.upgrade_recommended; + if (canceledRecommended) { + return; + } + } else { + messageResource = R.string.upgrade_available; + if (canceledAvailable) { + return; + } + } + + String message = parentActivity.getResources().getString(messageResource); + dialogBuilder.setMessage(String.format(message, + parentActivity.getResources().getString(R.string.app_name))); + parentActivity.getTabHost().post(new Runnable() { + public void run() { + dialog = dialogBuilder.create(); + dialog.show(); + } + }); + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/AccountChooser.java b/MyTracks/src/com/google/android/apps/mytracks/AccountChooser.java new file mode 100644 index 000000000..cfd1cda39 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/AccountChooser.java @@ -0,0 +1,128 @@ +/* + * 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; + +import com.google.android.accounts.Account; +import com.google.android.accounts.AccountManager; +import com.google.android.maps.mytracks.R; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.util.Log; + +/** + * Choose which account to upload track information to. + * @author Sandor Dornbush + */ +public class AccountChooser { + + /** + * The last selected account. + */ + private int selectedAccountIndex = -1; + private Account selectedAccount = null; + + /** + * An interface for receiving updates once the user has selected the account. + */ + public interface AccountHandler { + /** + * Handle the account being selected. + * @param account The selected account or null if none could be found + */ + public void handleAccountSelected(Account account); + } + + /** + * Chooses the best account to upload to. + * If no account is found the user will be alerted. + * If only one account is found that will be used. + * If multiple accounts are found the user will be allowed to choose. + * + * @param activity The parent activity + * @param handler The handler to be notified when an account has been selected + */ + public void chooseAccount(final Activity activity, + final AccountHandler handler) { + final Account[] accounts = AccountManager.get(activity) + .getAccountsByType(MyTracksConstants.ACCOUNT_TYPE); + if (accounts.length < 1) { + alertNoAccounts(activity, handler); + return; + } + if (accounts.length == 1) { + handler.handleAccountSelected(accounts[0]); + return; + } + + // TODO This should be read out of a preference. + if (selectedAccount != null) { + handler.handleAccountSelected(selectedAccount); + return; + } + + // Let the user choose. + Log.e(MyTracksConstants.TAG, "Multiple matching accounts found."); + final AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setTitle(R.string.choose_account_title); + builder.setCancelable(false); + builder.setPositiveButton(R.string.ok, + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + selectedAccount = accounts[selectedAccountIndex]; + handler.handleAccountSelected(selectedAccount); + } + }); + builder.setNegativeButton(R.string.cancel, + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + handler.handleAccountSelected(null); + } + }); + String[] choices = new String[accounts.length]; + for (int i = 0; i < accounts.length; i++) { + choices[i] = accounts[i].name; + } + builder.setSingleChoiceItems(choices, selectedAccountIndex, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + selectedAccountIndex = which; + } + }); + builder.show(); + } + + /** + * Puts up a dialog alerting the user that no suitable account was found. + */ + private void alertNoAccounts(final Activity activity, + final AccountHandler handler) { + Log.e(MyTracksConstants.TAG, "No matching accounts found."); + final AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setTitle(R.string.no_account_found_title); + builder.setMessage(R.string.no_account_found); + builder.setCancelable(true); + builder.setNegativeButton(R.string.ok, + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + handler.handleAccountSelected(null); + } + }); + builder.show(); + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/ChartActivity.java b/MyTracks/src/com/google/android/apps/mytracks/ChartActivity.java new file mode 100644 index 000000000..f8921b367 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/ChartActivity.java @@ -0,0 +1,592 @@ +/* + * Copyright 2009 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; + +import com.google.android.apps.mytracks.ChartView.Mode; +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.Waypoint; +import com.google.android.apps.mytracks.content.WaypointsColumns; +import com.google.android.apps.mytracks.stats.DoubleBuffer; +import com.google.android.apps.mytracks.stats.TripStatistics; +import com.google.android.apps.mytracks.util.MyTracksUtils; +import com.google.android.apps.mytracks.util.UnitConversions; +import com.google.android.maps.mytracks.R; + +import android.app.Activity; +import android.content.SharedPreferences; +import android.database.ContentObserver; +import android.database.Cursor; +import android.location.Location; +import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewGroup.LayoutParams; +import android.view.Window; +import android.widget.LinearLayout; +import android.widget.ZoomControls; + +import java.util.ArrayList; + +/** + * An activity that displays a chart from the track point provider. + * + * @author Sandor Dornbush + */ +public class ChartActivity extends Activity implements + SharedPreferences.OnSharedPreferenceChangeListener { + + private double profileLength = 0; + + private boolean metricUnits = true; + private boolean reportSpeed = true; + + /** + * The track id that is displayed. + */ + private long selectedTrackId = -1; + + /** + * Id of the last location that was seen when reading tracks from the + * provider. This is used to determine which locations are new compared to the + * last time the chart was updated. + */ + private long lastSeenLocationId = -1; + + private long startTime = -1; + + private Location lastLocation; + + /** + * The id of the track currently being recorded. + */ + private long recordingTrackId = -1; + + private final DoubleBuffer elevationBuffer = + new DoubleBuffer(MyTracksConstants.ELEVATION_SMOOTHING_FACTOR); + private final DoubleBuffer speedBuffer = + new DoubleBuffer(MyTracksConstants.SPEED_SMOOTHING_FACTOR); + + private Mode mode = Mode.BY_DISTANCE; + + /** + * Utilities to deal with the database. + */ + private MyTracksProviderUtils providerUtils; + + /* + * UI elements: + */ + private ChartView cv; + private MenuItem chartSettingsMenuItem; + private LinearLayout busyPane; + private ZoomControls zoomControls; + + /** Handler for callbacks to the UI thread */ + private final Handler uiHandler = new Handler(); + + /** + * A runnable that can be posted to the UI thread. It will remove the spinner + * (if any), enable/disable zoom controls and orange pointer as appropriate + * and redraw. + */ + private final Runnable updateChart = new Runnable() { + @Override + public void run() { + busyPane.setVisibility(View.GONE); + zoomControls.setIsZoomInEnabled(cv.canZoomIn()); + zoomControls.setIsZoomOutEnabled(cv.canZoomOut()); + cv.setShowPointer(selectedTrackIsRecording()); + cv.invalidate(); + } + }; + + /** + * A runnable that can be posted to the UI thread. It will show the spinner. + */ + private final Runnable showSpinner = new Runnable() { + @Override + public void run() { + busyPane.setVisibility(View.VISIBLE); + } + }; + + /** + * An observer for the tracks provider. Will listen to new track points being + * added and update the chart if necessary. + */ + private ContentObserver observer; + + /** + * An observer for the waypoints provider. Will listen to new way points being + * added and update the chart if necessary. + */ + private ContentObserver waypointObserver; + + /** + * A thread with a looper. Post to updateTrackHandler to execute Runnables on + * this thread. + */ + private final HandlerThread updateTrackThread = + new HandlerThread("updateTrackThread"); + + /** Handler for updateTrackThread */ + private Handler updateTrackHandler; + + /** + * A runnable that update the profile from the provider. + */ + private final Runnable updateTrackRunnable = new Runnable() { + @Override + public void run() { + Log.d(MyTracksConstants.TAG, "MyTracks: Updating chart."); + Track track = providerUtils.getTrack(recordingTrackId); + if (track == null) { + Log.w(MyTracksConstants.TAG, "MyTracks: track not found"); + return; + } + Cursor cursor = null; + try { + cursor = providerUtils.getLocationsCursor(recordingTrackId, + lastSeenLocationId + 1, + MyTracksConstants.MAX_DISPLAYED_TRACK_POINTS - cv.getData().size(), + true); + if (cursor != null) { + if (cursor.moveToLast()) { + final int idColumnIdx = + cursor.getColumnIndexOrThrow(TrackPointsColumns._ID); + ArrayList data = new ArrayList(); + do { + lastSeenLocationId = cursor.getLong(idColumnIdx); + Location location = providerUtils.createLocation(cursor); + if (location != null && MyTracksUtils.isValidLocation(location)) { + data.add(getDataPoint(location, track)); + } + } while (cursor.moveToPrevious()); + cv.addDataPoints(data); + } + } + } catch (RuntimeException e) { + Log.w(MyTracksConstants.TAG, "Caught an unexpected exception.", e); + } finally { + if (cursor != null) { + cursor.close(); + } + uiHandler.post(new Runnable() { + public void run() { + cv.invalidate(); + } + }); + } + } + }; + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, + String key) { + if (key != null) { + if (key.equals(MyTracksSettings.SELECTED_TRACK)) { + selectedTrackId = + sharedPreferences.getLong(MyTracksSettings.SELECTED_TRACK, -1); + readProfileAsync(); + } else if (key.equals(MyTracksSettings.METRIC_UNITS)) { + metricUnits = + sharedPreferences.getBoolean(MyTracksSettings.METRIC_UNITS, true); + cv.setMetricUnits(metricUnits); + readProfileAsync(); + } else if (key.equals(MyTracksSettings.REPORT_SPEED)) { + reportSpeed = + sharedPreferences.getBoolean(MyTracksSettings.REPORT_SPEED, true); + cv.setReportSpeed(reportSpeed, this); + readProfileAsync(); + } else if (key.equals(MyTracksSettings.RECORDING_TRACK)) { + recordingTrackId = + sharedPreferences.getLong(MyTracksSettings.RECORDING_TRACK, -1); + runOnUiThread(updateChart); + } + } + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + Log.w(MyTracksConstants.TAG, "ChartActivity.onCreate"); + super.onCreate(savedInstanceState); + MyTracks.getInstance().setChartActivity(this); + providerUtils = MyTracksProviderUtils.Factory.get(this); + + requestWindowFeature(Window.FEATURE_NO_TITLE); + setContentView(R.layout.mytracks_elevation); + ViewGroup layout = (ViewGroup) findViewById(R.id.elevation_chart); + cv = new ChartView(this); + cv.setMode(this.mode); + LayoutParams params = + new LayoutParams(LayoutParams.FILL_PARENT, LayoutParams.FILL_PARENT); + layout.addView(cv, params); + + SharedPreferences preferences = + getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + if (preferences != null) { + selectedTrackId = + preferences.getLong(MyTracksSettings.SELECTED_TRACK, -1); + recordingTrackId = + preferences.getLong(MyTracksSettings.RECORDING_TRACK, -1); + metricUnits = preferences.getBoolean(MyTracksSettings.METRIC_UNITS, true); + cv.setMetricUnits(metricUnits); + reportSpeed = preferences.getBoolean(MyTracksSettings.REPORT_SPEED, true); + cv.setReportSpeed(reportSpeed, this); + preferences.registerOnSharedPreferenceChangeListener(this); + } + + busyPane = (LinearLayout) findViewById(R.id.elevation_busypane); + zoomControls = (ZoomControls) findViewById(R.id.elevation_zoom); + zoomControls.setOnZoomInClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + zoomIn(); + } + }); + zoomControls.setOnZoomOutClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + zoomOut(); + } + }); + + updateTrackThread.start(); + updateTrackHandler = new Handler(updateTrackThread.getLooper()); + + // Register observer for the track point provider: + Handler contentHandler = new Handler(); + observer = new ContentObserver(contentHandler) { + @Override + public void onChange(boolean selfChange) { + Log.d(MyTracksConstants.TAG, "ChartActivity: ContentObserver.onChange"); + // Check for any new locations and append them to the currently + // recording track: + if (!MyTracks.getInstance().isRecording()) { + // No track is being recorded. We should not be here. + return; + } + if (selectedTrackId != recordingTrackId) { + // no track, or one other than the recording track is selected, don't + // bother. + return; + } + // Update can potentially be lengthy, put it in its own thread: + updateTrackHandler.post(updateTrackRunnable); + super.onChange(selfChange); + } + }; + + waypointObserver = new ContentObserver(contentHandler) { + @Override + public void onChange(boolean selfChange) { + Log.d(MyTracksConstants.TAG, + "MyTracksMap: ContentObserver.onChange waypoints"); + if (selectedTrackId < 0) { + return; + } + Thread t = new Thread() { + @Override + public void run() { + readWaypoints(); + ChartActivity.this.runOnUiThread(new Runnable() { + @Override + public void run() { + cv.invalidate(); + } + }); + } + }; + t.start(); + super.onChange(selfChange); + } + }; + + readProfileAsync(); + } + + @Override + protected void onPause() { + super.onPause(); + unregisterContentObservers(); + } + + @Override + protected void onResume() { + super.onResume(); + // Make sure any updates that might have happened are propagated to this + // activity: + observer.onChange(false); + waypointObserver.onChange(false); + registerContentObservers(); + } + + /** + * Register the content observer for the map overlay. + */ + private void registerContentObservers() { + getContentResolver().registerContentObserver(TrackPointsColumns.CONTENT_URI, + false/* notifyForDescendents */, observer); + getContentResolver().registerContentObserver(WaypointsColumns.CONTENT_URI, + false/* notifyForDescendents */, waypointObserver); + } + + /** + * Unregister the content observer for the map overlay. + */ + private void unregisterContentObservers() { + getContentResolver().unregisterContentObserver(observer); + getContentResolver().unregisterContentObserver(waypointObserver); + } + + private boolean selectedTrackIsRecording() { + return selectedTrackId == recordingTrackId; + } + + private void zoomIn() { + cv.zoomIn(); + zoomControls.setIsZoomInEnabled(cv.canZoomIn()); + zoomControls.setIsZoomOutEnabled(cv.canZoomOut()); + } + + private void zoomOut() { + cv.zoomOut(); + zoomControls.setIsZoomInEnabled(cv.canZoomIn()); + zoomControls.setIsZoomOutEnabled(cv.canZoomOut()); + } + + public void setMode(Mode newMode) { + if (this.mode != newMode) { + this.mode = newMode; + cv.setMode(this.mode); + readProfileAsync(); + } + } + + public Mode getMode() { + return mode; + } + + public void setSeriesEnabled(int index, boolean enabled) { + cv.getChartValueSeries(index).setEnabled(enabled); + runOnUiThread(updateChart); + } + + public boolean isSeriesEnabled(int index) { + return cv.getChartValueSeries(index).isEnabled(); + } + + private void readWaypoints() { + if (selectedTrackId < 0) { + return; + } + Cursor cursor = null; + cv.clearWaypoints(); + try { + // We will silently drop extra waypoints to make the app responsive. + cursor = + providerUtils.getWaypointsCursor(selectedTrackId, 0, + MyTracksConstants.MAX_DISPLAYED_TRACK_POINTS); + if (cursor != null) { + if (cursor.moveToFirst()) { + do { + Waypoint wpt = providerUtils.createWaypoint(cursor); + cv.addWaypoint(wpt); + } while (cursor.moveToNext()); + } + } + } catch (RuntimeException e) { + Log.w(MyTracksConstants.TAG, "Caught an unexpected exception.", e); + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + chartSettingsMenuItem = + menu.add(0, MyTracksConstants.MENU_CHART_SETTINGS, 0, + R.string.chart_settings); + chartSettingsMenuItem.setIcon(R.drawable.chart_settings); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case MyTracksConstants.MENU_CHART_SETTINGS: + MyTracks.getInstance().showDialogSafely(MyTracks.DIALOG_CHART_SETTINGS); + return true; + } + return super.onOptionsItemSelected(item); + } + + /** + * Given a location, creates a new data point for the chart. A data point is + * an array double[2], where: + * data[0] = the time or distance + * data[1] = the elevation + * data[2] = the speed + * + * @param location a location + * @return the data point + */ + public double[] getDataPoint(Location location, Track track) { + double[] result = new double[3]; + switch (mode) { + case BY_DISTANCE: + result[0] = profileLength; + if (lastLocation != null) { + double d = lastLocation.distanceTo(location); + if (metricUnits) { + profileLength += d / 1000.0; + } else { + profileLength += d * UnitConversions.KM_TO_MI / 1000.0; + } + } + break; + case BY_TIME: + if (startTime == -1) { + // Base case + startTime = location.getTime(); + } + result[0] = (location.getTime() - startTime); + break; + default: + Log.w(MyTracksConstants.TAG, "ChartActivity unknown mode: " + mode); + } + + elevationBuffer.setNext(metricUnits + ? location.getAltitude() + : location.getAltitude() * UnitConversions.M_TO_FT); + result[1] = elevationBuffer.getAverage(); + + if (lastLocation == null) { + if (Math.abs(location.getSpeed() - 128) > 1) { + speedBuffer.setNext(location.getSpeed()); + } + } else if (TripStatistics.isValidSpeed( + location.getTime(), location.getSpeed(), lastLocation.getTime(), + lastLocation.getSpeed(), speedBuffer) + && (location.getSpeed() <= track.getMaxSpeed())) { + speedBuffer.setNext(location.getSpeed()); + } + result[2] = speedBuffer.getAverage() * 3.6; + if (!metricUnits) { + result[2] *= UnitConversions.KM_TO_MI; + } + if (!reportSpeed && (result[2] != 0)) { + // Format as hours per unit + result[2] = (60.0 / result[2]); + } + lastLocation = location; + return result; + } + + /** + * Sets the chart data points reading from the provider. This is non-blocking. + */ + private void readProfileAsync() { + cv.reset(); + updateTrackHandler.post(new Runnable() { + public void run() { + runOnUiThread(showSpinner); + readProfile(); + readWaypoints(); + runOnUiThread(updateChart); + } + }); + } + + /** + * Reads the track profile from the provider. This is a blocking function and + * should not be run from the UI thread. + */ + private void readProfile() { + profileLength = 0; + lastLocation = null; + startTime = -1; + Cursor cursor = null; + if (selectedTrackId < 0) { + return; + } + Track track = providerUtils.getTrack(selectedTrackId); + if (track == null) { + return; + } + long lastLocationRead = track.getStartId(); + long totalLocations = track.getStopId() - track.getStartId(); + + // Limit the number of chart readings. Ideally we would want around 1024. + int chartSamplingFrequency = Math.max(1, (int) (totalLocations / 1024.0)); + int bufferSize = 1024; + try { + final ArrayList theData = new ArrayList(); + int points = 0; + while (lastLocationRead < track.getStopId()) { + cursor = providerUtils.getLocationsCursor( + selectedTrackId, lastLocationRead, bufferSize, false); + if (cursor != null) { + elevationBuffer.reset(); + speedBuffer.reset(); + if (cursor.moveToFirst()) { + final int idColumnIdx = + cursor.getColumnIndexOrThrow(TrackPointsColumns._ID); + while (cursor.moveToNext()) { + points++; + Location location = providerUtils.createLocation(cursor); + if (MyTracksUtils.isValidLocation(location)) { + lastLocationRead = lastSeenLocationId = + cursor.getLong(idColumnIdx); + double[] point = getDataPoint(location, track); + if (points % chartSamplingFrequency == 0) { + theData.add(point); + } + } + } + } else { + lastLocationRead += bufferSize; + } + } else { + lastLocationRead += bufferSize; + } + cursor.close(); + cursor = null; + } + runOnUiThread(new Runnable() { + public void run() { + cv.setDataPoints(theData); + } + }); + } catch (RuntimeException e) { + Log.w(MyTracksConstants.TAG, "Caught unexpected exception.", e); + } finally { + if (cursor != null) { + cursor.close(); + } + } + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/ChartSettingsDialog.java b/MyTracks/src/com/google/android/apps/mytracks/ChartSettingsDialog.java new file mode 100644 index 000000000..b0aa5a14f --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/ChartSettingsDialog.java @@ -0,0 +1,87 @@ +/* + * Copyright 2009 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; + +import com.google.android.apps.mytracks.ChartView.Mode; +import com.google.android.maps.mytracks.R; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.view.View; +import android.view.Window; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.RadioButton; +import android.widget.RadioGroup; + +/** + * An activity that allows the user to set the chart settings. + * + * @author Sandor Dornbush + */ +public class ChartSettingsDialog extends Dialog { + private RadioButton distance; + private CheckBox elevation; + private CheckBox speed; + + public ChartSettingsDialog(Context context) { + super(context); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + requestWindowFeature(Window.FEATURE_NO_TITLE); + setContentView(R.layout.chart_settings); + Button cancel = (Button) findViewById(R.id.chart_settings_cancel); + cancel.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + dismiss(); + } + }); + Button ok = (Button) findViewById(R.id.chart_settings_ok); + ok.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + handleOk(); + } + }); + + distance = (RadioButton) findViewById(R.id.chart_settings_by_distance); + elevation = (CheckBox) findViewById(R.id.chart_settings_elevation); + speed = (CheckBox) findViewById(R.id.chart_settings_speed); + } + + public void setup(ChartActivity chart) { + RadioGroup rd = (RadioGroup) findViewById(R.id.chart_settings_x); + rd.check(chart.getMode() == Mode.BY_DISTANCE + ? R.id.chart_settings_by_distance + : R.id.chart_settings_by_time); + elevation.setChecked(chart.isSeriesEnabled(ChartView.ELEVATION_SERIES)); + speed.setChecked(chart.isSeriesEnabled(ChartView.SPEED_SERIES)); + } + + private void handleOk() { + ChartActivity chart = MyTracks.getInstance().getChartActivity(); + chart.setMode(distance.isChecked() ? Mode.BY_DISTANCE : Mode.BY_TIME); + + // TODO: check that something is visible. + chart.setSeriesEnabled(ChartView.ELEVATION_SERIES, elevation.isChecked()); + chart.setSeriesEnabled(ChartView.SPEED_SERIES, speed.isChecked()); + + dismiss(); + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/ChartValueSeries.java b/MyTracks/src/com/google/android/apps/mytracks/ChartValueSeries.java new file mode 100644 index 000000000..d2bf16d29 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/ChartValueSeries.java @@ -0,0 +1,214 @@ +/* + * Copyright 2009 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; + +import com.google.android.apps.mytracks.stats.ExtremityMonitor; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; + +import java.text.DecimalFormat; + +/** + * This class encasulates meta data about one series of values for a chart. + * + * @author Sandor Dornbush + */ +public class ChartValueSeries { + + private final ExtremityMonitor monitor = new ExtremityMonitor(); + private final DecimalFormat format; + private final Path path = new Path(); + private final Paint fillPaint; + private final Paint strokePaint; + private final int rounding; + + private String title; + private double min = 0; + private double max = 1; + private int effectiveMax = 0; + private int effectiveMin = 0; + private double spread = 0; + private boolean enabled = true; + + /** + * Constructs a new chart value series. + * + * @param context The context for the chart + * @param formatString The format of the decimal format for this series + * @param fill The paint for filling the chart + * @param stroke The paint for stroking the outside the chart, optional + * @param rounding The factor to round the values by + */ + public ChartValueSeries(Context context, + String formatString, + Paint fill, + Paint stroke, + int rounding, + int titleId) { + this.format = new DecimalFormat(formatString); + this.fillPaint = fill; + this.strokePaint = stroke; + this.rounding = rounding; + this.title = context.getString(titleId); + } + + /** + * Draws the path of the chart + */ + public void drawPath(Canvas c) { + c.drawPath(path, fillPaint); + if (strokePaint != null) { + c.drawPath(path, strokePaint); + } + } + + /** + * Resets this series + */ + public void reset() { + monitor.reset(); + } + + /** + * Updates this series with a new value + */ + public void update(double d) { + monitor.update(d); + } + + /** + * @return The interval between markers + */ + public int getInterval() { + // Try to find 5 even looking intervals. + int interval = (int) (spread / 5); + int minInterval = rounding / 4; + if (interval < minInterval) { + // We won't be able to find 5 even intervals. + return minInterval; + } else if (interval < rounding) { + // The desired interval is less than the rounding value. + // We will have less than 5 intervals. + return rounding; + } else { + // Round the interval. + return (interval / rounding) * rounding; + } + } + + /** + * Determines what the min and max of the chart will be. + * This will round down and up the min and max respectively. + */ + public void updateDimension() { + if (monitor.getMax() == Double.NEGATIVE_INFINITY) { + min = 0; + max = 1; + } else { + min = monitor.getMin(); + max = monitor.getMax(); + } + // Round it up. + effectiveMax = ((int) (max / rounding)) * rounding + rounding; + // Round it down. + effectiveMin = ((int) (min / rounding)) * rounding; + if (min < 0) { + effectiveMin -= rounding; + } + spread = effectiveMax - effectiveMin; + } + + /** + * @return The length of the longest string from the series + */ + public int getMaxLabelLength() { + String minS = format.format(effectiveMin); + String maxS = format.format(effectiveMax); + return Math.max(minS.length(), maxS.length()); + } + + /** + * @return The rounded down minimum value + */ + public int getMin() { + return effectiveMin; + } + + /** + * @return The rounded up maximum value + */ + public int getMax() { + return effectiveMax; + } + + /** + * @return The difference between the min and max values in the series + */ + public double getSpread() { + return spread; + } + + /** + * @return The format for the decimal format for this series + */ + DecimalFormat getFormat() { + return format; + } + + /** + * @return The path for this series + */ + Path getPath() { + return path; + } + + /** + * @return The paint for this series + */ + Paint getPaint() { + return (strokePaint == null) + ? fillPaint + : strokePaint; + } + + /** + * @return The title of the series + */ + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + /** + * @return is this series enabled + */ + public boolean isEnabled() { + return enabled; + } + + /** + * Sets the series enabled flag. + */ + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/ChartView.java b/MyTracks/src/com/google/android/apps/mytracks/ChartView.java new file mode 100644 index 000000000..905374150 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/ChartView.java @@ -0,0 +1,798 @@ +/* + * Copyright 2008 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; + +import com.google.android.apps.mytracks.content.Waypoint; +import com.google.android.apps.mytracks.stats.ExtremityMonitor; +import com.google.android.apps.mytracks.util.StringUtils; +import com.google.android.apps.mytracks.util.UnitConversions; +import com.google.android.maps.mytracks.R; + +import android.content.Context; +import android.content.Intent; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Paint.Style; +import android.graphics.Path; +import android.graphics.drawable.Drawable; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.ViewConfiguration; +import android.widget.Scroller; + +import java.text.DecimalFormat; +import java.text.NumberFormat; +import java.util.ArrayList; + +/** + * Visualization of the chart. + * + * @author Sandor Dornbush + * @author Leif Hendrik Wilden + */ +public class ChartView extends View { + + /* + * Scrolling logic: + */ + private final Scroller scroller; + private VelocityTracker velocityTracker = null; + /** Position of the last motion event */ + private float lastMotionX; + + /* + * Zoom logic: + */ + private int zoomLevel = 1; + private final int minZoomLevel = 1; + private int maxZoomLevel = 5; + + /* + * Borders, margins, dimensions (in pixels): + */ + private int leftBorder = -1; + private static final int TOP_BORDER = 15; + private static final int BOTTOM_BORDER = 135; + private static final int RIGHT_BORDER = 17; + /** Space to leave for drawing the unit labels */ + private static final int UNIT_BORDER = 15; + private static final int FONT_HEIGHT = 10; + private int w = 0; + private int h = 0; + private int effectiveWidth = 0; + private int effectiveHeight = 0; + + /* + * Ranges (in data units): + */ + private double maxX = 1; + + /** + * The various series. + */ + public static final int ELEVATION_SERIES = 0; + public static final int SPEED_SERIES = 1; + public static final int NUM_SERIES = 2; + private ChartValueSeries[] series; + + private final ExtremityMonitor xMonitor = new ExtremityMonitor(); + private final NumberFormat xFormat = new DecimalFormat("###,###"); + private final NumberFormat xShortFormat = new DecimalFormat("#.0"); + + /* + * Paints etc. used when drawing the histogram: + */ + private final Paint borderPaint = new Paint(); + private final Paint labelPaint = new Paint(); + private final Paint gridPaint = new Paint(); + private final Paint clearPaint = new Paint(); + private final Drawable pointer; + private final Drawable statsMarker; + private final Drawable waypointMarker; + private final int markerWidth, markerHeight; + + /** + * The chart data stored as an array of double arrays. Each one dimensional + * array is composed of [x, y]. + */ + private final ArrayList data = new ArrayList(); + + /** + * List of way points to be displayed. + */ + private final ArrayList waypoints = new ArrayList(); + + private boolean metricUnits = true; + private boolean showPointer = false; + + private boolean yLabelMask[] = null; + + /** Display chart versus distance or time */ + public enum Mode { + BY_DISTANCE, BY_TIME + } + + private Mode mode = Mode.BY_DISTANCE; + + public ChartView(Context context) { + super(context); + + setUpChartValueSeries(context); + + labelPaint.setStyle(Style.STROKE); + labelPaint.setColor(context.getResources().getColor(R.color.black)); + labelPaint.setAntiAlias(true); + + borderPaint.setStyle(Style.STROKE); + borderPaint.setColor(context.getResources().getColor(R.color.black)); + borderPaint.setAntiAlias(true); + + gridPaint.setStyle(Style.STROKE); + gridPaint.setColor(context.getResources().getColor(R.color.gray)); + gridPaint.setAntiAlias(false); + + clearPaint.setStyle(Style.FILL); + clearPaint.setColor(context.getResources().getColor(R.color.white)); + clearPaint.setAntiAlias(false); + + pointer = context.getResources().getDrawable(R.drawable.arrow_180); + pointer.setBounds(0, 0, + pointer.getIntrinsicWidth(), pointer.getIntrinsicHeight()); + + statsMarker = getResources().getDrawable(R.drawable.ylw_pushpin); + markerWidth = statsMarker.getIntrinsicWidth(); + markerHeight = statsMarker.getIntrinsicHeight(); + statsMarker.setBounds(0, 0, markerWidth, markerHeight); + + waypointMarker = getResources().getDrawable(R.drawable.blue_pushpin); + waypointMarker.setBounds(0, 0, markerWidth, markerHeight); + + scroller = new Scroller(context); + setFocusable(true); + setClickable(true); + updateDimensions(); + } + + public void setUpChartValueSeries(Context context) { + series = new ChartValueSeries[NUM_SERIES]; + + // Create the paints + Paint fillPaint1 = new Paint(); + fillPaint1.setStyle(Style.FILL); + fillPaint1.setColor(context.getResources().getColor(R.color.green)); + fillPaint1.setAntiAlias(true); + + Paint fillPaint2 = new Paint(); + fillPaint2.setStyle(Style.FILL); + fillPaint2.setColor( + context.getResources().getColor(R.color.blue_transparent)); + fillPaint2.setAntiAlias(true); + + Paint strokePaint = new Paint(); + strokePaint.setStyle(Style.STROKE); + strokePaint.setColor(context.getResources().getColor(R.color.blue)); + strokePaint.setAntiAlias(true); + + // Create the value series + series[ELEVATION_SERIES] = + new ChartValueSeries(context, + "###,###", + fillPaint1, + null, + 100, + R.string.elevation); + + series[SPEED_SERIES] = + new ChartValueSeries(context, + "###,###.0", + fillPaint2, + strokePaint, + 5, + R.string.speed); + } + + public void clearWaypoints() { + waypoints.clear(); + } + + public void addWaypoint(Waypoint waypoint) { + waypoints.add(waypoint); + } + + /** + * Determines whether the pointer icon is shown on the last data point. + */ + public void setShowPointer(boolean showPointer) { + this.showPointer = showPointer; + } + + /** + * Sets whether metric units are used or not. + */ + public void setMetricUnits(boolean metricUnits) { + this.metricUnits = metricUnits; + } + + public void setReportSpeed(boolean reportSpeed, Context c) { + series[SPEED_SERIES].setTitle(c.getString(reportSpeed + ? R.string.speed + : R.string.pace_label)); + } + + /** + * Gets the data that is displayed by the chart. + * + * @return an array list with data points + */ + public ArrayList getData() { + return data; + } + + /** + * Sets the data that is to be displayed by the chart. + * + * @param theData an array list of data points + */ + public synchronized void setDataPoints(ArrayList theData) { + scrollTo(0, 0); + zoomLevel = 1; + data.clear(); + xMonitor.reset(); + for (ChartValueSeries cvs : series) { + cvs.reset(); + } + addDataPoints(theData); + } + + /** + * Adds a new data point to the chart. + * + * @param theData a data point + */ + public synchronized void addDataPoint(double[] theData) { + data.add(theData); + xMonitor.update(theData[0]); + int min = Math.min(series.length, theData.length - 1); + for (int i = 0; i < min; i++) { + series[i].update(theData[i + 1]); + } + updateDimensions(); + setupPath(); + } + + /** + * Adds multiple data points to the chart. + * + * @param theData an array list of data points to be added + */ + public synchronized void addDataPoints(ArrayList theData) { + data.addAll(theData); + for (int i = 0; i < theData.size(); i++) { + double d[] = theData.get(i); + xMonitor.update(d[0]); + int min = Math.min(series.length, d.length - 1); + for (int j = 0; j < min; j++) { + series[j].update(d[j + 1]); + } + } + updateDimensions(); + setupPath(); + } + + /** + * Clears all data. + * Call this only from the UI thread! + */ + public synchronized void reset() { + data.clear(); + zoomLevel = 1; + scrollTo(0, 0); + updateDimensions(); + } + + /** + * @return true if the chart can be zoomed into. + */ + public boolean canZoomIn() { + return zoomLevel < maxZoomLevel; + } + + /** + * @return true if the chart can be zoomed out + */ + public boolean canZoomOut() { + return zoomLevel > minZoomLevel; + } + + /** + * Zooms in one level (factor 2). + */ + public void zoomIn() { + if (canZoomIn()) { + zoomLevel++; + setupPath(); + invalidate(); + } + } + + /** + * Zooms out one level (factor 2). + */ + public void zoomOut() { + if (canZoomOut()) { + zoomLevel--; + scroller.abortAnimation(); + int scrollX = getScrollX(); + if (scrollX > effectiveWidth * (zoomLevel - 1)) { + scrollX = effectiveWidth * (zoomLevel - 1); + scrollTo(scrollX, 0); + } + setupPath(); + invalidate(); + } + } + + /** + * @return the current zoom level (1 equals to showing all data points) + */ + public int getZoomLevel() { + return zoomLevel; + } + + /** + * Initiates flinging. + * + * @param velocityX start velocity (pixels per second) + */ + public void fling(int velocityX) { + scroller.fling(getScrollX(), 0, velocityX, 0, 0, + effectiveWidth * (zoomLevel - 1), 0, 0); + invalidate(); + } + + /** + * Scrolls the view horizontally by the given amount. + * + * @param deltaX number of pixels to scroll + */ + public void scrollBy(int deltaX) { + int scrollX = getScrollX() + deltaX; + if (scrollX < 0) { + scrollX = 0; + } + int available = effectiveWidth * (zoomLevel - 1); + if (scrollX > available) { + scrollX = available; + } + scrollTo(scrollX, 0); + } + + /** + * Sets the scroll position of the chart. This will trigger a redraw. + */ + @Override + public void scrollTo(int x, int y) { + super.scrollTo(x, y); + } + + /** + * @return the current display mode (by distance, by time) + */ + public Mode getMode() { + return mode; + } + + /** + * Sets the display mode (by distance, by time). + */ + public void setMode(Mode mode) { + this.mode = mode; + } + + private int getWaypointX(Waypoint waypoint) { + return (mode == Mode.BY_DISTANCE) + ? getX(metricUnits + ? waypoint.getLength() / 1000.0 + : waypoint.getLength() * UnitConversions.KM_TO_MI / 1000.0) + : getX(waypoint.getDuration()); + } + + /** + * Called by the parent to indicate that the mScrollX/Y values need to be + * updated. Triggers a redraw during flinging. + */ + @Override + public void computeScroll() { + if (scroller.computeScrollOffset()) { + int oldX = getScrollX(); + int x = scroller.getCurrX(); + scrollTo(x, 0); + if (oldX != x) { + onScrollChanged(x, 0, oldX, 0); + postInvalidate(); + } + } + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (velocityTracker == null) { + velocityTracker = VelocityTracker.obtain(); + } + velocityTracker.addMovement(event); + final int action = event.getAction(); + final float x = event.getX(); + switch (action) { + case MotionEvent.ACTION_DOWN: + /* + * If being flinged and user touches, stop the fling. isFinished will be + * false if being flinged. + */ + if (!scroller.isFinished()) { + scroller.abortAnimation(); + } + // Remember where the motion event started + lastMotionX = x; + break; + case MotionEvent.ACTION_MOVE: + // Scroll to follow the motion event + final int deltaX = (int) (lastMotionX - x); + lastMotionX = x; + if (deltaX < 0) { + if (getScrollX() > 0) { + scrollBy(deltaX); + } + } else if (deltaX > 0) { + final int availableToScroll = + effectiveWidth * (zoomLevel - 1) - getScrollX(); + if (availableToScroll > 0) { + scrollBy(Math.min(availableToScroll, deltaX)); + } + } + break; + case MotionEvent.ACTION_UP: + // Check if top area with waypoint markers was touched and find the + // touched marker if any: + if (event.getY() < 100) { + int dmin = Integer.MAX_VALUE; + Waypoint nearestWaypoint = null; + for (int i = 0; i < waypoints.size(); i++) { + final Waypoint waypoint = waypoints.get(i); + final int d = Math.abs(getWaypointX(waypoint) - (int) event.getX() + - getScrollX()); + if (d < dmin) { + dmin = d; + nearestWaypoint = waypoint; + } + } + if (nearestWaypoint != null && dmin < 100) { + Intent intent = + new Intent(getContext(), MyTracksWaypointDetails.class); + intent.putExtra("waypointid", nearestWaypoint.getId()); + getContext().startActivity(intent); + return true; + } + } + + VelocityTracker myVelocityTracker = velocityTracker; + myVelocityTracker.computeCurrentVelocity(1000); + int initialVelocity = (int) myVelocityTracker.getXVelocity(); + if (Math.abs(initialVelocity) > + ViewConfiguration.getMinimumFlingVelocity()) { + fling(-initialVelocity); + } + if (velocityTracker != null) { + velocityTracker.recycle(); + velocityTracker = null; + } + break; + } + return true; + } + + @Override + protected synchronized void onDraw(Canvas c) { + if (w != c.getWidth() || h != c.getHeight()) { + // Dimensions have changed (for example due to orientation change): + w = c.getWidth(); + h = c.getHeight(); + effectiveWidth = w - leftBorder - RIGHT_BORDER; + effectiveHeight = h - TOP_BORDER - BOTTOM_BORDER; + setupPath(); + } + c.save(); + c.drawColor(Color.WHITE); + if (data.size() < 1) { + drawXAxis(c); + drawYAxis(c); + c.restore(); + return; + } + + // Draw the data series + for (ChartValueSeries cvs : series) { + if (cvs.isEnabled()) { + cvs.drawPath(c); + } + } + drawGrid(c); + + // Draw the waypoints: + for (int i = 1; i < waypoints.size(); i++) { + final Waypoint waypoint = waypoints.get(i); + if (waypoint.getLocation() == null) { + continue; + } + c.save(); + + final float x = getWaypointX(waypoint); + c.drawLine(x, h - BOTTOM_BORDER, x, TOP_BORDER, gridPaint); + c.translate(x - (markerWidth / 2), markerHeight); + if (waypoints.get(i).getType() == Waypoint.TYPE_STATISTICS) { + statsMarker.draw(c); + } else { + waypointMarker.draw(c); + } + c.restore(); + } + + // Draw the axis and labels: + drawXLabels(c); + drawXAxis(c); + drawSeriesTitles(c); + + yLabelMask = new boolean[effectiveHeight / FONT_HEIGHT + 1]; + c.translate(getScrollX(), 0); + drawYAxis(c); + for (ChartValueSeries cvs : series) { + if (cvs.isEnabled()) { + drawYLabels(cvs, c); + } + } + c.restore(); + if (showPointer && data.size() > 0) { + c.translate(getX(maxX) - pointer.getIntrinsicWidth() / 2, + (getY(series[0], data.get(data.size() - 1)[1]) + - pointer.getIntrinsicHeight() / 2 - 12)); + pointer.draw(c); + } + } + + private void drawSeriesTitles(Canvas c) { + int sections = 1; + for (ChartValueSeries cvs : series) { + if (cvs.isEnabled()) { + sections++; + } + } + int j = 0; + for (ChartValueSeries cvs : series) { + if (cvs.isEnabled()) { + int y = (int) (w * zoomLevel * ((double) (++j) / sections)); + c.drawText(cvs.getTitle(), y, TOP_BORDER, cvs.getPaint()); + } + } + } + + /** + * Sets up the path that is used to draw the histogram in onDraw(). The path + * needs to be updated any time after the data or histogram dimensions change. + */ + private synchronized void setupPath() { + for (ChartValueSeries cvs : series) { + cvs.getPath().reset(); + } + if (data.size() < 1) { + return; + } + + int min = Math.min(series.length, data.get(0).length - 1); + + // All of the data points to the respective series. + for (int i = 0; i < data.size(); i++) { + double[] d = data.get(i); + for (int j = 0; j < min; j++) { + ChartValueSeries cvs = series[j]; + Path path = cvs.getPath(); + path.lineTo(getX(d[0]), getY(cvs, d[j + 1])); + } + } + + // Close the path. + int yCorner = TOP_BORDER + effectiveHeight; + int xCorner = getX(data.get(0)[0]); + for (int j = 0; j < min; j++) { + ChartValueSeries cvs = series[j]; + Path path = cvs.getPath(); + // Bottom right corner + path.lineTo(getX(data.get(data.size() - 1)[0]), yCorner); + // Bottom left corner + path.lineTo(xCorner, yCorner); + // Top right corner + path.lineTo(xCorner, getY(cvs, data.get(0)[j + 1])); + } + } + + /** + * Update the histogram dimensions. + */ + private void updateDimensions() { + maxX = xMonitor.getMax(); + if (data.size() <= 1) { + maxX = 1; + } + for (ChartValueSeries cvs : series) { + cvs.updateDimension(); + } + int maxLength = 0; + for (ChartValueSeries cvs : series) { + maxLength = Math.max(maxLength, + cvs.getMaxLabelLength()); + } + leftBorder = 4 + 7 * maxLength; + effectiveWidth = w - leftBorder - RIGHT_BORDER; + effectiveHeight = h - TOP_BORDER - BOTTOM_BORDER; + } + + private int getX(double distance) { + return leftBorder + (int) ((distance * effectiveWidth / maxX) * zoomLevel); + } + + private int getY(ChartValueSeries cvs, double y) { + return TOP_BORDER + effectiveHeight + - (int) ((y - cvs.getMin()) * effectiveHeight / cvs.getSpread()); + } + + private void drawXLabels(Canvas c) { + int numLabels = 0; + double interval = (int) (maxX / zoomLevel / 4); + boolean shortFormat = false; + if (interval < 1) { + interval = .5; + shortFormat = true; + } else { + if (interval < 5) { + interval = 2; + } else if (interval < 10) { + interval = 5; + } else { + interval = (interval / 10) * 10; + } + } + drawXLabel(c, 0, shortFormat); + numLabels++; + for (int i = 1; i * interval < maxX; i++) { + drawXLabel(c, i * interval, shortFormat); + numLabels++; + } + if (numLabels < 2) { + drawXLabel(c, (int) maxX, shortFormat); + } + } + + private void drawYLabels(ChartValueSeries cvs, Canvas c) { + int numLabels = 0; + drawYLabel(cvs, c, 0); + final int y0 = getY(cvs, 0); + numLabels++; + int interval = cvs.getInterval(); + for (int i = 0; i * interval < cvs.getSpread(); i++) { + final int y = i * interval + cvs.getMin(); + final int yi = getY(cvs, y); + if (Math.abs(y0 - yi) > 15) { + drawYLabel(cvs, c, y); + numLabels++; + } + } + if (numLabels < 3) { + drawYLabel(cvs, c, cvs.getMax()); + } + } + + private void drawXLabel(Canvas c, double x, boolean shortFormat) { + String s = + (mode == Mode.BY_DISTANCE) + ? (shortFormat ? xShortFormat.format(x) : xFormat.format(x)) + : StringUtils.formatTime((long) x); + c.drawText(s, + getX(x), + effectiveHeight + UNIT_BORDER + TOP_BORDER, + labelPaint); + } + + private void drawYLabel(ChartValueSeries cvs, Canvas c, int y) { + int desiredY = + (int) ((y - cvs.getMin()) * effectiveHeight / cvs.getSpread()); + + // Make sure we don't write one label on top of another. + int slot = desiredY / FONT_HEIGHT; + if (slot < 0) { + return; + } + if (yLabelMask[slot]) { + desiredY = ++slot * FONT_HEIGHT; + } + desiredY = TOP_BORDER + effectiveHeight + 5 - desiredY; + if (slot < yLabelMask.length) { + yLabelMask[slot] = true; + } + c.drawText(cvs.getFormat().format(y), + 2, desiredY, + cvs.getPaint()); + } + + private void drawXAxis(Canvas canvas) { + float rightEdge = getX(maxX); + canvas.drawLine(leftBorder, effectiveHeight + TOP_BORDER, rightEdge, + effectiveHeight + TOP_BORDER, borderPaint); + Context c = getContext(); + String s = + (mode == Mode.BY_DISTANCE) + ? (metricUnits ? c.getString(R.string.kilometer) + : c.getString(R.string.mile)) : (c.getString(R.string.min)); + canvas.drawText(s, + rightEdge, + effectiveHeight + .2f * UNIT_BORDER + TOP_BORDER, + labelPaint); + } + + private void drawYAxis(Canvas canvas) { + canvas.drawLine(leftBorder, UNIT_BORDER + TOP_BORDER, + leftBorder, effectiveHeight + TOP_BORDER, + borderPaint); + canvas.drawRect(0, 0, + leftBorder - 1, effectiveHeight + TOP_BORDER + 1, + clearPaint); + Context c = getContext(); + // TODO: This should really show units for all series. + String s = metricUnits + ? c.getString(R.string.meter) + : c.getString(R.string.feet); + canvas.drawText(s, leftBorder - UNIT_BORDER * .2f, + UNIT_BORDER * .8f + TOP_BORDER, labelPaint); + } + + private synchronized void drawGrid(Canvas c) { + if (data.size() < 1) { + return; + } + for (ChartValueSeries cvs : series) { + if (cvs.isEnabled()) { + int interval = cvs.getInterval(); + for (int i = 1; i * interval < cvs.getSpread(); i++) { + int y = getY(cvs, i * interval + cvs.getMin()); + c.drawLine(getX(data.get(0)[0]), y, + getX(data.get(data.size() - 1)[0]), y, + cvs.getPaint()); + } + } + } + } + + /** + * Gets one of the chart value series. + * The index should be one of the following values: + * ELEVATION_SERIES or SPEED_SERIES + * + * @param index of the value series + * @return The chart series at the index + */ + public ChartValueSeries getChartValueSeries(int index) { + return series[index]; + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/CheckUnits.java b/MyTracks/src/com/google/android/apps/mytracks/CheckUnits.java new file mode 100644 index 000000000..5947f8900 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/CheckUnits.java @@ -0,0 +1,90 @@ +/* + * 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; + +import com.google.android.maps.mytracks.R; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import java.util.Locale; + +/** + * Check to see if the units are the default for the locale. + * This comes down to warning Americans that the default is metric. + * + * @author Sandor Dornbush + */ +class CheckUnits { + private static final String PREFERENCE_UNITS_CHECKED = "checkunits.checked"; + private static final String PREFERENCES_CHECK_UNITS = "checkunits"; + + static void check(final Context context) { + final SharedPreferences preferences = + context.getSharedPreferences(PREFERENCES_CHECK_UNITS, Activity.MODE_PRIVATE); + + // Has the user already warned about the default units? + if (preferences.getBoolean(PREFERENCE_UNITS_CHECKED, false)) { + return; + } + + // Is the user in the US? + Locale current = Locale.getDefault(); + Locale enUs = new Locale(Locale.ENGLISH.getLanguage(), + Locale.US.getCountry()); + if (!current.equals(enUs)) { + return; + } + + final AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.check_units_title); + builder.setCancelable(true); + builder.setPositiveButton(R.string.yes, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + accept(context, preferences); + } + }); + builder.setNegativeButton(R.string.no, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + recordCheckPerformed(preferences); + } + }); + builder.setOnCancelListener(new DialogInterface.OnCancelListener() { + public void onCancel(DialogInterface dialog) { + recordCheckPerformed(preferences); + } + }); + builder.setMessage(R.string.check_units_message); + builder.show(); + } + + private static void accept(Context context, SharedPreferences preferences) { + recordCheckPerformed(preferences); + Intent startIntent = new Intent(context, MyTracksSettings.class); + context.startActivity(startIntent); + } + + private static void recordCheckPerformed(SharedPreferences preferences) { + preferences.edit().putBoolean(PREFERENCE_UNITS_CHECKED, true).commit(); + } + + private CheckUnits() { + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/Eula.java b/MyTracks/src/com/google/android/apps/mytracks/Eula.java new file mode 100644 index 000000000..f070e310a --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/Eula.java @@ -0,0 +1,91 @@ +/* + * Copyright 2008 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; + +import com.google.android.apps.mytracks.util.ResourceUtils; +import com.google.android.maps.mytracks.R; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; + +/** + * Displays an EULA ("End User License Agreement") that the user has to accept + * before using the application. Your application should call + * {@link Eula#showEula(android.app.Activity)} in the onCreate() method of the + * first activity. If the user accepts the EULA, it will never be shown again. + * If the user refuses, {@link android.app.Activity#finish()} is invoked on your + * activity. + */ +class Eula { + private static final String PREFERENCE_EULA_ACCEPTED = "eula.accepted"; + private static final String PREFERENCES_EULA = "eula"; + + /** + * Displays the EULA if necessary. This method should be called from the + * onCreate() method of your main Activity. + * + * @param activity The Activity to finish if the user rejects the EULA + */ + static void showEula(final Activity activity) { + final SharedPreferences preferences = + activity.getSharedPreferences(PREFERENCES_EULA, Activity.MODE_PRIVATE); + if (preferences.getBoolean(PREFERENCE_EULA_ACCEPTED, false)) { + return; + } + + final AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setTitle(R.string.eula_title); + builder.setCancelable(true); + builder.setPositiveButton(R.string.accept, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + accept(activity, preferences); + } + }); + builder.setNegativeButton(R.string.decline, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + refuse(activity); + } + }); + builder.setOnCancelListener(new DialogInterface.OnCancelListener() { + @Override + public void onCancel(DialogInterface dialog) { + refuse(activity); + } + }); + builder.setMessage(ResourceUtils.readFile(activity, R.raw.eula)); + builder.show(); + } + + private static void accept(Activity activity, SharedPreferences preferences) { + preferences.edit().putBoolean(PREFERENCE_EULA_ACCEPTED, true).commit(); + Intent startIntent = new Intent(activity, WelcomeActivity.class); + activity.startActivityForResult(startIntent, MyTracksConstants.WELCOME); + } + + private static void refuse(Activity activity) { + activity.finish(); + } + + private Eula() { + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/ExportAllTracks.java b/MyTracks/src/com/google/android/apps/mytracks/ExportAllTracks.java new file mode 100644 index 000000000..07311fca2 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/ExportAllTracks.java @@ -0,0 +1,227 @@ +/* + * Copyright 2009 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; + +import com.google.android.apps.mytracks.content.MyTracksProviderUtils; +import com.google.android.apps.mytracks.content.TracksColumns; +import com.google.android.apps.mytracks.io.TrackWriterFactory; +import com.google.android.apps.mytracks.io.TrackWriterFactory.TrackFileFormat; +import com.google.android.maps.mytracks.R; + +import android.app.AlertDialog; +import android.app.ProgressDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.PowerManager; +import android.os.PowerManager.WakeLock; +import android.util.Log; +import android.widget.Toast; + +/** + * A class that will export all tracks to the sd card. + * + * @author Sandor Dornbush + */ +public class ExportAllTracks { + // These must line up with the index in the array. + public static final int GPX_OPTION_INDEX = 0; + public static final int KML_OPTION_INDEX = 1; + public static final int CSV_OPTION_INDEX = 2; + + private final Context context; + private WakeLock wakeLock; + private ProgressDialog progress; + + private TrackFileFormat format = TrackFileFormat.GPX; + + private final DialogInterface.OnClickListener itemClick = + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + switch (which) { + case GPX_OPTION_INDEX: + format = TrackFileFormat.GPX; + break; + case KML_OPTION_INDEX: + format = TrackFileFormat.KML; + break; + case CSV_OPTION_INDEX: + format = TrackFileFormat.CSV; + break; + } + } + }; + + public ExportAllTracks(Context c) { + this.context = c; + Log.i(MyTracksConstants.TAG, "ExportAllTracks: Starting"); + + AlertDialog.Builder builder = new AlertDialog.Builder(c); + builder.setSingleChoiceItems(R.array.export_formats, 0, itemClick); + builder.setPositiveButton(R.string.ok, positiveClick); + builder.setNegativeButton(R.string.cancel, null); + builder.show(); + } + + private final DialogInterface.OnClickListener positiveClick = + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + HandlerThread handlerThread; + Handler handler; + handlerThread = new HandlerThread("SendToMyMaps"); + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()); + handler.post(runner); + } + }; + + private final Runnable runner = new Runnable() { + public void run() { + aquireLocksAndExport(); + } + }; + + /** + * Makes sure that we keep the phone from sleeping. + * See if there is a current track. Aquire a wake lock if there is no + * current track. + */ + private void aquireLocksAndExport() { + SharedPreferences prefs = + context.getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + long recordingTrackId = -1; + if (prefs != null) { + recordingTrackId = prefs.getLong(MyTracksSettings.RECORDING_TRACK, -1); + } + if (recordingTrackId != -1) { + acquireWakeLock(); + } + + // Now we can safely export everything. + exportAll(); + + // Release the wake lock if we recorded one. + // TODO check what happens if we started recording after getting this lock. + if (wakeLock != null && wakeLock.isHeld()) { + wakeLock.release(); + Log.i(MyTracksConstants.TAG, "ExportAllTracks: Releasing wake lock."); + } + Log.i(MyTracksConstants.TAG, "ExportAllTracks: Done"); + Toast.makeText(context, R.string.export_done, Toast.LENGTH_SHORT).show(); + } + + private void makeProgressDialog(final int trackCount) { + String exportMsg = context.getString(R.string.tracklist_btn_export_all); + progress = new ProgressDialog(context); + progress.setIcon(android.R.drawable.ic_dialog_info); + progress.setTitle(exportMsg); + progress.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL); + progress.setMax(trackCount); + progress.setProgress(0); + progress.show(); + } + + /** + * Actually export the tracks. + * This should be called after the wake locks have been aquired. + */ + private void exportAll() { + // Get a cursor over all tracks. + Cursor cursor = null; + try { + MyTracksProviderUtils providerUtils = + MyTracksProviderUtils.Factory.get(context); + cursor = providerUtils.getTracksCursor(""); + if (cursor == null) { + return; + } + final int trackCount = cursor.getCount(); + Log.i(MyTracksConstants.TAG, + "ExportAllTracks: Exporting: " + cursor.getCount() + " tracks."); + int idxTrackId = cursor.getColumnIndexOrThrow(TracksColumns._ID); + MyTracks.getInstance().runOnUiThread(new Runnable() { + public void run() { + makeProgressDialog(trackCount); + }}); + for (int i = 0; cursor.moveToNext(); i++) { + final int status = i; + MyTracks.getInstance().runOnUiThread(new Runnable() { + public void run() { + synchronized (this) { + if (progress == null) { + return; + } + progress.setProgress(status); + } + } + }); + + long id = cursor.getLong(idxTrackId); + Log.i(MyTracksConstants.TAG, "ExportAllTracks: exporting: " + id); + TrackWriterFactory.newWriter(context, providerUtils, id, format) + .writeTrack(); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + if (progress != null) { + progress.dismiss(); + } + } + + /** + * Tries to acquire a partial wake lock if not already acquired. Logs errors + * and gives up trying in case the wake lock cannot be acquired. + */ + private void acquireWakeLock() { + Log.i(MyTracksConstants.TAG, "ExportAllTracks: Aquiring wake lock."); + try { + PowerManager pm = + (PowerManager) context.getSystemService(Context.POWER_SERVICE); + if (pm == null) { + Log.e(MyTracksConstants.TAG, + "ExportAllTracks: Power manager not found!"); + return; + } + if (wakeLock == null) { + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, + MyTracksConstants.TAG); + if (wakeLock == null) { + Log.e(MyTracksConstants.TAG, + "ExportAllTracks: Could not create wake lock (null)."); + return; + } + } + if (!wakeLock.isHeld()) { + wakeLock.acquire(); + if (!wakeLock.isHeld()) { + Log.e(MyTracksConstants.TAG, + "ExportAllTracks: Could not acquire wake lock."); + } + } + } catch (RuntimeException e) { + Log.e(MyTracksConstants.TAG, + "ExportAllTracks: Caught unexpected exception: " + e.getMessage(), e); + } + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/IntegerListPreference.java b/MyTracks/src/com/google/android/apps/mytracks/IntegerListPreference.java new file mode 100644 index 000000000..97037b15c --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/IntegerListPreference.java @@ -0,0 +1,95 @@ +/* + * 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; + +import android.content.Context; +import android.preference.ListPreference; +import android.util.AttributeSet; + +/** + * A list preference which persists its values as integers instead of strings. + * Code reading the values should use + * {@link android.content.SharedPreferences#getInt}. + * When using XML-declared arrays for entry values, the arrays should be regular + * string arrays containing valid integer values. + * + * @author Rodrigo Damazio + */ +public class IntegerListPreference extends ListPreference { + + public IntegerListPreference(Context context) { + super(context); + + verifyEntryValues(null); + } + + public IntegerListPreference(Context context, AttributeSet attrs) { + super(context, attrs); + + verifyEntryValues(null); + } + + @Override + public void setEntryValues(CharSequence[] entryValues) { + CharSequence[] oldValues = getEntryValues(); + super.setEntryValues(entryValues); + verifyEntryValues(oldValues); + } + + @Override + public void setEntryValues(int entryValuesResId) { + CharSequence[] oldValues = getEntryValues(); + super.setEntryValues(entryValuesResId); + verifyEntryValues(oldValues); + } + + @Override + protected String getPersistedString(String defaultReturnValue) { + // During initial load, there's no known default value + int defaultIntegerValue = Integer.MIN_VALUE; + if (defaultReturnValue != null) { + defaultIntegerValue = Integer.parseInt(defaultReturnValue); + } + + // When the list preference asks us to read a string, instead read an + // integer. + int value = getPersistedInt(defaultIntegerValue); + return Integer.toString(value); + } + + @Override + protected boolean persistString(String value) { + // When asked to save a string, instead save an integer + return persistInt(Integer.parseInt(value)); + } + + private void verifyEntryValues(CharSequence[] oldValues) { + CharSequence[] entryValues = getEntryValues(); + if (entryValues == null) { + return; + } + + for (CharSequence entryValue : entryValues) { + try { + Integer.parseInt(entryValue.toString()); + } catch (NumberFormatException nfe) { + super.setEntryValues(oldValues); + throw nfe; + } + } + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/MyTracks.java b/MyTracks/src/com/google/android/apps/mytracks/MyTracks.java new file mode 100644 index 000000000..59fe72732 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/MyTracks.java @@ -0,0 +1,1572 @@ +/* + * Copyright 2008 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; + +import com.google.android.accounts.Account; +import com.google.android.apps.mymaps.MyMapsConstants; +import com.google.android.apps.mymaps.MyMapsList; +import com.google.android.apps.mymaps.VersionChecker; +import com.google.android.apps.mytracks.content.MyTracksProviderUtils; +import com.google.android.apps.mytracks.content.Track; +import com.google.android.apps.mytracks.content.Waypoint; +import com.google.android.apps.mytracks.io.AuthManager; +import com.google.android.apps.mytracks.io.AuthManagerFactory; +import com.google.android.apps.mytracks.io.GpxImport; +import com.google.android.apps.mytracks.io.SendToDocs; +import com.google.android.apps.mytracks.io.SendToMyMaps; +import com.google.android.apps.mytracks.io.TrackWriter; +import com.google.android.apps.mytracks.io.TrackWriterFactory; +import com.google.android.apps.mytracks.io.TrackWriterFactory.TrackFileFormat; +import com.google.android.apps.mytracks.services.ITrackRecordingService; +import com.google.android.apps.mytracks.services.TrackRecordingService; +import com.google.android.maps.mytracks.R; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.ProgressDialog; +import android.app.TabActivity; +import android.content.ComponentName; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.SharedPreferences; +import android.content.SharedPreferences.OnSharedPreferenceChangeListener; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.database.Cursor; +import android.location.Location; +import android.location.LocationManager; +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.os.IBinder; +import android.os.RemoteException; +import android.speech.tts.TextToSpeech; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnTouchListener; +import android.view.ViewGroup.LayoutParams; +import android.view.Window; +import android.view.WindowManager.BadTokenException; +import android.widget.RelativeLayout; +import android.widget.TabHost; +import android.widget.Toast; + +import org.xml.sax.SAXException; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Random; + +import javax.xml.parsers.ParserConfigurationException; + +/** + * The super activity that embeds our sub activities. + * + * @author Leif Hendrik Wilden + */ +public class MyTracks extends TabActivity implements OnTouchListener, + OnSharedPreferenceChangeListener, ProgressIndicator { + + private static final String WAYPOINT_ICON_URL = + "http://maps.google.com/mapfiles/ms/micons/blue-pushpin.png"; + + /** + * Singleton instance + */ + private static MyTracks instance = null; + private ChartActivity chartActivity = null; + + public ChartActivity getChartActivity() { + return chartActivity; + } + + public void setChartActivity(ChartActivity chartActivity) { + this.chartActivity = chartActivity; + } + + /* + * Authentication: + */ + + private AuthManager auth; + private final HashMap authMap = + new HashMap(); + private AccountChooser accountChooser = new AccountChooser(); + + /* + * Dialogs: + */ + + public static final int DIALOG_PROGRESS = 1; + public static final int DIALOG_IMPORT_PROGRESS = 2; + public static final int DIALOG_WRITE_PROGRESS = 3; + public static final int DIALOG_SEND_TO_GOOGLE = 4; + public static final int DIALOG_SEND_TO_GOOGLE_RESULT = 5; + public static final int DIALOG_CHART_SETTINGS = 6; + + private ProgressDialog progressDialog; + private ProgressDialog importProgressDialog; + private ProgressDialog writeProgressDialog; + private SendToGoogleDialog sendToGoogleDialog; + private AlertDialog sendToGoogleResultDialog; + private ChartSettingsDialog chartSettingsDialog; + + /* + * Menu items: + */ + + private MenuItem startRecording; + private MenuItem stopRecording; + private MenuItem listTracks; + private MenuItem listMarkers; + private MenuItem settings; + private MenuItem help; + + /* + * Information on upload success to MyMaps/Docs. + * Used by SendToGoogleResultDialog. + */ + + public boolean sendToMyMapsSuccess = false; + public boolean sendToDocsSuccess = false; + public String sendToMyMapsMapId = null; + public String sendToMyMapsMessage = ""; + public String sendToDocsMessage = ""; + + /** + * True if a new track should be created after the track recording service + * binds. + */ + private boolean startNewTrackRequested = false; + + private ITrackRecordingService trackRecordingService; + + /** + * The id of the currently recording track. + */ + private long recordingTrackId = -1; + + /** + * The id of the currently selected track. + */ + private long selectedTrackId = -1; + + /** + * Does the user want to share the current track. + */ + private boolean shareRequested = false; + + /** + * Utilities to deal with the database. + */ + private MyTracksProviderUtils providerUtils; + + /** + * The connection to the track recording service. + */ + private final ServiceConnection serviceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName className, IBinder service) { + Log.d(MyTracksConstants.TAG, "MyTracks: Service now connected."); + trackRecordingService = ITrackRecordingService.Stub.asInterface(service); + if (startNewTrackRequested) { + startNewTrackRequested = false; + try { + recordingTrackId = trackRecordingService.startNewTrack(); + Toast.makeText(MyTracks.this, + R.string.status_now_recording, Toast.LENGTH_SHORT).show(); + setSelectedAndRecordingTrack(recordingTrackId, recordingTrackId); + } catch (RemoteException e) { + Toast.makeText(MyTracks.this, + R.string.error_unable_to_start_recording, Toast.LENGTH_SHORT) + .show(); + Log.w(MyTracksConstants.TAG, "Unable to start recording.", e); + } + } + } + + @Override + public void onServiceDisconnected(ComponentName className) { + Log.d(MyTracksConstants.TAG, "MyTracks: Service now disconnected."); + trackRecordingService = null; + } + }; + + /* + * Tabs/View navigation: + */ + + private static final int NUM_TABS = 3; + private int currentTab = 0; + + private NavControls navControls; + + private final int icons[] = + { R.drawable.arrow_grey, R.drawable.menu_by_time, + R.drawable.menu_elevation }; + + private final Runnable nextActivity = new Runnable() { + public void run() { + currentTab = (currentTab + 1) % NUM_TABS; + navControls.setLeftIcon(icons[(currentTab + NUM_TABS - 1) % NUM_TABS]); + navControls.setRightIcon(icons[(currentTab + NUM_TABS + 1) % NUM_TABS]); + getTabHost().setCurrentTab(currentTab); + navControls.show(); + } + }; + + private final Runnable prevActivity = new Runnable() { + public void run() { + currentTab--; + if (currentTab < 0) { + currentTab = NUM_TABS - 1; + } + navControls.setLeftIcon(icons[(currentTab + NUM_TABS - 1) % NUM_TABS]); + navControls.setRightIcon(icons[(currentTab + NUM_TABS + 1) % NUM_TABS]); + getTabHost().setCurrentTab(currentTab); + navControls.show(); + } + }; + + private final Random random = new Random(); + + public static MyTracks getInstance() { + return instance; + } + + /** + * @return true if the activity is bound to the track recording service and + * the service is recording a track. + */ + public boolean isRecording() { + if (trackRecordingService == null) { + return false; + } + try { + return trackRecordingService.isRecording(); + } catch (RemoteException e) { + Log.e(MyTracksConstants.TAG, "MyTracks: Remote exception.", e); + return false; + } + } + + /* + * Application lifetime events: + * ============================ + */ + + @Override + protected void onCreate(Bundle savedInstanceState) { + Log.d(MyTracksConstants.TAG, "MyTracks.onCreate"); + super.onCreate(savedInstanceState); + instance = this; + providerUtils = MyTracksProviderUtils.Factory.get(this); + + // The volume we want to control is the Text-To-Speech volume + setVolumeControlStream(TextToSpeech.Engine.DEFAULT_STREAM); + + // We don't need a window title bar: + requestWindowFeature(Window.FEATURE_NO_TITLE); + + final Resources res = getResources(); + final TabHost tabHost = getTabHost(); + tabHost.addTab(tabHost.newTabSpec("tab1") + .setIndicator("Map", res.getDrawable( + android.R.drawable.ic_menu_mapmode)) + .setContent(new Intent(this, MyTracksMap.class))); + tabHost.addTab(tabHost.newTabSpec("tab2") + .setIndicator("Stats", res.getDrawable(R.drawable.menu_stats)) + .setContent(new Intent(this, StatsActivity.class))); + tabHost.addTab(tabHost.newTabSpec("tab3") + .setIndicator("Chart", res.getDrawable(R.drawable.menu_elevation)) + .setContent(new Intent(this, ChartActivity.class))); + + // Hide the tab widget itself. We'll use overlayed prev/next buttons to + // switch between the tabs: + tabHost.getTabWidget().setVisibility(View.GONE); + + RelativeLayout layout = new RelativeLayout(this); + LayoutParams params = + new LayoutParams(LayoutParams.FILL_PARENT, LayoutParams.FILL_PARENT); + layout.setLayoutParams(params); + navControls = new NavControls(this, layout, prevActivity, nextActivity); + navControls.setLeftIcon(icons[NUM_TABS - 1]); + navControls.setRightIcon(icons[1]); + navControls.show(); + tabHost.addView(layout); + layout.setOnTouchListener(this); + + SharedPreferences prefs = + getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + if (prefs != null) { + selectedTrackId = prefs.getLong(MyTracksSettings.SELECTED_TRACK, -1); + recordingTrackId = prefs.getLong(MyTracksSettings.RECORDING_TRACK, -1); + prefs.registerOnSharedPreferenceChangeListener(this); + } + + // This will show the eula until the user accepts or quits the app. + Eula.showEula(this); + + // Check if new version is available and prompt user with update options: + new VersionChecker(this); + + // Check if we got invoked via the VIEW intent: + Intent intent = getIntent(); + if (intent != null && intent.getAction() != null) { + if (intent.getAction().equals(Intent.ACTION_VIEW)) { + if (intent.getScheme() != null && intent.getScheme().equals("file")) { + Log.w(MyTracksConstants.TAG, + "Received a VIEW intent with file scheme. Importing."); + importGpxFile(intent.getData().getPath()); + } else { + Log.w(MyTracksConstants.TAG, + "Received a VIEW intent with unsupported scheme " + + intent.getScheme()); + } + } else { + Log.w(MyTracksConstants.TAG, + "Received an intent with unsupported action " + intent.getAction()); + } + } else { + Log.d(MyTracksConstants.TAG, "Received an intent with no action."); + } + } + + @Override + protected void onPause() { + // Called when activity is going into the background, but has not (yet) been + // killed. Shouldn't block longer than approx. 2 seconds. + Log.d(MyTracksConstants.TAG, "MyTracks.onPause"); + super.onPause(); + tryUnbindTrackRecordingService(); + } + + @Override + protected void onResume() { + // Called when the current activity is being displayed or re-displayed + // to the user. + Log.d(MyTracksConstants.TAG, "MyTracks.onResume"); + super.onResume(); + tryBindTrackRecordingService(); + navControls.setLeftIcon(icons[(currentTab + NUM_TABS - 1) % NUM_TABS]); + navControls.setRightIcon(icons[(currentTab + NUM_TABS + 1) % NUM_TABS]); + } + + @Override + protected void onStop() { + super.onStop(); + // Clean up any temporary GPX and KML files. + cleanTmpDirectory("gpx"); + cleanTmpDirectory("kml"); + } + + private void cleanTmpDirectory(String name) { + if (!Environment.getExternalStorageState().equals( + Environment.MEDIA_MOUNTED)) { + return; // Can't do anything now. + } + String sep = System.getProperty("file.separator"); + File dir = new File( + Environment.getExternalStorageDirectory() + sep + name + sep + "tmp"); + if (!dir.exists()) { + return; + } + File[] list = dir.listFiles(); + long now = System.currentTimeMillis(); + long oldest = now - 1000 * 3600; + for (File f : list) { + if (f.lastModified() < oldest) { + f.delete(); + } + } + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + } + + /* + * Menu events: + * ============ + */ + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + startRecording = menu.add(0, MyTracksConstants.MENU_START_RECORDING, 0, + R.string.start_recording); + startRecording.setIcon(R.drawable.menu_record); + stopRecording = menu.add(0, MyTracksConstants.MENU_STOP_RECORDING, 0, + R.string.stop_recording); + stopRecording.setIcon(R.drawable.menu_stop); + listTracks = menu.add(0, MyTracksConstants.MENU_LIST_TRACKS, 0, + R.string.list_tracks); + listTracks.setIcon(R.drawable.menu_list_tracks); + listMarkers = menu.add(0, MyTracksConstants.MENU_LIST_MARKERS, 0, + R.string.list_markers); + listMarkers.setIcon(R.drawable.menu_marker); + settings = menu.add(0, MyTracksConstants.MENU_SETTINGS, 10001, + R.string.settings); + settings.setIcon(android.R.drawable.ic_menu_preferences); + help = menu.add(0, MyTracksConstants.MENU_HELP, 10002, R.string.help); + help.setIcon(android.R.drawable.ic_menu_info_details); + return true; + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + boolean hasRecorded = providerUtils.getLastTrack() != null; + boolean isRecording = isRecording(); + listTracks = menu.findItem(MyTracksConstants.MENU_LIST_TRACKS); + listTracks.setEnabled(hasRecorded); + listMarkers = menu.findItem(MyTracksConstants.MENU_LIST_MARKERS); + listMarkers.setEnabled(hasRecorded && selectedTrackId >= 0); + startRecording = menu.findItem(MyTracksConstants.MENU_START_RECORDING); + startRecording.setEnabled(!isRecording); + startRecording.setVisible(!isRecording); + stopRecording = menu.findItem(MyTracksConstants.MENU_STOP_RECORDING); + stopRecording.setEnabled(isRecording); + stopRecording.setVisible(isRecording); + return super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case MyTracksConstants.MENU_START_RECORDING: { + startRecording(); + return true; + } + case MyTracksConstants.MENU_STOP_RECORDING: { + stopRecording(); + return true; + } + case MyTracksConstants.MENU_LIST_TRACKS: { + Intent startIntent = new Intent(this, MyTracksList.class); + startActivityForResult(startIntent, MyTracksConstants.SHOW_TRACK); + return true; + } + case MyTracksConstants.MENU_LIST_MARKERS: { + Intent startIntent = new Intent(this, MyTracksWaypointsList.class); + startIntent.putExtra("trackid", selectedTrackId); + startActivityForResult(startIntent, MyTracksConstants.SHOW_WAYPOINT); + return true; + } + case MyTracksConstants.MENU_SETTINGS: { + Intent startIntent = new Intent(this, MyTracksSettings.class); + startActivity(startIntent); + return true; + } + case MyTracksConstants.MENU_HELP: { + Intent startIntent = new Intent(this, WelcomeActivity.class); + startActivity(startIntent); + return true; + } + case MyTracksConstants.MENU_CLEAR_MAP: { + setSelectedTrack(-1); + return true; + } + } + return super.onOptionsItemSelected(item); + } + + /* + * Dialog events: + * ============== + */ + + @Override + protected Dialog onCreateDialog(int id) { + switch (id) { + case DIALOG_PROGRESS: + progressDialog = new ProgressDialog(this); + progressDialog.setIcon(android.R.drawable.ic_dialog_info); + progressDialog.setTitle(getString(R.string.progress_title)); + progressDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL); + progressDialog.setMessage(""); + progressDialog.setMax(100); + progressDialog.setProgress(10); + return progressDialog; + case DIALOG_IMPORT_PROGRESS: + importProgressDialog = new ProgressDialog(this); + importProgressDialog.setIcon(android.R.drawable.ic_dialog_info); + importProgressDialog.setTitle(getString(R.string.progress_title)); + importProgressDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER); + importProgressDialog.setMessage( + getString(R.string.import_progress_message)); + return importProgressDialog; + case DIALOG_WRITE_PROGRESS: + writeProgressDialog = new ProgressDialog(this); + writeProgressDialog.setIcon(android.R.drawable.ic_dialog_info); + writeProgressDialog.setTitle(getString(R.string.progress_title)); + writeProgressDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER); + writeProgressDialog.setMessage( + getString(R.string.write_progress_message)); + return writeProgressDialog; + case DIALOG_SEND_TO_GOOGLE: + sendToGoogleDialog = new SendToGoogleDialog(this); + return sendToGoogleDialog; + case DIALOG_SEND_TO_GOOGLE_RESULT: + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setIcon(android.R.drawable.ic_dialog_info); + builder.setTitle("Title"); + builder.setMessage("Message"); + builder.setPositiveButton(getString(R.string.ok), null); + builder.setNeutralButton(getString(R.string.share_map), + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + shareLinkToMyMap(sendToMyMapsMapId); + dialog.dismiss(); + } + }); + sendToGoogleResultDialog = builder.create(); + return sendToGoogleResultDialog; + case DIALOG_CHART_SETTINGS: + chartSettingsDialog = new ChartSettingsDialog(this); + return chartSettingsDialog; + } + return null; + } + + @Override + protected void onPrepareDialog(int id, Dialog dialog) { + super.onPrepareDialog(id, dialog); + Log.d(MyTracksConstants.TAG, "MyTracks.onPrepareDialog: " + id); + switch (id) { + case DIALOG_SEND_TO_GOOGLE: + resetSendToGoogleStatus(); + break; + case DIALOG_SEND_TO_GOOGLE_RESULT: + boolean success = sendToMyMapsSuccess && sendToDocsSuccess; + sendToGoogleResultDialog.setTitle( + success ? R.string.success : R.string.error); + sendToGoogleResultDialog.setIcon(success + ? android.R.drawable.ic_dialog_info + : android.R.drawable.ic_dialog_alert); + sendToGoogleResultDialog.setMessage(getMapsResultMessage()); + + boolean canShare = sendToMyMapsMapId != null; + View share = + sendToGoogleResultDialog.findViewById(android.R.id.button3); + if (share != null) { + share.setVisibility(canShare ? View.VISIBLE : View.GONE); + } + break; + case DIALOG_CHART_SETTINGS: + Log.d(MyTracksConstants.TAG, "MyTracks.onPrepare chart dialog"); + chartSettingsDialog.setup(chartActivity); + break; + } + } + + /* + * Key events: + * =========== + */ + + @Override + public boolean onTrackballEvent(MotionEvent event) { + if (isRecording()) { + if (event.getAction() == MotionEvent.ACTION_DOWN) { + insertStatisticsMarker(); + return true; + } + } + return super.onTrackballEvent(event); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, + final Intent results) { + TrackFileFormat exportFormat = null; + switch (requestCode) { + case MyTracksConstants.GET_MAP: { + if (resultCode == RESULT_OK) { + results.putExtra("trackid", selectedTrackId); + if (results.hasExtra("mapid")) { + sendToMyMapsMapId = results.getStringExtra("mapid"); + } + setProgressMessage(getString( + R.string.progress_message_authenticating_mymaps)); + authenticate(results, MyTracksConstants.SEND_TO_GOOGLE, + MyMapsConstants.MAPSHOP_SERVICE); + } else { + dismissDialogSafely(DIALOG_PROGRESS); + } + break; + } + case MyTracksConstants.GET_LOGIN: { + if (resultCode == RESULT_OK && auth != null) { + if (!auth.authResult(resultCode, results)) { + dismissDialogSafely(DIALOG_PROGRESS); + } + } else { + dismissDialogSafely(DIALOG_PROGRESS); + } + break; + } + case MyTracksConstants.SHOW_TRACK: { + if (results != null) { + final long trackId = results.getLongExtra("trackid", -1); + if (trackId >= 0) { + setSelectedTrack(trackId); + // The track list passed the requested action as result code. Hand + // it off to the onAcitivtyResult for further processing: + if (resultCode != MyTracksConstants.SHOW_TRACK) { + onActivityResult(resultCode, Activity.RESULT_OK, results); + } + } + } + break; + } + case MyTracksConstants.SHOW_WAYPOINT: { + if (results != null) { + final long waypointId = results.getLongExtra("waypointid", -1); + if (waypointId >= 0) { + MyTracksMap map = + (MyTracksMap) getLocalActivityManager().getActivity("tab1"); + if (map != null) { + getTabHost().setCurrentTab(0); + map.showWaypoint(waypointId); + } + } + } + break; + } + case MyTracksConstants.DELETE_TRACK: { + if (results != null && resultCode == RESULT_OK) { + final long trackId = results.getLongExtra("trackid", selectedTrackId); + deleteTrack(trackId); + } + break; + } + case MyTracksConstants.EDIT_DETAILS: { + if (results != null && resultCode == RESULT_OK) { + final long trackId = results.getLongExtra("trackid", selectedTrackId); + Intent intent = new Intent(this, MyTracksDetails.class); + intent.putExtra("trackid", trackId); + startActivity(intent); + } + break; + } + case MyTracksConstants.AUTHENTICATE_TO_DOCS: { + if (resultCode == RESULT_OK) { + setProgressValue(0); + setProgressMessage( + getString(R.string.progress_message_authenticating_docs)); + authenticate(results, + MyTracksConstants.AUTHENTICATE_TO_TRIX, "writely"); + } else { + dismissDialogSafely(DIALOG_PROGRESS); + } + break; + } + case MyTracksConstants.AUTHENTICATE_TO_TRIX: { + if (resultCode == RESULT_OK) { + setProgressValue(30); + setProgressMessage( + getString(R.string.progress_message_authenticating_docs)); + authenticate(results, MyTracksConstants.SEND_TO_DOCS, "wise"); + } else { + dismissDialogSafely(DIALOG_PROGRESS); + } + break; + } + case MyTracksConstants.SEND_TO_DOCS: { + if (results != null && resultCode == RESULT_OK) { + Log.d(MyTracksConstants.TAG, "Sending to Docs...."); + setProgressValue(50); + setProgressMessage(getString(R.string.progress_message_sending_docs)); + final long trackId = results.getLongExtra("trackid", selectedTrackId); + final SendToDocs sender = new SendToDocs(this, authMap.get("wise"), + authMap.get("writely"), trackId); + Runnable onCompletion = new Runnable() { + public void run() { + setProgressValue(100); + dismissDialogSafely(DIALOG_PROGRESS); + runOnUiThread(new Runnable() { + public void run() { + sendToDocsMessage = sender.getStatusMessage(); + sendToDocsSuccess = sender.wasSuccess(); + handleMapsFinish(); + } + }); + } + }; + sender.setOnCompletion(onCompletion); + sender.run(); + } else { + dismissDialogSafely(DIALOG_PROGRESS); + } + break; + } + case MyTracksConstants.SEND_TO_GOOGLE_DIALOG: { + shareRequested = false; + showDialogSafely(DIALOG_SEND_TO_GOOGLE); + break; + } + case MyTracksConstants.SEND_TO_GOOGLE: { + if (results != null && resultCode == RESULT_OK) { + final String mapid; + final long trackId; + if (results.hasExtra("mapid")) { + mapid = results.getStringExtra("mapid"); + } else { + mapid = "new"; + } + if (results.hasExtra("trackid")) { + trackId = results.getLongExtra("trackid", -1); + } else { + trackId = selectedTrackId; + } + final SendToMyMaps sender = new SendToMyMaps(this, mapid, auth, + trackId, this/*progressIndicator*/); + Runnable onCompletion = new Runnable() { + public void run() { + sendToMyMapsMessage = sender.getStatusMessage(); + sendToMyMapsSuccess = sender.wasSuccess(); + if (sendToMyMapsSuccess) { + sendToMyMapsMapId = sender.getMapId(); + // Update the map id for this track: + try { + Track track = providerUtils.getTrack(trackId); + track.setMapId(sender.getMapId()); + providerUtils.updateTrack(track); + } catch (RuntimeException e) { + // If that fails whatever reasons we'll just log an error, but + // continue. + Log.w(MyTracksConstants.TAG, "Updating map id failed.", e); + } + } + if (sendToGoogleDialog.getSendToDocs()) { + onActivityResult(MyTracksConstants.AUTHENTICATE_TO_DOCS, + RESULT_OK, new Intent()); + } else { + dismissDialogSafely(DIALOG_PROGRESS); + runOnUiThread(new Runnable() { + public void run() { + handleMapsFinish(); + } + }); + } + } + }; + sender.setOnCompletion(onCompletion); + sender.run(); + } else { + dismissDialogSafely(DIALOG_PROGRESS); + } + break; + } + case MyTracksConstants.SAVE_GPX_FILE: + if (exportFormat == null) { exportFormat = TrackFileFormat.GPX; } + //$FALL-THROUGH$ + case MyTracksConstants.SAVE_KML_FILE: + if (exportFormat == null) { exportFormat = TrackFileFormat.KML; } + //$FALL-THROUGH$ + case MyTracksConstants.SAVE_CSV_FILE: { + if (exportFormat == null) { exportFormat = TrackFileFormat.CSV; } + + if (results != null && resultCode == Activity.RESULT_OK) { + final long trackId = results.getLongExtra("trackid", selectedTrackId); + if (trackId >= 0) { + saveTrack(trackId, exportFormat); + } + } + break; + } + case MyTracksConstants.SHARE_LINK: { + Track selectedTrack = providerUtils.getTrack(selectedTrackId); + if (selectedTrack != null) { + if (selectedTrack.getMapId().length() > 0) { + shareLinkToMyMap(selectedTrack.getMapId()); + } else { + shareRequested = true; + showDialogSafely(DIALOG_SEND_TO_GOOGLE); + } + } + break; + } + case MyTracksConstants.SHARE_GPX_FILE: + case MyTracksConstants.SHARE_KML_FILE: { + if (results != null && resultCode == RESULT_OK) { + long trackId = results.getLongExtra("trackid", selectedTrackId); + if (trackId >= 0) { + TrackFileFormat format = + (requestCode == MyTracksConstants.SHARE_GPX_FILE) + ? TrackFileFormat.GPX + : TrackFileFormat.KML; + sendTrack(trackId, format); + } + } + break; + } + case MyTracksConstants.CLEAR_MAP: { + setSelectedTrack(-1); + break; + } + case MyTracksConstants.WELCOME: { + CheckUnits.check(this); + break; + } + + default: { + Log.w(MyTracksConstants.TAG, + "Warning unhandled request code: " + requestCode); + } + } + } + + @Override + public boolean onTouch(View v, MotionEvent event) { + if (event.getAction() == MotionEvent.ACTION_DOWN) { + navControls.show(); + } + return false; + } + + @Override + public void onSharedPreferenceChanged( + SharedPreferences sharedPreferences, String key) { + // The service itself cannot listen to changes (not supported by Android for + // services that run in a separate process). So we'll notify it manually: + if (key != null && trackRecordingService != null) { + try { + trackRecordingService.sharedPreferenceChanged(key); + } catch (RemoteException e) { + Log.w(MyTracksConstants.TAG, + "MyTracks: Cannot notify track recording service of changes " + + "to shared preferences: ", e); + } + } + if (key != null && key.equals(MyTracksSettings.SELECTED_TRACK)) { + selectedTrackId = + sharedPreferences.getLong(MyTracksSettings.SELECTED_TRACK, -1); + } + } + + /** + * Simulates the recording of a random location. + * This is for debugging and testing only. Useful if there is no GPS signal + * available. + */ + public void recordRandomLocation() { + if (trackRecordingService != null) { + Location loc = new Location("gps"); + double latitude = 37.5 + random.nextDouble() / 1000; + double longitude = -120.0 + random.nextDouble() / 1000; + loc.setLatitude(latitude); + loc.setLongitude(longitude); + loc.setAltitude(random.nextDouble() * 100); + loc.setTime(System.currentTimeMillis()); + loc.setSpeed(random.nextFloat()); + MyTracksMap map = + (MyTracksMap) getLocalActivityManager().getActivity("tab1"); + if (map != null) { + map.onLocationChanged(loc); + } + StatsActivity stats = + (StatsActivity) getLocalActivityManager().getActivity("tab2"); + if (stats != null) { + stats.onLocationChanged(loc); + } + try { + trackRecordingService.recordLocation(loc); + } catch (RemoteException e) { + Log.e(MyTracksConstants.TAG, "MyTracks", e); + } + } + } + + /** + * Resets status information for sending to MyMaps/Docs. + */ + private void resetSendToGoogleStatus() { + sendToMyMapsMessage = ""; + sendToMyMapsSuccess = true; + sendToDocsMessage = ""; + sendToDocsSuccess = true; + sendToMyMapsMapId = null; + } + + /** + * Reads data from a provider, for example gmail attachment preview. Currently + * fails (due to permissions?!). This is currently dead code not invoked from + * anywhere. + */ + protected String getContent(Uri uri) { + Cursor cursor = null; + try { + cursor = managedQuery(uri, + null /*projection*/, null /*selection*/, null /*selectionArgs*/, + null /*sortOrder*/); + if (cursor == null) { + Toast.makeText(this, + R.string.error_unable_to_read_file, Toast.LENGTH_LONG).show(); + return null; + } + if (cursor.moveToFirst()) { + String cols[] = cursor.getColumnNames(); + String s = "Cols:"; + for (String c : cols) { s += c + ","; } + Toast.makeText(this, s, Toast.LENGTH_LONG).show(); + return ""; + } else { + Toast.makeText(this, R.string.error_generic, Toast.LENGTH_LONG).show(); + return null; + } + } finally { + if (cursor != null) { + stopManagingCursor(cursor); + cursor.close(); + } + } + } + + private void importGpxFile(final String fileName) { + showDialogSafely(DIALOG_IMPORT_PROGRESS); + Thread t = new Thread() { + @Override + public void run() { + boolean success = false; + ArrayList tracks = new ArrayList(); + int message = R.string.success; + try { + try { + GpxImport.importGPXFile(fileName, tracks); + success = true; + } catch (SAXException e) { + Log.e(MyTracksConstants.TAG, "Caught an unexpected exception.", e); + message = R.string.error_generic; + } catch (ParserConfigurationException e) { + Log.e(MyTracksConstants.TAG, "Caught an unexpected exception.", e); + message = R.string.error_generic; + } catch (IOException e) { + Log.e(MyTracksConstants.TAG, "Caught an unexpected exception.", e); + message = R.string.error_unable_to_read_file; + } catch (NullPointerException e) { + Log.e(MyTracksConstants.TAG, "Caught an unexpected exception.", e); + message = R.string.error_invalid_gpx_format; + } catch (OutOfMemoryError e) { + Log.e(MyTracksConstants.TAG, "Caught an unexpected exception.", e); + message = R.string.error_out_of_memory; + } + if (success) { + long trackId = -1; + for (Track track : tracks) { + Uri uri = providerUtils.insertTrackAndTrackPoints(track); + trackId = Long.parseLong(uri.getLastPathSegment()); + } + setSelectedTrack(trackId); + } else { + MyTracks.this.showMessageDialog(message, false/*success*/); + } + } finally { + runOnUiThread(new Runnable() { + public void run() { + dismissDialog(DIALOG_IMPORT_PROGRESS); + } + }); + } + } + }; + t.start(); + } + + /** + * Just like showDialog, but will catch a BadTokenException that sometimes + * (very rarely) gets thrown. This might happen if the user hits the "back" + * button immediately after sending tracks to google. + * + * @param id the dialog id + */ + public void showDialogSafely(final int id) { + runOnUiThread(new Runnable() { + public void run() { + try { + showDialog(id); + } catch (BadTokenException e) { + Log.w(MyTracksConstants.TAG, + "Could not display dialog with id " + id, e); + } catch (IllegalStateException e) { + Log.w(MyTracksConstants.TAG, + "Could not display dialog with id " + id, e); + } + } + }); + } + + /** + * Dismisses the progress dialog if it is showing. Executed on the UI thread. + */ + public void dismissDialogSafely(final int id) { + runOnUiThread(new Runnable() { + public void run() { + try { + dismissDialog(id); + } catch (IllegalArgumentException e) { + // This will be thrown if this dialog was not shown before. + } + } + }); + } + + public void setProgressMessage(final String text) { + runOnUiThread(new Runnable() { + public void run() { + synchronized (this) { + if (progressDialog != null) { + progressDialog.setMessage(text); + } + } + } + }); + } + + public void setProgressValue(final int percent) { + runOnUiThread(new Runnable() { + public void run() { + synchronized (this) { + if (progressDialog != null) { + progressDialog.setProgress(percent); + } + } + } + }); + } + + /** + * Shares a link to a My Map via external app (email, gmail, ...) + * A chooser with apps that support text/plain will be shown to the user. + */ + private void shareLinkToMyMap(String mapId) { + Intent shareIntent = new Intent(Intent.ACTION_SEND); + shareIntent.setType("text/plain"); + shareIntent.putExtra(Intent.EXTRA_SUBJECT, + getResources().getText(R.string.share_map_subject).toString()); + + SharedPreferences prefs = + getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + boolean shareUrlOnly = true; + if (prefs != null) { + shareUrlOnly = prefs.getBoolean(MyTracksSettings.SHARE_URL_ONLY, false); + } + + String url = MyMapsConstants.MAPSHOP_BASE_URL + "?msa=0&msid=" + mapId; + String msg = (shareUrlOnly + ? url + : String.format( + getResources().getText(R.string.share_map_body_format).toString(), + url)); + shareIntent.putExtra(Intent.EXTRA_TEXT, msg); + startActivity(Intent.createChooser(shareIntent, + getResources().getText(R.string.share_map).toString())); + } + + /** + * Deletes the track with the given id. + * Prompts the user if he want to really delete the track first. + * If the selected track is deleted, the selection will be removed. + */ + public void deleteTrack(final long trackId) { + AlertDialog dialog = null; + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setMessage(getString(R.string.track_will_be_permanently_deleted)); + builder.setTitle(getString(R.string.are_you_sure_question)); + builder.setIcon(android.R.drawable.ic_dialog_alert); + builder.setPositiveButton(getString(R.string.yes), + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int i) { + dialog.dismiss(); + providerUtils.deleteTrack(trackId); + if (trackId == selectedTrackId) { + setSelectedTrack(-1); + } + }}); + builder.setNegativeButton(getString(R.string.no), + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int i) { + dialog.dismiss(); + } + }); + dialog = builder.create(); + dialog.show(); + } + + public Location getCurrentLocation() { + // TODO: Let's look at more advanced algorithms to determine the best + // current location. + LocationManager locationManager = + (LocationManager) getSystemService(Context.LOCATION_SERVICE); + if (locationManager == null) { + return null; + } + final long maxAgeMilliSeconds = 1000 * 60 * 1; // 1 minute + final long maxAgeNetworkMilliSeconds = 1000 * 60 * 10; // 10 minutes + final long now = System.currentTimeMillis(); + Location loc = locationManager.getLastKnownLocation( + MyTracksConstants.GPS_PROVIDER); + if (loc == null || loc.getTime() < now - maxAgeMilliSeconds) { + // We don't have a recent GPS fix, just use cell towers if available + loc = locationManager.getLastKnownLocation( + LocationManager.NETWORK_PROVIDER); + if (loc == null || loc.getTime() < now - maxAgeNetworkMilliSeconds) { + // We don't have a recent cell tower location, let the user know: + Toast.makeText(this, getString(R.string.status_no_location), + Toast.LENGTH_LONG).show(); + return null; + } else { + // Let the user know we have only an approximate location: + Toast.makeText(this, getString(R.string.status_approximate_location), + Toast.LENGTH_LONG).show(); + } + } + return loc; + } + + public Location getLastLocation() { + if (providerUtils.getLastLocationId(recordingTrackId) < 0) { + return null; + } + LocationManager locationManager = + (LocationManager) getSystemService(Context.LOCATION_SERVICE); + return locationManager.getLastKnownLocation(MyTracksConstants.GPS_PROVIDER); + } + + /** + * Inserts a waypoint marker. + * + * @return the id of the inserted statistics marker, or + * -1 unable to find location + * -2 track recording service is not running? + * -3 remote exception when contacting track recording service + * -4 inserting marker into provider failed + */ + public long insertWaypointMarker() { + Location location = getCurrentLocation(); + if (location == null) { + Toast.makeText(this, R.string.error_unable_to_insert_marker, + Toast.LENGTH_LONG).show(); + return -1; + } + if (trackRecordingService != null) { + try { + Waypoint wpt = new Waypoint(); + wpt.setName(getString(R.string.waypoint)); + wpt.setType(Waypoint.TYPE_WAYPOINT); + wpt.setTrackId(recordingTrackId); + wpt.setIcon(WAYPOINT_ICON_URL); + wpt.setLocation(location); + long waypointId = trackRecordingService.insertWaypointMarker(wpt); + if (waypointId >= 0) { + Toast.makeText(this, R.string.status_waypoint_inserted, + Toast.LENGTH_LONG).show(); + return waypointId; + } else { + Toast.makeText(this, R.string.error_unable_to_insert_marker, + Toast.LENGTH_LONG).show(); + Log.e(MyTracksConstants.TAG, "Cannot insert waypoint marker?"); + return -4; + } + } catch (RemoteException e) { + Toast.makeText(this, R.string.error_unable_to_insert_marker, + Toast.LENGTH_LONG).show(); + Log.e(MyTracksConstants.TAG, "Cannot insert waypoint marker.", e); + } + return -3; + } + return -2; + } + + /** + * Inserts a statistics marker. + * + * @return the id of the inserted statistics marker, or + * -1 unable to find location + * -2 track recording service is not running? + * -3 remote exception when contacting track recording service + * -4 inserting marker into provider failed + */ + public long insertStatisticsMarker() { + Location location = getLastLocation(); + if (location == null) { + Toast.makeText(this, R.string.error_unable_to_insert_marker, + Toast.LENGTH_LONG).show(); + return -1; + } + if (trackRecordingService != null) { + try { + long waypointId = + trackRecordingService.insertStatisticsMarker(location); + if (waypointId >= 0) { + Toast.makeText(this, R.string.status_statistics_inserted, + Toast.LENGTH_LONG).show(); + return waypointId; + } else { + Toast.makeText(this, R.string.error_unable_to_insert_marker, + Toast.LENGTH_LONG).show(); + Log.e(MyTracksConstants.TAG, "Cannot insert statistics marker?"); + return -4; + } + } catch (RemoteException e) { + Toast.makeText(this, R.string.error_unable_to_insert_marker, + Toast.LENGTH_LONG).show(); + Log.e(MyTracksConstants.TAG, "Cannot insert statistics marker?", e); + } + return -3; + } + return -2; + } + + /** + * Initializes the authentication manager which obtains an authentication + * token, prompting the user for a login and password if needed. + */ + private void authenticate(final Intent results, final int requestCode, + final String service) { + auth = authMap.get(service); + if (auth == null) { + Log.i(MyTracksConstants.TAG, + "Creating a new authentication for service: " + service); + auth = AuthManagerFactory.getAuthManager(this, + MyTracksConstants.GET_LOGIN, + null, + true, + service); + authMap.put(service, auth); + } + Log.d(MyTracksConstants.TAG, "Loggin in to " + service + "..."); + if (AuthManagerFactory.useModernAuthManager()) { + runOnUiThread(new Runnable() { + @Override + public void run() { + accountChooser.chooseAccount(MyTracks.this, + new AccountChooser.AccountHandler() { + @Override + public void handleAccountSelected(Account account) { + if (account == null) { + dismissDialogSafely(DIALOG_PROGRESS); + return; + } + doLogin(results, requestCode, service, account); + } + }); + } + }); + } else { + doLogin(results, requestCode, service, null); + } + } + + private void doLogin(final Intent results, final int requestCode, + final String service, final Account account) { + auth.doLogin(new Runnable() { + public void run() { + Log.d(MyTracksConstants.TAG, "Loggin success for " + service + "!"); + onActivityResult(requestCode, RESULT_OK, results); + } + }, account); + } + + /** + * Starts the track recording service (if not already running) and binds to + * it. Starts recording a new track. + */ + private void startRecording() { + if (trackRecordingService == null) { + startNewTrackRequested = true; + Intent startIntent = new Intent(this, TrackRecordingService.class); + startService(startIntent); + tryBindTrackRecordingService(); + } else { + try { + recordingTrackId = trackRecordingService.startNewTrack(); + Toast.makeText(this, getString(R.string.status_now_recording), + Toast.LENGTH_SHORT).show(); + setSelectedAndRecordingTrack(recordingTrackId, recordingTrackId); + } catch (RemoteException e) { + Toast.makeText(this, + getString(R.string.error_unable_to_start_recording), + Toast.LENGTH_SHORT).show(); + Log.e(MyTracksConstants.TAG, + "Failed to start track recording service", e); + } + } + } + + /** + * Stops the track recording service and unbinds from it. Will display a toast + * "Stopped recording" and pop up the Track Details activity. + */ + private void stopRecording() { + if (trackRecordingService != null) { + try { + trackRecordingService.endCurrentTrack(); + } catch (RemoteException e) { + Log.e(MyTracksConstants.TAG, "Unable to stop recording.", e); + } + setRecordingTrack(-1); + Intent intent = new Intent(MyTracks.this, MyTracksDetails.class); + intent.putExtra("trackid", recordingTrackId); + intent.putExtra("hasCancelButton", false); + recordingTrackId = -1; + startActivity(intent); + } + tryUnbindTrackRecordingService(); + try { + stopService(new Intent(MyTracks.this, TrackRecordingService.class)); + } catch (SecurityException e) { + Log.e(MyTracksConstants.TAG, + "Encountered a security exception when trying to stop service.", e); + } + trackRecordingService = null; + } + + /** + * Initiates the process to send tracks to google. + * This is called once the user has selected sending options via the + * SendToGoogleDialog. + */ + public void sendToGoogle() { + if (sendToGoogleDialog == null) { + return; + } + setProgressValue(0); + setProgressMessage(""); + showDialogSafely(DIALOG_PROGRESS); + if (sendToGoogleDialog.getSendToMyMaps()) { + if (!sendToGoogleDialog.getCreateNewMap()) { + Intent listIntent = new Intent(this, MyMapsList.class); + startActivityForResult(listIntent, MyTracksConstants.GET_MAP); + } else { + setProgressValue(0); + setProgressMessage(getString( + R.string.progress_message_authenticating_mymaps)); + authenticate(new Intent(), MyTracksConstants.SEND_TO_GOOGLE, + MyMapsConstants.MAPSHOP_SERVICE); + } + } else { + onActivityResult(MyTracksConstants.AUTHENTICATE_TO_DOCS, RESULT_OK, + new Intent()); + } + } + + /** + * Writes the selected track id to the shared preferences. + * Executed on the UI thread. + * + * @param trackId the id of the track + */ + private void setSelectedTrack(final long trackId) { + runOnUiThread(new Runnable() { + public void run() { + SharedPreferences prefs = + getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + SharedPreferences.Editor editor = prefs.edit(); + editor.putLong(MyTracksSettings.SELECTED_TRACK, trackId); + editor.commit(); + } + }); + } + + /** + * Writes the recording track id to the shared preferences. + * Executed on the UI thread. + * + * @param trackId the id of the track + */ + private void setRecordingTrack(final long trackId) { + runOnUiThread(new Runnable() { + public void run() { + SharedPreferences prefs = + getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + SharedPreferences.Editor editor = prefs.edit(); + editor.putLong(MyTracksSettings.RECORDING_TRACK, trackId); + editor.commit(); + } + }); + } + + /** + * Writes the selected and the recording track id to the shared preferences. + * Executed on UI thread. + */ + private void setSelectedAndRecordingTrack(final long theSelectedTrackId, + final long theRecordingTrackId) { + runOnUiThread(new Runnable() { + public void run() { + SharedPreferences prefs = + getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + if (prefs != null) { + SharedPreferences.Editor editor = prefs.edit(); + editor.putLong(MyTracksSettings.SELECTED_TRACK, theSelectedTrackId); + editor.putLong(MyTracksSettings.RECORDING_TRACK, theRecordingTrackId); + editor.commit(); + } + } + }); + } + + /** + * Binds to track recording service if it is running. + */ + private void tryBindTrackRecordingService() { + Log.d(MyTracksConstants.TAG, + "MyTracks: Trying to bind to track recording service..."); + bindService(new Intent(this, TrackRecordingService.class), + serviceConnection, 0); + } + + /** + * Tries to unbind the track recording service. Catches exception silently in + * case service is not registered anymore. + */ + private void tryUnbindTrackRecordingService() { + Log.d(MyTracksConstants.TAG, + "MyTracks: Trying to unbind from track recording service..."); + try { + unbindService(serviceConnection); + } catch (IllegalArgumentException e) { + Log.d(MyTracksConstants.TAG, + "MyTracks: Tried unbinding, but service was not registered.", e); + } + } + + /** + * Shows a dialog with the given message. + * Does it on the UI thread. + * + * @param success if true, displays an info icon/title, otherwise an error + * icon/title + * @param message resource string id + */ + public void showMessageDialog(final int message, final boolean success) { + runOnUiThread(new Runnable() { + public void run() { + AlertDialog dialog = null; + AlertDialog.Builder builder = new AlertDialog.Builder(MyTracks.this); + builder.setMessage(MyTracks.this.getString(message)); + builder.setNegativeButton(MyTracks.this.getString(R.string.ok), null); + builder.setIcon(success ? android.R.drawable.ic_dialog_info : + android.R.drawable.ic_dialog_alert); + builder.setTitle(success ? R.string.success : R.string.error); + dialog = builder.create(); + dialog.show(); + } + }); + } + + /** + * Saves the track with the given id to the SD card. + * + * @param trackId The id of the track to be sent + */ + public void saveTrack(long trackId, TrackFileFormat format) { + showDialogSafely(DIALOG_WRITE_PROGRESS); + final TrackWriter writer = + TrackWriterFactory.newWriter(this, providerUtils, trackId, format); + writer.setOnCompletion(new Runnable() { + public void run() { + dismissDialogSafely(DIALOG_WRITE_PROGRESS); + showMessageDialog(writer.getErrorMessage(), writer.wasSuccess()); + } + }); + writer.writeTrackAsync(); + } + + /** + * Sends the requested track as an email attachment. + * This will leave the gpx file on the SD card for at least one hour. + * Temporary gpx files will be deleted in onStop. + * + * @param trackId The id of the track to be sent + */ + public void sendTrack(long trackId, final TrackFileFormat format) { + showDialogSafely(DIALOG_WRITE_PROGRESS); + final TrackWriter writer = + TrackWriterFactory.newWriter(this, providerUtils, trackId, format); + String sep = System.getProperty("file.separator"); + String extension = format.getExtension(); + File dir = new File(Environment.getExternalStorageDirectory() + + sep + extension + sep + "tmp"); + writer.setDirectory(dir); + writer.setOnCompletion(new Runnable() { + public void run() { + dismissDialogSafely(DIALOG_WRITE_PROGRESS); + if (!writer.wasSuccess()) { + showMessageDialog(writer.getErrorMessage(), writer.wasSuccess()); + } else { + Intent shareIntent = new Intent(Intent.ACTION_SEND); + shareIntent.putExtra(Intent.EXTRA_SUBJECT, + getResources().getText(R.string.send_track_subject).toString()); + shareIntent.putExtra(Intent.EXTRA_TEXT, + getResources().getText(R.string.send_track_body_format) + .toString()); + shareIntent.setType(format.getMimeType()); + Uri u = Uri.fromFile(new File(writer.getAbsolutePath())); + shareIntent.putExtra(Intent.EXTRA_STREAM, u); + startActivity(Intent.createChooser(shareIntent, + getResources().getText(R.string.share_track).toString())); + } + } + }); + writer.writeTrackAsync(); + } + + /** + * Notifies that uploading to maps is finished. + */ + private void handleMapsFinish() { + if (shareRequested && sendToMyMapsSuccess) { + // Just share + Toast.makeText(this, getMapsResultMessage(), Toast.LENGTH_LONG).show(); + shareLinkToMyMap(sendToMyMapsMapId); + } else { + showDialogSafely(DIALOG_SEND_TO_GOOGLE_RESULT); + } + } + + private String getMapsResultMessage() { + StringBuilder message = new StringBuilder(); + if (sendToGoogleDialog.getSendToMyMaps()) { + message.append(sendToMyMapsMessage); + } + if (sendToGoogleDialog.getSendToDocs()) { + if (message.length() > 0) { + message.append(' '); + } + message.append(sendToDocsMessage); + } + if (sendToMyMapsSuccess && sendToDocsSuccess) { + message.append(' '); + message.append(getString(R.string.status_mymap_info)); + } + return message.toString(); + } + + public AccountChooser getAccountChooser() { + return accountChooser; + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/MyTracksConstants.java b/MyTracks/src/com/google/android/apps/mytracks/MyTracksConstants.java index b0543e6f7..226bd2ddb 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/MyTracksConstants.java +++ b/MyTracks/src/com/google/android/apps/mytracks/MyTracksConstants.java @@ -1,12 +1,12 @@ /* * Copyright 2008 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 @@ -27,8 +27,173 @@ public abstract class MyTracksConstants { */ public static final String TAG = "MyTracks"; + /** + * Name of the gps location provider: + */ + public static final String GPS_PROVIDER = "gps"; + + /* + * onActivityResult request codes: + */ + + public static final int GET_LOGIN = 0; + public static final int GET_MAP = 1; + public static final int CREATE_MAP = 2; + public static final int SHOW_TRACK = 3; + public static final int ADD_LIST = 4; + public static final int FEATURE_DETAILS = 5; + public static final int START_RECORDING = 6; + public static final int STOP_RECORDING = 7; + public static final int AUTHENTICATE_TO_DOCS = 8; + public static final int AUTHENTICATE_TO_TRIX = 9; + public static final int SEND_TO_DOCS = 10; + public static final int DELETE_TRACK = 11; + public static final int SEND_TO_GOOGLE = 12; + public static final int SEND_TO_GOOGLE_DIALOG = 13; + public static final int SHARE_LINK = 14; + public static final int SHARE_GPX_FILE = 15; + public static final int SHARE_KML_FILE = 16; + public static final int SHARE_CSV_FILE = 17; + public static final int EDIT_DETAILS = 18; + public static final int SAVE_GPX_FILE = 19; + public static final int SAVE_KML_FILE = 20; + public static final int SAVE_CSV_FILE = 21; + public static final int CLEAR_MAP = 22; + public static final int SHOW_WAYPOINT = 23; + public static final int EDIT_WAYPOINT = 24; + public static final int WELCOME = 25; + + /* + * Menu ids: + */ + + public static final int MENU_START_RECORDING = 1; + public static final int MENU_STOP_RECORDING = 2; + public static final int MENU_LIST_TRACKS = 3; + public static final int MENU_LIST_MARKERS = 4; + public static final int MENU_SETTINGS = 5; + public static final int MENU_MY_LOCATION = 6; + public static final int MENU_TOGGLE_LAYERS = 7; + public static final int MENU_CHART_SETTINGS = 8; + public static final int MENU_HELP = 9; + public static final int MENU_CURRENT_SEGMENT = 10; + + /* + * Context menu ids: + */ + + public static final int MENU_EDIT = 100; + public static final int MENU_DELETE = 101; + public static final int MENU_SEND_TO_GOOGLE = 102; + public static final int MENU_SHARE = 103; + public static final int MENU_SHOW = 104; + public static final int MENU_SHARE_LINK = 200; + public static final int MENU_SHARE_GPX_FILE = 201; + public static final int MENU_SHARE_KML_FILE = 202; + public static final int MENU_SHARE_CSV_FILE = 203; + public static final int MENU_WRITE_TO_SD_CARD = 204; + public static final int MENU_SAVE_GPX_FILE = 205; + public static final int MENU_SAVE_KML_FILE = 206; + public static final int MENU_SAVE_CSV_FILE = 207; + public static final int MENU_CLEAR_MAP = 208; + + /** + * The number of distance readings to smooth to get a stable signal. + */ + public static final int DISTANCE_SMOOTHING_FACTOR = 25; + + /** + * The number of elevation readings to smooth to get a somewhat accurate + * signal. + */ + public static final int ELEVATION_SMOOTHING_FACTOR = 25; + + /** + * The number of grade readings to smooth to get a somewhat accurate signal. + */ + public static final int GRADE_SMOOTHING_FACTOR = 5; + + /** + * The number of speed reading to smooth to get a somewhat accurate signal. + */ + public static final int SPEED_SMOOTHING_FACTOR = 25; + + /** + * Maximum number of track points displayed by the map overlay. + */ + public static final int MAX_DISPLAYED_TRACK_POINTS = 10000; + + /** + * Maximum number of track points ever loaded at once from the provider into + * memory. + * With a recording frequency of 2 seconds, 15000 corresponds to 8.3 hours. + */ + public static final int MAX_LOADED_TRACK_POINTS = 20000; + + /** + * Maximum number of way points displayed by the map overlay. + */ + public static final int MAX_DISPLAYED_WAYPOINTS_POINTS = 128; + + /** + * Maximum number of way points that will be loaded at one time. + */ + public static final int MAX_LOADED_WAYPOINTS_POINTS = 10000; + + /** + * Any time segment where the distance traveled is less than this value will + * not be considered moving. + */ + public static final double MAX_NO_MOVEMENT_DISTANCE = 2; + + /** + * Anything faster than that (in meters per second) will be considered moving. + */ + public static final double MAX_NO_MOVEMENT_SPEED = 0.224; + + /** + * Ignore any acceleration faster than this. + * Will ignore any speeds that imply accelaration greater than 2g's + * 2g = 19.6 m/s^2 = 0.0002 m/ms^2 = 0.02 m/(m*ms) + */ + public static final double MAX_ACCELERATION = 0.02; + + /** + * The type of account that we can use for gdata uploads. + */ + public static final String ACCOUNT_TYPE = "com.google"; + + public static int getActionFromMenuId(int menuId) { + switch (menuId) { + case MyTracksConstants.MENU_SEND_TO_GOOGLE: + return MyTracksConstants.SEND_TO_GOOGLE_DIALOG; + case MyTracksConstants.MENU_EDIT: + return MyTracksConstants.EDIT_DETAILS; + case MyTracksConstants.MENU_DELETE: + return MyTracksConstants.DELETE_TRACK; + case MyTracksConstants.MENU_SHARE_LINK: + return MyTracksConstants.SHARE_LINK; + case MyTracksConstants.MENU_SHARE_KML_FILE: + return MyTracksConstants.SHARE_KML_FILE; + case MyTracksConstants.MENU_SHARE_GPX_FILE: + return MyTracksConstants.SHARE_GPX_FILE; + case MyTracksConstants.MENU_SHARE_CSV_FILE: + return MyTracksConstants.SHARE_CSV_FILE; + case MyTracksConstants.MENU_SAVE_GPX_FILE: + return MyTracksConstants.SAVE_GPX_FILE; + case MyTracksConstants.MENU_SAVE_KML_FILE: + return MyTracksConstants.SAVE_KML_FILE; + case MyTracksConstants.MENU_SAVE_CSV_FILE: + return MyTracksConstants.SAVE_CSV_FILE; + case MyTracksConstants.MENU_CLEAR_MAP: + return MyTracksConstants.CLEAR_MAP; + default: + return -1; + } + } + /** * This is an abstract utility class. */ - private MyTracksConstants() { } + protected MyTracksConstants() { } } diff --git a/MyTracks/src/com/google/android/apps/mytracks/MyTracksDeleteAllTracks.java b/MyTracks/src/com/google/android/apps/mytracks/MyTracksDeleteAllTracks.java new file mode 100755 index 000000000..4762c840e --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/MyTracksDeleteAllTracks.java @@ -0,0 +1,82 @@ +/* + * Copyright 2008 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; + +import com.google.android.apps.mytracks.content.MyTracksProviderUtils; +import com.google.android.maps.mytracks.R; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.Message; +import android.util.Log; + +/** + * A utility class that can be used to delete all tracks and track points + * from the provider, including asking for confirmation from the user via + * a dialog. + * + * @author Leif Hendrik Wilden + */ +public class MyTracksDeleteAllTracks extends Handler { + + private final Context context; + private final Runnable done; + + public MyTracksDeleteAllTracks(Context context, Runnable done) { + this.context = context; + this.done = done; + } + + @Override + public void handleMessage(Message msg) { + super.handleMessage(msg); + + AlertDialog dialog = null; + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setMessage( + context.getString(R.string.all_data_will_be_permanently_deleted)); + builder.setTitle(context.getString(R.string.are_you_sure_question)); + builder.setIcon(android.R.drawable.ic_dialog_alert); + builder.setPositiveButton(context.getString(R.string.yes), + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int i) { + dialog.dismiss(); + Log.w(MyTracksConstants.TAG, "deleting all!"); + MyTracksProviderUtils.Factory.get(context).deleteAllTracks(); + SharedPreferences prefs = + context.getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + SharedPreferences.Editor editor = prefs.edit(); + editor.putLong(MyTracksSettings.SELECTED_TRACK, -1); + editor.commit(); + if (done != null) { + Handler h = new Handler(); + h.post(done); + } + } + }); + builder.setNegativeButton(context.getString(R.string.no), + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int i) { + dialog.dismiss(); + } + }); + dialog = builder.create(); + dialog.show(); + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/MyTracksDetails.java b/MyTracks/src/com/google/android/apps/mytracks/MyTracksDetails.java new file mode 100644 index 000000000..47d387d37 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/MyTracksDetails.java @@ -0,0 +1,124 @@ +/* + * Copyright 2008 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; + +import com.google.android.apps.mytracks.content.MyTracksProviderUtils; +import com.google.android.apps.mytracks.content.Track; +import com.google.android.apps.mytracks.content.TracksColumns; +import com.google.android.maps.mytracks.R; + +import android.app.Activity; +import android.content.ContentValues; +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.ArrayAdapter; +import android.widget.AutoCompleteTextView; +import android.widget.Button; +import android.widget.EditText; + +/** + * An activity that let's the user see and edit the user editable track meta + * data such as name, activity type and description. + * + * @author Leif Hendrik Wilden + */ +public class MyTracksDetails extends Activity implements OnClickListener { + + /** + * The id of the track being edited (taken from bundle, "trackid") + */ + private Long trackId; + + private EditText name; + private EditText description; + private AutoCompleteTextView category; + + @Override + protected void onCreate(Bundle bundle) { + super.onCreate(bundle); + setContentView(R.layout.mytracks_detail); + + // Required extra when launching this intent: + trackId = getIntent().getLongExtra("trackid", -1); + if (trackId < 0) { + Log.d(MyTracksConstants.TAG, + "MyTracksDetails intent was launched w/o track id."); + finish(); + return; + } + + // Optional extra that can be used to suppress the cancel button: + boolean hasCancelButton = + getIntent().getBooleanExtra("hasCancelButton", true); + + name = (EditText) findViewById(R.id.trackdetails_name); + description = (EditText) findViewById(R.id.trackdetails_description); + category = (AutoCompleteTextView) findViewById(R.id.trackdetails_category); + ArrayAdapter adapter = ArrayAdapter.createFromResource( + this, + R.array.activity_types, + android.R.layout.simple_dropdown_item_1line); + category.setAdapter(adapter); + + Button cancel = (Button) findViewById(R.id.trackdetails_cancel); + if (hasCancelButton) { + cancel.setOnClickListener(this); + cancel.setVisibility(View.VISIBLE); + } else { + cancel.setVisibility(View.GONE); + } + Button save = (Button) findViewById(R.id.trackdetails_save); + save.setOnClickListener(this); + + fillDialog(); + } + + private void fillDialog() { + Track track = MyTracksProviderUtils.Factory.get(this).getTrack(trackId); + if (track != null) { + name.setText(track.getName()); + description.setText(track.getDescription()); + category.setText(track.getCategory()); + } + } + + private void saveDialog() { + ContentValues values = new ContentValues(); + values.put(TracksColumns.NAME, name.getText().toString()); + values.put(TracksColumns.DESCRIPTION, description.getText().toString()); + values.put(TracksColumns.CATEGORY, category.getText().toString()); + getContentResolver().update( + TracksColumns.CONTENT_URI, + values, + "_id = " + trackId, + null/*selectionArgs*/); + } + + @Override + public void onClick(View v) { + switch (v.getId()) { + case R.id.trackdetails_cancel: + finish(); + break; + case R.id.trackdetails_save: + saveDialog(); + finish(); + break; + } + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/MyTracksList.java b/MyTracks/src/com/google/android/apps/mytracks/MyTracksList.java new file mode 100644 index 000000000..c2be23dce --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/MyTracksList.java @@ -0,0 +1,269 @@ +/* + * Copyright 2008 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; + +import com.google.android.apps.mytracks.content.TracksColumns; +import com.google.android.apps.mytracks.util.StringUtils; +import com.google.android.apps.mytracks.util.UnitConversions; +import com.google.android.maps.mytracks.R; + +import android.app.ListActivity; +import android.content.Intent; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.os.Bundle; +import android.os.Handler; +import android.view.ContextMenu; +import android.view.ContextMenu.ContextMenuInfo; +import android.view.MenuItem; +import android.view.SubMenu; +import android.view.View; +import android.view.View.OnCreateContextMenuListener; +import android.view.Window; +import android.widget.AdapterView; +import android.widget.ListView; +import android.widget.SimpleCursorAdapter; +import android.widget.TextView; + +/** + * A list activity displaying all the recorded tracks. There's a context + * menu (via long press) displaying various options such as showing, editing, + * deleting, sending to MyMaps, or writing to SD card. + * + * @author Leif Hendrik Wilden + */ +public class MyTracksList extends ListActivity + implements SharedPreferences.OnSharedPreferenceChangeListener, + View.OnClickListener { + + private int contextPosition = -1; + private long trackId = -1; + private ListView listView = null; + private boolean metricUnits = true; + + private Cursor tracksCursor = null; + + /** + * The id of the currently recording track. + */ + private long recordingTrackId = -1; + + private final OnCreateContextMenuListener contextMenuListener = + new OnCreateContextMenuListener() { + @Override + public void onCreateContextMenu(ContextMenu menu, View v, + ContextMenuInfo menuInfo) { + menu.setHeaderTitle(R.string.tracklist_this_track); + AdapterView.AdapterContextMenuInfo info = + (AdapterView.AdapterContextMenuInfo) menuInfo; + contextPosition = info.position; + trackId = MyTracksList.this.listView.getAdapter().getItemId( + contextPosition); + menu.add(0, MyTracksConstants.MENU_SHOW, 0, + R.string.tracklist_show_track); + menu.add(0, MyTracksConstants.MENU_EDIT, 0, + R.string.tracklist_edit_track); + if (!MyTracks.getInstance().isRecording() + || trackId != recordingTrackId) { + menu.add(0, MyTracksConstants.MENU_SEND_TO_GOOGLE, 0, + R.string.tracklist_send_to_google); + SubMenu share = menu.addSubMenu(0, MyTracksConstants.MENU_SHARE, 0, + R.string.tracklist_share_track); + share.add(0, MyTracksConstants.MENU_SHARE_LINK, 0, + R.string.tracklist_share_link); + share.add(0, MyTracksConstants.MENU_SHARE_GPX_FILE, 0, + R.string.tracklist_share_gpx_file); + share.add(0, MyTracksConstants.MENU_SHARE_KML_FILE, 0, + R.string.tracklist_share_kml_file); + share.add(0, MyTracksConstants.MENU_SHARE_CSV_FILE, 0, + R.string.tracklist_share_csv_file); + SubMenu save = menu.addSubMenu(0, + MyTracksConstants.MENU_WRITE_TO_SD_CARD, 0, + R.string.tracklist_write_to_sd); + save.add(0, MyTracksConstants.MENU_SAVE_GPX_FILE, 0, + R.string.tracklist_save_as_gpx); + save.add(0, MyTracksConstants.MENU_SAVE_KML_FILE, 0, + R.string.tracklist_save_as_kml); + save.add(0, MyTracksConstants.MENU_SAVE_CSV_FILE, 0, + R.string.tracklist_save_as_csv); + menu.add(0, MyTracksConstants.MENU_DELETE, 0, + R.string.tracklist_delete_track); + } + } + }; + + @Override + public void onSharedPreferenceChanged( + SharedPreferences sharedPreferences, String key) { + if (key == null) { + return; + } + if (key.equals(MyTracksSettings.METRIC_UNITS)) { + metricUnits = sharedPreferences.getBoolean(MyTracksSettings.METRIC_UNITS, true); + if (tracksCursor != null && !tracksCursor.isClosed()) { + tracksCursor.requery(); + } + } + if (key.equals(MyTracksSettings.RECORDING_TRACK)) { + recordingTrackId = sharedPreferences.getLong(MyTracksSettings.RECORDING_TRACK, -1); + } + } + + @Override + protected void onListItemClick(ListView l, View v, int position, long id) { + Intent result = new Intent(); + result.putExtra("trackid", id); + setResult(MyTracksConstants.SHOW_TRACK, result); + finish(); + } + + @Override + public boolean onMenuItemSelected(int featureId, MenuItem item) { + if (!super.onMenuItemSelected(featureId, item)) { + switch (item.getItemId()) { + case MyTracksConstants.MENU_SHOW: { + onListItemClick(null, null, 0, trackId); + return true; + } + case MyTracksConstants.MENU_EDIT: { + Intent intent = new Intent(this, MyTracksDetails.class); + intent.putExtra("trackid", trackId); + startActivity(intent); + return true; + } + case MyTracksConstants.MENU_SHARE: + case MyTracksConstants.MENU_WRITE_TO_SD_CARD: + return false; + default: { + Intent result = new Intent(); + result.putExtra("trackid", trackId); + setResult( + MyTracksConstants.getActionFromMenuId(item.getItemId()), result); + finish(); + return true; + } + } + } + return false; + } + + @Override + public void onClick(View v) { + switch (v.getId()) { + case R.id.tracklist_btn_delete_all: { + Handler h = new MyTracksDeleteAllTracks(this, null); + h.handleMessage(null); + break; + } + case R.id.tracklist_btn_export_all: { + new ExportAllTracks(this); + break; + } + } + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // We don't need a window title bar: + requestWindowFeature(Window.FEATURE_NO_TITLE); + + setContentView(R.layout.mytracks_list); + + listView = getListView(); + listView.setOnCreateContextMenuListener(contextMenuListener); + + findViewById(R.id.tracklist_btn_delete_all).setOnClickListener(this); + findViewById(R.id.tracklist_btn_export_all).setOnClickListener(this); + + SharedPreferences preferences = + getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + preferences.registerOnSharedPreferenceChangeListener(this); + metricUnits = preferences.getBoolean(MyTracksSettings.METRIC_UNITS, true); + recordingTrackId = + preferences.getLong(MyTracksSettings.RECORDING_TRACK, -1); + + tracksCursor = getContentResolver().query( + TracksColumns.CONTENT_URI, null, null, null, "_id DESC"); + startManagingCursor(tracksCursor); + setListAdapter(); + } + + private void setListAdapter() { + // Get a cursor with all tracks + SimpleCursorAdapter adapter = new SimpleCursorAdapter( + this, + R.layout.mytracks_list_item, + tracksCursor, + new String[] { TracksColumns.NAME, TracksColumns.STARTTIME, + TracksColumns.TOTALDISTANCE, TracksColumns.DESCRIPTION, + TracksColumns.CATEGORY }, + new int[] { R.id.trackdetails_item_name, R.id.trackdetails_item_time, + R.id.trackdetails_item_stats, R.id.trackdetails_item_description, + R.id.trackdetails_item_category }); + + final int startTimeIdx = + tracksCursor.getColumnIndexOrThrow(TracksColumns.STARTTIME); + final int totalTimeIdx = + tracksCursor.getColumnIndexOrThrow(TracksColumns.TOTALTIME); + final int totalDistanceIdx = + tracksCursor.getColumnIndexOrThrow(TracksColumns.TOTALDISTANCE); + + adapter.setViewBinder(new SimpleCursorAdapter.ViewBinder() { + @Override + public boolean setViewValue(View view, Cursor cursor, int columnIndex) { + TextView textView = (TextView) view; + if (columnIndex == startTimeIdx) { + long time = cursor.getLong(startTimeIdx); + textView.setText(String.format("%tc", time)); + } else if (columnIndex == totalDistanceIdx) { + double length = cursor.getDouble(totalDistanceIdx); + String lengthUnit = null; + if (metricUnits) { + if (length > 1000) { + length /= 1000; + lengthUnit = getString(R.string.kilometer); + } else { + lengthUnit = getString(R.string.meter); + } + } else { + if (length > UnitConversions.MI_TO_M) { + length /= UnitConversions.MI_TO_M; + lengthUnit = getString(R.string.mile); + } else { + length *= UnitConversions.M_TO_FT; + lengthUnit = getString(R.string.feet); + } + } + textView.setText(String.format("%s %.2f %s", + StringUtils.formatTime(cursor.getLong(totalTimeIdx)), + length, + lengthUnit)); + } else { + textView.setText(cursor.getString(columnIndex)); + if (textView.getText().length() < 1) { + textView.setVisibility(View.GONE); + } else { + textView.setVisibility(View.VISIBLE); + } + } + return true; + } + }); + setListAdapter(adapter); + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/MyTracksMap.java b/MyTracks/src/com/google/android/apps/mytracks/MyTracksMap.java new file mode 100755 index 000000000..9a8c57b43 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/MyTracksMap.java @@ -0,0 +1,978 @@ +/* + * Copyright 2008 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; + +import com.google.android.apps.mymaps.GeoRect; +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.Waypoint; +import com.google.android.apps.mytracks.content.WaypointsColumns; +import com.google.android.apps.mytracks.util.MyTracksUtils; +import com.google.android.maps.GeoPoint; +import com.google.android.maps.MapActivity; +import com.google.android.maps.MapController; +import com.google.android.maps.MapView; +import com.google.android.maps.mytracks.R; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.database.ContentObserver; +import android.database.Cursor; +import android.hardware.GeomagneticField; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.location.LocationProvider; +import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; +import android.provider.Settings; +import android.util.Log; +import android.view.ContextMenu; +import android.view.ContextMenu.ContextMenuInfo; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.SubMenu; +import android.view.View; +import android.view.View.OnCreateContextMenuListener; +import android.view.Window; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.TextView; +import android.widget.Toast; + +/** + * The map view activity of the MyTracks application. + * + * @author Leif Hendrik Wilden + */ +public class MyTracksMap extends MapActivity + implements LocationListener, SensorEventListener, View.OnTouchListener, + View.OnClickListener, SharedPreferences.OnSharedPreferenceChangeListener { + + // Saved instance state keys: + // --------------------------- + + public static final String KEY_CURRENT_LOCATION = "currentLocation"; + public static final String KEY_KEEP_MY_LOCATION_VISIBLE = + "keepMyLocationVisible"; + public static final String KEY_HAVE_GOOD_FIX = "haveGoodFix"; + + /** + * The currently selected track (or null if nothing selected). + */ + private Track selectedTrack; + + /** + * The id of the currently recording track. + */ + private long recordingTrackId = -1; + + /** + * True if the map should be scrolled so that the pointer is always in the + * visible area. + */ + private boolean keepMyLocationVisible; + + /** + * Id of the last location that was seen when reading tracks from the + * provider. This is used to determine which locations are new compared to the + * last time the mapOverlay was updated. + */ + private long lastSeenLocationId = -1; + + /** + * Magnetic variation. + */ + private double variation; + + /** + * From the shared preferences: + */ + private int minRequiredAccuracy = + MyTracksSettings.DEFAULT_MIN_REQUIRED_ACCURACY; + + /** + * True, if the application thinks it has a good fix, i.e. accuracy is better + * than the required accuracy. + */ + private boolean haveGoodFix; + + /** + * The current pointer location. + */ + private Location currentLocation; + + /** + * A thread with a looper. Post to updateTrackHandler to execute Runnables on + * this thread. + */ + private final HandlerThread updateTrackThread = + new HandlerThread("updateTrackThread"); + + /** Handler for updateTrackThread */ + private Handler updateTrackHandler; + + private MyTracksProviderUtils providerUtils; + + /** + * A runnable that updates the track from the provider (looking for points + * added after "lastSeenLocationId". + */ + private final Runnable updateTrackRunnable = new Runnable() { + @Override + public void run() { + if (selectedTrack == null) { + return; + } + Cursor cursor = null; + try { + cursor = providerUtils.getLocationsCursor( + recordingTrackId, + lastSeenLocationId + 1, + MyTracksConstants.MAX_DISPLAYED_TRACK_POINTS + - selectedTrack.getLocations().size(), + true); + if (cursor != null) { + if (cursor.moveToLast()) { + final int idColumnIdx = + cursor.getColumnIndexOrThrow(TrackPointsColumns._ID); + do { + lastSeenLocationId = cursor.getLong(idColumnIdx); + Location location = providerUtils.createLocation(cursor); + if (location != null) { + selectedTrack.addLocation(location); + } + } while (cursor.moveToPrevious()); + } + } + } catch (RuntimeException e) { + Log.w(MyTracksConstants.TAG, "Caught an unexpected exception: ", e); + } finally { + if (cursor != null) { + cursor.close(); + } + mapView.postInvalidate(); + } + } + }; + + /** + * A runnable that restores all track points from the provider. + */ + private Runnable restoreTrackRunnable = new Runnable() { + @Override + public void run() { + if (selectedTrack == null) { + return; + } + lastSeenLocationId = providerUtils.getTrackPoints(selectedTrack, + MyTracksConstants.MAX_DISPLAYED_TRACK_POINTS); + mapView.postInvalidate(); + } + }; + + /** + * A runnable that restores all waypoints from the provider. + */ + private final Runnable restoreWaypointsRunnable = new Runnable() { + @Override + public void run() { + if (selectedTrack == null) { + return; + } + + Cursor cursor = null; + mapOverlay.clearWaypoints(); + try { + // We will silently drop extra waypoints to make the app responsive. + // TODO: Try to only load the waypoints in the view port. + cursor = providerUtils.getWaypointsCursor( + selectedTrack.getId(), 0, + MyTracksConstants.MAX_DISPLAYED_WAYPOINTS_POINTS); + if (cursor != null) { + if (cursor.moveToFirst()) { + do { + Waypoint wpt = providerUtils.createWaypoint(cursor); + mapOverlay.addWaypoint(wpt); + } while (cursor.moveToNext()); + } + } + } catch (RuntimeException e) { + Log.w(MyTracksConstants.TAG, "Caught an unexpected exception.", e); + } finally { + if (cursor != null) { + cursor.close(); + } + } + mapView.postInvalidate(); + } + }; + + /** + * A runnable intended to be posted to the updateTrackThread after the + * selected track changes. It will post to the ui thread to update the screen + * elements and move the map to show the selected track. + */ + private final Runnable setSelectedTrackRunnable = new Runnable() { + @Override + public void run() { + uiHandler.post(new Runnable() { + public void run() { + showTrack(selectedTrack); + mapOverlay.setSelectedTrack(selectedTrack); + mapOverlay.setShowEndMarker(selectedTrack == null || + selectedTrack.getId() != recordingTrackId); + mapView.invalidate(); + busyPane.setVisibility(View.GONE); + updateOptionsButton(); + } + }); + } + }; + + // UI elements: + // ------------- + + private RelativeLayout screen; + private MapView mapView; + private MyTracksOverlay mapOverlay; + private LinearLayout messagePane; + private TextView messageText; + private LinearLayout busyPane; + private ImageButton optionsBtn; + + private MenuItem myLocation; + private MenuItem toggleLayers; + + private SensorManager sensorManager; + private LocationManager locationManager; + private ContentObserver observer; + private ContentObserver waypointObserver; + + /** Handler for callbacks to the UI thread */ + private final Handler uiHandler = new Handler(); + + /** + * We are not displaying driving directions. Just an arbitrary track that is + * not associated to any licensed mapping data. Therefore it should be okay to + * return false here and still comply with the terms of service. + */ + @Override + protected boolean isRouteDisplayed() { + return false; + } + + /** + * We are displaying a location. This needs to return true in order to comply + * with the terms of service. + */ + @Override + protected boolean isLocationDisplayed() { + return true; + } + + // Application life cycle: + // ------------------------ + + @Override + public void onCreate(Bundle bundle) { + Log.d(MyTracksConstants.TAG, "MyTracksMap.onCreate"); + super.onCreate(bundle); + + providerUtils = MyTracksProviderUtils.Factory.get(this); + + // We don't need a window title bar: + requestWindowFeature(Window.FEATURE_NO_TITLE); + + // Inflate the layout: + setContentView(R.layout.mytracks_layout); + + // Remove the window's background because the MapView will obscure it + getWindow().setBackgroundDrawable(null); + + // Set up a map overlay: + screen = (RelativeLayout) findViewById(R.id.screen); + mapView = (MapView) findViewById(R.id.map); + mapView.requestFocus(); + mapOverlay = new MyTracksOverlay(this); + mapView.getOverlays().add(mapOverlay); + mapView.setOnTouchListener(this); + messagePane = (LinearLayout) findViewById(R.id.messagepane); + messageText = (TextView) findViewById(R.id.messagetext); + busyPane = (LinearLayout) findViewById(R.id.busypane); + optionsBtn = (ImageButton) findViewById(R.id.showOptions); + + optionsBtn.setOnCreateContextMenuListener(contextMenuListener); + optionsBtn.setOnClickListener(this); + + setupZoomControls(); + + // Get the sensor and location managers: + sensorManager = (SensorManager) getSystemService(SENSOR_SERVICE); + locationManager = + (LocationManager) getSystemService(Context.LOCATION_SERVICE); + + updateTrackThread.start(); + updateTrackHandler = new Handler(updateTrackThread.getLooper()); + + // Register observer for the track point provider: + Handler contentHandler = new Handler(); + observer = new ContentObserver(contentHandler) { + @Override + public void onChange(boolean selfChange) { + Log.d(MyTracksConstants.TAG, "MyTracksMap: ContentObserver.onChange"); + // Check for any new locations and append them to the currently + // recording track: + if (!MyTracks.getInstance().isRecording()) { + // No track is being recorded. We should not be here. + return; + } + if (selectedTrack == null + || selectedTrack.getId() != recordingTrackId) { + // No track, or one other than the recording track is selected, + // don't bother. + return; + } + // Update can potentially be lengthy, put it in its own thread: + updateTrackHandler.post(updateTrackRunnable); + super.onChange(selfChange); + } + }; + + waypointObserver = new ContentObserver(contentHandler) { + @Override + public void onChange(boolean selfChange) { + Log.d(MyTracksConstants.TAG, + "MyTracksMap: ContentObserver.onChange waypoints"); + if (selectedTrack == null) { + return; + } + updateTrackHandler.post(restoreWaypointsRunnable); + super.onChange(selfChange); + } + }; + + // Read shared preferences and register change listener: + SharedPreferences preferences = + getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + if (preferences != null) { + minRequiredAccuracy = preferences.getInt( + MyTracksSettings.MIN_REQUIRED_ACCURACY, + MyTracksSettings.DEFAULT_MIN_REQUIRED_ACCURACY); + recordingTrackId = + preferences.getLong(MyTracksSettings.RECORDING_TRACK, -1); + long selectedTrackId = + preferences.getLong(MyTracksSettings.SELECTED_TRACK, -1); + if (selectedTrackId >= 0) { + setSelectedTrack(selectedTrackId); + } + updateOptionsButton(); + preferences.registerOnSharedPreferenceChangeListener(this); + } + } + + @SuppressWarnings("deprecation") + protected void setupZoomControls() { + mapView.setBuiltInZoomControls(true); + } + + @Override + protected void onDestroy() { + // Final cleanup before activity is destroyed. + // May not be called at all in some situations. + Log.d(MyTracksConstants.TAG, "MyTracksMap.onDestroy"); + super.onDestroy(); + } + + @Override + protected void onStart() { + // Called after onCreate or onStop. + // Will be followed by onRestart. + Log.d(MyTracksConstants.TAG, "MyTracksMap.onStart"); + super.onStart(); + } + + @Override + protected void onStop() { + // Called when activity is no longer visible to user. + // Next either onStart, onDestroy or nothing will be called. + // This method may never be called in low memory situations. + Log.d(MyTracksConstants.TAG, "MyTracksMap.onStop"); + super.onStop(); + } + + @Override + protected void onRestart() { + // Called when the current activity is being re-displayed. + // Will be followed by onResume. + Log.d(MyTracksConstants.TAG, "MyTracksMap.onRestart"); + super.onRestart(); + } + + @Override + protected void onPause() { + // Called when activity is going into the background, but has not (yet) been + // killed. Shouldn't block longer than approx. 2 seconds. + Log.d(MyTracksConstants.TAG, "MyTracksMap.onPause"); + unregisterLocationAndSensorListeners(); + unregisterContentObservers(); + super.onPause(); + } + + @Override + protected void onResume() { + // Called when the current activity is being displayed or re-displayed + // to the user. + Log.d(MyTracksConstants.TAG, "MyTracksMap.onResume"); + super.onResume(); + + // Make sure any updates that might have happened are propagated to the + // Map overlay: + observer.onChange(false); + waypointObserver.onChange(false); + + registerContentObservers(); + registerLocationAndSensorListeners(); + + if (locationManager.isProviderEnabled(MyTracksConstants.GPS_PROVIDER)) { + messageText.setText(R.string.wait_for_fix); + messagePane.setOnClickListener(null); + } else { + messageText.setText(R.string.status_enable_gps); + messagePane.setVisibility(View.VISIBLE); + messagePane.setOnClickListener(this); + screen.requestLayout(); + } + + // While this activity was paused the user may have deleted the selected + // track. In that case the map overlay needs to be cleared: + Track track = mapOverlay.getSelectedTrack(); + if (track != null && !providerUtils.trackExists(track.getId())) { + // The recording track must have been deleted meanwhile. + mapOverlay.setSelectedTrack(null); + mapView.invalidate(); + } + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + Log.d(MyTracksConstants.TAG, "MyTracksMap.onSaveInstanceState"); + outState.putBoolean(KEY_HAVE_GOOD_FIX, haveGoodFix); + outState.putBoolean(KEY_KEEP_MY_LOCATION_VISIBLE, keepMyLocationVisible); + if (currentLocation != null) { + outState.putParcelable(KEY_CURRENT_LOCATION, currentLocation); + } + super.onSaveInstanceState(outState); + } + + @Override + protected void onRestoreInstanceState(Bundle bundle) { + Log.d(MyTracksConstants.TAG, "MyTracksMap.onRestoreInstanceState"); + if (bundle != null) { + super.onRestoreInstanceState(bundle); + haveGoodFix = bundle.getBoolean(KEY_HAVE_GOOD_FIX, false); + keepMyLocationVisible = + bundle.getBoolean(KEY_KEEP_MY_LOCATION_VISIBLE, false); + if (bundle.containsKey(KEY_CURRENT_LOCATION)) { + currentLocation = (Location) bundle.getParcelable(KEY_CURRENT_LOCATION); + if (currentLocation != null) { + setVariation(currentLocation); + showCurrentLocation(); + } + } else { + currentLocation = null; + } + } + } + + // Utility functions: + // ------------------- + + /** + * Toggles between satellite and map view. + */ + public void toggleLayer() { + mapView.setSatellite(!mapView.isSatellite()); + } + + /** + * Registers to receive location updates from the GPS location provider and + * sensor updated from the compass. + */ + void registerLocationAndSensorListeners() { + if (locationManager != null) { + LocationProvider gpsProvider = + locationManager.getProvider(MyTracksConstants.GPS_PROVIDER); + if (gpsProvider == null) { + alert(getString(R.string.error_no_gps_location_provider)); + return; + } else { + Log.d(MyTracksConstants.TAG, "MyTracksMap: Using location provider " + + gpsProvider.getName()); + } + locationManager.requestLocationUpdates(gpsProvider.getName(), + 0 /*minTime*/, 0 /*minDist*/, this); + try { + locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, + 1000 * 60 * 5 /*minTime*/, 0 /*minDist*/, this); + } catch (RuntimeException e) { + // If anything at all goes wrong with getting a cell location do not + // abort. Cell location is not essential to this app. + Log.w(MyTracksConstants.TAG, + "Could not register network location listener."); + } + } + if (sensorManager == null) { + return; + } + Sensor compass = sensorManager.getDefaultSensor(Sensor.TYPE_ORIENTATION); + if (compass == null) { + return; + } + Log.d(MyTracksConstants.TAG, + "MyTracksMap: Now registering sensor listeners."); + sensorManager.registerListener( + this, compass, SensorManager.SENSOR_DELAY_UI); + } + + /** + * Unregisters all location and sensor listeners + */ + void unregisterLocationAndSensorListeners() { + if (locationManager != null) { + Log.d(MyTracksConstants.TAG, + "MyTracksMap: Now unregistering location listeners."); + locationManager.removeUpdates(this); + } + if (sensorManager != null) { + Log.d(MyTracksConstants.TAG, + "MyTracksMap: Now unregistering sensor listeners."); + sensorManager.unregisterListener(this); + } + } + + /** + * Registers the content observer for the map overlay. + */ + private void registerContentObservers() { + getContentResolver().registerContentObserver( + TrackPointsColumns.CONTENT_URI, false /* notifyForDescendents */, + observer); + getContentResolver().registerContentObserver( + WaypointsColumns.CONTENT_URI, false /* notifyForDescendents */, + waypointObserver); + } + + /** + * Unregisters the content observer for the map overlay. + */ + private void unregisterContentObservers() { + getContentResolver().unregisterContentObserver(observer); + getContentResolver().unregisterContentObserver(waypointObserver); + } + + /** + * Shows the options button if a track is selected, or hide it if not. + */ + private void updateOptionsButton() { + optionsBtn.setVisibility( + selectedTrack != null ? View.VISIBLE : View.INVISIBLE); + } + + /** + * Tests if a location is visible. + * + * @param location a given location + * @return true if the given location is within the visible map area + */ + private boolean locationIsVisible(Location location) { + if (location == null || mapView == null) { + return false; + } + GeoPoint center = mapView.getMapCenter(); + int latSpan = mapView.getLatitudeSpan(); + int lonSpan = mapView.getLongitudeSpan(); + + // Bottom of map view is obscured by zoom controls/buttons. + // Subtract a margin from the visible area: + GeoPoint marginBottom = mapView.getProjection().fromPixels( + 0, mapView.getHeight()); + GeoPoint marginTop = mapView.getProjection().fromPixels(0, + mapView.getHeight() + - mapView.getZoomButtonsController().getZoomControls().getHeight()); + int margin = + Math.abs(marginTop.getLatitudeE6() - marginBottom.getLatitudeE6()); + GeoRect r = new GeoRect(center, latSpan, lonSpan); + r.top += margin; + + GeoPoint geoPoint = MyTracksUtils.getGeoPoint(location); + return r.contains(geoPoint); + } + + /** + * Moves the location pointer to the current location and center the map if + * the current location is outside the visible area. + */ + private void showCurrentLocation() { + if (currentLocation == null || mapOverlay == null || mapView == null) { + return; + } + mapOverlay.setMyLocation(currentLocation); + mapView.invalidate(); + if (keepMyLocationVisible && !locationIsVisible(currentLocation)) { + MapController controller = mapView.getController(); + GeoPoint geoPoint = MyTracksUtils.getGeoPoint(currentLocation); + controller.animateTo(geoPoint); + } + } + + /** + * Zooms and pans the map so that the given track is visible. + * + * @param track a given track + */ + public void showTrack(Track track) { + if (track == null || mapView == null || track.getNumberOfPoints() < 2) { + return; + } + int latSpanE6 = track.getTop() - track.getBottom(); + int lonSpanE6 = track.getRight() - track.getLeft(); + if (latSpanE6 > 0 && latSpanE6 < 180E6 && lonSpanE6 > 0 + && lonSpanE6 < 180E6) { + keepMyLocationVisible = false; + GeoPoint center = new GeoPoint( + track.getBottom() + latSpanE6 / 2, + track.getLeft() + lonSpanE6 / 2); + if (MyTracksUtils.isValidGeoPoint(center)) { + mapView.getController().setCenter(center); + mapView.getController().zoomToSpan(latSpanE6, lonSpanE6); + } + } + } + + /** + * Zooms and pans the map so that the given waypoint is visible. + */ + public void showWaypoint(long waypointId) { + Waypoint wpt = providerUtils.getWaypoint(waypointId); + if (wpt != null && wpt.getLocation() != null) { + keepMyLocationVisible = false; + GeoPoint center = new GeoPoint( + (int) (wpt.getLocation().getLatitude() * 1E6), + (int) (wpt.getLocation().getLongitude() * 1E6)); + mapView.getController().setCenter(center); + mapView.getController().setZoom(20); + mapView.invalidate(); + } + } + + /** + * Sets the selected track and zoom and pan the map so that it is visible. + * + * @param trackId a given track id + */ + public void setSelectedTrack(final long trackId) { + if (selectedTrack != null && selectedTrack.getId() == trackId) { + // Selected track did not change, nothing to do. + mapOverlay.setSelectedTrack(selectedTrack); + mapView.invalidate(); + updateOptionsButton(); + return; + } + if (trackId < 0) { + // Remove selection. + selectedTrack = null; + mapOverlay.setSelectedTrack(null); + mapOverlay.clearWaypoints(); + updateOptionsButton(); + mapView.invalidate(); + return; + } + busyPane.setVisibility(View.VISIBLE); + selectedTrack = providerUtils.getTrack(trackId); + updateTrackHandler.post(restoreTrackRunnable); + updateTrackHandler.post(restoreWaypointsRunnable); + updateTrackHandler.post(setSelectedTrackRunnable); + } + + /** + * Displays an alert for a few seconds. + * + * @param txt The text to be displayed + */ + public void alert(String txt) { + Toast.makeText(this, txt, Toast.LENGTH_LONG).show(); + } + + public void launchMyLocationSettings() { + startActivity(new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)); + } + + public void setVariation(Location location) { + long timestamp = location.getTime(); + if (timestamp == 0) { + // Hack for Samsung phones which don't populate the time field + timestamp = System.currentTimeMillis(); + } + + GeomagneticField field = new GeomagneticField( + (float) location.getLatitude(), + (float) location.getLongitude(), + (float) location.getAltitude(), + timestamp); + variation = field.getDeclination(); + + Log.d(MyTracksConstants.TAG, + "MyTracksMap: Variation reset to " + variation + " degrees."); + } + + public MyTracksOverlay getMapOverlay() { + return mapOverlay; + } + + public MapView getMapView() { + return mapView; + } + + // Event listeners: + // ----------------- + + private final OnCreateContextMenuListener contextMenuListener = + new OnCreateContextMenuListener() { + @Override + public void onCreateContextMenu(ContextMenu menu, View v, + ContextMenuInfo menuInfo) { + menu.setHeaderTitle(R.string.tracklist_this_track); + menu.add(0, MyTracksConstants.MENU_EDIT, 0, + R.string.tracklist_edit_track); + if (!MyTracks.getInstance().isRecording() || (selectedTrack != null + && selectedTrack.getId() != recordingTrackId)) { + menu.add(0, MyTracksConstants.MENU_SEND_TO_GOOGLE, 0, + R.string.tracklist_send_to_google); + SubMenu share = menu.addSubMenu(0, MyTracksConstants.MENU_SHARE, 0, + R.string.tracklist_share_track); + share.add(0, MyTracksConstants.MENU_SHARE_LINK, 0, + R.string.tracklist_share_link); + share.add(0, MyTracksConstants.MENU_SHARE_GPX_FILE, 0, + R.string.tracklist_share_gpx_file); + share.add(0, MyTracksConstants.MENU_SHARE_KML_FILE, 0, + R.string.tracklist_share_kml_file); + share.add(0, MyTracksConstants.MENU_SHARE_CSV_FILE, 0, + R.string.tracklist_share_csv_file); + SubMenu save = menu.addSubMenu(0, + MyTracksConstants.MENU_WRITE_TO_SD_CARD, 0, + R.string.tracklist_write_to_sd); + save.add(0, MyTracksConstants.MENU_SAVE_GPX_FILE, 0, + R.string.tracklist_save_as_gpx); + save.add(0, MyTracksConstants.MENU_SAVE_KML_FILE, 0, + R.string.tracklist_save_as_kml); + save.add(0, MyTracksConstants.MENU_SAVE_CSV_FILE, 0, + R.string.tracklist_save_as_csv); + menu.add(0, MyTracksConstants.MENU_CLEAR_MAP, 0, + R.string.tracklist_clear_map); + menu.add(0, MyTracksConstants.MENU_DELETE, 0, + R.string.tracklist_delete_track); + } + } + }; + + @Override + public boolean onMenuItemSelected(int featureId, MenuItem item) { + if (!super.onMenuItemSelected(featureId, item)) { + if (selectedTrack != null) { + MyTracks.getInstance().onActivityResult( + MyTracksConstants.getActionFromMenuId(item.getItemId()), RESULT_OK, + new Intent()); + return true; + } + } + return false; + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + myLocation = menu.add(0, MyTracksConstants.MENU_MY_LOCATION, 0, + R.string.mylocation); + myLocation.setIcon(android.R.drawable.ic_menu_mylocation); + toggleLayers = menu.add(0, MyTracksConstants.MENU_TOGGLE_LAYERS, 0, + R.string.switch_to_sat); + toggleLayers.setIcon(android.R.drawable.ic_menu_mapmode); + return true; + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + toggleLayers.setTitle(mapView.isSatellite() ? + R.string.switch_to_map : R.string.switch_to_sat); + return super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case MyTracksConstants.MENU_MY_LOCATION: { + Location loc = MyTracks.getInstance().getCurrentLocation(); + if (loc != null) { + currentLocation = loc; + setVariation(currentLocation); + mapOverlay.setMyLocation(loc); + mapView.invalidate(); + GeoPoint geoPoint = MyTracksUtils.getGeoPoint(loc); + MapController controller = mapView.getController(); + controller.animateTo(geoPoint); + if (mapView.getZoomLevel() < 18) { + controller.setZoom(18); + } + keepMyLocationVisible = true; + } + return true; + } + case MyTracksConstants.MENU_TOGGLE_LAYERS: { + toggleLayer(); + return true; + } + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onClick(View v) { + if (v == messagePane) { + launchMyLocationSettings(); + } + if (v == optionsBtn) { + optionsBtn.performLongClick(); + } + } + + /** + * We want the pointer to become visible again in case of the next location + * update: + */ + @Override + public boolean onTouch(View view, MotionEvent event) { + if (keepMyLocationVisible && event.getAction() == MotionEvent.ACTION_MOVE) { + if (!locationIsVisible(currentLocation)) { + keepMyLocationVisible = false; + } + } + return false; + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, + String key) { + if (key != null) { + if (key.equals(MyTracksSettings.MIN_REQUIRED_ACCURACY)) { + minRequiredAccuracy = sharedPreferences.getInt( + MyTracksSettings.MIN_REQUIRED_ACCURACY, + MyTracksSettings.DEFAULT_MIN_REQUIRED_ACCURACY); + } else if (key.equals(MyTracksSettings.SELECTED_TRACK)) { + long selectedTrackId = + sharedPreferences.getLong(MyTracksSettings.SELECTED_TRACK, -1); + setSelectedTrack(selectedTrackId); + } else if (key.equals(MyTracksSettings.RECORDING_TRACK)) { + recordingTrackId = + sharedPreferences.getLong(MyTracksSettings.RECORDING_TRACK, -1); + if (selectedTrack != null) { + mapOverlay.setShowEndMarker( + selectedTrack.getId() != recordingTrackId); + mapView.postInvalidate(); + } + } + } + } + + @Override + public void onProviderEnabled(String provider) { + if (provider.equals(MyTracksConstants.GPS_PROVIDER)) { + messageText.setText(R.string.wait_for_fix); + } + } + + @Override + public void onProviderDisabled(String provider) { + if (provider.equals(MyTracksConstants.GPS_PROVIDER)) { + messageText.setText(R.string.status_enable_gps); + messagePane.setVisibility(View.VISIBLE); + messagePane.setOnClickListener(this); + screen.requestLayout(); + } + } + + @Override + public void onLocationChanged(Location location) { + if (location.getProvider().equals(MyTracksConstants.GPS_PROVIDER)) { + // Recalculate the variation if there was a jump in location > 1km: + if (currentLocation == null + || location.distanceTo(currentLocation) > 1000) { + setVariation(location); + } + currentLocation = location; + boolean haveGoodFixNow = + currentLocation.getAccuracy() < minRequiredAccuracy; + if (haveGoodFixNow != haveGoodFix) { + haveGoodFix = haveGoodFixNow; + messagePane.setVisibility(haveGoodFix ? View.GONE : View.VISIBLE); + screen.requestLayout(); + } + showCurrentLocation(); + } else { + Log.d(MyTracksConstants.TAG, + "MyTracksMap: Network location update received."); + } + } + + @Override + public void onStatusChanged(String provider, int status, Bundle extras) { + if (provider.equals(MyTracksConstants.GPS_PROVIDER)) { + switch (status) { + case LocationProvider.OUT_OF_SERVICE: + case LocationProvider.TEMPORARILY_UNAVAILABLE: + haveGoodFix = false; + messagePane.setVisibility(View.VISIBLE); + screen.requestLayout(); + break; + } + } + } + + @Override + public void onSensorChanged(SensorEvent se) { + synchronized (this) { + float magneticHeading = se.values[0]; + double heading = magneticHeading + variation; + if (mapOverlay.setHeading((float) heading)) { + mapView.invalidate(); + } + } + } + + @Override + public void onAccuracyChanged(Sensor s, int accuracy) { + // do nothing + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/MyTracksOverlay.java b/MyTracks/src/com/google/android/apps/mytracks/MyTracksOverlay.java new file mode 100755 index 000000000..fabd7555f --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/MyTracksOverlay.java @@ -0,0 +1,383 @@ +/* + * Copyright 2008 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; + +import com.google.android.apps.mytracks.content.Track; +import com.google.android.apps.mytracks.content.Waypoint; +import com.google.android.apps.mytracks.util.MyTracksUtils; + +import com.google.android.maps.GeoPoint; +import com.google.android.maps.MapView; +import com.google.android.maps.Overlay; +import com.google.android.maps.mytracks.R; + +import android.content.Context; +import android.content.Intent; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.Point; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.location.Location; + +import java.util.ArrayList; + +/** + * A map overlay that displays a "MyLocation" arrow, an error circle, the + * currently recording track and optionally a selected track. + * + * @author Leif Hendrik Wilden + */ +public class MyTracksOverlay extends Overlay { + + private final Drawable arrow[] = new Drawable[18]; + private final int arrowWidth, arrowHeight; + private final Drawable statsMarker; + private final Drawable waypointMarker; + private final Drawable startMarker; + private final Drawable endMarker; + private final int markerWidth, markerHeight; + private final Paint selectedTrackPaint; + private final Paint errorCirclePaint; + private final Context context; + private final ArrayList waypoints = new ArrayList(); + + private Track selectedTrack; + private int lastHeading = 0; + private Location myLocation; + private boolean showEndMarker = true; + + public MyTracksOverlay(Context context) { + this.context = context; + + // TODO: Can we use a FrameAnimation or similar here rather + // than individual resources for each arrow direction? + + arrow[0] = context.getResources().getDrawable(R.drawable.arrow_0); + arrow[1] = context.getResources().getDrawable(R.drawable.arrow_20); + arrow[2] = context.getResources().getDrawable(R.drawable.arrow_40); + arrow[3] = context.getResources().getDrawable(R.drawable.arrow_60); + arrow[4] = context.getResources().getDrawable(R.drawable.arrow_80); + arrow[5] = context.getResources().getDrawable(R.drawable.arrow_100); + arrow[6] = context.getResources().getDrawable(R.drawable.arrow_120); + arrow[7] = context.getResources().getDrawable(R.drawable.arrow_140); + arrow[8] = context.getResources().getDrawable(R.drawable.arrow_160); + arrow[9] = context.getResources().getDrawable(R.drawable.arrow_180); + arrow[10] = context.getResources().getDrawable(R.drawable.arrow_200); + arrow[11] = context.getResources().getDrawable(R.drawable.arrow_220); + arrow[12] = context.getResources().getDrawable(R.drawable.arrow_240); + arrow[13] = context.getResources().getDrawable(R.drawable.arrow_260); + arrow[14] = context.getResources().getDrawable(R.drawable.arrow_280); + arrow[15] = context.getResources().getDrawable(R.drawable.arrow_300); + arrow[16] = context.getResources().getDrawable(R.drawable.arrow_320); + arrow[17] = context.getResources().getDrawable(R.drawable.arrow_340); + arrowWidth = arrow[lastHeading].getIntrinsicWidth(); + arrowHeight = arrow[lastHeading].getIntrinsicHeight(); + for (int i = 0; i <= 17; i++) { + arrow[i].setBounds(0, 0, arrowWidth, arrowHeight); + } + + statsMarker = context.getResources().getDrawable(R.drawable.ylw_pushpin); + markerWidth = statsMarker.getIntrinsicWidth(); + markerHeight = statsMarker.getIntrinsicHeight(); + statsMarker.setBounds(0, 0, markerWidth, markerHeight); + + startMarker = context.getResources().getDrawable(R.drawable.green_dot); + startMarker.setBounds(0, 0, markerWidth, markerHeight); + + endMarker = context.getResources().getDrawable(R.drawable.red_dot); + endMarker.setBounds(0, 0, markerWidth, markerHeight); + + waypointMarker = + context.getResources().getDrawable(R.drawable.blue_pushpin); + waypointMarker.setBounds(0, 0, markerWidth, markerHeight); + + selectedTrackPaint = new Paint(); + selectedTrackPaint.setColor(context.getResources().getColor(R.color.red)); + selectedTrackPaint.setStrokeWidth(3); + selectedTrackPaint.setStyle(Paint.Style.STROKE); + selectedTrackPaint.setAntiAlias(true); + + errorCirclePaint = new Paint(); + errorCirclePaint.setColor(context.getResources().getColor(R.color.blue)); + errorCirclePaint.setStyle(Paint.Style.STROKE); + errorCirclePaint.setStrokeWidth(3); + errorCirclePaint.setAlpha(127); + errorCirclePaint.setAntiAlias(true); + } + + public void setSelectedTrack(Track track) { + selectedTrack = track; + } + + public Track getSelectedTrack() { + return selectedTrack; + } + + public void addWaypoint(Waypoint wpt) { + waypoints.add(wpt); + } + + public void clearWaypoints() { + waypoints.clear(); + } + + public void setShowEndMarker(boolean showEndMarker) { + this.showEndMarker = showEndMarker; + } + + @Override + public void draw(Canvas canvas, MapView mapView, boolean shadow) { + + if (shadow) { + return; + } + + // Draw the selected track: + drawTrack(canvas, mapView, selectedTrack, true); + + // Draw the waypoints: + ArrayList currentWaypoints = waypoints; + for (int i = 1; i < currentWaypoints.size(); i++) { + Location loc = currentWaypoints.get(i).getLocation(); + if (loc == null) { + continue; + } + GeoPoint geoPoint = MyTracksUtils.getGeoPoint(loc); + Point pt = new Point(); + mapView.getProjection().toPixels(geoPoint, pt); + canvas.save(); + canvas.translate(pt.x - (markerWidth / 2) + 3, pt.y - (markerHeight)); + if (currentWaypoints.get(i).getType() == Waypoint.TYPE_STATISTICS) { + statsMarker.draw(canvas); + } else { + waypointMarker.draw(canvas); + } + canvas.restore(); + } + + // Draw the arrow icon: + if (myLocation == null) { + return; + } + GeoPoint geoPoint = new GeoPoint( + (int) (myLocation.getLatitude() * 1E6), + (int) (myLocation.getLongitude() * 1E6)); + Point pt = new Point(); + mapView.getProjection().toPixels(geoPoint, pt); + canvas.save(); + canvas.translate(pt.x - (arrowWidth / 2), pt.y - (arrowHeight / 2)); + arrow[lastHeading].draw(canvas); + canvas.restore(); + + // Draw the error circle: + float radius = + mapView.getProjection().metersToEquatorPixels(myLocation.getAccuracy()); + canvas.drawCircle(pt.x, pt.y, radius, errorCirclePaint); + } + + public void drawTrack(Canvas canvas, MapView mapView, Track track, + boolean selected) { + + if (track == null) { + return; + } + ArrayList points = track.getLocations(); + if (points.size() < 2) { + return; + } + + // Get the current viewing window: + int w = mapView.getLongitudeSpan(); + int h = mapView.getLatitudeSpan(); + int cx = mapView.getMapCenter().getLongitudeE6(); + int cy = mapView.getMapCenter().getLatitudeE6(); + Rect rect = new Rect(cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2); + + Point pt = new Point(); + GeoPoint geoPoint; + Location loc; + Location lastValidLocation = null; + Path path; + boolean wasInside; + + // Do as much allocation and preparation outside the loop over track + // points: + wasInside = false; + int locLon = 0, locLat = 0, minLon, maxLon, minLat, maxLat; + int lastLocLon = (int) (points.get(0).getLongitude() * 1E6); + int lastLocLat = (int) (points.get(0).getLatitude() * 1E6); + + // Line decimation for dummies: + // Skip di additional points, where di depends on zoom level: + int di = 0; + int dl = 17 - mapView.getZoomLevel(); + if (dl > 0) { + di += (dl * dl); + } + + // Loop over track points: + path = new Path(); + for (int i = 1; i < points.size(); i++) { + + loc = points.get(i); + if (loc == null) { + continue; + } + locLon = (int) (loc.getLongitude() * 1E6); + locLat = (int) (loc.getLatitude() * 1E6); + + // Skip to every n-th point (depends on zoom level, see above): + for (int j = 0; j < di && i < points.size() - 1; j++) { + // TODO Check the thread synchronization. + // There is no reason that points.get(i + 1) should be null but users + // have reported it causing null pointer exceptions. + if ((locLat > 90E6) || + (lastLocLat > 90E6) || + (points.get(i + 1) == null) || + (points.get(i + 1).getLatitude() > 90)) { + break; + } + i++; + loc = points.get(i); + locLon = (int) (loc.getLongitude() * 1E6); + locLat = (int) (loc.getLatitude() * 1E6); + } + + // Draw a line segment if it's inside the viewing window: + if (locLat < 90E6 && lastLocLat < 90E6) { + lastValidLocation = loc; + minLon = Math.min(locLon, lastLocLon); + maxLon = Math.max(locLon, lastLocLon); + minLat = Math.min(locLat, lastLocLat); + maxLat = Math.max(locLat, lastLocLat); + if (rect.intersects(minLon, minLat, maxLon, maxLat)) { + if (!wasInside) { + geoPoint = new GeoPoint(lastLocLat, lastLocLon); + mapView.getProjection().toPixels(geoPoint, pt); + path.moveTo(pt.x, pt.y); + } + geoPoint = new GeoPoint(locLat, locLon); + mapView.getProjection().toPixels(geoPoint, pt); + path.lineTo(pt.x, pt.y); + wasInside = rect.contains(locLon, locLat); + } + } else { + wasInside = false; + } + lastLocLon = locLon; + lastLocLat = locLat; + } + canvas.drawPath(path, selectedTrackPaint); + + // Draw the "End" marker: + Location currentLastValidLocation = lastValidLocation; + if (showEndMarker && currentLastValidLocation != null) { + canvas.save(); + geoPoint = MyTracksUtils.getGeoPoint(currentLastValidLocation); + mapView.getProjection().toPixels(geoPoint, pt); + canvas.translate(pt.x - (markerWidth / 2), pt.y - markerHeight); + endMarker.draw(canvas); + canvas.restore(); + } + + // Draw the "Start" marker: + for (int i = 0; i < points.size(); i++) { + loc = points.get(i); + if (loc.getLatitude() < 90) { + locLon = (int) (loc.getLongitude() * 1E6); + locLat = (int) (loc.getLatitude() * 1E6); + geoPoint = new GeoPoint(locLat, locLon); + mapView.getProjection().toPixels(geoPoint, pt); + canvas.save(); + canvas.translate(pt.x - (markerWidth / 2), pt.y - markerHeight); + startMarker.draw(canvas); + canvas.restore(); + break; + } + } + } + + @Override + public boolean draw(Canvas canvas, MapView mapView, boolean shadow, + long when) { + draw(canvas, mapView, shadow); + return false; + } + + /** + * Sets the pointer location (will be drawn on next invalidate). + */ + public void setMyLocation(Location myLocation) { + this.myLocation = myLocation; + } + + /** + * Sets the pointer heading in degrees (will be drawn on next invalidate). + * + * @return true if the visible heading changed (i.e. a redraw of pointer is + * potentially necessary) + */ + public boolean setHeading(float heading) { + int newhdg = Math.round(-heading / 360 * 18 + 180); + while (newhdg < 0) + newhdg += 18; + while (newhdg > 17) + newhdg -= 18; + if (newhdg != lastHeading) { + lastHeading = newhdg; + return true; + } else { + return false; + } + } + + @Override + public boolean onTap(GeoPoint p, MapView mapView) { + if (p.equals(mapView.getMapCenter())) { + // There is (unfortunately) no good way to determine whether the tap was + // caused by an actual tap on the screen or the track ball. If the + // location is equal to the map center,then it was a track ball press with + // very high likelihood. + return false; + } + + final Location tapLocation = MyTracksUtils.getLocation(p); + double dmin = Double.MAX_VALUE; + Waypoint waypoint = null; + for (int i = 0; i < waypoints.size(); i++) { + final Location waypointLocation = waypoints.get(i).getLocation(); + if (waypointLocation == null) { + continue; + } + final double d = waypointLocation.distanceTo(tapLocation); + if (d < dmin) { + dmin = d; + waypoint = waypoints.get(i); + } + } + + if (waypoint != null + && dmin < 15000000 / Math.pow(2, mapView.getZoomLevel())) { + Intent intent = new Intent(context, MyTracksWaypointDetails.class); + intent.putExtra("waypointid", waypoint.getId()); + context.startActivity(intent); + return true; + } + return super.onTap(p, mapView); + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/MyTracksSettings.java b/MyTracks/src/com/google/android/apps/mytracks/MyTracksSettings.java new file mode 100644 index 000000000..776b5612f --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/MyTracksSettings.java @@ -0,0 +1,152 @@ +/* + * Copyright 2008 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; + +import com.google.android.apps.mytracks.services.SafeStatusAnnouncerTask; +import com.google.android.maps.mytracks.R; + +import android.os.Bundle; +import android.preference.CheckBoxPreference; +import android.preference.ListPreference; +import android.preference.Preference; +import android.preference.Preference.OnPreferenceChangeListener; +import android.preference.PreferenceActivity; +import android.preference.PreferenceManager; +import android.util.Log; + +/** + * An activity that let's the user see and edit the settings. + * + * @author Leif Hendrik Wilden + * @author Rodrigo Damazio + */ +public class MyTracksSettings extends PreferenceActivity { + + public static final String SETTINGS_NAME = "MyTracksSettings"; + + public static final String ANNOUNCEMENT_FREQUENCY = "announcementFrequency"; + public static final String DEFAULT_MAP_PUBLIC = "defaultMapPublic"; + public static final String MAX_RECORDING_DISTANCE = "maxRecordingDistance"; + public static final String METRIC_UNITS = "metricUnits"; + public static final String MIN_RECORDING_DISTANCE = "minRecordingDistance"; + public static final String MIN_RECORDING_INTERVAL = "minRecordingInterval"; + public static final String MIN_REQUIRED_ACCURACY = "minRequiredAccuracy"; + public static final String PICK_EXISTING_MAP = "pickExistingMap"; + public static final String RECORDING_TRACK = "recordingTrack"; + public static final String REPORT_SPEED = "reportSpeed"; + public static final String SELECTED_TRACK = "selectedTrack"; + public static final String SEND_STATS_AND_POINTS = "sendStatsAndPoints"; + public static final String SEND_TO_DOCS = "sendToDocs"; + public static final String SEND_TO_MYMAPS = "sendToMyMaps"; + public static final String SHARE_URL_ONLY = "shareUrlOnly"; + public static final String SIGNAL_SAMPLING_FREQUENCY = + "signalSamplingFrequency"; + public static final String SPLIT_FREQUENCY = "splitFrequency"; + public static final String WRITE_TO_SD_CARD = "writeToSdCard"; + + /* + * Default values - keep in sync with those in preferences.xml. + */ + public static final int DEFAULT_ANNOUNCEMENT_FREQUENCY = -1; + public static final int DEFAULT_MAX_RECORDING_DISTANCE = 200; + public static final int DEFAULT_MIN_RECORDING_DISTANCE = 5; + public static final int DEFAULT_MIN_RECORDING_INTERVAL = 0; + public static final int DEFAULT_MIN_REQUIRED_ACCURACY = 200; + public static final int DEFAULT_SPLIT_FREQUENCY = 0; + + private static boolean mTTSAvailable; + + /* establish whether the tts class is available to us */ + static { + try { + SafeStatusAnnouncerTask.checkAvailable(); + mTTSAvailable = true; + } catch (Throwable t) { + Log.d(MyTracksConstants.TAG, "TTS not available.", t); + mTTSAvailable = false; + } + } + + /** Called when the activity is first created. */ + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + + // Tell it where to read/write preferences + PreferenceManager preferenceManager = getPreferenceManager(); + preferenceManager.setSharedPreferencesName(SETTINGS_NAME); + preferenceManager.setSharedPreferencesMode(0); + + // Load the preferences to be displayed + addPreferencesFromResource(R.xml.preferences); + + // Hook up switching of displayed list entries between metric and imperial + // units + CheckBoxPreference metricUnitsPreference = + (CheckBoxPreference) findPreference(METRIC_UNITS); + metricUnitsPreference.setOnPreferenceChangeListener( + new OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, + Object newValue) { + boolean isMetric = (Boolean) newValue; + updatePreferenceUnits(isMetric); + return true; + } + }); + updatePreferenceUnits(metricUnitsPreference.isChecked()); + + // Disable TTS announcement preference if not available + if (!mTTSAvailable) { + IntegerListPreference announcementFrequency = + (IntegerListPreference) findPreference(ANNOUNCEMENT_FREQUENCY); + announcementFrequency.setEnabled(false); + announcementFrequency.setValue("-1"); + announcementFrequency.setSummary( + R.string.settings_announcement_not_available_summary); + } + } + + /** + * Updates all the preferences which give options with distance units to use + * the proper unit the user has selected. + * + * @param isMetric true to use metric units, false to use imperial + */ + private void updatePreferenceUnits(boolean isMetric) { + final ListPreference minRecordingDistance = + (ListPreference) findPreference(MIN_RECORDING_DISTANCE); + final ListPreference maxRecordingDistance = + (ListPreference) findPreference(MAX_RECORDING_DISTANCE); + final ListPreference minRequiredAccuracy = + (ListPreference) findPreference(MIN_REQUIRED_ACCURACY); + final ListPreference splitFrequency = + (ListPreference) findPreference(SPLIT_FREQUENCY); + + minRecordingDistance.setEntries(isMetric + ? R.array.min_recording_distance_options + : R.array.min_recording_distance_options_ft); + maxRecordingDistance.setEntries(isMetric + ? R.array.max_recording_distance_options + : R.array.max_recording_distance_options_ft); + minRequiredAccuracy.setEntries(isMetric + ? R.array.min_required_accuracy_options + : R.array.min_required_accuracy_options_ft); + splitFrequency.setEntries(isMetric + ? R.array.split_frequency_options + : R.array.split_frequency_options_ft); + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/MyTracksWaypointDetails.java b/MyTracks/src/com/google/android/apps/mytracks/MyTracksWaypointDetails.java new file mode 100644 index 000000000..41f058c03 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/MyTracksWaypointDetails.java @@ -0,0 +1,172 @@ +/* + * Copyright 2009 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; + +import com.google.android.apps.mytracks.content.MyTracksProviderUtils; +import com.google.android.apps.mytracks.content.Waypoint; +import com.google.android.apps.mytracks.content.WaypointsColumns; +import com.google.android.maps.mytracks.R; + +import android.app.Activity; +import android.content.ContentValues; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.ArrayAdapter; +import android.widget.AutoCompleteTextView; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageView; + +/** + * Screen in which the user enters details about a waypoint. + * + * @author Leif Hendrik Wilden + */ +public class MyTracksWaypointDetails extends Activity + implements OnClickListener { + + /** + * The id of the way point being edited (taken from bundle, "waypointid") + */ + private Long waypointId; + + private EditText name; + private EditText description; + private AutoCompleteTextView category; + private View detailsView; + private View statsView; + + private StatsUtilities utils; + private Waypoint waypoint; + + @Override + protected void onCreate(Bundle bundle) { + super.onCreate(bundle); + setContentView(R.layout.mytracks_waypoint_details); + + utils = new StatsUtilities(this); + SharedPreferences preferences = + getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + if (preferences != null) { + boolean useMetric = + preferences.getBoolean(MyTracksSettings.METRIC_UNITS, true); + utils.setMetricUnits(useMetric); + + boolean displaySpeed = + preferences.getBoolean(MyTracksSettings.REPORT_SPEED, true); + utils.setReportSpeed(displaySpeed); + + utils.updateWaypointUnits(); + utils.setSpeedLabels(); + } + + // Required extra when launching this intent: + waypointId = getIntent().getLongExtra("waypointid", -1); + if (waypointId < 0) { + Log.d(MyTracksConstants.TAG, + "MyTracksWaypointsDetails intent was launched w/o waypoint id."); + finish(); + return; + } + + // Optional extra that can be used to suppress the cancel button: + boolean hasCancelButton = + getIntent().getBooleanExtra("hasCancelButton", true); + + name = (EditText) findViewById(R.id.waypointdetails_name); + description = (EditText) findViewById(R.id.waypointdetails_description); + category = + (AutoCompleteTextView) findViewById(R.id.waypointdetails_category); + statsView = findViewById(R.id.waypoint_stats); + ArrayAdapter adapter = ArrayAdapter.createFromResource( + this, + R.array.waypoint_types, + android.R.layout.simple_dropdown_item_1line); + category.setAdapter(adapter); + detailsView = findViewById(R.id.waypointdetails_description_layout); + + Button cancel = (Button) findViewById(R.id.waypointdetails_cancel); + if (hasCancelButton) { + cancel.setOnClickListener(this); + cancel.setVisibility(View.VISIBLE); + } else { + cancel.setVisibility(View.GONE); + } + Button save = (Button) findViewById(R.id.waypointdetails_save); + save.setOnClickListener(this); + + fillDialog(); + } + + private void fillDialog() { + waypoint = MyTracksProviderUtils.Factory.get(this).getWaypoint(waypointId); + if (waypoint != null) { + name.setText(waypoint.getName()); + ImageView icon = (ImageView) findViewById(R.id.waypointdetails_icon); + int iconId = -1; + switch(waypoint.getType()) { + case Waypoint.TYPE_WAYPOINT: + description.setText(waypoint.getDescription()); + detailsView.setVisibility(View.VISIBLE); + category.setText(waypoint.getCategory()); + statsView.setVisibility(View.GONE); + iconId = R.drawable.blue_pushpin; + break; + case Waypoint.TYPE_STATISTICS: + detailsView.setVisibility(View.GONE); + statsView.setVisibility(View.VISIBLE); + iconId = R.drawable.ylw_pushpin; + utils.setAllStats(waypoint); + utils.setTime(R.id.total_time_register, waypoint.getTotalTime()); + utils.setAltitude( + R.id.elevation_register, waypoint.getLocation().getAltitude()); + break; + } + icon.setImageDrawable(getResources().getDrawable(iconId)); + } + } + + private void saveDialog() { + ContentValues values = new ContentValues(); + values.put(WaypointsColumns.NAME, name.getText().toString()); + if (waypoint != null && waypoint.getType() == Waypoint.TYPE_WAYPOINT) { + values.put(WaypointsColumns.DESCRIPTION, + description.getText().toString()); + values.put(WaypointsColumns.CATEGORY, category.getText().toString()); + } + getContentResolver().update( + WaypointsColumns.CONTENT_URI, + values, + "_id = " + waypointId, + null /*selectionArgs*/); + } + + @Override + public void onClick(View v) { + switch (v.getId()) { + case R.id.waypointdetails_cancel: + finish(); + break; + case R.id.waypointdetails_save: + saveDialog(); + finish(); + break; + } + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/MyTracksWaypointsList.java b/MyTracks/src/com/google/android/apps/mytracks/MyTracksWaypointsList.java new file mode 100644 index 000000000..7a523060d --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/MyTracksWaypointsList.java @@ -0,0 +1,261 @@ +/* + * Copyright 2009 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; + +import com.google.android.apps.mytracks.content.MyTracksProviderUtils; +import com.google.android.apps.mytracks.content.Waypoint; +import com.google.android.apps.mytracks.content.WaypointsColumns; +import com.google.android.apps.mytracks.util.StringUtils; +import com.google.android.maps.mytracks.R; + +import android.app.AlertDialog; +import android.app.ListActivity; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.os.Bundle; +import android.view.ContextMenu; +import android.view.ContextMenu.ContextMenuInfo; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnCreateContextMenuListener; +import android.view.Window; +import android.widget.AdapterView; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.SimpleCursorAdapter; +import android.widget.TextView; + +/** + * Activity which shows the list of waypoints in a track. + * + * @author Leif Hendrik Wilden + */ +public class MyTracksWaypointsList extends ListActivity + implements View.OnClickListener { + + private int contextPosition = -1; + private long trackId = -1; + private long waypointId = -1; + private ListView listView = null; + private Button insertWaypointButton = null; + private Button insertStatisticsButton = null; + private long recordingTrackId = -1; + private long selectedTrackId = -1; + private MyTracksProviderUtils providerUtils; + + private Cursor waypointsCursor = null; + + private final OnCreateContextMenuListener contextMenuListener = + new OnCreateContextMenuListener() { + public void onCreateContextMenu(ContextMenu menu, View v, + ContextMenuInfo menuInfo) { + menu.setHeaderTitle(R.string.waypointslist_this_waypoint); + AdapterView.AdapterContextMenuInfo info = + (AdapterView.AdapterContextMenuInfo) menuInfo; + contextPosition = info.position; + waypointId = MyTracksWaypointsList.this.listView.getAdapter() + .getItemId(contextPosition); + int type = providerUtils.getWaypoint(info.id).getType(); + menu.add(0, MyTracksConstants.MENU_SHOW, 0, + R.string.waypointslist_show_waypoint); + menu.add(0, MyTracksConstants.MENU_EDIT, 0, + R.string.waypointslist_edit_waypoint); + menu.add(0, MyTracksConstants.MENU_DELETE, 0, + R.string.waypointslist_delete_waypoint).setEnabled( + recordingTrackId < 0 || type == Waypoint.TYPE_WAYPOINT || + info.id != providerUtils.getLastWaypointId(recordingTrackId)); + } + }; + + @Override + protected void onListItemClick(ListView l, View v, int position, long id) { + Intent result = new Intent(); + result.putExtra("trackid", trackId); + result.putExtra("waypointid", id); + setResult(MyTracksConstants.EDIT_WAYPOINT, result); + finish(); + } + + @Override + public boolean onMenuItemSelected(int featureId, MenuItem item) { + if (!super.onMenuItemSelected(featureId, item)) { + switch (item.getItemId()) { + case MyTracksConstants.MENU_SHOW: { + onListItemClick(null, null, 0, waypointId); + return true; + } + case MyTracksConstants.MENU_EDIT: { + Intent intent = new Intent(this, MyTracksWaypointDetails.class); + intent.putExtra("trackid", trackId); + intent.putExtra("waypointid", waypointId); + startActivity(intent); + return true; + } + case MyTracksConstants.MENU_DELETE: { + deleteWaypoint(waypointId); + } + } + } + return false; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + providerUtils = MyTracksProviderUtils.Factory.get(this); + + // We don't need a window title bar: + requestWindowFeature(Window.FEATURE_NO_TITLE); + + setContentView(R.layout.mytracks_waypoints_list); + + listView = getListView(); + listView.setOnCreateContextMenuListener(contextMenuListener); + + insertWaypointButton = + (Button) findViewById(R.id.waypointslist_btn_insert_waypoint); + insertWaypointButton.setOnClickListener(this); + insertStatisticsButton = + (Button) findViewById(R.id.waypointslist_btn_insert_statistics); + insertStatisticsButton.setOnClickListener(this); + SharedPreferences preferences = + getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + if (preferences != null) { + recordingTrackId = + preferences.getLong(MyTracksSettings.RECORDING_TRACK, -1); + selectedTrackId = + preferences.getLong(MyTracksSettings.SELECTED_TRACK, -1); + } + boolean selectedRecording = selectedTrackId > 0 + && selectedTrackId == recordingTrackId; + insertWaypointButton.setEnabled(selectedRecording); + insertStatisticsButton.setEnabled(selectedRecording); + + if (getIntent() != null && getIntent().getExtras() != null) { + trackId = getIntent().getExtras().getLong("trackid", -1); + } else { + trackId = -1; + } + + final long firstWaypointId = providerUtils.getFirstWaypointId(trackId); + waypointsCursor = getContentResolver().query( + WaypointsColumns.CONTENT_URI, null, + WaypointsColumns.TRACKID + "=" + trackId + " AND " + + WaypointsColumns._ID + "!=" + firstWaypointId, null, null); + startManagingCursor(waypointsCursor); + setListAdapter(); + } + + @Override + public void onClick(View v) { + switch (v.getId()) { + case R.id.waypointslist_btn_insert_waypoint: { + long id = MyTracks.getInstance().insertWaypointMarker(); + Intent intent = new Intent(this, MyTracksWaypointDetails.class); + intent.putExtra("waypointid", id); + startActivity(intent); + break; + } + case R.id.waypointslist_btn_insert_statistics: { + long id = MyTracks.getInstance().insertStatisticsMarker(); + Intent intent = new Intent(this, MyTracksWaypointDetails.class); + intent.putExtra("waypointid", id); + startActivity(intent); + break; + } + + } + } + + private void setListAdapter() { + // Get a cursor with all tracks + SimpleCursorAdapter adapter = new SimpleCursorAdapter( + this, + R.layout.mytracks_marker_item, + waypointsCursor, + new String[] { WaypointsColumns.NAME, WaypointsColumns.TIME, + WaypointsColumns.CATEGORY, WaypointsColumns.TYPE }, + new int[] { R.id.waypointslist_item_name, + R.id.waypointslist_item_time, + R.id.waypointslist_item_category, + R.id.waypointslist_item_icon }); + + final int timeIdx = + waypointsCursor.getColumnIndexOrThrow(WaypointsColumns.TIME); + final int typeIdx = + waypointsCursor.getColumnIndexOrThrow(WaypointsColumns.TYPE); + adapter.setViewBinder(new SimpleCursorAdapter.ViewBinder() { + @Override + public boolean setViewValue(View view, Cursor cursor, int columnIndex) { + if (columnIndex == timeIdx) { + long time = cursor.getLong(timeIdx); + TextView textView = (TextView) view; + textView.setText(String.format("%tc", time)); + textView.setVisibility( + textView.getText().length() < 1 ? View.GONE : View.VISIBLE); + } else if (columnIndex == typeIdx) { + int type = cursor.getInt(typeIdx); + ImageView imageView = (ImageView) view; + imageView.setImageDrawable(getResources().getDrawable( + type == Waypoint.TYPE_STATISTICS + ? R.drawable.ylw_pushpin + : R.drawable.blue_pushpin)); + } else { + TextView textView = (TextView) view; + textView.setText(cursor.getString(columnIndex)); + textView.setVisibility( + textView.getText().length() < 1 ? View.GONE : View.VISIBLE); + } + return true; + } + }); + setListAdapter(adapter); + } + + /** + * Deletes the way point with the given id. + * Prompts the user if he want to really delete it. + */ + public void deleteWaypoint(final long waypointId) { + AlertDialog dialog = null; + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setMessage(getString(R.string.marker_will_be_permanently_deleted)); + builder.setTitle(getString(R.string.are_you_sure_question)); + builder.setIcon(android.R.drawable.ic_dialog_alert); + builder.setPositiveButton(getString(R.string.yes), + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int i) { + dialog.dismiss(); + providerUtils.deleteWaypoint(waypointId, + new StringUtils(MyTracksWaypointsList.this)); + } + }); + builder.setNegativeButton(getString(R.string.no), + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int i) { + dialog.dismiss(); + } + }); + dialog = builder.create(); + dialog.show(); + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/NavControls.java b/MyTracks/src/com/google/android/apps/mytracks/NavControls.java new file mode 100644 index 000000000..3b402b851 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/NavControls.java @@ -0,0 +1,220 @@ +/* + * Copyright 2008 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; + +import com.google.android.maps.mytracks.R; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.os.Handler; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.widget.ImageView; +import android.widget.RelativeLayout; +import android.widget.RelativeLayout.LayoutParams; + +/** + * Creates previous and next arrows for a given activity. + * + * @author Leif Hendrik Wilden + */ +public class NavControls { + + private static final int KEEP_VISIBLE_MILLIS = 4000; + private static final boolean FADE_CONTROLS = true; + + private static final Animation SHOW_NEXT_ANIMATION = + new AlphaAnimation(0F, 1F); + private static final Animation HIDE_NEXT_ANIMATION = + new AlphaAnimation(1F, 0F); + private static final Animation SHOW_PREV_ANIMATION = + new AlphaAnimation(0F, 1F); + private static final Animation HIDE_PREV_ANIMATION = + new AlphaAnimation(1F, 0F); + + /** + * A touchable image view. + */ + public class TouchLayout extends RelativeLayout { + private final ImageView arrow; + private final ImageView icon; + + public TouchLayout(Context context, boolean isLeft) { + super(context); + arrow = new ImageView(context); + arrow.setImageDrawable(context.getResources().getDrawable( + isLeft ? R.drawable.btn_arrow_left : R.drawable.btn_arrow_right)); + icon = new ImageView(context); + icon.setVisibility(View.GONE); + addView(arrow); + addView(icon); + icon.setPadding((isLeft ? 15 : 10), 27, 15, 0); + } + + public void setIcon(Drawable drawable) { + icon.setImageDrawable(drawable); + icon.setVisibility(View.VISIBLE); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + setPressed(true); + hide(); + if (this == prevImage) { + if (prevRunnable != null) { + handler.post(prevRunnable); + return true; + } + } else if (this == nextImage) { + if (nextRunnable != null) { + handler.post(nextRunnable); + return true; + } + } + break; + case MotionEvent.ACTION_UP: + setPressed(false); + break; + } + return super.onTouchEvent(event); + } + } + + private final Context context; + private final Runnable prevRunnable; + private final Runnable nextRunnable; + private final Handler handler = new Handler(); + private final Runnable dismissControls = new Runnable() { + public void run() { + hide(); + } + }; + private final TouchLayout prevImage; + private final TouchLayout nextImage; + + private boolean isVisible = false; + private boolean hasNext = true; + private boolean hasPrev = true; + + public NavControls(Context context, ViewGroup container, + Runnable prevRunnable, Runnable nextRunnable) { + this.context = context; + this.prevRunnable = prevRunnable; + this.nextRunnable = nextRunnable; + LayoutParams prevParams = new LayoutParams( + LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT); + prevParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT); + prevParams.addRule(RelativeLayout.CENTER_VERTICAL); + prevImage = new TouchLayout(context, true); + prevImage.setLayoutParams(prevParams); + prevImage.setVisibility(View.INVISIBLE); + container.addView(prevImage); + LayoutParams nextParams = new LayoutParams( + LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT); + nextParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT); + nextParams.addRule(RelativeLayout.CENTER_VERTICAL); + nextImage = new TouchLayout(context, false); + nextImage.setLayoutParams(nextParams); + nextImage.setVisibility(View.INVISIBLE); + container.addView(nextImage); + } + + public void setHasNext(boolean hasNext) { + this.hasNext = hasNext; + } + + public void setHasPrev(boolean hasPrev) { + this.hasPrev = hasPrev; + } + + private void keepVisible() { + if (isVisible && FADE_CONTROLS) { + handler.removeCallbacks(dismissControls); + handler.postDelayed(dismissControls, KEEP_VISIBLE_MILLIS); + } + } + + public void show() { + if (!isVisible) { + if (prevRunnable != null && hasPrev) { + SHOW_PREV_ANIMATION.setDuration(500); + SHOW_PREV_ANIMATION.startNow(); + prevImage.setPressed(false); + prevImage.setAnimation(SHOW_PREV_ANIMATION); + prevImage.setVisibility(View.VISIBLE); + } + if (nextRunnable != null && hasNext) { + SHOW_NEXT_ANIMATION.setDuration(500); + SHOW_NEXT_ANIMATION.startNow(); + nextImage.setPressed(false); + nextImage.setAnimation(SHOW_NEXT_ANIMATION); + nextImage.setVisibility(View.VISIBLE); + } + isVisible = true; + keepVisible(); + } else { + keepVisible(); + } + } + + public void hideNow() { + handler.removeCallbacks(dismissControls); + isVisible = false; + prevImage.clearAnimation(); + prevImage.setVisibility(View.INVISIBLE); + nextImage.clearAnimation(); + nextImage.setVisibility(View.INVISIBLE); + } + + public void hide() { + isVisible = false; + if (prevRunnable != null) { + if (hasPrev) { + prevImage.setAnimation(HIDE_PREV_ANIMATION); + HIDE_PREV_ANIMATION.setDuration(500); + HIDE_PREV_ANIMATION.startNow(); + } else { + prevImage.clearAnimation(); + } + prevImage.setVisibility(View.INVISIBLE); + } + if (nextRunnable != null) { + if (hasNext) { + nextImage.setAnimation(HIDE_NEXT_ANIMATION); + HIDE_NEXT_ANIMATION.setDuration(500); + HIDE_NEXT_ANIMATION.startNow(); + } else { + nextImage.clearAnimation(); + } + nextImage.setVisibility(View.INVISIBLE); + } + } + + public void setLeftIcon(int resourceId) { + prevImage.setIcon(context.getResources().getDrawable(resourceId)); + } + + public void setRightIcon(int resourceId) { + nextImage.setIcon(context.getResources().getDrawable(resourceId)); + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/ProgressIndicator.java b/MyTracks/src/com/google/android/apps/mytracks/ProgressIndicator.java new file mode 100644 index 000000000..e013e4b04 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/ProgressIndicator.java @@ -0,0 +1,30 @@ +/* + * Copyright 2008 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; + +/** + * Interface that allows to set a progress dialog message and progress value + * in percent. + * + * @author Leif Hendrik Wilden + */ +public interface ProgressIndicator { + + public void setProgressMessage(String text); + + public void setProgressValue(int percent); + +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/SendToGoogleDialog.java b/MyTracks/src/com/google/android/apps/mytracks/SendToGoogleDialog.java new file mode 100644 index 000000000..8e7847cb0 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/SendToGoogleDialog.java @@ -0,0 +1,159 @@ +/* + * Copyright 2008 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; + +import com.google.android.maps.mytracks.R; + +import android.app.Dialog; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; +import android.os.Bundle; +import android.view.View; +import android.view.Window; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.CompoundButton.OnCheckedChangeListener; +import android.widget.RadioButton; +import android.widget.RadioGroup; + +/** + * A dialog where the user can choose where to send the tracks to, i.e. + * to Google My Maps, Google Docs, etc. + * + * @author Leif Hendrik Wilden + */ +public class SendToGoogleDialog extends Dialog { + + private RadioGroup groupMyMaps; + private RadioButton createNewMapRadioButton; + private RadioButton pickMapRadioButton; + private CheckBox sendToMyMapsCheckBox; + private CheckBox sendToDocsCheckBox; + private RadioButton sendStatsRadioButton; + private RadioButton sendStatsAndPointsRadioButton; + private Button sendButton; + + public SendToGoogleDialog(Context context) { + super(context); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + requestWindowFeature(Window.FEATURE_NO_TITLE); + setContentView(R.layout.mytracks_send_to_google); + + Button cancel = (Button) findViewById(R.id.sendtogoogle_cancel); + cancel.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + MyTracks.getInstance().dismissDialog(MyTracks.DIALOG_SEND_TO_GOOGLE); + } + }); + + Button send = (Button) findViewById(R.id.sendtogoogle_send_now); + send.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + dismiss(); + MyTracks.getInstance().sendToGoogle(); + } + }); + + OnCheckedChangeListener checkBoxListener = new OnCheckedChangeListener() { + public void onCheckedChanged(CompoundButton button, boolean checked) { + sendButton.setEnabled( + sendToMyMapsCheckBox.isChecked() || + sendToDocsCheckBox.isChecked()); + groupMyMaps.setVisibility( + sendToMyMapsCheckBox.isChecked() ? View.VISIBLE : View.INVISIBLE); + //groupDocs.setVisibility( + // sendToDocsCheckBox.isChecked() ? View.VISIBLE : View.INVISIBLE); + } + }; + + sendButton = (Button) findViewById(R.id.sendtogoogle_send_now); + groupMyMaps = (RadioGroup) findViewById(R.id.sendtogoogle_group_mymaps); + sendToMyMapsCheckBox = + (CheckBox) findViewById(R.id.sendtogoogle_google_mymaps); + sendToMyMapsCheckBox.setOnCheckedChangeListener(checkBoxListener); + sendToDocsCheckBox = (CheckBox) findViewById(R.id.sendtogoogle_google_docs); + sendToDocsCheckBox.setOnCheckedChangeListener(checkBoxListener); + createNewMapRadioButton = + (RadioButton) findViewById(R.id.sendtogoogle_create_new_map); + pickMapRadioButton = + (RadioButton) findViewById(R.id.sendtogoogle_pick_existing_map); + sendStatsRadioButton = + (RadioButton) findViewById(R.id.sendtogoogle_send_stats); + sendStatsAndPointsRadioButton = (RadioButton) findViewById( + R.id.sendtogoogle_send_stats_and_points); + + SharedPreferences prefs = + getContext().getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + if (prefs != null) { + sendToMyMapsCheckBox.setChecked( + prefs.getBoolean(MyTracksSettings.SEND_TO_MYMAPS, true)); + sendToDocsCheckBox.setChecked( + prefs.getBoolean(MyTracksSettings.SEND_TO_DOCS, true)); + pickMapRadioButton.setChecked( + prefs.getBoolean(MyTracksSettings.PICK_EXISTING_MAP, false)); + createNewMapRadioButton.setChecked( + !prefs.getBoolean(MyTracksSettings.PICK_EXISTING_MAP, false)); + sendStatsAndPointsRadioButton.setChecked( + prefs.getBoolean(MyTracksSettings.SEND_STATS_AND_POINTS, false)); + sendStatsRadioButton.setChecked( + !prefs.getBoolean(MyTracksSettings.SEND_STATS_AND_POINTS, false)); + } + } + + @Override + protected void onStop() { + SharedPreferences prefs = + getContext().getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + if (prefs != null) { + Editor editor = prefs.edit(); + if (editor != null) { + editor.putBoolean( + MyTracksSettings.SEND_TO_MYMAPS, sendToMyMapsCheckBox.isChecked()); + editor.putBoolean( + MyTracksSettings.SEND_TO_DOCS, sendToDocsCheckBox.isChecked()); + editor.putBoolean( + MyTracksSettings.PICK_EXISTING_MAP, pickMapRadioButton.isChecked()); + editor.putBoolean( + MyTracksSettings.SEND_STATS_AND_POINTS, + sendStatsAndPointsRadioButton.isChecked()); + editor.commit(); + } + } + super.onStop(); + } + + public boolean getSendToMyMaps() { + return sendToMyMapsCheckBox.isChecked(); + } + + public boolean getSendToDocs() { + return sendToDocsCheckBox.isChecked(); + } + + public boolean getCreateNewMap() { + return createNewMapRadioButton.isChecked(); + } + + public boolean getSendStatsAndPoints() { + return sendStatsAndPointsRadioButton.isChecked(); + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/StatsActivity.java b/MyTracks/src/com/google/android/apps/mytracks/StatsActivity.java new file mode 100644 index 000000000..c16bb69fa --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/StatsActivity.java @@ -0,0 +1,474 @@ +/* + * Copyright 2008 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; + +import com.google.android.apps.mytracks.content.TracksColumns; +import com.google.android.apps.mytracks.content.WaypointsColumns; +import com.google.android.maps.mytracks.R; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.SharedPreferences.OnSharedPreferenceChangeListener; +import android.database.ContentObserver; +import android.database.Cursor; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.location.LocationProvider; +import android.os.Bundle; +import android.os.Handler; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.Window; +import android.widget.ScrollView; +import android.widget.TextView; +import android.widget.Toast; + +/** + * An activity that displays track statistics to the user. + * + * @author Sandor Dornbush + */ +public class StatsActivity extends Activity + implements OnSharedPreferenceChangeListener, LocationListener { + + /** + * Columns we need from the provider + */ + private static final String[] PROJECTION_COLUMNS = { + TracksColumns._ID, + TracksColumns.STARTTIME, + TracksColumns.TOTALTIME, + TracksColumns.MOVINGTIME, + TracksColumns.TOTALDISTANCE, + TracksColumns.AVGSPEED, + TracksColumns.AVGMOVINGSPEED, + TracksColumns.MAXSPEED, + TracksColumns.MINELEVATION, + TracksColumns.MAXELEVATION, + TracksColumns.ELEVATIONGAIN, + TracksColumns.MINGRADE, + TracksColumns.MAXGRADE }; + + private final StatsUtilities utils; + private UIUpdateThread thread; + + private ContentObserver observer; + private LocationManager locationManager; + + /** + * The id of the currently selected track. + */ + private long selectedTrackId = -1; + + /** + * The id of the currently recording track. + */ + private long recordingTrackId = -1; + + /** + * The start time of the selected track. + */ + private long startTime = -1; + + /** + * True if distances should be displayed in metric units (from shared + * preferences). + */ + private boolean metricUnits = true; + + /** + * True if pace should be displayed as dist/time (from shared preferences). + */ + private boolean displaySpeed = true; + + /** + * true if activity has resumed and is on top + */ + private boolean activityOnTop = false; + + /** + * If true, the statistics for the current segment are shown, otherwise + * for the full track. + */ + private boolean showCurrentSegment = false; + + /** + * A runnable for posting to the UI thread. Will update the total time field. + */ + private final Runnable updateResults = new Runnable() { + public void run() { + updateTotalTime(); + } + }; + + /** + * A thread that updates the total time field every second. + */ + private class UIUpdateThread extends Thread { + + public UIUpdateThread() { + super(); + Log.i(MyTracksConstants.TAG, "Created UI update thread"); + } + + @Override + public void run() { + Log.i(MyTracksConstants.TAG, "Started UI update thread"); + while (MyTracks.getInstance().isRecording()) { + long sleeptime = 1000; + runOnUiThread(updateResults); + try { + Thread.sleep(sleeptime); + } catch (InterruptedException e) { + Log.w(MyTracksConstants.TAG, + "StatsActivity: Caught exception on sleep.", e); + break; + } + } + Log.w(MyTracksConstants.TAG, "UIUpdateThread finished."); + } + } + + public StatsActivity() { + this.utils = new StatsUtilities(this); + } + + /** Called when the activity is first created. */ + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // We don't need a window title bar: + requestWindowFeature(Window.FEATURE_NO_TITLE); + + setContentView(R.layout.stats); + + Handler contentHandler = new Handler(); + observer = new ContentObserver(contentHandler) { + @Override + public void onChange(boolean selfChange) { + Log.d(MyTracksConstants.TAG, "StatsActivity: ContentObserver.onChange"); + restoreStats(); + super.onChange(selfChange); + } + }; + + ScrollView sv = ((ScrollView) findViewById(R.id.scrolly)); + sv.setScrollBarStyle(ScrollView.SCROLLBARS_OUTSIDE_INSET); + + SharedPreferences preferences = + getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + if (preferences != null) { + selectedTrackId = preferences.getLong(MyTracksSettings.SELECTED_TRACK, + -1); + recordingTrackId = preferences.getLong(MyTracksSettings.RECORDING_TRACK, + -1); + metricUnits = preferences.getBoolean(MyTracksSettings.METRIC_UNITS, + true); + displaySpeed = + preferences.getBoolean(MyTracksSettings.REPORT_SPEED, true); + checkLiveTrack(); + restoreStats(); + updateLocation(null); + preferences.registerOnSharedPreferenceChangeListener(this); + } + locationManager = + (LocationManager) getSystemService(Context.LOCATION_SERVICE); + utils.setMetricUnits(metricUnits); + utils.setReportSpeed(displaySpeed); + utils.updateUnits(); + utils.setSpeedLabel(R.id.speed_label, R.string.speed, R.string.pace_label); + utils.setSpeedLabels(); + + DisplayMetrics metrics = new DisplayMetrics(); + getWindowManager().getDefaultDisplay().getMetrics(metrics); + if (metrics.heightPixels > 600) { + ((TextView) findViewById(R.id.speed_register)).setTextSize(80.0f); + } + } + + @Override + protected void onPause() { + unregisterLocationListener(); + if (thread != null) { + thread.interrupt(); + thread = null; + } + getContentResolver().unregisterContentObserver(observer); + activityOnTop = false; + super.onPause(); + } + + @Override + protected void onResume() { + activityOnTop = true; + checkLiveTrack(); + restoreStats(); + updateLocation(null); + super.onResume(); + } + + @Override + public void onSharedPreferenceChanged( + SharedPreferences sharedPreferences, String key) { + Log.d(MyTracksConstants.TAG, + "StatsActivity: onSharedPreferences changed " + key); + if (key != null) { + if (key.equals(MyTracksSettings.SELECTED_TRACK)) { + selectedTrackId = + sharedPreferences.getLong(MyTracksSettings.SELECTED_TRACK, -1); + checkLiveTrack(); + restoreStats(); + updateLocation(null); + } else if (key.equals(MyTracksSettings.RECORDING_TRACK)) { + recordingTrackId = + sharedPreferences.getLong(MyTracksSettings.RECORDING_TRACK, -1); + checkLiveTrack(); + restoreStats(); + updateLocation(null); + } else if (key.equals(MyTracksSettings.METRIC_UNITS)) { + metricUnits = + sharedPreferences.getBoolean(MyTracksSettings.METRIC_UNITS, true); + utils.setMetricUnits(metricUnits); + utils.updateUnits(); + restoreStats(); + } else if (key.equals(MyTracksSettings.REPORT_SPEED)) { + displaySpeed = + sharedPreferences.getBoolean(MyTracksSettings.REPORT_SPEED, true); + utils.setReportSpeed(displaySpeed); + utils.updateUnits(); + utils.setSpeedLabel( + R.id.speed_label, R.string.speed, R.string.pace_label); + Log.w(MyTracksConstants.TAG, "Setting speed labels"); + utils.setSpeedLabels(); + restoreStats(); + } + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + MenuItem currentSegment = menu.add(0, + MyTracksConstants.MENU_CURRENT_SEGMENT, 0, R.string.current_segment); + currentSegment.setIcon(R.drawable.menu_by_distance); + return true; + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + MenuItem item = menu.findItem(MyTracksConstants.MENU_CURRENT_SEGMENT); + if (item != null) { + item.setTitle(showCurrentSegment + ? getString(R.string.current_track) + : getString(R.string.current_segment)); + } + return super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case MyTracksConstants.MENU_CURRENT_SEGMENT: + showCurrentSegment = !showCurrentSegment; + restoreStats(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onLocationChanged(Location l) { + if (selectedTrackIsRecording()) { + updateLocation(l); + } + } + + @Override + public void onProviderDisabled(String provider) { + } + + @Override + public void onProviderEnabled(String provider) { + } + + @Override + public void onStatusChanged(String provider, int status, Bundle extras) { + } + + /** + * Registers to receive location updates from the GPS location provider. + */ + private void registerLocationListener() { + if (locationManager != null) { + LocationProvider gpsProvider = + locationManager.getProvider(MyTracksConstants.GPS_PROVIDER); + if (gpsProvider == null) { + Toast.makeText(this, getString(R.string.error_no_gps_location_provider), + Toast.LENGTH_LONG).show(); + return; + } else { + Log.d(MyTracksConstants.TAG, "StatsActivity: Using location provider " + + gpsProvider.getName()); + } + locationManager.requestLocationUpdates(gpsProvider.getName(), + 0/*minTime*/, 0/*minDist*/, this); + } + } + + /** + * Unregisters all location listener. + */ + private void unregisterLocationListener() { + if (locationManager != null) { + locationManager.removeUpdates(this); + } + } + + /** + * @return true if the selected track is the currently recording track + */ + private boolean selectedTrackIsRecording() { + return MyTracks.getInstance().isRecording() + && selectedTrackId == recordingTrackId; + } + + /** + * Reads values for selected tracks from provider and update the UI. + */ + private void restoreStats() { + if (selectedTrackId < 0) { + utils.setAllToUnknown(); + return; + } + + Cursor cursor = null; + try { + cursor = getContentResolver().query( + showCurrentSegment + ? WaypointsColumns.CONTENT_URI + : TracksColumns.CONTENT_URI, + PROJECTION_COLUMNS, + showCurrentSegment + ? WaypointsColumns.TRACKID + "=" + selectedTrackId + : "_id=" + selectedTrackId, + null, + "_id LIMIT 1"); + + if (cursor != null && cursor.moveToFirst()) { + final int idxStartTime = + cursor.getColumnIndexOrThrow(TracksColumns.STARTTIME); + final int idxTotalTime = + cursor.getColumnIndexOrThrow(TracksColumns.TOTALTIME); + final int idxMovingTime = + cursor.getColumnIndexOrThrow(TracksColumns.MOVINGTIME); + final int idxTotalDistance = + cursor.getColumnIndexOrThrow(TracksColumns.TOTALDISTANCE); + final int idxAverageSpeed = + cursor.getColumnIndexOrThrow(TracksColumns.AVGSPEED); + final int idxAverageMovingSpeed = + cursor.getColumnIndexOrThrow(TracksColumns.AVGMOVINGSPEED); + final int idxMaxSpeed = + cursor.getColumnIndexOrThrow(TracksColumns.MAXSPEED); + final int idxMinElevation = + cursor.getColumnIndexOrThrow(TracksColumns.MINELEVATION); + final int idxMaxElevation = + cursor.getColumnIndexOrThrow(TracksColumns.MAXELEVATION); + final int idxElevationGain = + cursor.getColumnIndexOrThrow(TracksColumns.ELEVATIONGAIN); + final int idxMinGrade = + cursor.getColumnIndexOrThrow(TracksColumns.MINGRADE); + final int idxMaxGrade = + cursor.getColumnIndexOrThrow(TracksColumns.MAXGRADE); + startTime = cursor.getLong(idxStartTime); + if (!selectedTrackIsRecording()) { + utils.setTime(R.id.total_time_register, cursor.getLong(idxTotalTime)); + } + utils.setAllStats(cursor.getLong(idxMovingTime), + cursor.getFloat(idxTotalDistance), cursor.getFloat(idxAverageSpeed), + cursor.getFloat(idxAverageMovingSpeed), + cursor.getFloat(idxMaxSpeed), cursor.getFloat(idxMinElevation), + cursor.getFloat(idxMaxElevation), cursor.getFloat(idxElevationGain), + cursor.getFloat(idxMinGrade), cursor.getFloat(idxMaxGrade)); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + /** + * Checks if this activity needs to update live track data or not. + * If so, make sure that: + * a) a thread keeps updating the total time + * b) a location listener is registered + * c) a content observer is registered + * Otherwise unregister listeners, observers, and kill update thread. + */ + private void checkLiveTrack() { + final boolean isRecording = selectedTrackIsRecording(); + final boolean startThread = + (thread == null) && isRecording && activityOnTop; + final boolean killThread = + (thread != null) && (!isRecording || !activityOnTop); + if (startThread) { + thread = new UIUpdateThread(); + thread.start(); + getContentResolver().registerContentObserver( + TracksColumns.CONTENT_URI, false, observer); + getContentResolver().registerContentObserver( + WaypointsColumns.CONTENT_URI, false, observer); + registerLocationListener(); + } else if (killThread) { + thread.interrupt(); + thread = null; + getContentResolver().unregisterContentObserver(observer); + unregisterLocationListener(); + } + } + + public void updateTotalTime() { + if (selectedTrackIsRecording()) { + utils.setTime(R.id.total_time_register, + System.currentTimeMillis() - startTime); + } + } + + /** + * Updates the given location fields (latitude, longitude, altitude) and all + * other fields. + * + * @param l may be null (will set location fields to unknown) + */ + private void updateLocation(Location l) { + if (l == null) { + utils.setUnknown(R.id.elevation_register); + utils.setUnknown(R.id.latitude_register); + utils.setUnknown(R.id.longitude_register); + utils.setUnknown(R.id.speed_register); + } else { + utils.setAltitude(R.id.elevation_register, l.getAltitude()); + utils.setLatLong(R.id.latitude_register, l.getLatitude()); + utils.setLatLong(R.id.longitude_register, l.getLongitude()); + utils.setSpeed(R.id.speed_register, l.getSpeed() * 3.6); + } + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/StatsUtilities.java b/MyTracks/src/com/google/android/apps/mytracks/StatsUtilities.java new file mode 100644 index 000000000..74a512f97 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/StatsUtilities.java @@ -0,0 +1,274 @@ +/* + * Copyright 2009 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; + +import com.google.android.apps.mytracks.content.Track; +import com.google.android.apps.mytracks.content.Waypoint; +import com.google.android.apps.mytracks.util.StringUtils; +import com.google.android.apps.mytracks.util.UnitConversions; +import com.google.android.maps.mytracks.R; + +import android.app.Activity; +import android.util.Log; +import android.widget.TextView; + +import java.text.DecimalFormat; +import java.text.NumberFormat; + +/** + * Various utility functions for views that display statistics information. + * + * @author Sandor Dornbush + */ +public class StatsUtilities { + + private final Activity activity; + private static final NumberFormat LAT_LONG_FORMAT = + new DecimalFormat("##,###.00000"); + private static final NumberFormat ALTITUDE_FORMAT = + new DecimalFormat("###,###"); + private static final NumberFormat SPEED_FORMAT = + new DecimalFormat("#,###,###.00"); + private static final NumberFormat GRADE_FORMAT = + new DecimalFormat("##.0%"); + + /** + * True if distances should be displayed in metric units (from shared + * preferences). + */ + private boolean metricUnits = true; + + /** + * True - report speed + * False - report pace + */ + private boolean reportSpeed = true; + + public StatsUtilities(Activity a) { + this.activity = a; + } + + public boolean isMetricUnits() { + return metricUnits; + } + + public void setMetricUnits(boolean metricUnits) { + this.metricUnits = metricUnits; + } + + public boolean isReportSpeed() { + return reportSpeed; + } + + public void setReportSpeed(boolean reportSpeed) { + this.reportSpeed = reportSpeed; + } + + public void setUnknown(int id) { + ((TextView) activity.findViewById(id)).setText(R.string.unknown); + } + + public void setText(int id, double d, NumberFormat format) { + if (!Double.isNaN(d) && !Double.isInfinite(d)) { + setText(id, format.format(d)); + } + } + + public void setText(int id, String s) { + int lengthLimit = 8; + String displayString = s.length() > lengthLimit + ? s.substring(0, lengthLimit - 3) + "..." + : s; + ((TextView) activity.findViewById(id)).setText(displayString); + } + + public void setLatLong(int id, double d) { + TextView msgTextView = (TextView) activity.findViewById(id); + msgTextView.setText(LAT_LONG_FORMAT.format(d)); + } + + public void setAltitude(int id, double d) { + setText(id, (metricUnits ? d : (d * UnitConversions.M_TO_FT)), + ALTITUDE_FORMAT); + } + + public void setDistance(int id, double d) { + setText(id, (metricUnits ? d : (d * UnitConversions.KM_TO_MI)), + SPEED_FORMAT); + } + + public void setSpeed(int id, double d) { + if (d == 0) { + setUnknown(id); + return; + } + double speed = metricUnits ? d : d * UnitConversions.KM_TO_MI; + if (reportSpeed) { + setText(id, speed, SPEED_FORMAT); + } else { + // Format as milliseconds per unit + long pace = (long) (3600000.0 / speed); + setTime(id, pace); + } + } + + public void setAltitudeUnits(int unitLabelId) { + TextView unitTextView = (TextView) activity.findViewById(unitLabelId); + unitTextView.setText(metricUnits ? R.string.meter : R.string.feet); + } + + public void setDistanceUnits(int unitLabelId) { + TextView unitTextView = (TextView) activity.findViewById(unitLabelId); + unitTextView.setText(metricUnits ? R.string.kilometer : R.string.mile); + } + + public void setSpeedUnits(int unitLabelId, int unitLabelBottomId) { + TextView unitTextView = (TextView) activity.findViewById(unitLabelId); + unitTextView.setText(reportSpeed + ? (metricUnits ? R.string.kilometer : R.string.mile) + : R.string.min); + + unitTextView = (TextView) activity.findViewById(unitLabelBottomId); + unitTextView.setText(reportSpeed + ? R.string.hr + : (metricUnits ? R.string.kilometer : R.string.mile)); + } + + public void setTime(int id, long l) { + setText(id, StringUtils.formatTime(l)); + } + + public void setGrade(int id, double d) { + setText(id, d, GRADE_FORMAT); + } + + /** + * Updates the unit fields. + */ + public void updateUnits() { + setSpeedUnits(R.id.speed_unit_label_top, R.id.speed_unit_label_bottom); + updateWaypointUnits(); + } + + /** + * Updates the units fields used by waypoints. + */ + public void updateWaypointUnits() { + setSpeedUnits(R.id.average_moving_speed_unit_label_top, + R.id.average_moving_speed_unit_label_bottom); + setSpeedUnits(R.id.average_speed_unit_label_top, + R.id.average_speed_unit_label_bottom); + setDistanceUnits(R.id.total_distance_unit_label); + setSpeedUnits(R.id.max_speed_unit_label_top, + R.id.max_speed_unit_label_bottom); + setAltitudeUnits(R.id.elevation_unit_label); + setAltitudeUnits(R.id.elevation_gain_unit_label); + setAltitudeUnits(R.id.min_elevation_unit_label); + setAltitudeUnits(R.id.max_elevation_unit_label); + } + + /** + * Sets all fields to "-" (unknown). + */ + public void setAllToUnknown() { + // "Instant" values: + setUnknown(R.id.elevation_register); + setUnknown(R.id.latitude_register); + setUnknown(R.id.longitude_register); + setUnknown(R.id.speed_register); + // Values from provider: + setUnknown(R.id.total_time_register); + setUnknown(R.id.moving_time_register); + setUnknown(R.id.total_distance_register); + setUnknown(R.id.average_speed_register); + setUnknown(R.id.average_moving_speed_register); + setUnknown(R.id.max_speed_register); + setUnknown(R.id.min_elevation_register); + setUnknown(R.id.max_elevation_register); + setUnknown(R.id.elevation_gain_register); + setUnknown(R.id.min_grade_register); + setUnknown(R.id.max_grade_register); + } + + public void setAllStats(long movingTime, double totalDistance, + double averageSpeed, double averageMovingSpeed, double maxSpeed, + double minElevation, double maxElevation, double elevationGain, + double minGrade, double maxGrade) { + setTime(R.id.moving_time_register, movingTime); + setDistance(R.id.total_distance_register, totalDistance / 1000); + setSpeed(R.id.average_speed_register, averageSpeed * 3.6); + setSpeed(R.id.average_moving_speed_register, averageMovingSpeed * 3.6); + setSpeed(R.id.max_speed_register, maxSpeed * 3.6); + setAltitude(R.id.min_elevation_register, minElevation); + setAltitude(R.id.max_elevation_register, maxElevation); + setAltitude(R.id.elevation_gain_register, elevationGain); + setGrade(R.id.min_grade_register, minGrade); + setGrade(R.id.max_grade_register, maxGrade); + } + + public void setAllStats(Waypoint wpt) { + setTime(R.id.moving_time_register, wpt.getMovingTime()); + setDistance(R.id.total_distance_register, wpt.getTotalDistance() / 1000); + setSpeed(R.id.average_speed_register, wpt.getAverageSpeed() * 3.6); + setSpeed(R.id.average_moving_speed_register, + wpt.getAverageMovingSpeed() * 3.6); + setSpeed(R.id.max_speed_register, wpt.getMaxSpeed() * 3.6); + setAltitude(R.id.min_elevation_register, wpt.getMinElevation()); + setAltitude(R.id.max_elevation_register, wpt.getMaxElevation()); + setAltitude(R.id.elevation_gain_register, wpt.getTotalElevationGain()); + setGrade(R.id.min_grade_register, wpt.getMinGrade()); + setGrade(R.id.max_grade_register, wpt.getMaxGrade()); + } + + public void setAllStats(Track track) { + setTime(R.id.moving_time_register, track.getMovingTime()); + setDistance(R.id.total_distance_register, track.getTotalDistance() / 1000); + setSpeed(R.id.average_speed_register, track.getAverageSpeed() * 3.6); + setSpeed(R.id.average_moving_speed_register, + track.getAverageMovingSpeed() * 3.6); + setSpeed(R.id.max_speed_register, track.getMaxSpeed() * 3.6); + setAltitude(R.id.min_elevation_register, track.getMinElevation()); + setAltitude(R.id.max_elevation_register, track.getMaxElevation()); + setAltitude(R.id.elevation_gain_register, track.getTotalElevationGain()); + setGrade(R.id.min_grade_register, track.getMinGrade()); + setGrade(R.id.max_grade_register, track.getMaxGrade()); + } + + public void setSpeedLabel(int id, int speedString, int paceString) { + Log.w(MyTracksConstants.TAG, "Setting view " + id + + " to " + reportSpeed + + " speed: " + speedString + + " pace: " + paceString); + TextView tv = ((TextView) activity.findViewById(id)); + if (tv != null) { + tv.setText(reportSpeed ? speedString : paceString); + } else { + Log.w(MyTracksConstants.TAG, "Could not find id: " + id); + } + } + + public void setSpeedLabels() { + setSpeedLabel(R.id.average_speed_label, + R.string.average_speed_label, + R.string.average_pace_label); + setSpeedLabel(R.id.average_moving_speed_label, + R.string.average_moving_speed_label, + R.string.average_moving_pace_label); + setSpeedLabel(R.id.max_speed_label, + R.string.max_speed_label, + R.string.min_pace_label); + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/WelcomeActivity.java b/MyTracks/src/com/google/android/apps/mytracks/WelcomeActivity.java new file mode 100644 index 000000000..4b717cc9a --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/WelcomeActivity.java @@ -0,0 +1,76 @@ +/* + * Copyright 2008 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; + +import com.google.android.maps.mytracks.R; + +import android.app.Activity; +import android.app.AlertDialog; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.Window; +import android.webkit.WebView; + +/** + * An activity that displays a welcome screen. + * + * @author Sandor Dornbush + */ +public class WelcomeActivity extends Activity { + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + requestWindowFeature(Window.FEATURE_NO_TITLE); + requestWindowFeature(Window.FEATURE_PROGRESS); + + setContentView(R.layout.welcome); + + WebView web = (WebView) findViewById(R.id.welcome_web); + web.loadUrl("file:///android_asset/welcome.html"); + + findViewById(R.id.welcome_read_later).setOnClickListener( + new OnClickListener() { + public void onClick(View v) { + finish(); + } + }); + + findViewById(R.id.welcome_about).setOnClickListener(new OnClickListener() { + public void onClick(View v) { + about(); + } + }); + } + + /** + * Shows the "about" dialog. + * + * TODO: Add a menu option for showing the same thing. + */ + public void about() { + LayoutInflater li = LayoutInflater.from(this); + View view = li.inflate(R.layout.about, null); + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setView(view); + builder.setPositiveButton(R.string.ok, null); + builder.setIcon(R.drawable.arrow_icon); + AlertDialog dialog = builder.create(); + dialog.show(); + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/content/DescriptionGenerator.java b/MyTracks/src/com/google/android/apps/mytracks/content/DescriptionGenerator.java index a2dfcc66c..513b0b5ea 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/content/DescriptionGenerator.java +++ b/MyTracks/src/com/google/android/apps/mytracks/content/DescriptionGenerator.java @@ -1,12 +1,12 @@ /* * 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 @@ -19,16 +19,16 @@ import java.util.Vector; /** * An interface for an object that can generate descriptions of waypoints. - * + * * @author Sandor Dornbush */ public interface DescriptionGenerator { - + /** * Generate a description of the waypoint. */ public String generateWaypointDescription(Waypoint waypoint); - + /** * Generates a description for a track (with information about the * statistics). diff --git a/MyTracks/src/com/google/android/apps/mytracks/content/MyTracksProvider.java b/MyTracks/src/com/google/android/apps/mytracks/content/MyTracksProvider.java index 587bc1257..a4b542e25 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/content/MyTracksProvider.java +++ b/MyTracks/src/com/google/android/apps/mytracks/content/MyTracksProvider.java @@ -1,12 +1,12 @@ /* * Copyright 2008 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 diff --git a/MyTracks/src/com/google/android/apps/mytracks/content/MyTracksProviderUtils.java b/MyTracks/src/com/google/android/apps/mytracks/content/MyTracksProviderUtils.java index 557dc18de..8f73fb05d 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/content/MyTracksProviderUtils.java +++ b/MyTracks/src/com/google/android/apps/mytracks/content/MyTracksProviderUtils.java @@ -1,12 +1,12 @@ /* * 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 @@ -60,7 +60,7 @@ public interface MyTracksProviderUtils { * Finds the next statistics waypoint after the given waypoint. * * @param waypoint a given waypoint - * @return the next statistics waypoint, or null if none found + * @return the next statistics waypoint, or null if none found. */ Waypoint getNextStatisticsWaypointAfter(Waypoint waypoint); @@ -275,7 +275,7 @@ public interface MyTracksProviderUtils { /** * Creates a waypoint object from a given cursor. * - * @param cursor a cursor pointing at a db or provider with waypoints + * @param cursor a cursor pointing at a db or provider with waypoints. * @return a new waypoint object */ public Waypoint createWaypoint(Cursor cursor); diff --git a/MyTracks/src/com/google/android/apps/mytracks/content/MyTracksProviderUtilsImpl.java b/MyTracks/src/com/google/android/apps/mytracks/content/MyTracksProviderUtilsImpl.java index 1577225db..5c0db42c5 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/content/MyTracksProviderUtilsImpl.java +++ b/MyTracks/src/com/google/android/apps/mytracks/content/MyTracksProviderUtilsImpl.java @@ -1,12 +1,12 @@ /* * Copyright 2008 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 @@ -768,7 +768,7 @@ public class MyTracksProviderUtilsImpl implements MyTracksProviderUtils { @Override public Cursor getLocationsCursor(long trackId, long minTrackPointId, int maxLocations, boolean descending) { - String selection; + String selection; if (minTrackPointId > 0) { selection = String.format("%s=%d AND %s>=%d", TrackPointsColumns.TRACKID, trackId, diff --git a/MyTracks/src/com/google/android/apps/mytracks/content/Track.java b/MyTracks/src/com/google/android/apps/mytracks/content/Track.java index 40a519911..866cca8af 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/content/Track.java +++ b/MyTracks/src/com/google/android/apps/mytracks/content/Track.java @@ -1,12 +1,12 @@ /* * Copyright 2008 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 @@ -31,7 +31,7 @@ public class Track implements Parcelable { /** * Creator for a Track object */ - public class Creator implements Parcelable.Creator { + public static class Creator implements Parcelable.Creator { public Track createFromParcel(Parcel source) { Track track = new Track(); @@ -55,10 +55,10 @@ public class Track implements Parcelable { track.totalElevationGain = source.readDouble(); track.minGrade = source.readDouble(); track.maxGrade = source.readDouble(); - left = source.readInt(); - top = source.readInt(); - right = source.readInt(); - bottom = source.readInt(); + track.left = source.readInt(); + track.top = source.readInt(); + track.right = source.readInt(); + track.bottom = source.readInt(); track.numberOfPoints = source.readInt(); for (int i = 0; i < track.numberOfPoints; ++i) { Location loc = source.readParcelable(null); @@ -72,7 +72,7 @@ public class Track implements Parcelable { } } - public static final Creator CREATOR = new Track().new Creator(); + public static final Creator CREATOR = new Creator(); /** * The track points. diff --git a/MyTracks/src/com/google/android/apps/mytracks/content/TrackBuffer.java b/MyTracks/src/com/google/android/apps/mytracks/content/TrackBuffer.java index 84efcab78..88d569002 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/content/TrackBuffer.java +++ b/MyTracks/src/com/google/android/apps/mytracks/content/TrackBuffer.java @@ -1,12 +1,12 @@ /* * Copyright 2009 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 @@ -87,8 +87,8 @@ public class TrackBuffer { /** * Adds a location to the end of the buffer. - * @param location The location to add - * @param id The id of the location to be added + * @param location The location to add. + * @param id The id of the location to be added. */ public void add(Location location, long id) { buffer[locationsLoaded++] = location; @@ -103,7 +103,7 @@ public class TrackBuffer { } /** - * @return The number of locations loaded into the buffer + * @return The number of locations loaded into the buffer. */ public int getLocationsLoaded() { return locationsLoaded; diff --git a/MyTracks/src/com/google/android/apps/mytracks/content/TrackPointsColumns.java b/MyTracks/src/com/google/android/apps/mytracks/content/TrackPointsColumns.java index f970e3014..206fd0eb8 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/content/TrackPointsColumns.java +++ b/MyTracks/src/com/google/android/apps/mytracks/content/TrackPointsColumns.java @@ -1,12 +1,12 @@ /* * Copyright 2008 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 diff --git a/MyTracks/src/com/google/android/apps/mytracks/content/TracksColumns.java b/MyTracks/src/com/google/android/apps/mytracks/content/TracksColumns.java index c461477c9..6fb122b89 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/content/TracksColumns.java +++ b/MyTracks/src/com/google/android/apps/mytracks/content/TracksColumns.java @@ -1,12 +1,12 @@ /* * Copyright 2008 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 diff --git a/MyTracks/src/com/google/android/apps/mytracks/content/Waypoint.java b/MyTracks/src/com/google/android/apps/mytracks/content/Waypoint.java index 7535e5130..c77223745 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/content/Waypoint.java +++ b/MyTracks/src/com/google/android/apps/mytracks/content/Waypoint.java @@ -1,12 +1,12 @@ /* * Copyright 2009 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 diff --git a/MyTracks/src/com/google/android/apps/mytracks/content/WaypointsColumns.java b/MyTracks/src/com/google/android/apps/mytracks/content/WaypointsColumns.java index 8d60c36f5..8184bd583 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/content/WaypointsColumns.java +++ b/MyTracks/src/com/google/android/apps/mytracks/content/WaypointsColumns.java @@ -1,12 +1,12 @@ /* * Copyright 2009 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 diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/AuthManager.java b/MyTracks/src/com/google/android/apps/mytracks/io/AuthManager.java new file mode 100644 index 000000000..a1a7fd1ea --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/AuthManager.java @@ -0,0 +1,77 @@ +/* + * 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; + +import android.content.Intent; + +/** + * This interface describes a class that will fetch and maintain a Google + * authentication token. + * + * @author Sandor Dornbush + */ +public interface AuthManager { + + /** + * Initializes the login process. The user should be asked to login if they + * haven't already. The {@link Runnable} provided will be executed when the + * auth token is successfully fetched. + * + * @param whenFinished A {@link Runnable} to execute when the auth token + * has been successfully fetched and is available via + * {@link #getAuthToken()} + */ + public abstract void doLogin(Runnable whenFinished, Object o); + + /** + * The {@link android.app.Activity} owner of this class should call this + * function when it gets {@link android.app.Activity#onActivityResult} with + * the request code passed into the constructor. The resultCode and results + * should come directly from the {@link android.app.Activity#onActivityResult} + * function. This function will return true if an auth token was successfully + * fetched or the process is not finished. + * + * @param resultCode The result code passed in to the + * {@link android.app.Activity}'s + * {@link android.app.Activity#onActivityResult} function + * @param results The data passed in to the {@link android.app.Activity}'s + * {@link android.app.Activity#onActivityResult} function + * @return True if the auth token was fetched or we aren't done fetching + * the auth token, or False if there was an error or the request was + * canceled + */ + public abstract boolean authResult(int resultCode, Intent results); + + /** + * Returns the current auth token. Response may be null if no valid auth + * token has been fetched. + * + * @return The current auth token or null if no auth token has been + * fetched + */ + public abstract String getAuthToken(); + + /** + * Invalidates the existing auth token and request a new one. The + * {@link Runnable} provided will be executed when the new auth token is + * successfully fetched. + * + * @param whenFinished A {@link Runnable} to execute when a new auth token + * is successfully fetched + */ + public abstract void invalidateAndRefresh(Runnable whenFinished); + +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/AuthManagerFactory.java b/MyTracks/src/com/google/android/apps/mytracks/io/AuthManagerFactory.java new file mode 100644 index 000000000..d701a530c --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/AuthManagerFactory.java @@ -0,0 +1,59 @@ +/* + * 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; + +import com.google.android.apps.mytracks.MyTracksConstants; + +import android.app.Activity; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; + +/** + * A factory for getting the platform specific AuthManager. + * + * @author Sandor Dornbush + */ +public class AuthManagerFactory { + + private AuthManagerFactory() { + } + + /** + * Returns whether the modern AuthManager should be used + */ + public static boolean useModernAuthManager() { + return Integer.parseInt(Build.VERSION.SDK) >= 7; + } + + /** + * Get a right {@link AuthManager} for the platform. + * @return A new AuthManager + */ + public static AuthManager getAuthManager(Activity activity, int code, + Bundle extras, boolean requireGoogle, String service) { + if (useModernAuthManager()) { + Log.i(MyTracksConstants.TAG, + "Creating modern auth manager: " + service); + return new ModernAuthManager(activity, code, extras, requireGoogle, service); + } else { + Log.i(MyTracksConstants.TAG, + "Creating legacy auth manager: " + service); + return new AuthManagerOld(activity, code, extras, requireGoogle, service); + } + } + +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/AuthManagerOld.java b/MyTracks/src/com/google/android/apps/mytracks/io/AuthManagerOld.java new file mode 100644 index 000000000..1ccaf7330 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/AuthManagerOld.java @@ -0,0 +1,198 @@ +/* + * Copyright 2009 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; + +import com.google.android.googlelogindist.GoogleLoginServiceConstants; +import com.google.android.googlelogindist.GoogleLoginServiceHelper; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; + +import java.util.Iterator; +import java.util.Vector; + +/** + * AuthManager keeps track of the current auth token for a user. The advantage + * over just passing around a String is that this class can renew the auth + * token if necessary, and it will change for all classes using this + * AuthManager. + */ +public class AuthManagerOld implements AuthManager { + /** The activity that will handle auth result callbacks. */ + private final Activity activity; + + /** The code used to tell the activity that it is an auth result. */ + private final int code; + + /** Extras to pass into the getCredentials function. */ + private final Bundle extras; + + /** True if the account must be a Google account (not a domain account). */ + private final boolean requireGoogle; + + /** The name of the service to authorize for. */ + private final String service; + + /** A list of handlers to call when a new auth token is fetched. */ + private final Vector newTokenListeners = new Vector(); + + /** The most recently fetched auth token or null if none is available. */ + private String authToken; + + /** + * The number of handlers at the beginning of the above list that shouldn't + * be removed after they are called. + */ + private int stickyNewTokenListenerCount; + + /** + * AuthManager requires many of the same parameters as + * {@link GoogleLoginServiceHelper#getCredentials(Activity, int, Bundle, + * boolean, String, boolean)}. The activity must have + * a handler in {@link Activity#onActivityResult} that calls + * {@link #authResult(int, Intent)} if the request code is the code given + * here. + * + * @param activity An activity with a handler in + * {@link Activity#onActivityResult} that calls + * {@link #authResult(int, Intent)} when {@literal code} is the request + * code + * @param code The request code to pass to + * {@link Activity#onActivityResult} when + * {@link #authResult(int, Intent)} should be called + * @param extras A {@link Bundle} of extras for + * {@link GoogleLoginServiceHelper} + * @param requireGoogle True if the account must be a Google account + * @param service The name of the service to authenticate as + */ + public AuthManagerOld(Activity activity, int code, Bundle extras, + boolean requireGoogle, String service) { + this.activity = activity; + this.code = code; + this.extras = extras; + this.requireGoogle = requireGoogle; + this.service = service; + } + + /* (non-Javadoc) + * @see com.google.android.apps.mytracks.io.AuthManager#doLogin(java.lang.Runnable) + */ + public void doLogin(Runnable whenFinished, Object o) { + synchronized (newTokenListeners) { + if (whenFinished != null) { + newTokenListeners.add(whenFinished); + } + } + activity.runOnUiThread(new LoginRunnable()); + } + + /** + * Runnable which actually gets login credentials. + */ + private class LoginRunnable implements Runnable { + @Override + public void run() { + GoogleLoginServiceHelper.getCredentials( + activity, code, extras, requireGoogle, service, true); + } + } + + /* (non-Javadoc) + * @see com.google.android.apps.mytracks.io.AuthManager#authResult(int, android.content.Intent) + */ + public boolean authResult(int resultCode, Intent results) { + if (resultCode == Activity.RESULT_OK) { + authToken = results.getStringExtra( + GoogleLoginServiceConstants.AUTHTOKEN_KEY); + if (authToken == null) { + GoogleLoginServiceHelper.getCredentials( + activity, code, extras, requireGoogle, service, false); + return true; + } else { + // Notify all active listeners that we have a new auth token. + synchronized (newTokenListeners) { + Iterator iter = newTokenListeners.iterator(); + while (iter.hasNext()) { + iter.next().run(); + } + iter = null; + // Remove anything not in the sticky part of the list. + newTokenListeners.setSize(stickyNewTokenListenerCount); + } + return true; + } + } + return false; + } + + /* (non-Javadoc) + * @see com.google.android.apps.mytracks.io.AuthManager#getAuthToken() + */ + public String getAuthToken() { + return authToken; + } + + /* (non-Javadoc) + * @see com.google.android.apps.mytracks.io.AuthManager#invalidateAndRefresh(java.lang.Runnable) + */ + public void invalidateAndRefresh(Runnable whenFinished) { + synchronized (newTokenListeners) { + if (whenFinished != null) { + newTokenListeners.add(whenFinished); + } + } + activity.runOnUiThread(new Runnable() { + public void run() { + GoogleLoginServiceHelper.invalidateAuthToken(activity, code, authToken); + } + }); + } + + /** + * Adds a {@link Runnable} to be executed every time the auth token is + * updated. The {@link Runnable} will not be removed until manually removed + * with {@link #removeStickyNewTokenListener(Runnable)}. + * + * @param listener The {@link Runnable} to execute every time a new auth + * token is fetched + */ + public void addStickyNewTokenListener(Runnable listener) { + synchronized (newTokenListeners) { + newTokenListeners.add(0, listener); + stickyNewTokenListenerCount++; + } + } + + /** + * Stops executing the given {@link Runnable} every time the auth token is + * updated. This {@link Runnable} must have been added with + * {@link #addStickyNewTokenListener(Runnable)} above. If the + * {@link Runnable} was added more than once, only the first occurrence + * will be removed. + * + * @param listener The {@link Runnable} to stop executing every time a new + * auth token is fetched + */ + public void removeStickyNewTokenListener(Runnable listener) { + synchronized (newTokenListeners) { + if (stickyNewTokenListenerCount > 0 + && newTokenListeners.remove(listener)) { + stickyNewTokenListenerCount--; + } + } + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/CsvTrackWriter.java b/MyTracks/src/com/google/android/apps/mytracks/io/CsvTrackWriter.java new file mode 100644 index 000000000..0356442ad --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/CsvTrackWriter.java @@ -0,0 +1,176 @@ +/* + * 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; + +import com.google.android.apps.mytracks.content.Track; +import com.google.android.apps.mytracks.content.Waypoint; +import com.google.android.apps.mytracks.io.TrackWriterFactory.TrackFileFormat; + +import android.location.Location; +import android.os.Environment; + +import java.io.OutputStream; +import java.io.PrintWriter; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.TimeZone; + +/** + * Exports a track as a CSV file, according to RFC 4180. + * + * The first field is a type: + * TRACK - track description + * P - point + * WAYPOINT - waypoint + * + * For each type, the fields are: + * + * TRACK,,,,,,,,name,description, + * P,time,lat,lon,alt,bearing,accurancy,speed,,,segmentIdx + * WAYPOINT,time,lat,lon,alt,bearing,accuracy,speed,name,description, + * + * @author Rodrigo Damazio + */ +public class CsvTrackWriter implements TrackFormatWriter { + static final SimpleDateFormat TIMESTAMP_FORMAT = + new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); + { + TIMESTAMP_FORMAT.setTimeZone(TimeZone.getTimeZone("UTC")); + } + + private int segmentIdx = 0; + private int numFields = -1; + private PrintWriter pw; + private Track track; + + @Override + public String getDefaultDirectory() { + return Environment.getExternalStorageDirectory() + + System.getProperty("file.separator") + + getExtension(); + } + + @Override + public String getExtension() { + return TrackFileFormat.CSV.getExtension(); + } + + @Override + public void prepare(Track track, OutputStream out) { + this.track = track; + this.pw = new PrintWriter(out); + } + + @Override + public void writeHeader() { + writeCommaSeparatedLine("TYPE", "TIME", "LAT", "LON", "ALT", "BEARING", + "ACCURACY", "SPEED", "NAME", "DESCRIPTION", "SEGMENT"); + } + + @Override + public void writeBeginTrack(Location firstPoint) { + writeCommaSeparatedLine("TRACK", + null, null, null, null, null, null, null, + track.getName(), track.getDescription(), + null); + } + + @Override + public void writeOpenSegment() { + // Do nothing + } + + @Override + public void writeLocation(Location location) { + String timeStr = TIMESTAMP_FORMAT.format(new Date(location.getTime())); + writeCommaSeparatedLine("P", + timeStr, + Double.toString(location.getLatitude()), + Double.toString(location.getLongitude()), + Double.toString(location.getAltitude()), + Double.toString(location.getBearing()), + Double.toString(location.getAccuracy()), + Double.toString(location.getSpeed()), + null, null, + Integer.toString(segmentIdx)); + } + + @Override + public void writeWaypoint(Waypoint waypoint) { + Location location = waypoint.getLocation(); + String timeStr = TIMESTAMP_FORMAT.format(new Date(location.getTime())); + writeCommaSeparatedLine("WAYPOINT", + timeStr, + Double.toString(location.getLatitude()), + Double.toString(location.getLongitude()), + Double.toString(location.getAltitude()), + Double.toString(location.getBearing()), + Double.toString(location.getAccuracy()), + Double.toString(location.getSpeed()), + waypoint.getName(), + waypoint.getDescription(), + null); + } + + /** + * Writes a single line of a comma-separated-value file. + * + * @param strs the values to be written as comma-separated + */ + private void writeCommaSeparatedLine(String... strs) { + if (numFields == -1) { + numFields = strs.length; + } else if (strs.length != numFields) { + throw new IllegalArgumentException( + "CSV lines with different number of fields"); + } + + boolean isFirst = true; + for (String str : strs) { + if (!isFirst) { + pw.print(','); + } + isFirst = false; + + if (str != null) { + pw.print('"'); + pw.print(str.replaceAll("\"", "\"\"")); + pw.print('"'); + } + } + pw.println(); + } + + @Override + public void writeCloseSegment() { + segmentIdx++; + } + + @Override + public void writeEndTrack(Location lastPoint) { + // Do nothing + } + + @Override + public void writeFooter() { + // Do nothing + } + + @Override + public void close() { + pw.close(); + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/GpxImport.java b/MyTracks/src/com/google/android/apps/mytracks/io/GpxImport.java new file mode 100644 index 000000000..043c5ab22 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/GpxImport.java @@ -0,0 +1,237 @@ +/* + * Copyright 2008 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; + +import com.google.android.apps.mytracks.content.Track; +import com.google.android.apps.mytracks.stats.TripStatistics; +import com.google.android.apps.mytracks.util.MyTracksUtils; + +import android.location.Location; + +import org.w3c.dom.Document; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +import java.io.File; +import java.io.IOException; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +/** + * Helper class to parse a GPX file or string and convert it into a track + * object. + * + * TODO: See if we can use a SAX style parser as the DOM style + * parsing uses too much memory and will not allow import of very large GPX + * files (limit currently set to 500KB). + * + * @author Leif Hendrik Wilden + */ +public class GpxImport { + private static final SimpleDateFormat DATE_FORMAT1 = + new SimpleDateFormat("yyyy-MM-dd'T'hh:mm:ssZ"); + private static final SimpleDateFormat DATE_FORMAT2 = + new SimpleDateFormat("yyyy-MM-dd'T'hh:mm:ss'Z'"); + private static final SimpleDateFormat DATE_FORMAT3 = + new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ"); + + /** + * Reads GPS tracks from a GPX file and append tracks and their coordinates to + * the given list of tracks. + * + * @param tracks a list of tracks + * @param filename a file name + * @throws SAXException a parsing error + * @throws ParserConfigurationException internal error + * @throws IOException a file reading problem + */ + public static void importGPXFile( + final String filename, final ArrayList tracks) + throws SAXException, ParserConfigurationException, IOException, + OutOfMemoryError { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + DocumentBuilder builder = factory.newDocumentBuilder(); + File file = new File(filename); + if (file.length() > 500 * 1024) { + // Better to throw an error now then, to let the VM do it. If the VM does + // it, it will throw another exception while throwing the OutOfMemoryError + // and that will kill out app with a nasty error message. In that way we + // can at least display a reasonable message to the user and let her know + // what the problem is. + throw new OutOfMemoryError(); + } + Document doc = builder.parse(file); + importGPXDocument(tracks, doc); + } + + /** + * This is a utility class with only static members. + */ + private GpxImport() { + } + + /** + * Reads GPS tracks from a GPX document and append then to the given list of + * tracks. + * + * TODO: Break this method down into smaller ones + * + * @param tracks an array list of tracks + * @param doc a document + */ + public static void importGPXDocument( + final ArrayList tracks, final Document doc) { + NodeList trackNodes = doc.getElementsByTagName("trk"); + for (int n = 0; n < trackNodes.getLength(); n++) { + Track track = new Track(); + tracks.add(track); + ArrayList locations = track.getLocations(); + Location lastLocation = null; + + Node trkNode = trackNodes.item(n); + NodeList segmentNodes = trkNode.getChildNodes(); + ArrayList nodes = new ArrayList(); + int nSegments = 0; + for (int k = 0; k < segmentNodes.getLength(); k++) { + Node segmentNode = segmentNodes.item(k); + if (segmentNode.getNodeName().equals("name")) { + track.setName(segmentNode.getFirstChild().getNodeValue()); + } else if (segmentNode.getNodeName().equals("description")) { + track.setDescription(segmentNode.getFirstChild().getNodeValue()); + } else if (segmentNode.getNodeName().equals("trkseg")) { + long pointTime = locations.get(locations.size() - 1).getTime(); + if (nSegments > 0) { + // Add a segment separator: + Location location = new Location("gps"); + location.setLatitude(100.0); + location.setLongitude(100.0); + location.setAltitude(0); + if (locations.size() > 0) { + location.setTime(pointTime); + } + track.addLocation(location); + lastLocation = null; + } + nSegments++; + NodeList segmentChildren = segmentNode.getChildNodes(); + nodes.clear(); + for (int j = 0; j < segmentChildren.getLength(); j++) { + Node child = segmentChildren.item(j); + if (child.getNodeName().equals("trkpt")) { + nodes.add(child); + } + } + for (int i = 0; i < nodes.size(); i++) { + NamedNodeMap namedNodes = nodes.get(i).getAttributes(); + double lat = Double.parseDouble(namedNodes.getNamedItem("lat") + .getNodeValue()); + double lon = Double.parseDouble(namedNodes.getNamedItem("lon") + .getNodeValue()); + NodeList children = nodes.get(i).getChildNodes(); + Node elementNode = null; + Node timeNode = null; + for (int j = 0; j < children.getLength(); j++) { + Node child = children.item(j); + if (child.getNodeName().equals("ele")) { + elementNode = child; + } else if (child.getNodeName().equals("time")) { + timeNode = child; + } + } + String altitudeStr = null; + if (elementNode != null) { + altitudeStr = elementNode.getFirstChild().getNodeValue(); + } else { + altitudeStr = "0"; + } + String timeContents = null; + if (timeNode != null) { + timeContents = timeNode.getFirstChild().getNodeValue(); + } else { + timeContents = ""; + } + double altitude = Double.parseDouble(altitudeStr); + long t = -1; + try { + // 1st try with time zone at end a la "+0000" + t = DATE_FORMAT1.parse(timeContents).getTime(); + } catch (ParseException e) { + // 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): + try { + t = DATE_FORMAT2.parse(timeContents).getTime(); + } catch (ParseException ex) { + // some gpx timestamps have 3 additional digits at the end. + try { + t = DATE_FORMAT3.parse(timeContents).getTime(); + } catch (ParseException exc) { + t = 0; + } + } + } + + Location location = new Location("gps"); + location.setLatitude(lat); + location.setLongitude(lon); + location.setAltitude(altitude); + location.setTime(t); + // We don't have a speed and bearing in GPX, make something up from + // the last two points: + if (lastLocation != null) { + final long dt = location.getTime() - lastLocation.getTime(); + if (dt > 0) { + final float speed = + location.distanceTo(lastLocation) / (dt / 1000); + location.setSpeed(speed); + } + location.setBearing(lastLocation.bearingTo(location)); + } + lastLocation = location; + if (MyTracksUtils.isValidLocation(location)) { + track.addLocation(location); + } + } + + if (locations.size() > 0) { + long startTime = locations.get(0).getTime(); + track.setStartTime(startTime); + + // Calculate statistics for the imported track + int numberOfPoints = locations.size(); + TripStatistics stats = new TripStatistics(startTime); + for (Location location : locations) { + if (MyTracksUtils.isValidLocation(location)) { + /* Any time works here. The totalTime will be set by "pauseAt" later: */ + stats.addLocation(location, location.getTime()); + } + } + stats.pauseAt(pointTime); + track.setStopTime(pointTime); + + stats.fillStatisticsForTrack(track); + } + } + } + } + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/GpxTrackWriter.java b/MyTracks/src/com/google/android/apps/mytracks/io/GpxTrackWriter.java new file mode 100644 index 000000000..323edf90b --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/GpxTrackWriter.java @@ -0,0 +1,162 @@ +/* + * Copyright 2008 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; + +import com.google.android.apps.mytracks.content.Track; +import com.google.android.apps.mytracks.content.Waypoint; +import com.google.android.apps.mytracks.io.TrackWriterFactory.TrackFileFormat; +import com.google.android.apps.mytracks.util.StringUtils; + +import android.location.Location; +import android.os.Environment; + +import java.io.OutputStream; +import java.io.PrintWriter; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.TimeZone; + +/** + * Log of one track. + * + * @author Sandor Dornbush + */ +public class GpxTrackWriter implements TrackFormatWriter { + + static final SimpleDateFormat TIMESTAMP_FORMAT = + new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); + { + TIMESTAMP_FORMAT.setTimeZone(TimeZone.getTimeZone("UTC")); + } + + private PrintWriter pw = null; + private Track track; + + @Override + public void prepare(Track track, OutputStream out) { + this.track = track; + this.pw = new PrintWriter(out); + } + + @Override + public String getDefaultDirectory() { + return Environment.getExternalStorageDirectory() + + System.getProperty("file.separator") + + getExtension(); + } + + @Override + public String getExtension() { + return TrackFileFormat.GPX.getExtension(); + } + + @Override + public void writeHeader() { + if (pw != null) { + pw.println(""); + pw.println(""); + pw.println(""); + // TODO: Author etc. + } + } + + @Override + public void writeFooter() { + if (pw != null) { + pw.println(""); + } + } + + @Override + public void writeBeginTrack(Location firstPoint) { + if (pw != null) { + pw.println(""); + pw.println("" + StringUtils.stringAsCData(track.getName()) + + ""); + pw.println("" + StringUtils.stringAsCData(track.getDescription()) + + ""); + pw.println("" + track.getId() + ""); + pw.println("c0c0c0"); + } + } + + @Override + public void writeEndTrack(Location lastPoint) { + if (pw != null) { + pw.println(""); + } + } + + @Override + public void writeOpenSegment() { + pw.println(""); + } + + @Override + public void writeCloseSegment() { + pw.println(""); + } + + @Override + public void writeLocation(Location l) { + if (pw != null) { + pw.println(""); + Date d = new Date(l.getTime()); + pw.println("" + l.getAltitude() + ""); + pw.println(""); + pw.println(""); + } + } + + @Override + public void close() { + if (pw != null) { + pw.close(); + pw = null; + } + } + + @Override + public void writeWaypoint(Waypoint waypoint) { + if (pw != null) { + Location l = waypoint.getLocation(); + if (l != null) { + pw.println(""); + pw.println("" + StringUtils.stringAsCData(waypoint.getName()) + + ""); + pw.println("" + + StringUtils.stringAsCData(waypoint.getDescription()) + ""); + pw.println(""); + pw.println("" + l.getAltitude() + ""); + pw.println(""); + } + } + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/KmlTrackWriter.java b/MyTracks/src/com/google/android/apps/mytracks/io/KmlTrackWriter.java new file mode 100644 index 000000000..d6457a8a4 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/KmlTrackWriter.java @@ -0,0 +1,239 @@ +/* + * Copyright 2008 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; + +import com.google.android.apps.mytracks.content.Track; +import com.google.android.apps.mytracks.content.Waypoint; +import com.google.android.apps.mytracks.io.TrackWriterFactory.TrackFileFormat; +import com.google.android.apps.mytracks.util.StringUtils; + +import android.content.Context; +import android.location.Location; +import android.os.Environment; + +import java.io.OutputStream; +import java.io.PrintWriter; +import java.util.Vector; + +/** + * Write track as KML to a file. + * + * @author Leif Hendrik Wilden + */ +public class KmlTrackWriter implements TrackFormatWriter { + + private final Vector distances = new Vector(); + private final Vector elevations = new Vector(); + private final StringUtils stringUtils; + private PrintWriter pw = null; + private Track track; + + public KmlTrackWriter(Context context) { + stringUtils = new StringUtils(context); + } + + /** + * Testing constructor. + */ + KmlTrackWriter(StringUtils stringUtils) { + this.stringUtils = stringUtils; + } + + @Override + public void prepare(Track track, OutputStream out) { + this.track = track; + this.pw = new PrintWriter(out); + } + + @Override + public String getDefaultDirectory() { + return Environment.getExternalStorageDirectory() + + System.getProperty("file.separator") + getExtension(); + } + + @Override + public String getExtension() { + return TrackFileFormat.KML.getExtension(); + } + + @Override + public void writeHeader() { + if (pw != null) { + pw.println(""); + pw.print(""); + pw.println(""); + pw.println("My Tracks running on Android" + + ""); + pw.println("" + StringUtils.stringAsCData(track.getName()) + + ""); + pw.println("" + + StringUtils.stringAsCData(track.getDescription()) + + ""); + writeStyles(); + } + } + + @Override + public void writeFooter() { + if (pw != null) { + pw.println(""); + pw.println(""); + } + } + + @Override + public void writeBeginTrack(Location firstPoint) { + if (pw != null) { + writePlacemark("(Start)", track.getDescription(), "#sh_green-circle", + firstPoint); + pw.println(""); + pw.println("" + StringUtils.stringAsCData(track.getName()) + + ""); + pw.println("" + + StringUtils.stringAsCData(track.getDescription()) + + ""); + pw.println("#track"); + pw.println(""); + } + } + + @Override + public void writeEndTrack(Location lastPoint) { + if (pw != null) { + pw.println(""); + pw.println(""); + String description = stringUtils.generateTrackDescription( + track, distances, elevations); + writePlacemark("(End)", description, "#sh_red-circle", lastPoint); + } + } + + @Override + public void writeOpenSegment() { + if (pw != null) { + pw.print(""); + } + } + + @Override + public void writeCloseSegment() { + if (pw != null) { + pw.println(""); + } + } + + @Override + public void writeLocation(Location l) { + if (pw != null) { + pw.print(l.getLongitude() + "," + l.getLatitude() + "," + + l.getAltitude() + " "); + } + } + + private String getPinStyle(Waypoint waypoint) { + if (waypoint.getType() == Waypoint.TYPE_STATISTICS) { + return "#sh_ylw-pushpin"; + } + // Try to find the icon color. + // The string should be of the form: + // "http://maps.google.com/mapfiles/ms/micons/XXX.png" + int slash = waypoint.getIcon().lastIndexOf('/'); + int png = waypoint.getIcon().lastIndexOf('.'); + if ((slash != -1) && (slash < png)) { + String color = waypoint.getIcon().substring(slash + 1, png); + return "#sh_" + color + "-pushpin"; + } + return "#sh_blue-pushpin"; + } + + @Override + public void writeWaypoint(Waypoint waypoint) { + if (pw != null) { + writePlacemark( + waypoint.getName(), + waypoint.getDescription(), + getPinStyle(waypoint), + waypoint.getLocation()); + } + } + + @Override + public void close() { + if (pw != null) { + pw.close(); + pw = null; + } + } + + private void writeStyles() { + pw.println(""); + + pw.print(""); + + pw.print(""); + + pw.print(""); + + pw.print(""); + + pw.print(""); + + pw.print(""); + } + + private void writePlacemark(String name, String description, String style, + Location location) { + if (location != null) { + pw.println(""); + pw.println(" " + StringUtils.stringAsCData(name) + ""); + pw.println(" " + StringUtils.stringAsCData(description) + + ""); + pw.println(" " + style + ""); + pw.println(" "); + pw.println(" " + location.getLongitude() + "," + + location.getLatitude() + ""); + pw.println(" "); + pw.println(""); + } + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/ModernAuthManager.java b/MyTracks/src/com/google/android/apps/mytracks/io/ModernAuthManager.java new file mode 100644 index 000000000..511584541 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/ModernAuthManager.java @@ -0,0 +1,211 @@ +/* + * 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; + +import com.google.android.accounts.Account; +import com.google.android.accounts.AccountManager; +import com.google.android.accounts.AccountManagerCallback; +import com.google.android.accounts.AccountManagerFuture; +import com.google.android.accounts.AuthenticatorException; +import com.google.android.accounts.OperationCanceledException; +import com.google.android.apps.mytracks.AccountChooser; +import com.google.android.apps.mytracks.MyTracks; +import com.google.android.apps.mytracks.MyTracksConstants; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; + +import java.io.IOException; + +/** + * AuthManager keeps track of the current auth token for a user. The advantage + * over just passing around a String is that this class can renew the auth + * token if necessary, and it will change for all classes using this + * AuthManager. + */ +public class ModernAuthManager implements AuthManager { + /** The activity that will handle auth result callbacks. */ + private final Activity activity; + + /** The name of the service to authorize for. */ + private final String service; + + /** The most recently fetched auth token or null if none is available. */ + private String authToken; + + private final AccountManager accountManager; + + private Runnable whenFinished; + + /** + * AuthManager requires many of the same parameters as + * {@link com.google.android.googlelogindist.GoogleLoginServiceHelper + * #getCredentials(Activity, int, Bundle, boolean, String, boolean)}. + * The activity must have a handler in {@link Activity#onActivityResult} that + * calls {@link #authResult(int, Intent)} if the request code is the code + * given here. + * + * @param activity An activity with a handler in + * {@link Activity#onActivityResult} that calls + * {@link #authResult(int, Intent)} when {@literal code} is the request + * code + * @param code The request code to pass to + * {@link Activity#onActivityResult} when + * {@link #authResult(int, Intent)} should be called + * @param extras A {@link Bundle} of extras for + * {@link com.google.android.googlelogindist.GoogleLoginServiceHelper} + * @param requireGoogle True if the account must be a Google account + * @param service The name of the service to authenticate as + */ + public ModernAuthManager(Activity activity, int code, Bundle extras, + boolean requireGoogle, String service) { + this.activity = activity; + this.service = service; + this.accountManager = AccountManager.get(activity); + } + + /** + * Call this to do the initial login. The user will be asked to login if + * they haven't already. The {@link Runnable} provided will be executed + * when the auth token is successfully fetched. + * + * @param runnable A {@link Runnable} to execute when the auth token + * has been successfully fetched and is available via + * {@link #getAuthToken()} + */ + public void doLogin(final Runnable runnable, Object o) { + this.whenFinished = runnable; + if (!(o instanceof Account)) { + throw new IllegalArgumentException("FroyoAuthManager requires an account."); + } + Account account = (Account) o; + accountManager.getAuthToken(account, service, true, + new AccountManagerCallback() { + public void run(AccountManagerFuture future) { + try { + Bundle result = future.getResult(); + + // AccountManager needs user to grant permission + if (result.containsKey(AccountManager.KEY_INTENT)) { + Intent intent = (Intent) result.get(AccountManager.KEY_INTENT); + clearNewTaskFlag(intent); + activity.startActivityForResult(intent, MyTracksConstants.GET_LOGIN); + return; + } + + authToken = result.getString( + AccountManager.KEY_AUTHTOKEN); + Log.e(MyTracksConstants.TAG, "Got auth token."); + runWhenFinished(); + } catch (OperationCanceledException e) { + Log.e(MyTracksConstants.TAG, "Operation Canceled", e); + } catch (IOException e) { + Log.e(MyTracksConstants.TAG, "IOException", e); + } catch (AuthenticatorException e) { + Log.e(MyTracksConstants.TAG, "Authentication Failed", e); + } + } + }, null /* handler */); + } + + private static void clearNewTaskFlag(Intent intent) { + int flags = intent.getFlags(); + flags &= ~Intent.FLAG_ACTIVITY_NEW_TASK; + intent.setFlags(flags); + } + + /** + * The {@link Activity} passed into the constructor should call this + * function when it gets {@link Activity#onActivityResult} with the request + * code passed into the constructor. The resultCode and results should + * come directly from the {@link Activity#onActivityResult} function. This + * function will return true if an auth token was successfully fetched or + * the process is not finished. + * + * @param resultCode The result code passed in to the {@link Activity}'s + * {@link Activity#onActivityResult} function + * @param results The data passed in to the {@link Activity}'s + * {@link Activity#onActivityResult} function + * @return True if the auth token was fetched or we aren't done fetching + * the auth token, or False if there was an error or the request was + * canceled + */ + public boolean authResult(int resultCode, Intent results) { + if (results != null) { + authToken = results.getStringExtra( + AccountManager.KEY_AUTHTOKEN); + Log.w(MyTracksConstants.TAG, "authResult: " + authToken); + } else { + Log.e(MyTracksConstants.TAG, "No auth result results!!"); + } + runWhenFinished(); + return authToken != null; + } + + /** + * Returns the current auth token. Response may be null if no valid auth + * token has been fetched. + * + * @return The current auth token or null if no auth token has been + * fetched + */ + public String getAuthToken() { + return authToken; + } + + /** + * Invalidates the existing auth token and request a new one. The + * {@link Runnable} provided will be executed when the new auth token is + * successfully fetched. + * + * @param runnable A {@link Runnable} to execute when a new auth token + * is successfully fetched + */ + public void invalidateAndRefresh(final Runnable runnable) { + this.whenFinished = runnable; + + activity.runOnUiThread(new Runnable() { + public void run() { + accountManager.invalidateAuthToken(MyTracksConstants.ACCOUNT_TYPE, + authToken); + MyTracks.getInstance().getAccountChooser().chooseAccount(activity, + new AccountChooser.AccountHandler() { + @Override + public void handleAccountSelected(Account account) { + if (account != null) { + doLogin(whenFinished, account); + } else { + runWhenFinished(); + } + } + }); + } + }); + } + + private void runWhenFinished() { + if (whenFinished != null) { + (new Thread() { + @Override + public void run() { + whenFinished.run(); + } + }).start(); + } + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/SendToDocs.java b/MyTracks/src/com/google/android/apps/mytracks/io/SendToDocs.java new file mode 100644 index 000000000..6fab274c6 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/SendToDocs.java @@ -0,0 +1,525 @@ +/* + * Copyright 2008 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; + +import com.google.android.apps.mymaps.MyMapsConstants; +import com.google.android.apps.mytracks.MyTracks; +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.io.gdata.GDataClientFactory; +import com.google.android.apps.mytracks.io.gdata.GDataWrapper; +import com.google.android.apps.mytracks.io.gdata.GDataWrapper.QueryFunction; +import com.google.android.apps.mytracks.util.ResourceUtils; +import com.google.android.apps.mytracks.util.StringUtils; +import com.google.android.apps.mytracks.util.UnitConversions; +import com.google.android.common.gdata.AndroidXmlParserFactory; +import com.google.android.maps.mytracks.R; +import com.google.wireless.gdata.client.GDataClient; +import com.google.wireless.gdata.client.GDataServiceClient; +import com.google.wireless.gdata.client.HttpException; +import com.google.wireless.gdata.data.Entry; +import com.google.wireless.gdata.docs.DocumentsClient; +import com.google.wireless.gdata.docs.SpreadsheetsClient; +import com.google.wireless.gdata.docs.SpreadsheetsClient.WorksheetEntry; +import com.google.wireless.gdata.maps.xml.XmlMapsGDataParserFactory; +import com.google.wireless.gdata.parser.GDataParser; +import com.google.wireless.gdata.parser.ParseException; +import com.google.wireless.gdata2.client.AuthenticationException; + +import android.app.Activity; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.Log; + +import java.io.BufferedReader; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.net.URL; +import java.net.URLConnection; +import java.text.DecimalFormat; +import java.text.NumberFormat; + +/** + * A helper class used to transmit tracks statistics to Google Docs/Trix. + * + * @author Sandor Dornbush + */ +public class SendToDocs { + private static final NumberFormat LARGE_UNIT_FORMAT = + new DecimalFormat("#,###,###.00"); + private static final NumberFormat SMALL_UNIT_FORMAT = + new DecimalFormat("###,###"); + private static final String DOCS_FEED_URL = + "http://docs.google.com/feeds/documents/private/full"; + private static final String DOCS_MY_SPREADSHEETS_FEED_URL = + "http://docs.google.com/feeds/documents/private/full?" + + "category=mine,spreadsheet"; + private static final String DOCS_SPREADSHEET_URL = + "http://docs.google.com/feeds/documents/private/full/spreadsheet%3A"; + private static final String DOCS_SPREADSHEET_URL_FORMAT = + "http://spreadsheets.google.com/feeds/list/%s/%s/private/full"; + protected static final String DOCS_WORKSHEETS_URL_FORMAT = + "http://spreadsheets.google.com/feeds/worksheets/%s/private/full"; + private static final String CONTENT_TYPE_PARAM = "Content-Type"; + private static final String ATOM_FEED_MIME_TYPE = "application/atom+xml"; + private static final String OPENDOCUMENT_SPREADSHEET_MIME_TYPE = + "application/x-vnd.oasis.opendocument.spreadsheet"; + + private final Activity activity; + private final AuthManager wiseAuth; + private final AuthManager writelyAuth; + private final long trackId; + private final boolean metricUnits; + private final HandlerThread handlerThread; + private final Handler handler; + + private boolean createdNewSpreadSheet = false; + + private String spreadSheetId = null; + private String workSheetId = null; + + private boolean success = true; + private String statusMessage = ""; + private Runnable onCompletion = null; + + public SendToDocs(Activity activity, AuthManager wiseAuth, + AuthManager writelyAuth, long trackId) { + this.activity = activity; + this.wiseAuth = wiseAuth; + this.writelyAuth = writelyAuth; + this.trackId = trackId; + + SharedPreferences preferences = activity.getSharedPreferences( + MyTracksSettings.SETTINGS_NAME, 0); + if (preferences != null) { + metricUnits = preferences.getBoolean(MyTracksSettings.METRIC_UNITS, true); + } else { + metricUnits = true; + } + + Log.d(MyTracksConstants.TAG, + "Sending to Google Docs: trackId = " + trackId); + handlerThread = new HandlerThread("SendToGoogleDocs"); + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()); + } + + public void run() { + handler.post(new Runnable() { + @Override + public void run() { + doUpload(); + } + }); + } + + private void doUpload() { + statusMessage = activity.getString(R.string.error_sending_to_mymap); + success = false; + + try { + if (trackId == -1) { + Log.w(MyTracksConstants.TAG, "Cannot get track id."); + return; + } + + // Get the track from the provider: + Track track = + MyTracksProviderUtils.Factory.get(activity).getTrack(trackId); + if (track == null) { + Log.w(MyTracksConstants.TAG, "Cannot get track."); + return; + } + + // Transmit track stats via GData feed: + // ------------------------------- + + Log.d(MyTracksConstants.TAG, "SendToDocs: Uploading to spreadsheet"); + success = uploadToDocs(track); + if (success) { + if (createdNewSpreadSheet) { + statusMessage = activity.getString( + R.string.status_tracks_have_been_uploaded_to_new_doc); + } else { + statusMessage = activity.getString( + R.string.status_tracks_have_been_uploaded_to_docs); + } + } else { + statusMessage = activity.getString(R.string.error_sending_to_docs); + } + Log.d(MyTracksConstants.TAG, "SendToDocs: Done."); + } finally { + if (onCompletion != null) { + activity.runOnUiThread(onCompletion); + } + } + } + + public boolean wasSuccess() { + return success; + } + + public String getStatusMessage() { + return statusMessage; + } + + public void setOnCompletion(Runnable onCompletion) { + this.onCompletion = onCompletion; + } + + /** + * Uploads the statistics about a track to Google Docs using the docs GData + * feed. + * + * @param track the track + */ + private boolean uploadToDocs(Track track) { + GDataWrapper wiseWrapper = new GDataWrapper(); + wiseWrapper.setAuthManager(wiseAuth); + wiseWrapper.setRetryOnAuthFailure(true); + + GDataWrapper writelyWrapper = new GDataWrapper(); + writelyWrapper.setAuthManager(writelyAuth); + writelyWrapper.setRetryOnAuthFailure(true); + + GDataClient androidClient = null; + try { + androidClient = GDataClientFactory.getGDataClient(activity); + SpreadsheetsClient gdataClient = new SpreadsheetsClient(androidClient, + new XmlMapsGDataParserFactory(new AndroidXmlParserFactory())); + wiseWrapper.setClient(androidClient, gdataClient); + Log.d(MyTracksConstants.TAG, + "GData connection prepared: " + this.writelyAuth); + String sheetTitle = "My Tracks"; + + if (track.getCategory() != null && !track.getCategory().equals("")) { + sheetTitle += "-" + track.getCategory(); + } + + DocumentsClient docsGdataClient = new DocumentsClient(androidClient, + new XmlMapsGDataParserFactory(new AndroidXmlParserFactory())); + writelyWrapper.setClient(androidClient, docsGdataClient); + + // First try to find the spreadsheet: + if (!getSpreadsheetId(writelyWrapper, sheetTitle)) { + Log.i(MyTracksConstants.TAG, "Spreadsheet lookup failed."); + return false; + } + + if (spreadSheetId == null) { + MyTracks.getInstance().setProgressValue(65); + // Waiting a few seconds and trying again. Maybe the server just had a + // hickup (unfortunately that happens quite a lot...). + try { + Thread.sleep(5000); + } catch (InterruptedException e) { + Log.e(MyTracksConstants.TAG, "Sleep interrupted", e); + } + if (!getSpreadsheetId(writelyWrapper, sheetTitle)) { + Log.i(MyTracksConstants.TAG, "2nd spreadsheet lookup failed."); + return false; + } + } + + MyTracks.getInstance().setProgressValue(70); + if (spreadSheetId == null) { + Log.i(MyTracksConstants.TAG, "Creating new spreadsheet: " + sheetTitle); + + if (!createSpreadSheet(writelyWrapper, sheetTitle)) { + return false; + } + MyTracks.getInstance().setProgressValue(80); + + if (spreadSheetId == null) { + MyTracks.getInstance().setProgressValue(85); + // The previous creation might have succeeded even though GData + // reported an error. Seems to be a know bug, + // see http://code.google.com/p/gdata-issues/issues/detail?id=929 + // Try to find the created spreadsheet: + Log.w(MyTracksConstants.TAG, + "Create might have failed. Trying to find created document."); + try { + Thread.sleep(5000); + } catch (InterruptedException e) { + Log.e(MyTracksConstants.TAG, "Sleep interrupted", e); + } + if (!getSpreadsheetId(writelyWrapper, sheetTitle)) { + return false; + } + if (spreadSheetId == null) { + MyTracks.getInstance().setProgressValue(87); + // Re-try + try { + Thread.sleep(5000); + } catch (InterruptedException e) { + Log.e(MyTracksConstants.TAG, "Sleep interrupted", e); + } + if (!getSpreadsheetId(writelyWrapper, sheetTitle)) { + return false; + } + } + if (spreadSheetId == null) { + Log.i(MyTracksConstants.TAG, + "Creating new spreadsheet really failed."); + return false; + } + } + } + + if (!getWorkSheetId(wiseWrapper)) { + Log.i(MyTracksConstants.TAG, "Looking up worksheet id failed."); + return false; + } + + MyTracks.getInstance().setProgressValue(90); + + insertRowNet(track, spreadSheetId, workSheetId); + Log.i(MyTracksConstants.TAG, "Done uploading to docs."); + } catch (IOException e) { + Log.e(MyTracksConstants.TAG, "Unable to upload docs.", e); + return false; + } finally { + if (androidClient != null) { + androidClient.close(); + } + } + return true; + } + + private boolean getSpreadsheetId(GDataWrapper wrapper, final String title) { + spreadSheetId = null; + return wrapper.runQuery(new QueryFunction() { + @Override + public void query(GDataServiceClient client) + throws IOException, ParseException, HttpException { + GDataParser listParser; + listParser = client.getParserForFeed(Entry.class, + DOCS_MY_SPREADSHEETS_FEED_URL, writelyAuth.getAuthToken()); + listParser.init(); + while (listParser.hasMoreData()) { + Entry entry = listParser.readNextEntry(null); + String entryTitle = entry.getTitle(); + Log.i(MyTracksConstants.TAG, "Found docs entry: " + entryTitle); + if (entryTitle.equals(title)) { + String entryId = entry.getId(); + int lastSlash = entryId.lastIndexOf('/'); + spreadSheetId = entryId.substring(lastSlash + 15); + break; + } + } + } + }); + } + + private boolean getWorkSheetId(GDataWrapper wrapper) { + workSheetId = null; + return wrapper.runQuery(new QueryFunction() { + @Override + public void query(GDataServiceClient client) + throws AuthenticationException, IOException, ParseException { + String uri = String.format(DOCS_WORKSHEETS_URL_FORMAT, spreadSheetId); + GDataParser sheetParser = + ((SpreadsheetsClient) client).getParserForWorksheetsFeed(uri, + wiseAuth.getAuthToken()); + sheetParser.init(); + if (!sheetParser.hasMoreData()) { + Log.i(MyTracksConstants.TAG, "Found no worksheets"); + return; // failure + } + + // just grab the first. + WorksheetEntry worksheetEntry = + (WorksheetEntry) sheetParser.readNextEntry(new WorksheetEntry()); + + int lastSlash = worksheetEntry.getId().lastIndexOf('/'); + workSheetId = worksheetEntry.getId().substring(lastSlash + 1); + } + }); + } + + /** + * Creates a new MyTracks spreadsheet with the given name. + */ + private boolean createSpreadSheet(GDataWrapper writelyWrapper, + final String name) { + spreadSheetId = null; + + return writelyWrapper.runQuery(new QueryFunction() { + @Override + public void query(GDataServiceClient client) throws IOException { + // Construct data + // Send data + URL url = new URL(DOCS_FEED_URL); + URLConnection conn = url.openConnection(); + conn.addRequestProperty(CONTENT_TYPE_PARAM, + OPENDOCUMENT_SPREADSHEET_MIME_TYPE); + conn.addRequestProperty("Slug", name); + conn.addRequestProperty("Authorization", + "GoogleLogin auth=" + writelyAuth.getAuthToken()); + conn.setDoOutput(true); + OutputStream os = conn.getOutputStream(); + ResourceUtils.readBinaryFileToOutputStream( + activity, R.raw.mytracks_empty_spreadsheet, os); + + // Get the response + // TODO: The following is a horrible ugly hack. + // Hopefully we can retire it when there is a proper gdata api. + BufferedReader rd = null; + String line; + StringBuilder resultBuilder = new StringBuilder(); + try { + rd = new BufferedReader(new InputStreamReader(conn.getInputStream())); + while ((line = rd.readLine()) != null) { + resultBuilder.append(line); + } + os.close(); + rd.close(); + } catch (FileNotFoundException e) { + // The GData API sometimes throws an error, even though creation of + // the document succeeded. In that case let's just return. The caller + // then needs to check if the doc actually exists. + return; + } finally { + os.close(); + if (rd != null) { + rd.close(); + } + } + + String result = resultBuilder.toString(); + // Try to find the id. + int idTagIndex = result.indexOf(""); + if (idTagIndex == -1) { + return; + } + int idTagCloseIndex = result.indexOf("", idTagIndex); + if (idTagCloseIndex == -1) { + return; + } + int idStringStart = result.indexOf(DOCS_SPREADSHEET_URL, idTagIndex); + if (idStringStart == -1) { + return; + } + spreadSheetId = result.substring( + idStringStart + DOCS_SPREADSHEET_URL.length(), idTagCloseIndex); + Log.i(MyTracksConstants.TAG, "Created new spreadsheet: " + spreadSheetId); + }}); + } + + private void insertRowNet(Track track, String spreadsheetId, + String worksheetId) throws IOException { + String worksheetUri = String.format(DOCS_SPREADSHEET_URL_FORMAT, + spreadsheetId, worksheetId); + + /* Prepare the Post-Text we are going to send. */ + StringBuilder sb = new StringBuilder(); + sb.append(""); + appendTag("name", track.getName(), sb); + appendTag("description", track.getDescription(), sb); + appendTag("date", String.format("%tc", track.getStartTime()), sb); + appendTag("totaltime", StringUtils.formatTimeAlwaysShowingHours( + track.getTotalTime()), sb); + appendTag("movingtime", StringUtils.formatTimeAlwaysShowingHours( + track.getMovingTime()), sb); + appendLargeUnitsTag("distance", track.getTotalDistance() / 1000, sb); + appendTag("distanceunit", + metricUnits + ? activity.getString(R.string.kilometer) + : activity.getString(R.string.mile), + sb); + appendLargeUnitsTag("averagespeed", track.getAverageSpeed() * 3.6, sb); + appendLargeUnitsTag("averagemovingspeed", + track.getAverageMovingSpeed() * 3.6, sb); + appendLargeUnitsTag("maxspeed", track.getMaxSpeed() * 3.6, sb); + appendTag("speedunit", + metricUnits + ? activity.getString(R.string.kilometer_per_hour) + : activity.getString(R.string.mile_per_hour), + sb); + appendSmallUnitsTag("elevationgain", track.getTotalElevationGain(), sb); + appendSmallUnitsTag("minelevation", track.getMinElevation(), sb); + appendSmallUnitsTag("maxelevation", track.getMaxElevation(), sb); + appendTag("elevationunit", + metricUnits + ? activity.getString(R.string.meter) + : activity.getString(R.string.feet), + sb); + if (track.getMapId().length() > 0) { + appendTag("map", MyMapsConstants.MAPSHOP_BASE_URL + "?msa=0&msid=" + + track.getMapId(), sb); + } + sb.append(""); + Log.i(MyTracksConstants.TAG, + "Inserting at: " + spreadsheetId + " => " + worksheetUri); + + String postText = sb.toString(); + Log.i(MyTracksConstants.TAG, postText); + + // Send data + // No need for a wrapper because we know that the authorization was good + // enough to get this far + URL url = new URL(worksheetUri); + URLConnection conn = url.openConnection(); + conn.addRequestProperty(CONTENT_TYPE_PARAM, ATOM_FEED_MIME_TYPE); + conn.addRequestProperty("Authorization", + "GoogleLogin auth=" + wiseAuth.getAuthToken()); + conn.setDoOutput(true); + OutputStreamWriter wr = new OutputStreamWriter(conn.getOutputStream()); + wr.write(postText); + wr.flush(); + + // Get the response + BufferedReader rd = + new BufferedReader(new InputStreamReader(conn.getInputStream())); + String line; + while ((line = rd.readLine()) != null) { + // Process line... + Log.i(MyTracksConstants.TAG, "r: " + line); + } + wr.close(); + rd.close(); + + Log.i(MyTracksConstants.TAG, "Post finished."); + } + + private void appendTag(String name, String value, StringBuilder sb) { + sb.append("'); + sb.append(StringUtils.stringAsCData(value)); + sb.append("'); + } + + private void appendLargeUnitsTag(String name, double d, StringBuilder sb) { + double value = metricUnits ? d : (d * UnitConversions.KM_TO_MI); + appendTag(name, LARGE_UNIT_FORMAT.format(value), sb); + } + + private void appendSmallUnitsTag(String name, double d, StringBuilder sb) { + double value = metricUnits ? d : (d * UnitConversions.M_TO_FT); + appendTag(name, SMALL_UNIT_FORMAT.format(value), sb); + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/SendToMyMaps.java b/MyTracks/src/com/google/android/apps/mytracks/io/SendToMyMaps.java new file mode 100644 index 000000000..5731e5a79 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/SendToMyMaps.java @@ -0,0 +1,689 @@ +/* + * Copyright 2008 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; + +import com.google.android.apps.mymaps.MyMapsFeature; +import com.google.android.apps.mymaps.MyMapsGDataConverter; +import com.google.android.apps.mymaps.MyMapsGDataWrapper; +import com.google.android.apps.mymaps.MyMapsGDataWrapper.QueryFunction; +import com.google.android.apps.mymaps.MyMapsMapMetadata; +import com.google.android.apps.mytracks.MyTracksConstants; +import com.google.android.apps.mytracks.MyTracksSettings; +import com.google.android.apps.mytracks.ProgressIndicator; +import com.google.android.apps.mytracks.content.MyTracksProviderUtils; +import com.google.android.apps.mytracks.content.Track; +import com.google.android.apps.mytracks.content.TrackBuffer; +import com.google.android.apps.mytracks.content.Waypoint; +import com.google.android.apps.mytracks.stats.DoubleBuffer; +import com.google.android.apps.mytracks.util.MyTracksUtils; +import com.google.android.apps.mytracks.util.StringUtils; +import com.google.android.apps.mytracks.util.UnitConversions; +import com.google.android.maps.mytracks.R; +import com.google.wireless.gdata.client.HttpException; +import com.google.wireless.gdata.data.Entry; +import com.google.wireless.gdata.maps.MapsClient; +import com.google.wireless.gdata.parser.ParseException; +import com.google.wireless.gdata2.client.AuthenticationException; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.location.Location; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.Log; + +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Vector; + +/** + * A helper class used to transmit tracks to Google MyMaps. + * + * @author Leif Hendrik Wilden + */ +public class SendToMyMaps { + + private static final String END_ICON_URL = + "http://maps.google.com/mapfiles/ms/micons/red-dot.png"; + private static final String START_ICON_URL = + "http://maps.google.com/mapfiles/ms/micons/green-dot.png"; + + private final Activity context; + private final AuthManager auth; + private final long trackId; + private final ProgressIndicator progressIndicator; + private final StringUtils stringUtils; + private final MyTracksProviderUtils providerUtils; + private String mapId; + + private String statusMessage = ""; + private boolean success = true; + private Runnable onCompletion; + + private HandlerThread handlerThread; + private Handler handler; + + private String originalDescription; + + private MyMapsGDataWrapper wrapper; + private MyMapsGDataConverter gdataConverter; + + private Location last = null; + + private Vector distances; + private Vector elevations; + private DoubleBuffer elevationBuffer; + + /** + * Prepares a buffer of locations for transmission to google maps. + * + * @param track the original track with meta data + * @param buffer a buffer of locations on the track + * @return an array of tracks each with a sub section of the points in the + * original buffer + */ + private ArrayList prepareLocations(Track track, + TrackBuffer buffer) { + ArrayList splitTracks = new ArrayList(); + + // Create segments from each full track: + Track segment = new Track(); + segment.setId(track.getId()); + segment.setName(track.getName()); + segment.setDescription(/* track.getDescription() */ ""); + segment.setCategory(track.getCategory()); + segment.setStartTime(track.getStartTime()); + segment.setStopTime(track.getStopTime()); + boolean startNewTrackSegment = false; + for (int i = 0; i < buffer.getLocationsLoaded(); ++i) { + Location loc = buffer.get(i); + if (loc.getLatitude() > 90) { + startNewTrackSegment = true; + } + + if (startNewTrackSegment) { + // Close up the last segment. + prepareTrackSegment(segment, splitTracks); + + Log.d(MyTracksConstants.TAG, + "MyTracksSendToMyMaps: Starting new track segment..."); + startNewTrackSegment = false; + segment = new Track(); + segment.setId(track.getId()); + segment.setName(track.getName()); + segment.setDescription(/* track.getDescription() */ ""); + segment.setCategory(track.getCategory()); + } + + if (loc.getLatitude() <= 90) { + segment.addLocation(loc); + if (segment.getStartTime() < 0) { + segment.setStartTime(loc.getTime()); + } + } + } + + prepareTrackSegment(segment, splitTracks); + + return splitTracks; + } + + /** + * Prepares a track segment for sending to google maps. + * The main steps are: + * - correcting end time + * - decimating locations + * - splitting into smaller tracks. + * + * The final track pieces will be put in the array list splitTracks. + * + * @param segment the original segment of the track + * @param splitTracks an array of smaller track segments + */ + private void prepareTrackSegment( + Track segment, ArrayList splitTracks) { + if (segment.getStopTime() < 0 && segment.getLocations().size() > 0) { + segment.setStopTime(segment.getLocations().size() - 1); + } + + /* + * Decimate to 2 meter precision. Mapshop doesn't like too many + * points: + */ + MyTracksUtils.decimate(segment, 2.0); + + /* It the track still has > 500 points, split it in pieces: */ + if (segment.getLocations().size() > 500) { + splitTracks.addAll(MyTracksUtils.split(segment, 500)); + } else if (segment.getLocations().size() >= 2) { + splitTracks.add(segment); + } + } + + /** + * Inserts a place mark. Second try if 1st try fails. Will throw exception on + * 2nd failure. + */ + private void insertMarker(Context context, MapsClient client, + String featureFeed, Track track, Location loc, + boolean isStart) throws IOException, Exception { + Entry entry = gdataConverter.getEntryForFeature( + buildMyMapsPlacemarkFeature(context, track, loc, isStart)); + Log.d(MyTracksConstants.TAG, "SendToMyMaps: Creating placemark " + + entry.getTitle()); + try { + client.createEntry(featureFeed, auth.getAuthToken(), entry); + Log.d(MyTracksConstants.TAG, "SendToMyMaps: createEntry success!"); + } catch (IOException e) { + Log.w(MyTracksConstants.TAG, + "SendToMyMaps: createEntry 1st try failed. Trying again."); + // Retry once (often IOException is thrown on a timeout): + client.createEntry(featureFeed, auth.getAuthToken(), entry); + Log.d(MyTracksConstants.TAG, + "SendToMyMaps: createEntry success on 2nd try!"); + } + } + + private boolean uploadMarker(final Track track, + final Location location, + final boolean isStart) { + boolean okay = wrapper.runQuery(new QueryFunction() { + @Override + public void query(MapsClient client) + throws AuthenticationException, IOException, Exception { + String featureFeed = MapsClient.getFeaturesFeed(mapId); + insertMarker(context, client, featureFeed, track, location, isStart); + } + }); + return okay; + } + + /** + * Sets the current upload progress. + * + * @param track The track being uploaded + * @param totalLocationsRead The total number of locations already read and + * uploaded + * @param segmentSize The percent of total that each segment corresponds to + * @param progressInSegment The approximate progress in this segment + */ + private void setProgress(final Track track, int totalLocationsRead, + double segmentSize, double progressInSegment) { + long totalLocations = track.getStopId() - track.getStartId(); + + // The percent of the total that represents the completed part of this + // segment. + double percentSegmentInTotal = (segmentSize * progressInSegment); + + int progress = (int) ((100.0 * totalLocationsRead / totalLocations) + + percentSegmentInTotal); + progress = Math.min(99, progress); + progressIndicator.setProgressValue(progress); + } + + private boolean uploadAllTrackPoints(final Track track) { + int totalLocationsRead = 0; + // TODO: This might not actually be accurate. + long totalLocations = track.getStopId() - track.getStartId(); + + // Limit the number of elevation readings. Ideally we would want around 250. + int elevationSamplingFrequency = + Math.max(1, (int) (totalLocations / 250.0)); + Log.d(MyTracksConstants.TAG, + "Using elevation sampling factor: " + elevationSamplingFrequency + + " on " + totalLocations); + double totalDistance = 0; + Location lastLocation = null; + + TrackBuffer buffer = new TrackBuffer((int) Math.min(2048, totalLocations)); + int part = 1; + + SharedPreferences preferences = context.getSharedPreferences( + MyTracksSettings.SETTINGS_NAME, 0); + boolean metricUnits = true; + if (preferences != null) { + metricUnits = preferences.getBoolean(MyTracksSettings.METRIC_UNITS, true); + } + + // For the progress monitor: + // For each track segment 33% = read, 33% = prep & 33% = upload + // Note: These numbers are very rough. + + // The size of each segment in the 100% progress + double segmentSize = buffer.getSize() * 100.0 / totalLocations; + while (success && buffer.getLastLocationRead() < track.getStopId()) { + setProgress(track, totalLocationsRead, segmentSize, 0.0); + progressIndicator.setProgressMessage( + context.getString(R.string.progress_message_reading_track)); + + providerUtils.getTrackPoints(track, buffer); + if (buffer.getLocationsLoaded() <= 0) { + continue; + } + if (totalLocationsRead == 0) { + // Put a marker at the first point of the first valid segment: + uploadMarker(track, buffer.get(0), true); + } + last = buffer.get(buffer.getLocationsLoaded() - 1); + + // Extract the elevation profile. + for (int i = 0; i < buffer.getLocationsLoaded(); i++) { + // Add all points to the smoothing buffer. + Location l = buffer.get(i); + if (l != null && MyTracksUtils.isValidLocation(l)) { + elevationBuffer.setNext(metricUnits ? l.getAltitude() + : l.getAltitude() * UnitConversions.M_TO_FT); + if (lastLocation != null) { + double dist = lastLocation.distanceTo(l); + totalDistance += dist; + } + lastLocation = l; + + // Only add some data points to keep the url short. + if (i % elevationSamplingFrequency == 0) { + distances.add(totalDistance); + elevations.add(elevationBuffer.getAverage()); + } + } + } + + setProgress(track, totalLocationsRead, segmentSize, 0.3333); + progressIndicator.setProgressMessage( + context.getString(R.string.progress_message_preparing_track)); + + ArrayList splitTracks = prepareLocations(track, buffer); + + int i = 0; + for (Track t : splitTracks) { + if (part > 1) { + t.setName(t.getName() + " " + + String.format(context.getString(R.string.part), part)); + } + part++; + Log.d(MyTracksConstants.TAG, + "SendToMyMaps: Prepared feature for upload w/ " + + t.getLocations().size() + " points."); + + // + // Transmit tracks via GData feed: + // ------------------------------- + Log.d(MyTracksConstants.TAG, + "SendToMyMaps: Uploading to map " + mapId + " w/ auth " + auth); + if (!uploadTrackPoints(track, t, + totalLocationsRead, segmentSize)) { + success = false; + return false; + } + i++; + } + totalLocationsRead += buffer.getLocationsLoaded(); + } + + return true; + } + + /** + * Uploads a given list of tracks to Google MyMaps using the maps GData feed. + * + * @param track the track meta-data is used for the map name + */ + private boolean uploadTrackPoints(final Track track, final Track currentTrack, + int totalLocationsRead, double segmentSize) { + setProgress(track, totalLocationsRead, segmentSize, 0.6666); + progressIndicator.setProgressMessage( + context.getString(R.string.progress_message_sending_mymaps)); + return wrapper.runQuery(new QueryFunction() { + @Override + public void query(MapsClient client) + throws AuthenticationException, IOException, Exception { + String featureFeed = MapsClient.getFeaturesFeed(mapId); + Log.d(MyTracksConstants.TAG, "Feature feed url: " + featureFeed); + uploadTrackPoints(track, currentTrack, client, featureFeed); + } + }); + } + + /** + * Creates a new map for the given track. + * + * @param track The track that will be uploaded to this map + * @return True on success. + */ + private boolean createNewMap(final Track track) { + progressIndicator.setProgressMessage(context + .getString(R.string.progress_message_creating_map)); + return wrapper.runQuery(new QueryFunction() { + @Override + public void query(MapsClient client) throws IOException, Exception { + Log.d(MyTracksConstants.TAG, "Creating a new map."); + String mapFeed = MapsClient.getMapsFeed(); + Log.d(MyTracksConstants.TAG, "Map feed is " + mapFeed); + MyMapsMapMetadata metaData = new MyMapsMapMetadata(); + metaData.setTitle(track.getName()); + metaData.setDescription(originalDescription + " - " + + track.getCategory() + " - " + + context.getString(R.string.new_map_description)); + SharedPreferences preferences = context.getSharedPreferences( + MyTracksSettings.SETTINGS_NAME, 0); + boolean mapPublic = true; + if (preferences != null) { + mapPublic = preferences.getBoolean( + MyTracksSettings.DEFAULT_MAP_PUBLIC, true); + } + metaData.setSearchable(mapPublic); + Entry entry = MyMapsGDataConverter.getMapEntryForMetadata(metaData); + Log.d(MyTracksConstants.TAG, "Title: " + entry.getTitle()); + Entry map = client.createEntry(mapFeed, auth.getAuthToken(), entry); + mapId = MapsClient.getMapIdFromMapEntryId(map.getId()); + Log.d(MyTracksConstants.TAG, "New map id is: " + mapId); + } + }); + } + + private boolean uploadTrackPoints(final Track track, + Track splitTrack, + MapsClient client, + String featureFeed) + throws IOException, Exception { + Entry entry = null; + if (splitTrack.getId() != track.getId() + || splitTrack.getLocations().size() < 2) { + // Need at least two points for a polyline: + return true; + } + + // Put the line: + entry = gdataConverter.getEntryForFeature( + buildMyMapsLineFeature(splitTrack)); + Log.d(MyTracksConstants.TAG, + "SendToMyMaps: Creating line " + entry.getTitle()); + try { + client.createEntry(featureFeed, auth.getAuthToken(), entry); + Log.d(MyTracksConstants.TAG, "SendToMyMaps: createEntry success!"); + } catch (IOException e) { + Log.w(MyTracksConstants.TAG, + "SendToMyMaps: createEntry 1st try failed. Trying again."); + // Retry once (often IOException is thrown on a timeout): + client.createEntry(featureFeed, auth.getAuthToken(), entry); + Log.d(MyTracksConstants.TAG, + "SendToMyMaps: createEntry success on 2nd try!"); + } + return true; + } + + /** + * Uploads all of the waypoints associated with this track to map. + * + * @param track The track to upload waypoints for. + * + * @return True on success. + */ + private boolean uploadWaypoints(final Track track) { + return wrapper.runQuery(new QueryFunction() { + public void query(MapsClient client) { + // TODO: Stream through he waypoints in chunks. + // I am leaving the number of waypoints very high which should not be a + // problem because we don't try to load them into objects all at the + // same time. + Cursor c = null; + c = providerUtils.getWaypointsCursor( + track.getId(), 0, + MyTracksConstants.MAX_LOADED_WAYPOINTS_POINTS); + String featureFeed = MapsClient.getFeaturesFeed(mapId); + if (c != null) { + try { + if (c.moveToFirst()) { + // This will skip the 1st waypoint (it carries the stats for the + // last segment). + while (c.moveToNext()) { + Waypoint wpt = providerUtils.createWaypoint(c); + Entry entry = gdataConverter.getEntryForFeature( + buildMyMapsPlacemarkFeature(wpt)); + Log.d(MyTracksConstants.TAG, + "SendToMyMaps: Creating waypoint."); + try { + client.createEntry(featureFeed, auth.getAuthToken(), entry); + Log.d(MyTracksConstants.TAG, + "SendToMyMaps: createEntry success!"); + } catch (IOException e) { + Log.w(MyTracksConstants.TAG, + "SendToMyMaps: createEntry 1st try failed. Retrying."); + + // Retry once (often IOException is thrown on a timeout): + client.createEntry(featureFeed, auth.getAuthToken(), entry); + Log.d(MyTracksConstants.TAG, + "SendToMyMaps: createEntry success on 2nd try!"); + } + } + } + } catch (ParseException e) { + Log.w(MyTracksConstants.TAG, "ParseException caught.", e); + } catch (HttpException e) { + Log.w(MyTracksConstants.TAG, "HttpException caught.", e); + } catch (IOException e) { + Log.w(MyTracksConstants.TAG, "IOException caught.", e); + } finally { + c.close(); + } + } + } + }); + } + + public SendToMyMaps(Activity context, String mapId, AuthManager auth, + long trackId, ProgressIndicator progressIndicator) { + this.context = context; + this.mapId = mapId; + this.auth = auth; + this.trackId = trackId; + this.progressIndicator = progressIndicator; + this.stringUtils = new StringUtils(context); + this.providerUtils = MyTracksProviderUtils.Factory.get(context); + + Log.d(MyTracksConstants.TAG, "Sending to MyMap: trackId = " + trackId); + handlerThread = new HandlerThread("SendToMyMaps"); + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()); + } + + public void run() { + handler.post(new Runnable() { + @Override + public void run() { + doUpload(); + } + }); + } + + public void setOnCompletion(Runnable onCompletion) { + this.onCompletion = onCompletion; + } + + public String getMapId() { + return mapId; + } + + public boolean wasSuccess() { + return success; + } + + public String getStatusMessage() { + return statusMessage; + } + + private void doUpload() { + statusMessage = context.getString(R.string.error_sending_to_mymap); + success = true; + try { + gdataConverter = new MyMapsGDataConverter(); + + progressIndicator.setProgressValue(1); + progressIndicator.setProgressMessage( + context.getString(R.string.progress_message_reading_track)); + + // Get the track meta-data + Track track = providerUtils.getTrack(trackId); + originalDescription = track.getDescription(); + track.setDescription("

" + track.getDescription() + "

" + + stringUtils.generateTrackDescription(track, null, null) + "

"); + wrapper = new MyMapsGDataWrapper(context); + wrapper.setAuthManager(auth); + wrapper.setRetryOnAuthFailure(true); + + distances = new Vector(); + elevations = new Vector(); + elevationBuffer = + new DoubleBuffer(MyTracksConstants.ELEVATION_SMOOTHING_FACTOR); + + // Create a new map if necessary: + if (mapId.equals("new")) { + if (!createNewMap(track)) { + success = false; + } + } + + // Upload all of the segments of the track. + if (success) { + if (!uploadAllTrackPoints(track)) { + success = false; + } + } + + // Start end end markers. + if (success) { + // Put an end marker at the last point of the last valid segment: + if (last != null) { + track.setDescription("

" + originalDescription + "

" + + stringUtils.generateTrackDescription( + track, distances, elevations) + + "

"); + if (!uploadMarker(track, last, false)) { + success = false; + } + } + } + + // Put waypoints. + if (success) { + if (!uploadWaypoints(track)) { + Log.w(MyTracksConstants.TAG, + "SendToMyMaps: upload waypoints failed."); + success = false; + } + } + + if (success) { + if (mapId.equals("new")) { + statusMessage = + context.getString(R.string.status_new_mymap_has_been_created); + } else { + statusMessage = + context.getString(R.string.status_tracks_have_been_uploaded); + } + } + Log.d(MyTracksConstants.TAG, "SendToMyMaps: Done: " + success); + progressIndicator.setProgressValue(100); + } catch (XmlPullParserException e) { + Log.e(MyTracksConstants.TAG, "Caught an unexpected exception.", e); + } finally { + if (wrapper != null) { + wrapper.cleanUp(); + } + + context.runOnUiThread(new Runnable() { + public void run() { + if (onCompletion != null) { + onCompletion.run(); + } + } + }); + } + } + + /** + * Builds a MyMapsFeature from a track. + * + * @param track the track + * @return a MyMapsFeature + */ + private static MyMapsFeature buildMyMapsLineFeature(Track track) { + MyMapsFeature myMapsFeature = new MyMapsFeature(); + myMapsFeature.generateAndroidId(); + myMapsFeature.setType(MyMapsFeature.LINE); + if (track.getName().length() < 1) { + // Features must have a name (otherwise GData upload may fail): + myMapsFeature.setTitle("-"); + } else { + myMapsFeature.setTitle(track.getName()); + } + myMapsFeature.setColor(0x80FF0000); + for (Location loc : track.getLocations()) { + myMapsFeature.addPoint(MyTracksUtils.getGeoPoint(loc)); + } + return myMapsFeature; + } + + /** + * Builds a placemark MyMapsFeature from a track. + * + * @param track the track + * @param isStart true if it's the start of the track, or false for end + * @return a MyMapsFeature + */ + private static MyMapsFeature buildMyMapsPlacemarkFeature(Context context, + Track track, Location loc, boolean isStart) { + MyMapsFeature myMapsFeature = new MyMapsFeature(); + myMapsFeature.generateAndroidId(); + myMapsFeature.setType(MyMapsFeature.MARKER); + if (isStart) { + myMapsFeature.setIconUrl(START_ICON_URL); + } else { + myMapsFeature.setIconUrl(END_ICON_URL); + } + myMapsFeature.addPoint(MyTracksUtils.getGeoPoint(loc)); + String name = track.getName() + " " + + (isStart ? context.getString(R.string.start) + : context.getString(R.string.end)); + myMapsFeature.setTitle(name); + return myMapsFeature; + } + + /** + * Builds a MyMapsFeature from a track. + * + * @param wpt the waypoint + * @return a MyMapsFeature + */ + private static MyMapsFeature buildMyMapsPlacemarkFeature(Waypoint wpt) { + MyMapsFeature myMapsFeature = new MyMapsFeature(); + myMapsFeature.generateAndroidId(); + myMapsFeature.setType(MyMapsFeature.MARKER); + myMapsFeature.setIconUrl(wpt.getIcon()); + myMapsFeature.setDescription(wpt.getDescription()); + myMapsFeature.addPoint(MyTracksUtils.getGeoPoint(wpt.getLocation())); + if (wpt.getName().length() < 1) { + // Features must have a name (otherwise GData upload may fail): + myMapsFeature.setTitle("-"); + } else { + myMapsFeature.setTitle(wpt.getName()); + } + return myMapsFeature; + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/TrackFormatWriter.java b/MyTracks/src/com/google/android/apps/mytracks/io/TrackFormatWriter.java new file mode 100644 index 000000000..60f462e75 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/TrackFormatWriter.java @@ -0,0 +1,121 @@ +/* + * 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; + +import com.google.android.apps.mytracks.content.Track; +import com.google.android.apps.mytracks.content.Waypoint; + +import android.location.Location; + +import java.io.OutputStream; + +/** + * Interface for writing data to a specific track file format. + * + * The expected sequence of calls is: + *
    + *
  1. {@link #prepare} + *
  2. {@link #writeHeader} + *
  3. {@link #writeBeginTrack} + *
  4. For each segment: + *
      + *
    1. {@link #writeOpenSegment} + *
    2. For each location in the segment: {@link #writeLocation} + *
    3. {@link #writeCloseSegment} + *
    + *
  5. {@link #writeEndTrack} + *
  6. For each waypoint: {@link #writeWaypoint} + *
  7. {@link #writeFooter} + *
+ * + * @author Rodrigo Damazio + */ +public interface TrackFormatWriter { + + /** + * Sets up the writer to write the given track to the given output. + * + * @param track the track to write + * @param out the stream to write the track contents to + */ + void prepare(Track track, OutputStream out); + + /** + * Determines the default location that will be written to (if no other is + * specified). + * + * @return The absolute path for the the file created, or null on failure + */ + String getDefaultDirectory(); + + /** + * @return The file extentsion (i.e. gpx, kml, ...) + */ + String getExtension(); + + /** + * Writes the header. + * This is chance for classes to write out opening information. + */ + void writeHeader(); + + /** + * Writes the footer. + * This is chance for classes to write out closing information. + */ + void writeFooter(); + + /** + * Write the given location object. + * + * TODO Add some flexible handling of other sensor data. + * + * @param location the location to write + */ + void writeLocation(Location location); + + /** + * Write a way point. + * + * @param waypoint + */ + void writeWaypoint(Waypoint waypoint); + + /** + * Write the beginning of a track. + */ + void writeBeginTrack(Location firstPoint); + + /** + * Write the end of a track. + */ + void writeEndTrack(Location lastPoint); + + /** + * Write the statements necessary to open a new segment. + */ + void writeOpenSegment(); + + /** + * Write the statements necessary to close a segment. + */ + void writeCloseSegment(); + + /** + * Close the underlying file handle. + */ + void close(); +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/TrackWriter.java b/MyTracks/src/com/google/android/apps/mytracks/io/TrackWriter.java new file mode 100644 index 000000000..46362928b --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/TrackWriter.java @@ -0,0 +1,365 @@ +/* + * Copyright 2008 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; + +import com.google.android.apps.mytracks.MyTracksConstants; +import com.google.android.apps.mytracks.content.MyTracksProviderUtils; +import com.google.android.apps.mytracks.content.Track; +import com.google.android.apps.mytracks.content.TrackBuffer; +import com.google.android.apps.mytracks.content.Waypoint; +import com.google.android.apps.mytracks.util.MyTracksUtils; +import com.google.android.maps.mytracks.R; + +import android.app.Activity; +import android.content.Context; +import android.database.Cursor; +import android.location.Location; +import android.os.Environment; +import android.util.Log; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.OutputStream; +import java.util.HashSet; + +/** + * The class which exports tracks to the SD card. + * This writer is format-neutral - it handles creating the output file + * and reading the track to be exported, but requires an instance of + * {@link TrackFormatWriter} to actually format the data. + * + * @author Sandor Dornbush + * @author Rodrigo Damazio + */ +public class TrackWriter { + + private final Context context; + private final MyTracksProviderUtils providerUtils; + private final Track track; + private final TrackFormatWriter writer; + private Runnable onCompletion = null; + private boolean success = false; + private int errorMessage = -1; + private File directory = null; + private File file = null; + + /** + * A set of characters that are prohibited from being in file names. + */ + private static final HashSet PROHIBITED_CHARACTERS = + new HashSet(); + + static { + for (int i = 0; i < 48; i++) { + PROHIBITED_CHARACTERS.add(new Character((char) i)); + } + for (int i = 58; i < 65; i++) { + PROHIBITED_CHARACTERS.add(new Character((char) i)); + } + for (int i = 91; i < 97; i++) { + PROHIBITED_CHARACTERS.add(new Character((char) i)); + } + for (int i = 123; i < 128; i++) { + PROHIBITED_CHARACTERS.add(new Character((char) i)); + } + } + + public TrackWriter(Context context, MyTracksProviderUtils providerUtils, + Track track, TrackFormatWriter writer) { + this.context = context; + this.providerUtils = providerUtils; + this.track = track; + this.writer = writer; + } + + /** + * Sets a completion callback. + * + * @param onCompletion Runnable that will be executed when finished + */ + public void setOnCompletion(Runnable onCompletion) { + this.onCompletion = onCompletion; + } + + /** + * Sets a custom directory where the file will be written. + */ + public void setDirectory(File directory) { + this.directory = directory; + } + + public String getAbsolutePath() { + return file.getAbsolutePath(); + } + + /** + * Writes the given track id to the SD card. + * This is non-blocking. + */ + public void writeTrackAsync() { + Thread t = new Thread() { + @Override + public void run() { + writeTrack(); + } + }; + t.start(); + } + + /** + * Writes the given track id to the SD card. + * This is blocking. + */ + public void writeTrack() { + // Open the input and output + success = false; + errorMessage = R.string.error_track_does_not_exist; + if (track != null) { + if (openFile()) { + writeDocument(); + } + } + finished(); + } + + public int getErrorMessage() { + return errorMessage; + } + + public boolean wasSuccess() { + return success; + } + + /* + * Helper methods: + * =============== + */ + + /** + * Makes sure the given directory exists, creating it if necessary. + * + * @param dir the directory to ensure exists + * @return True on success + */ + private static boolean ensureExists(File dir) { + if (dir.exists()) { + return true; + } + File parent = dir.getParentFile(); + if (parent == null) { + return true; + } + if (!ensureExists(parent)) { + return false; + } + return dir.mkdir(); + } + + private void finished() { + if (onCompletion != null) { + runOnUiThread(onCompletion); + return; + } + } + + /** + * Runs the given runnable in the UI thread. + */ + protected void runOnUiThread(Runnable runnable) { + if (context instanceof Activity) { + ((Activity) context).runOnUiThread(runnable); + } + } + + /** + * Opens the file and prepares the format writer for it. + * + * @return true on success, false otherwise (and errorMessage is set) + */ + protected boolean openFile() { + if (!canWriteFile()) { + return false; + } + + // Make sure the name will work on FAT + String fileName = + sanitizeName(track.getName()) + "." + writer.getExtension(); + Log.i(MyTracksConstants.TAG, "Writing track to: " + fileName); + try { + writer.prepare(track, newOutputStream(fileName)); + } catch (FileNotFoundException e) { + Log.e(MyTracksConstants.TAG, "Failed to open output file.", e); + errorMessage = R.string.io_write_failed; + return false; + } + return true; + } + + /** + * Checks and returns whether we're ready to create the output file. + */ + protected boolean canWriteFile() { + if (directory == null) { + directory = newFile(writer.getDefaultDirectory()); + } + + if (!Environment.getExternalStorageState() + .equals(Environment.MEDIA_MOUNTED)) { + Log.i(MyTracksConstants.TAG, "Could not find SD card."); + errorMessage = R.string.io_no_external_storage_found; + return false; + } + if (!ensureExists(directory)) { + Log.i(MyTracksConstants.TAG, "Could not create GPX directory."); + errorMessage = R.string.io_create_dir_failed; + return false; + } + + return true; + } + + /** + * Creates a new output stream to write to the given filename. + * + * @throws FileNotFoundException if the file could't be created + */ + protected OutputStream newOutputStream(String fileName) + throws FileNotFoundException { + file = new File(directory, fileName); + return new FileOutputStream(file); + } + + /** + * Creates a new file object for the given path. + */ + protected File newFile(String path) { + return new File(path); + } + + /** + * Normalizes the input string and make sure it is a valid fat32 file name. + */ + static String sanitizeName(String name) { + StringBuilder cleaned = new StringBuilder(); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (!PROHIBITED_CHARACTERS.contains(c)) { + cleaned.append(c); + } + } + + // Max = 260 + // Boiler plate /[gpx|kml]/ .[gpx|kml] = 8 + return (cleaned.length() > 252) + ? cleaned.substring(0, 252) + : cleaned.toString(); + } + + /** + * Writes the waypoints for the given track. + * + * @param trackId the ID of the track to write waypoints for + */ + private void writeWaypoints(long trackId) { + // TODO: Stream through he waypoints in chunks. + // I am leaving the number of waypoints very high which should not be a + // problem because we don't try to load them into objects all at the + // same time. + Cursor cursor = null; + cursor = providerUtils.getWaypointsCursor(trackId, 0, + MyTracksConstants.MAX_LOADED_WAYPOINTS_POINTS); + if (cursor != null) { + try { + if (cursor.moveToFirst()) { + // Yes, this will skip the 1st way point and that is intentional + // as the 1st points holds the stats for the current/last segment. + while (cursor.moveToNext()) { + Waypoint wpt = providerUtils.createWaypoint(cursor); + writer.writeWaypoint(wpt); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + } + + /** + * Does the actual work of writing the track to the now open file. + */ + void writeDocument() { + Log.d(MyTracksConstants.TAG, "Started writing track."); + writer.writeHeader(); + TrackBuffer buffer = new TrackBuffer(1024); + Location last = null; + boolean wroteFirst = false; + boolean segmentOpen = false; + int nValidLocations = 0; + + // Fetch small pieces of the track. + while (buffer.getLastLocationRead() < track.getStopId()) { + Log.d(MyTracksConstants.TAG, + "Reading track points starting at: " + buffer.getLastLocationRead()); + providerUtils.getTrackPoints(track, buffer); + if (!wroteFirst) { + Location first = buffer.findStartLocation(); + writer.writeBeginTrack(first); + wroteFirst = true; + } + + Log.d(MyTracksConstants.TAG, + "Reading " + buffer.getLocationsLoaded() + + " Ending at: " + buffer.getLastLocationRead()); + for (int i = 0; i < buffer.getLocationsLoaded(); i++) { + Location location = buffer.get(i); + if (MyTracksUtils.isValidLocation(location)) { + nValidLocations++; + if (!segmentOpen) { + writer.writeOpenSegment(); + segmentOpen = true; + } + writer.writeLocation(location); + if (nValidLocations >= 2) { + last = location; + } + } else { + nValidLocations = 0; + last = null; + if (segmentOpen) { + writer.writeCloseSegment(); + segmentOpen = false; + } + } + } + } + if (segmentOpen) { + writer.writeCloseSegment(); + segmentOpen = false; + } + if (wroteFirst) { + writer.writeEndTrack(last); + } + writeWaypoints(track.getId()); + writer.writeFooter(); + writer.close(); + success = true; + Log.d(MyTracksConstants.TAG, "Done writing track."); + errorMessage = R.string.io_write_finished; + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/TrackWriterFactory.java b/MyTracks/src/com/google/android/apps/mytracks/io/TrackWriterFactory.java new file mode 100644 index 000000000..98e7c177b --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/TrackWriterFactory.java @@ -0,0 +1,107 @@ +/* + * 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; + +import com.google.android.apps.mytracks.content.MyTracksProviderUtils; +import com.google.android.apps.mytracks.content.Track; + +import android.content.Context; + +/** + * A factory to produce track writers for any format. + * + * @author Rodrigo Damazio + */ +public class TrackWriterFactory { + + /** + * Definition of all possible track formats. + */ + public enum TrackFileFormat { + GPX { + @Override + TrackFormatWriter newFormatWriter(Context context) { + return new GpxTrackWriter(); + } + }, + KML { + @Override + TrackFormatWriter newFormatWriter(Context context) { + return new KmlTrackWriter(context); + } + }, + CSV { + @Override + public TrackFormatWriter newFormatWriter(Context context) { + return new CsvTrackWriter(); + } + }; + + /** + * Creates and returns a new format writer for each format. + */ + abstract TrackFormatWriter newFormatWriter(Context context); + + /** + * Returns the mime type for each format. + */ + public String getMimeType() { + return "application/" + getExtension() + "+xml"; + } + + /** + * Returns the file extension for each format. + */ + public String getExtension() { + return this.name().toLowerCase(); + } + } + + /** + * Creates a new track writer to write the track with the given ID. + * + * @param context the context in which the track will be read + * @param providerUtils the data provider utils to read the track with + * @param trackId the ID of the track to be written + * @param format the output format to write in + * @return the new track writer + */ + public static TrackWriter newWriter(Context context, + MyTracksProviderUtils providerUtils, + long trackId, TrackFileFormat format) { + Track track = providerUtils.getTrack(trackId); + return newWriter(context, providerUtils, track, format); + } + + /** + * Creates a new track writer to write the given track. + * + * @param context the context in which the track will be read + * @param providerUtils the data provider utils to read the track with + * @param track the track to be written + * @param format the output format to write in + * @return the new track writer + */ + public static TrackWriter newWriter(Context context, + MyTracksProviderUtils providerUtils, + Track track, TrackFileFormat format) { + TrackFormatWriter writer = format.newFormatWriter(context); + return new TrackWriter(context, providerUtils, track, writer); + } + + private TrackWriterFactory() { } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/gdata/AndroidGDataClient.java b/MyTracks/src/com/google/android/apps/mytracks/io/gdata/AndroidGDataClient.java new file mode 100644 index 000000000..5ecea718d --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/gdata/AndroidGDataClient.java @@ -0,0 +1,429 @@ +/* + * 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.gdata; + +import com.google.wireless.gdata.client.GDataClient; +import com.google.wireless.gdata.client.HttpException; +import com.google.wireless.gdata.client.QueryParams; +import com.google.wireless.gdata.data.StringUtils; +import com.google.wireless.gdata.parser.ParseException; +import com.google.wireless.gdata.serializer.GDataSerializer; + +import android.text.TextUtils; +import android.util.Config; +import android.util.Log; + +import org.apache.http.Header; +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.StatusLine; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.entity.AbstractHttpEntity; +import org.apache.http.entity.BasicHttpEntity; +import org.apache.http.entity.InputStreamEntity; +import org.apache.http.impl.client.DefaultHttpClient; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; + +/** + * Implementation of a GDataClient using GoogleHttpClient to make HTTP requests. + * Always issues GETs and POSTs, using the X-HTTP-Method-Override header when a + * PUT or DELETE is desired, to avoid issues with firewalls, etc., that do not + * allow methods other than GET or POST. + */ +public class AndroidGDataClient implements GDataClient { + + private static final String TAG = "GDataClient"; + private static final boolean DEBUG = false; + private static final boolean LOCAL_LOGV = DEBUG ? Config.LOGD : Config.LOGV; + + private static final String X_HTTP_METHOD_OVERRIDE = "X-HTTP-Method-Override"; + + private static final int MAX_REDIRECTS = 10; + + private final HttpClient httpClient; + + /** + * Interface for creating HTTP requests. Used by + * {@link AndroidGDataClient#createAndExecuteMethod}, since HttpUriRequest + * does not allow for changing the URI after creation, e.g., when you want to + * follow a redirect. + */ + private interface HttpRequestCreator { + HttpUriRequest createRequest(URI uri); + } + + private static class GetRequestCreator implements HttpRequestCreator { + public HttpUriRequest createRequest(URI uri) { + return new HttpGet(uri); + } + } + + private static class PostRequestCreator implements HttpRequestCreator { + private final String mMethodOverride; + private final HttpEntity mEntity; + + public PostRequestCreator(String methodOverride, HttpEntity entity) { + mMethodOverride = methodOverride; + mEntity = entity; + } + + public HttpUriRequest createRequest(URI uri) { + HttpPost post = new HttpPost(uri); + if (mMethodOverride != null) { + post.addHeader(X_HTTP_METHOD_OVERRIDE, mMethodOverride); + } + post.setEntity(mEntity); + return post; + } + } + + // MAJOR TODO: make this work across redirects (if we can reset the + // InputStream). + // OR, read the bits into a local buffer (yuck, the media could be large). + private static class MediaPutRequestCreator implements HttpRequestCreator { + private final InputStream mMediaInputStream; + private final String mContentType; + + public MediaPutRequestCreator(InputStream mediaInputStream, + String contentType) { + mMediaInputStream = mediaInputStream; + mContentType = contentType; + } + + public HttpUriRequest createRequest(URI uri) { + HttpPost post = new HttpPost(uri); + post.addHeader(X_HTTP_METHOD_OVERRIDE, "PUT"); + InputStreamEntity entity = + new InputStreamEntity(mMediaInputStream, -1 /* read until EOF */); + entity.setContentType(mContentType); + post.setEntity(entity); + return post; + } + } + + /** + * Creates a new AndroidGDataClient. + */ + public AndroidGDataClient() { + httpClient = new DefaultHttpClient(); + } + + public void close() { + } + + /* + * (non-Javadoc) + * + * @see GDataClient#encodeUri(java.lang.String) + */ + public String encodeUri(String uri) { + String encodedUri; + try { + encodedUri = URLEncoder.encode(uri, "UTF-8"); + } catch (UnsupportedEncodingException uee) { + // should not happen. + Log.e("JakartaGDataClient", "UTF-8 not supported -- should not happen. " + + "Using default encoding.", uee); + encodedUri = URLEncoder.encode(uri); + } + return encodedUri; + } + + /* + * (non-Javadoc) + * + * @see com.google.wireless.gdata.client.GDataClient#createQueryParams() + */ + public QueryParams createQueryParams() { + return new QueryParamsImpl(); + } + + // follows redirects + private InputStream createAndExecuteMethod(HttpRequestCreator creator, + String uriString, String authToken) throws HttpException, IOException { + + HttpResponse response = null; + int status = 500; + int redirectsLeft = MAX_REDIRECTS; + + URI uri; + try { + uri = new URI(uriString); + } catch (URISyntaxException use) { + Log.w(TAG, "Unable to parse " + uriString + " as URI.", use); + throw new IOException("Unable to parse " + uriString + " as URI: " + + use.getMessage()); + } + + // we follow redirects ourselves, since we want to follow redirects even on + // POSTs, which + // the HTTP library does not do. following redirects ourselves also allows + // us to log + // the redirects using our own logging. + while (redirectsLeft > 0) { + + HttpUriRequest request = creator.createRequest(uri); + + // only add the auth token if not null (to allow for GData feeds that do + // not require + // authentication.) + if (!TextUtils.isEmpty(authToken)) { + request.addHeader("Authorization", "GoogleLogin auth=" + authToken); + } + if (LOCAL_LOGV) { + for (Header h : request.getAllHeaders()) { + Log.v(TAG, h.getName() + ": " + h.getValue()); + } + } + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Executing " + request.getRequestLine().toString()); + } + + response = null; + + try { + response = httpClient.execute(request); + } catch (IOException ioe) { + Log.w(TAG, "Unable to execute HTTP request." + ioe); + throw ioe; + } + + StatusLine statusLine = response.getStatusLine(); + if (statusLine == null) { + Log.w(TAG, "StatusLine is null."); + throw new NullPointerException( + "StatusLine is null -- should not happen."); + } + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, response.getStatusLine().toString()); + for (Header h : response.getAllHeaders()) { + Log.d(TAG, h.getName() + ": " + h.getValue()); + } + } + status = statusLine.getStatusCode(); + + HttpEntity entity = response.getEntity(); + + // TODO: handle 301, 307? + // TODO: let the http client handle the redirects, if we can be sure we'll + // never get a + // redirect on POST. + if (status == 302) { + // consume the content, so the connection can be closed. + entity.consumeContent(); + Header location = response.getFirstHeader("Location"); + if (location == null) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Redirect requested but no Location " + "specified."); + } + break; + } + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Following redirect to " + location.getValue()); + } + try { + uri = new URI(location.getValue()); + } catch (URISyntaxException use) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Unable to parse " + location.getValue() + " as URI.", + use); + throw new IOException("Unable to parse " + location.getValue() + + " as URI."); + } + break; + } + --redirectsLeft; + } else { + break; + } + } + + if (Log.isLoggable(TAG, Log.VERBOSE)) { + Log.v(TAG, "Received " + status + " status code."); + } + String errorMessage = null; + HttpEntity entity = response.getEntity(); + try { + if (response != null && entity != null) { + InputStream in = entity.getContent(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int bytesRead = -1; + while ((bytesRead = in.read(buf)) != -1) { + baos.write(buf, 0, bytesRead); + } + // TODO: use appropriate encoding, picked up from Content-Type. + errorMessage = new String(baos.toByteArray()); + if (Log.isLoggable(TAG, Log.VERBOSE)) { + Log.v(TAG, errorMessage); + } + } + } finally { + if (entity != null) { + entity.consumeContent(); + } + } + String exceptionMessage = "Received " + status + " status code"; + if (errorMessage != null) { + exceptionMessage += (": " + errorMessage); + } + throw new HttpException(exceptionMessage, status, null /* InputStream */); + } + + /* + * (non-Javadoc) + * + * @see GDataClient#getFeedAsStream(java.lang.String, java.lang.String) + */ + public InputStream getFeedAsStream(String feedUrl, String authToken) + throws HttpException, IOException { + + InputStream in = + createAndExecuteMethod(new GetRequestCreator(), feedUrl, authToken); + if (in != null) { + return in; + } + throw new IOException("Unable to access feed."); + } + + public InputStream getMediaEntryAsStream(String mediaEntryUrl, + String authToken) throws HttpException, IOException { + + InputStream in = + createAndExecuteMethod(new GetRequestCreator(), mediaEntryUrl, + authToken); + + if (in != null) { + return in; + } + throw new IOException("Unable to access media entry."); + } + + /* + * (non-Javadoc) + * + * @see GDataClient#createEntry + */ + public InputStream createEntry(String feedUrl, String authToken, + GDataSerializer entry) throws HttpException, IOException { + + HttpEntity entity = + createEntityForEntry(entry, GDataSerializer.FORMAT_CREATE); + InputStream in = + createAndExecuteMethod(new PostRequestCreator(null /* override */, + entity), feedUrl, authToken); + if (in != null) { + return in; + } + throw new IOException("Unable to create entry."); + } + + /* + * (non-Javadoc) + * + * @see GDataClient#updateEntry + */ + public InputStream updateEntry(String editUri, String authToken, + GDataSerializer entry) throws HttpException, IOException { + HttpEntity entity = + createEntityForEntry(entry, GDataSerializer.FORMAT_UPDATE); + InputStream in = + createAndExecuteMethod(new PostRequestCreator("PUT", entity), editUri, + authToken); + if (in != null) { + return in; + } + throw new IOException("Unable to update entry."); + } + + /* + * (non-Javadoc) + * + * @see GDataClient#deleteEntry + */ + public void deleteEntry(String editUri, String authToken) + throws HttpException, IOException { + if (StringUtils.isEmpty(editUri)) { + throw new IllegalArgumentException( + "you must specify an non-empty edit url"); + } + InputStream in = + createAndExecuteMethod( + new PostRequestCreator("DELETE", null /* entity */), editUri, + authToken); + if (in == null) { + throw new IOException("Unable to delete entry."); + } + try { + in.close(); + } catch (IOException ioe) { + // ignore + } + } + + public InputStream updateMediaEntry(String editUri, String authToken, + InputStream mediaEntryInputStream, String contentType) + throws HttpException, IOException { + InputStream in = + createAndExecuteMethod(new MediaPutRequestCreator( + mediaEntryInputStream, contentType), editUri, authToken); + if (in != null) { + return in; + } + throw new IOException("Unable to write media entry."); + } + + private HttpEntity createEntityForEntry(GDataSerializer entry, int format) + throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + entry.serialize(baos, format); + } catch (IOException ioe) { + Log.e(TAG, "Unable to serialize entry.", ioe); + throw ioe; + } catch (ParseException pe) { + Log.e(TAG, "Unable to serialize entry.", pe); + throw new IOException("Unable to serialize entry: " + pe.getMessage()); + } + + byte[] entryBytes = baos.toByteArray(); + + if (entryBytes != null && Log.isLoggable(TAG, Log.DEBUG)) { + try { + Log.d(TAG, "Serialized entry: " + new String(entryBytes, "UTF-8")); + } catch (UnsupportedEncodingException uee) { + // should not happen + throw new IllegalStateException("UTF-8 should be supported!", uee); + } + } + + AbstractHttpEntity entity = new BasicHttpEntity(); + entity.setContentType(entry.getContentType()); + return entity; + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/gdata/GDataClientFactory.java b/MyTracks/src/com/google/android/apps/mytracks/io/gdata/GDataClientFactory.java new file mode 100644 index 000000000..15dd588d0 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/gdata/GDataClientFactory.java @@ -0,0 +1,50 @@ +/* + * 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.gdata; + +import com.google.android.apps.mytracks.MyTracksConstants; +import com.google.wireless.gdata.client.GDataClient; + +import android.content.Context; +import android.util.Log; + +/** + * This factory will fetch the right class for the platform. + * + * @author Sandor Dornbush + */ +public class GDataClientFactory { + + private GDataClientFactory() { } + + /** + * Creates a new GData client. + * This factory will fetch the right class for the platform. + * @return A GDataClient appropriate for this platform + */ + public static GDataClient getGDataClient(Context context) { + try { + // Try to use the official unbundled gdata client implementation. + // This should work on Froyo and beyond. + return new com.google.android.common.gdata.AndroidGDataClient(context); + } catch (LinkageError e) { + // On all other platforms use the client implementation packaged in the + // apk. + Log.i(MyTracksConstants.TAG, "Using mytracks AndroidGDataClient.", e); + return new com.google.android.apps.mytracks.io.gdata.AndroidGDataClient(); + } + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/gdata/GDataWrapper.java b/MyTracks/src/com/google/android/apps/mytracks/io/gdata/GDataWrapper.java new file mode 100644 index 000000000..07f700007 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/gdata/GDataWrapper.java @@ -0,0 +1,224 @@ +/* + * Copyright 2008 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.gdata; + +import com.google.android.apps.mytracks.MyTracksConstants; +import com.google.android.apps.mytracks.io.AuthManager; +import com.google.wireless.gdata.client.GDataClient; +import com.google.wireless.gdata.client.GDataServiceClient; +import com.google.wireless.gdata.client.HttpException; +import com.google.wireless.gdata.parser.ParseException; +import com.google.wireless.gdata2.ConflictDetectedException; +import com.google.wireless.gdata2.client.AuthenticationException; + +import android.util.Log; + +import java.io.FileNotFoundException; +import java.io.IOException; + + +/** + * GDataWrapper provides a wrapper around GData operations that maintains the + * GData client, and provides a method to run GData queries with proper error + * handling. After a query is run, the wrapper can be queried about the error + * that occurred. + * + * @author Sandor Dornbush + */ +public class GDataWrapper { + + /** + * A QueryFunction is passed in when executing a query. The query function of + * the class is called with the GData client as a parameter. The function + * should execute whatever operations it desires on the client without concern + * for whether the client will throw an error. + */ + public interface QueryFunction { + public abstract void query(GDataServiceClient client) + throws AuthenticationException, IOException, ParseException, + ConflictDetectedException, HttpException; + } + + /** + * A AuthenticatedFunction is passed in when executing the google + * authenticated service. The authenticated function of the class is called + * with the current authentication token for the service. The function should + * execute whatever operations with the google service without concern for + * whether the client will throw an error. + */ + public interface AuthenticatedFunction { + public abstract void run(String authenticationToken) + throws AuthenticationException, IOException; + } + + // The types of error that may be encountered + // No error occurred. + public static final int ERROR_NO_ERROR = 0; + // There was an authentication error, the auth token may be invalid. + public static final int ERROR_AUTH = 1; + // There was an internal error on the server side. + public static final int ERROR_INTERNAL = 2; + // There was an error connecting to the server. + public static final int ERROR_CONNECTION = 3; + // The item queried did not exit. + public static final int ERROR_NOT_FOUND = 4; + // There was an error parsing or serializing locally. + public static final int ERROR_LOCAL = 5; + // There was a conflict, update the entry and try again. + public static final int ERROR_CONFLICT = 6; + // A query was run after cleaning up the wrapper, so the client was invalid. + public static final int ERROR_CLEANED_UP = 7; + // An unknown error occurred. + public static final int ERROR_UNKNOWN = 100; + + private String errorMessage; + private int errorType; + private GDataClient androidGDataClient; + private GDataServiceClient gdataServiceClient; + private AuthManager auth; + private boolean retryOnAuthFailure; + private int retriesPending; + private boolean cleanupCalled; + + public GDataWrapper() { + errorType = ERROR_NO_ERROR; + errorMessage = null; + auth = null; + retryOnAuthFailure = false; + retriesPending = 0; + cleanupCalled = false; + } + + public void setClient(GDataClient androidGDataClient, + GDataServiceClient gdataServiceClient) { + this.androidGDataClient = androidGDataClient; + this.gdataServiceClient = gdataServiceClient; + } + + public boolean runAuthenticatedFunction( + final AuthenticatedFunction function) { + return runOne(function, null); + } + + public boolean runQuery(final QueryFunction query) { + return runOne(null, query); + } + + /** + * Runs an arbitrary piece of code. + */ + private boolean runOne(final AuthenticatedFunction function, + final QueryFunction query) { + try { + if (function != null) { + function.run(this.auth.getAuthToken()); + } else if (query != null) { + query.query(gdataServiceClient); + } else { + return false; + } + errorType = ERROR_NO_ERROR; + errorMessage = null; + return true; + } catch (AuthenticationException e) { + Log.e(MyTracksConstants.TAG, "Exception", e); + errorType = ERROR_AUTH; + errorMessage = e.getMessage(); + } catch (HttpException e) { + Log.e(MyTracksConstants.TAG, "HttpException", e); + errorMessage = e.getMessage(); + if (errorMessage.contains("401")) { + errorType = ERROR_AUTH; + } else { + errorType = ERROR_CONNECTION; + } + } catch (FileNotFoundException e) { + Log.e(MyTracksConstants.TAG, "Exception", e); + errorType = ERROR_AUTH; + errorMessage = e.getMessage(); + } catch (IOException e) { + Log.e(MyTracksConstants.TAG, "Exception", e); + errorMessage = e.getMessage(); + if (errorMessage.contains("503")) { + errorType = ERROR_INTERNAL; + } else { + errorType = ERROR_CONNECTION; + } + } catch (ParseException e) { + Log.e(MyTracksConstants.TAG, "Exception", e); + errorType = ERROR_LOCAL; + errorMessage = e.getMessage(); + } catch (ConflictDetectedException e) { + Log.e(MyTracksConstants.TAG, "Exception", e); + errorType = ERROR_CONFLICT; + errorMessage = e.getMessage(); + } + + Log.d(MyTracksConstants.TAG, "GData error encountered: " + errorMessage); + if (errorType == ERROR_AUTH && auth != null) { + Runnable whenFinished = null; + if (retryOnAuthFailure) { + retriesPending++; + // This is a little odd. This method cannot return in the the thread + // that called the original runQuery(). We should look at reworking this + // API. + whenFinished = new Runnable() { + public void run() { + retriesPending--; + try { + runOne(function, query); + } catch (IllegalStateException ise) { + // This can happen if the connection pool was shut down. + Log.e(MyTracksConstants.TAG, "Failed to rerun query.", ise); + } + if (cleanupCalled && retriesPending == 0) { + cleanUp(); + } + } + }; + } + auth.invalidateAndRefresh(whenFinished); + } + return false; + } + + public int getErrorType() { + return errorType; + } + + public String getErrorMessage() { + return errorMessage; + } + + // cleanUp must be called when done using this wrapper to close the client. + // Note that the cleanup will be delayed if auth failure retries were + // requested and there is a pending retry. + public void cleanUp() { + cleanupCalled = true; + if (retriesPending == 0) { + androidGDataClient.close(); + androidGDataClient = null; + } + } + + public void setAuthManager(AuthManager auth) { + this.auth = auth; + } + + public void setRetryOnAuthFailure(boolean retry) { + retryOnAuthFailure = retry; + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/gdata/QueryParamsImpl.java b/MyTracks/src/com/google/android/apps/mytracks/io/gdata/QueryParamsImpl.java new file mode 100644 index 000000000..57b4d171d --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/gdata/QueryParamsImpl.java @@ -0,0 +1,106 @@ +/* + * 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.gdata; + +import com.google.wireless.gdata.client.QueryParams; + +import android.text.TextUtils; +import android.util.Log; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * Simple implementation of the QueryParams interface. + */ +// TODO: deal with categories +public class QueryParamsImpl extends QueryParams { + + private final Map mParams = new HashMap(); + + @Override + public void clear() { + setEntryId(null); + mParams.clear(); + } + + @Override + public String generateQueryUrl(String feedUrl) { + + if (TextUtils.isEmpty(getEntryId()) && mParams.isEmpty()) { + // nothing to do + return feedUrl; + } + + // handle entry IDs + if (!TextUtils.isEmpty(getEntryId())) { + if (!mParams.isEmpty()) { + throw new IllegalStateException("Cannot set both an entry ID " + + "and other query paramters."); + } + return feedUrl + '/' + getEntryId(); + } + + // otherwise, append the querystring params. + StringBuilder sb = new StringBuilder(); + sb.append(feedUrl); + Set params = mParams.keySet(); + boolean first = true; + if (feedUrl.contains("?")) { + first = false; + } else { + sb.append('?'); + } + for (String param : params) { + if (first) { + first = false; + } else { + sb.append('&'); + } + sb.append(param); + sb.append('='); + String value = mParams.get(param); + String encodedValue = null; + + try { + encodedValue = URLEncoder.encode(value, "UTF-8"); + } catch (UnsupportedEncodingException uee) { + // should not happen. + Log.w("QueryParamsImpl", "UTF-8 not supported -- should not happen. " + + "Using default encoding.", uee); + encodedValue = URLEncoder.encode(value); + } + sb.append(encodedValue); + } + return sb.toString(); + } + + @Override + public String getParamValue(String param) { + if (!(mParams.containsKey(param))) { + return null; + } + return mParams.get(param); + } + + @Override + public void setParamValue(String param, String value) { + mParams.put(param, value); + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/services/AbsoluteLocationListenerPolicy.java b/MyTracks/src/com/google/android/apps/mytracks/services/AbsoluteLocationListenerPolicy.java new file mode 100644 index 000000000..2ae2aa899 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/services/AbsoluteLocationListenerPolicy.java @@ -0,0 +1,59 @@ +/* + * Copyright 2009 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.services; + +/** + * This is a simple location listener policy that will always dictate the same + * polling interval. + * + * @author Sandor Dornbush + */ +public class AbsoluteLocationListenerPolicy implements LocationListenerPolicy { + + /** + * The interval to request for gps signal. + */ + private final long interval; + + /** + * @param interval The interval to request for gps signal + */ + public AbsoluteLocationListenerPolicy(final long interval) { + this.interval = interval; + } + + /** + * @return The interval given in the constructor + */ + public long getDesiredPollingInterval() { + return interval; + } + + /** + * Discards the idle time. + */ + public void updateIdleTime(long idleTime) { + } + + /** + * Returns the minimum distance between updates. + * Get all updates to properly measure moving time. + */ + public int getMinDistance() { + return 0; + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/services/AdaptiveLocationListenerPolicy.java b/MyTracks/src/com/google/android/apps/mytracks/services/AdaptiveLocationListenerPolicy.java new file mode 100644 index 000000000..eab300f73 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/services/AdaptiveLocationListenerPolicy.java @@ -0,0 +1,81 @@ +/* + * Copyright 2009 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.services; + +/** + * A LocationListenerPolicy that will change based on how long the user has been + * stationary. + * + * This policy will dictate a policy based on a min, max and idle time. + * The policy will dictate an interval bounded by min and max whic is half of + * the idle time. + * + * @author Sandor Dornbush + */ +public class AdaptiveLocationListenerPolicy implements LocationListenerPolicy { + + /** + * Smallest interval this policy will dictate, in milliseconds. + */ + private final long minInterval; + + /** + * Largest interval this policy will dictate, in milliseconds. + */ + private final long maxInterval; + + private final int minDistance; + + /** + * The time the user has been at the current location, in milliseconds. + */ + private long idleTime; + + /** + * Creates a policy that will be bounded by the given min and max. + * + * @param min Smallest interval this policy will dictate, in milliseconds + * @param max Largest interval this policy will dictate, in milliseconds + */ + public AdaptiveLocationListenerPolicy(long min, long max, int minDistance) { + this.minInterval = min; + this.maxInterval = max; + this.minDistance = minDistance; + } + + /** + * @return An interval bounded by min and max which is half of the idle time + */ + public long getDesiredPollingInterval() { + long desiredInterval = idleTime / 2; + // Round to avoid setting the interval too often. + desiredInterval = (desiredInterval / 1000) * 1000; + return Math.max(Math.min(maxInterval, desiredInterval), + minInterval); + } + + public void updateIdleTime(long newIdleTime) { + this.idleTime = newIdleTime; + } + + /** + * Returns the minimum distance between updates. + */ + public int getMinDistance() { + return minDistance; + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/services/ITrackRecordingService.aidl b/MyTracks/src/com/google/android/apps/mytracks/services/ITrackRecordingService.aidl index a274a7980..6e692311b 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/services/ITrackRecordingService.aidl +++ b/MyTracks/src/com/google/android/apps/mytracks/services/ITrackRecordingService.aidl @@ -16,6 +16,7 @@ package com.google.android.apps.mytracks.services; import com.google.android.apps.mytracks.content.Waypoint; +import com.google.android.apps.mytracks.stats.TripStatisticsData; /** * MyTracks service. @@ -35,10 +36,18 @@ interface ITrackRecordingService { */ boolean isRecording(); + /** + * Returns the track ID of the track currently being recorded, or -1 if none + * is being recorded. This ID can then be used to read track data from the + * content source. + */ + long getRecordingTrackId(); + /** * Inserts a waypoint marker in the track being recorded. * * @param waypoint the waypoint to insert + * @return the unique ID of the inserted marker */ long insertWaypointMarker(in Waypoint waypoint); diff --git a/MyTracks/src/com/google/android/apps/mytracks/services/LocationListenerPolicy.java b/MyTracks/src/com/google/android/apps/mytracks/services/LocationListenerPolicy.java new file mode 100644 index 000000000..1fab6102f --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/services/LocationListenerPolicy.java @@ -0,0 +1,48 @@ +/* + * Copyright 2009 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.services; + +/** + * This is an interface for classes that will manage the location listener policy. + * Different policy options are: + * Absolute + * Addaptive + * + * @author Sandor Dornbush + */ +public interface LocationListenerPolicy { + + /** + * Returns the polling time this policy would like at this time. + * + * @return The polling that this policy dictates + */ + public long getDesiredPollingInterval(); + + /** + * Returns the minimum distance between updates. + */ + public int getMinDistance(); + + /** + * Notifies the amount of time the user has been idle at their current + * location. + * + * @param idleTime The time that the user has been idle at this spot + */ + public void updateIdleTime(long idleTime); +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/services/PeriodicTask.java b/MyTracks/src/com/google/android/apps/mytracks/services/PeriodicTask.java new file mode 100644 index 000000000..354b4bccd --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/services/PeriodicTask.java @@ -0,0 +1,39 @@ +/* + * Copyright 2009 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.services; + +/** + * This is interface for a task that will be executed on some schedule. + * + * @author Sandor Dornbush + */ +public interface PeriodicTask { + /** + * Sets up this task for subsequent calls to the run method. + */ + public void start(); + + /** + * This method will be called periodically. + */ + public void run(TrackRecordingService service); + + /** + * Shuts down this task and clean up resources. + */ + public void shutdown(); +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/services/PeriodicTaskExecuter.java b/MyTracks/src/com/google/android/apps/mytracks/services/PeriodicTaskExecuter.java new file mode 100644 index 000000000..f476c5983 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/services/PeriodicTaskExecuter.java @@ -0,0 +1,92 @@ +/* + * Copyright 2009 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.services; + +import com.google.android.apps.mytracks.MyTracksConstants; + +import android.util.Log; + +import java.util.Date; +import java.util.Timer; +import java.util.TimerTask; + +/** + * This class will periodically announce the user's trip statitics. + * + * @author Sandor Dornbush + */ +public class PeriodicTaskExecuter { + + private final TrackRecordingService service; + + /** + * A timer to schedule the announcements. + */ + private Timer timer = new Timer(); + + private final PeriodicTask task; + + public PeriodicTaskExecuter(PeriodicTask task, + TrackRecordingService service) { + this.task = task; + this.service = service; + } + + /** + * Schedules the task at the given interval. + * + * @param interval The interval in milliseconds + */ + public void scheduleTask(long interval) { + timer.cancel(); + timer.purge(); + timer = new Timer(); + if (interval <= 0) { + return; + } + + long now = System.currentTimeMillis(); + long next = service.getTripStatistics().getStartTime(); + while (next < now) next += interval; + + Date start = new Date(next); + Log.i(MyTracksConstants.TAG, + "StatusAnnouncer scheduled to start at " + start + " every " + + interval + " milliseconds."); + timer.scheduleAtFixedRate(new PeriodicTimerTask(), start, interval); + } + + /** + * Cleans up this object. + */ + public void shutdown() { + timer.cancel(); + timer.purge(); + timer = null; + task.shutdown(); + } + + /** + * The timer task to announce the trip status. + */ + private class PeriodicTimerTask extends TimerTask { + @Override + public void run() { + task.run(service); + } + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/services/SafeStatusAnnouncerTask.java b/MyTracks/src/com/google/android/apps/mytracks/services/SafeStatusAnnouncerTask.java new file mode 100644 index 000000000..08924bc16 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/services/SafeStatusAnnouncerTask.java @@ -0,0 +1,62 @@ +/* + * Copyright 2009 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.services; + +import android.content.Context; + +/** + * This class will periodically announce the user's trip statitics. This class + * is a wrapper for cupcake devices. + * + * @author Sandor Dornbush + */ +public class SafeStatusAnnouncerTask implements PeriodicTask { + + private StatusAnnouncerTask announcer; + + /* class initialization fails when this throws an exception */ + static { + try { + Class.forName( + "com.google.android.apps.mytracks.services.StatusAnnouncerTask"); + } catch (ClassNotFoundException ex) { + throw new RuntimeException(ex); + } catch (LinkageError er) { + throw new RuntimeException(er); + } + } + + /* calling here forces class initialization */ + public static void checkAvailable() { + } + + public SafeStatusAnnouncerTask(Context context) { + announcer = new StatusAnnouncerTask(context); + } + + public void run(TrackRecordingService service) { + announcer.run(service); + } + + public void shutdown() { + announcer.shutdown(); + } + + @Override + public void start() { + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/services/SignalStrengthTask.java b/MyTracks/src/com/google/android/apps/mytracks/services/SignalStrengthTask.java new file mode 100644 index 000000000..379759bb0 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/services/SignalStrengthTask.java @@ -0,0 +1,193 @@ +/* + * 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.services; + +import com.google.android.apps.mytracks.MyTracksConstants; +import com.google.android.apps.mytracks.content.Waypoint; + +import android.content.Context; +import android.location.Location; +import android.telephony.CellLocation; +import android.telephony.NeighboringCellInfo; +import android.telephony.PhoneStateListener; +import android.telephony.TelephonyManager; +import android.util.Log; + +import java.util.List; + +/** + * A class to monitor the network signal strength. + * + * @author Sandor Dornbush + */ +public class SignalStrengthTask extends PhoneStateListener + implements PeriodicTask { + + private final Context context; + private TelephonyManager manager; + private int signalStrength = -1; + + public SignalStrengthTask(Context c) { + context = c; + } + + public void start() { + manager = + (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + if (manager == null) { + Log.e(MyTracksConstants.TAG, "Cannot get telephony manager."); + } else { + manager.listen(this, getListenEvents()); + } + } + + protected int getListenEvents() { + return PhoneStateListener.LISTEN_SIGNAL_STRENGTHS; + } + + @Override + public void onSignalStrengthChanged(int asu) { + this.signalStrength = asu; + Log.d(MyTracksConstants.TAG, "Signal Strength: " + signalStrength); + } + + @Override + public void run(TrackRecordingService service) { + Log.d(MyTracksConstants.TAG, "Adding signal marker"); + Location location = service.getLastLocation(); + if (manager == null) { + Log.d(MyTracksConstants.TAG, "Adding signal marker: marker null"); + return; + } + if (location == null) { + Log.d(MyTracksConstants.TAG, "Adding signal marker: location null"); + return; + } + Waypoint wpt = new Waypoint(); + wpt.setName("Signal Strength"); + wpt.setType(Waypoint.TYPE_WAYPOINT); + wpt.setTrackId(service.getRecordingTrackId()); + int networkType = manager.getNetworkType(); + wpt.setIcon(getIcon(networkType)); + wpt.setLocation(location); + wpt.setDescription(getDescription()); + long waypointId = service.insertWaypointMarker(wpt); + if (waypointId >= 0) { + Log.d(MyTracksConstants.TAG, "Added signal marker"); + } else { + Log.e(MyTracksConstants.TAG, "Cannot insert waypoint marker?"); + } + } + + /** + * Gets a human readable description for the network type. + * + * @param type The integer constant for the network type + * @return A human readable description of the network type + */ + protected String getTypeAsString(int type) { + switch (type) { + case TelephonyManager.NETWORK_TYPE_EDGE: + return "EDGE"; + case TelephonyManager.NETWORK_TYPE_GPRS: + return "GPRS"; + case TelephonyManager.NETWORK_TYPE_UMTS: + return "UMTS"; + case TelephonyManager.NETWORK_TYPE_UNKNOWN: + default: + return "UNKNOWN"; + } + } + + /** + * Builds a description for the current signal strength. + * + * @return A human readable description of the network state + */ + private String getDescription() { + StringBuffer sb = new StringBuffer(); + sb.append(getStrengthAsString()); + + sb.append("Network Type: "); + sb.append(getTypeAsString(manager.getNetworkType())); + sb.append('\n'); + + sb.append("Operator: "); + sb.append(manager.getNetworkOperatorName()); + sb.append(" / "); + sb.append(manager.getNetworkOperator()); + sb.append('\n'); + + sb.append("Roaming: "); + sb.append(manager.isNetworkRoaming()); + sb.append('\n'); + + List infos = manager.getNeighboringCellInfo(); + Log.i(MyTracksConstants.TAG, "Found " + infos.size() + " cells."); + if (infos.size() > 0) { + sb.append("Neighbors: "); + for (NeighboringCellInfo info : infos) { + sb.append(info.toString()); + sb.append(' '); + } + sb.append('\n'); + } + + CellLocation cell = manager.getCellLocation(); + if (cell != null) { + sb.append("Cell: "); + sb.append(cell.toString()); + sb.append('\n'); + } + + return sb.toString(); + } + + /** + * Gets the url for the waypoint icon for the current network type. + * + * @param type The network type + * @return A url to a image to use as the waypoint icon + */ + protected String getIcon(int type) { + switch (type) { + case TelephonyManager.NETWORK_TYPE_GPRS: + case TelephonyManager.NETWORK_TYPE_EDGE: + return "http://maps.google.com/mapfiles/ms/micons/green.png"; + case TelephonyManager.NETWORK_TYPE_UMTS: + return "http://maps.google.com/mapfiles/ms/micons/blue.png"; + case TelephonyManager.NETWORK_TYPE_UNKNOWN: + default: + return "http://maps.google.com/mapfiles/ms/micons/red.png"; + } + } + + public void shutdown() { + if (manager != null) { + manager.listen(this, PhoneStateListener.LISTEN_NONE); + manager = null; + } + } + + public String getStrengthAsString() { + return "Strength: " + signalStrength + "\n"; + } + + protected Context getContext() { + return context; + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/services/SignalStrengthTaskModern.java b/MyTracks/src/com/google/android/apps/mytracks/services/SignalStrengthTaskModern.java new file mode 100644 index 000000000..12c6f74ee --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/services/SignalStrengthTaskModern.java @@ -0,0 +1,155 @@ +/* + * 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.services; + +import com.google.android.apps.mytracks.MyTracksConstants; +import com.google.android.maps.mytracks.R; + +import android.content.Context; +import android.telephony.PhoneStateListener; +import android.telephony.SignalStrength; +import android.telephony.TelephonyManager; +import android.util.Log; + +/** + * A class to monitor the network signal strength. + * + * @author Sandor Dornbush + */ +public class SignalStrengthTaskModern extends SignalStrengthTask { + + private SignalStrength signalStrength = null; + + public SignalStrengthTaskModern(Context c) { + super(c); + } + + @Override + protected int getListenEvents() { + return PhoneStateListener.LISTEN_SIGNAL_STRENGTHS; + } + + /** + * Gets a human readable description for the network type. + * + * @param type The integer constant for the network type + * @return A human readable description of the network type + */ + @Override + protected String getTypeAsString(int type) { + switch (type) { + case TelephonyManager.NETWORK_TYPE_1xRTT: + return "1xRTT"; + case TelephonyManager.NETWORK_TYPE_CDMA: + return "CDMA"; + case TelephonyManager.NETWORK_TYPE_EDGE: + return "EDGE"; + case TelephonyManager.NETWORK_TYPE_EVDO_0: + return "EVDO 0"; + case TelephonyManager.NETWORK_TYPE_EVDO_A: + return "EVDO A"; + case TelephonyManager.NETWORK_TYPE_GPRS: + return "GPRS"; + case TelephonyManager.NETWORK_TYPE_HSDPA: + return "HSDPA"; + case TelephonyManager.NETWORK_TYPE_HSPA: + return "HSPA"; + case TelephonyManager.NETWORK_TYPE_HSUPA: + return "HSUPA"; + case TelephonyManager.NETWORK_TYPE_UMTS: + return "UMTS"; + case TelephonyManager.NETWORK_TYPE_UNKNOWN: + default: + return "UNKNOWN"; + } + } + + /** + * Gets the url for the waypoint icon for the current network type. + * + * @param type The network type + * @return A url to a image to use as the waypoint icon + */ + @Override + protected String getIcon(int type) { + switch (type) { + case TelephonyManager.NETWORK_TYPE_1xRTT: + case TelephonyManager.NETWORK_TYPE_CDMA: + case TelephonyManager.NETWORK_TYPE_GPRS: + case TelephonyManager.NETWORK_TYPE_EDGE: + return "http://maps.google.com/mapfiles/ms/micons/green.png"; + case TelephonyManager.NETWORK_TYPE_EVDO_0: + case TelephonyManager.NETWORK_TYPE_EVDO_A: + case TelephonyManager.NETWORK_TYPE_HSDPA: + case TelephonyManager.NETWORK_TYPE_HSPA: + case TelephonyManager.NETWORK_TYPE_HSUPA: + case TelephonyManager.NETWORK_TYPE_UMTS: + return "http://maps.google.com/mapfiles/ms/micons/blue.png"; + case TelephonyManager.NETWORK_TYPE_UNKNOWN: + default: + return "http://maps.google.com/mapfiles/ms/micons/red.png"; + } + } + + @Override + public String getStrengthAsString() { + if (signalStrength == null) { + return "Strength: " + getContext().getString(R.string.unknown) + "\n"; + } + StringBuffer sb = new StringBuffer(); + if (signalStrength.isGsm()) { + appendSignal(signalStrength.getGsmSignalStrength(), + R.string.gsm_strength, + sb); + maybeAppendSignal(signalStrength.getGsmBitErrorRate(), + R.string.error_rate, + sb); + } else { + appendSignal(signalStrength.getCdmaDbm(), R.string.cdma_strength, sb); + appendSignal(signalStrength.getCdmaEcio() / 10.0, R.string.ecio, sb); + appendSignal(signalStrength.getEvdoDbm(), R.string.evdo_strength, sb); + appendSignal(signalStrength.getEvdoEcio() / 10.0, R.string.ecio, sb); + appendSignal(signalStrength.getEvdoSnr(), + R.string.signal_to_noise_ratio, + sb); + } + return sb.toString(); + } + + private void maybeAppendSignal( + int signal, int signalFormat, StringBuffer sb) { + if (signal > 0) { + sb.append(getContext().getString(signalFormat, signal)); + } + } + + private void appendSignal(int signal, int signalFormat, StringBuffer sb) { + sb.append(getContext().getString(signalFormat, signal)); + sb.append("\n"); + } + + private void appendSignal(double signal, int signalFormat, StringBuffer sb) { + sb.append(getContext().getString(signalFormat, signal)); + sb.append("\n"); + } + + @Override + public void onSignalStrengthsChanged(SignalStrength signalStrength) { + Log.d(MyTracksConstants.TAG, "Signal Strength Modern: " + signalStrength); + this.signalStrength = signalStrength; + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/services/SplitManager.java b/MyTracks/src/com/google/android/apps/mytracks/services/SplitManager.java new file mode 100644 index 000000000..0ab66142f --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/services/SplitManager.java @@ -0,0 +1,146 @@ +/* + * 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.services; + +import com.google.android.apps.mytracks.MyTracksConstants; +import com.google.android.apps.mytracks.util.UnitConversions; + +import android.util.Log; + +/** + * This class manages inserting time or distance splits. + * + * @author Sandor Dornbush + */ +public class SplitManager { + + /** + * The frequency of splits. + */ + private int splitFrequency = 0; + + /** + * The next distance when a split should be taken. + */ + private double nextSplitDistance = 0; + + /** + * Splits executer. + */ + private PeriodicTaskExecuter splitExecuter = null; + + private boolean metricUnits; + + private final TrackRecordingService service; + + public SplitManager(TrackRecordingService service) { + this.service = service; + } + + /** + * Restores the manager. + */ + public void restore() { + if ((splitFrequency > 0) && (splitExecuter != null)) { + splitExecuter.scheduleTask(splitFrequency * 60000); + } + calculateNextSplit(); + } + + /** + * Shuts down the manager. + */ + public void shutdown() { + if (splitExecuter != null) { + splitExecuter.shutdown(); + } + } + + /** + * Calculates the next distance that a split should be inserted at. + */ + public void calculateNextSplit() { + if (splitFrequency >= 0) { + nextSplitDistance = Double.MAX_VALUE; + Log.d(MyTracksConstants.TAG, + "SplitManager: Distance splits dissabled."); + return; + } + + double dist = service.getTripStatistics().getTotalDistance() / 1000; + if (!metricUnits) { + dist *= UnitConversions.KM_TO_MI; + } + // The index will be negative since the frequency is negative. + int index = (int) (dist / splitFrequency); + index -= 1; + nextSplitDistance = splitFrequency * index; + Log.d(MyTracksConstants.TAG, + "SplitManager: Next split distance: " + nextSplitDistance); + } + + /** + * Updates split manager with new trip statistics. + */ + public void updateSplits() { + if (this.splitFrequency >= 0) { + return; + } + // Convert the distance in meters to km or mi. + double distance = service.getTripStatistics().getTotalDistance() / 1000.0; + if (!metricUnits) { + distance *= UnitConversions.KM_TO_MI; + } + + if (distance > this.nextSplitDistance) { + service.insertStatisticsMarker(service.getLastLocation()); + calculateNextSplit(); + } + } + + /** + * Sets the split frequency. + * < 0 Use the absolute value as a distance in the current measurement km + * or mi + * 0 Turn off splits + * > 0 Use the value as a time in minutes + * @param splitFrequency The frequency in time or distance + */ + public void setSplitFrequency(int splitFrequency) { + this.splitFrequency = splitFrequency; + if (splitFrequency < 1) { + if (splitExecuter != null) { + splitExecuter.shutdown(); + splitExecuter = null; + } + } + if (splitFrequency > 0) { + if (splitExecuter == null) { + TimeSplitTask splitter = new TimeSplitTask(); + splitExecuter = new PeriodicTaskExecuter(splitter, service); + } + splitExecuter.scheduleTask(splitFrequency * 60000); + } else { + // For distance based splits. + calculateNextSplit(); + } + } + + public void setMetricUnits(boolean metricUnits) { + this.metricUnits = metricUnits; + calculateNextSplit(); + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/services/StatusAnnouncerTask.java b/MyTracks/src/com/google/android/apps/mytracks/services/StatusAnnouncerTask.java new file mode 100644 index 000000000..d0ec9a41e --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/services/StatusAnnouncerTask.java @@ -0,0 +1,180 @@ +/* + * Copyright 2009 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.services; + +import com.google.android.apps.mytracks.MyTracksConstants; +import com.google.android.apps.mytracks.MyTracksSettings; +import com.google.android.apps.mytracks.stats.TripStatistics; +import com.google.android.apps.mytracks.util.StringUtils; +import com.google.android.apps.mytracks.util.UnitConversions; +import com.google.android.maps.mytracks.R; + +import android.content.Context; +import android.content.SharedPreferences; +import android.speech.tts.TextToSpeech; +import android.util.Log; + +import java.util.Locale; + +/** + * This class will periodically announce the user's trip statitics. + * + * @author Sandor Dornbush + */ +public class StatusAnnouncerTask + implements TextToSpeech.OnInitListener, PeriodicTask { + + /** + * The rate at which announcements are spoken. + */ + private static final float TTS_SPEECH_RATE = 0.9f; + + /** + * A pointer to the service context. + */ + private final Context context; + + /** + * String utilities. + */ + private final StringUtils stringUtils; + + /** + * The interface to the text to speech engine. + */ + private TextToSpeech tts = null; + + /** + * The response recieved from the TTS engine ater initialization. + */ + private int ttsStatus = TextToSpeech.ERROR; + + /** + * Constructs the announcer and start the TTS engine. + */ + public StatusAnnouncerTask(Context context) { + this.context = context; + this.stringUtils = new StringUtils(context); + + tts = new TextToSpeech(context, this); + // Force the language to be the same as the string we will be speaking. + tts.setLanguage(Locale.getDefault()); + // Slow down the speed just a bit as it is hard to hear when exercising. + tts.setSpeechRate(TTS_SPEECH_RATE); + } + + /** + * Notifies that the tts engine is done with initialization. + */ + public void onInit(int status) { + this.ttsStatus = status; + Log.i(MyTracksConstants.TAG, "TrackRecordingService.TTS init: " + status); + } + + /** + * Announces the trip status. + */ + @Override + public void run(TrackRecordingService service) { + if (ttsStatus != TextToSpeech.SUCCESS && tts != null) { + Log.e(MyTracksConstants.TAG, "StatusAnnouncer Tts not initialized."); + return; + } + + if (service == null || service.getTripStatistics() == null) { + Log.e(MyTracksConstants.TAG, "StatusAnnouncer stats not initialized."); + return; + } + + String announcement = getAnnouncement(service.getTripStatistics()); + Log.d(MyTracksConstants.TAG, "Announcement: " + announcement); + tts.speak(announcement, TextToSpeech.QUEUE_FLUSH, null); + } + + /** + * Builds the announcement string. + * + * @return The string that will be read to the user + */ + private String getAnnouncement(final TripStatistics stats) { + SharedPreferences preferences = + context.getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + boolean metricUnits = true; + boolean reportSpeed = true; + if (preferences != null) { + metricUnits = preferences.getBoolean(MyTracksSettings.METRIC_UNITS, + true); + reportSpeed = preferences.getBoolean(MyTracksSettings.REPORT_SPEED, + true); + } + + double d = stats.getTotalDistance() / 1000; + double s = stats.getAverageMovingSpeed() * 3.6; + if (d == 0) { + return context.getString(R.string.announce_no_distance); + } + + int speedLabel; + if (metricUnits) { + if (reportSpeed) { + speedLabel = R.string.kilometer_per_hour_long; + } else { + speedLabel = R.string.per_kilometer; + } + } else { + s *= UnitConversions.KMH_TO_MPH; + d *= UnitConversions.KM_TO_MI; + if (reportSpeed) { + speedLabel = R.string.mile_per_hour_long; + } else { + speedLabel = R.string.per_mile; + } + } + + String speed = null; + if ((s == 0) || Double.isNaN(s)) { + speed = context.getString(R.string.unknown); + } else { + if (reportSpeed) { + speed = String.format("%.1f", s); + } else { + double pace = 3600000.0 / s; + Log.w(MyTracksConstants.TAG, + "Converted speed: " + s + " to pace: " + pace); + speed = stringUtils.formatTimeLong((long) pace); + } + } + + return context.getString(R.string.announce_template, + context.getString(R.string.total_distance_label), + d, + context.getString(metricUnits + ? R.string.kilometers_long + : R.string.miles_long), + stringUtils.formatTimeLong(stats.getMovingTime()), + speed, + context.getString(speedLabel)); + } + + public void shutdown() { + tts.shutdown(); + } + + @Override + public void start() { + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/services/TaskExecuterManager.java b/MyTracks/src/com/google/android/apps/mytracks/services/TaskExecuterManager.java new file mode 100644 index 000000000..d378ab301 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/services/TaskExecuterManager.java @@ -0,0 +1,80 @@ +/* + * 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.services; + +import android.util.Log; + +import com.google.android.apps.mytracks.MyTracksConstants; + +/** + * This class will manage a period task executer. + * + * @author Sandor Dornbush + */ +public class TaskExecuterManager { + + int frequency; + PeriodicTask task; + PeriodicTaskExecuter executer; + + public TaskExecuterManager(int frequency, + PeriodicTask task, + TrackRecordingService service) { + this.task = task; + setFrequency(frequency, service); + } + + public int getFrequency() { + return frequency; + } + + /** + * Sets the frequency that the task should be run at. + * If needed the task will be scheduled. + * + * @param frequency The frequency in minutes for the task to run + * @param service The service to run the task on + */ + public void setFrequency(int frequency, TrackRecordingService service) { + this.frequency = frequency; + Log.i(MyTracksConstants.TAG, "Frequency set to: " + frequency); + + if (frequency == -1) { + if (executer != null) { + executer.shutdown(); + executer = null; + Log.i(MyTracksConstants.TAG, + "Shut down service: " + task.getClass().getSimpleName()); + } + } else { + if (executer == null) { + task.start(); + executer = new PeriodicTaskExecuter(task, service); + } + executer.scheduleTask(frequency * 60000); + } + } + + /** + * Restore the task at the current frequency. + */ + public void restore() { + if (frequency > 0) { + task.start(); + executer.scheduleTask(frequency * 60000); + } + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/services/TimeSplitTask.java b/MyTracks/src/com/google/android/apps/mytracks/services/TimeSplitTask.java new file mode 100644 index 000000000..9535ad4b0 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/services/TimeSplitTask.java @@ -0,0 +1,37 @@ +/* + * 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.services; + +/** + * A simple task to insert statistics markers every n minutes. + * @author Sandor Dornbush + */ +public class TimeSplitTask implements PeriodicTask { + + @Override + public void run(TrackRecordingService service) { + service.insertStatisticsMarker(service.getLastLocation()); + } + + @Override + public void shutdown() { + } + + @Override + public void start() { + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/services/TrackRecordingService.java b/MyTracks/src/com/google/android/apps/mytracks/services/TrackRecordingService.java new file mode 100644 index 000000000..e415aa5ef --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/services/TrackRecordingService.java @@ -0,0 +1,964 @@ +/* + * Copyright 2008 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.services; + +import com.google.android.apps.mytracks.MyTracks; +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.TracksColumns; +import com.google.android.apps.mytracks.content.Waypoint; +import com.google.android.apps.mytracks.content.WaypointsColumns; +import com.google.android.apps.mytracks.stats.TripStatistics; +import com.google.android.apps.mytracks.util.MyTracksUtils; +import com.google.android.apps.mytracks.util.StringUtils; +import com.google.android.maps.mytracks.R; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.database.sqlite.SQLiteException; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.PowerManager; +import android.os.PowerManager.WakeLock; +import android.util.Log; + +import java.util.Timer; +import java.util.TimerTask; + +/** + * A background service that registers a location listener and records track + * points. Track points are saved to the MyTracksProvider. + * + * @author Leif Hendrik Wilden + */ +public class TrackRecordingService extends Service implements LocationListener { + + private static final String STATISTICS_ICON_URL = + "http://maps.google.com/mapfiles/ms/micons/ylw-pushpin.png"; + + private NotificationManager notificationManager; + private LocationManager locationManager; + private WakeLock wakeLock; + + private int minRecordingInterval = + MyTracksSettings.DEFAULT_MIN_RECORDING_INTERVAL; + private int minRecordingDistance = + MyTracksSettings.DEFAULT_MIN_RECORDING_DISTANCE; + private int maxRecordingDistance = + MyTracksSettings.DEFAULT_MAX_RECORDING_DISTANCE; + private int minRequiredAccuracy = + MyTracksSettings.DEFAULT_MIN_REQUIRED_ACCURACY; + private long recordingTrackId = -1; + + private long currentWaypointId = -1; + + /** + * For debugging. Keep track of calls to onCreate(). + */ + private boolean onCreateWasCalled = false; + + /** The timer posts a runnable to the main thread via this handler. */ + private final Handler handler = new Handler(); + + /** + * Utilities to deal with the database. + */ + private MyTracksProviderUtils providerUtils; + + private TripStatistics stats = new TripStatistics(); + private TripStatistics waypointStats = new TripStatistics(); + + /** + * Current length of the recorded track. This length is calculated from the + * recorded points (as compared to each location fix). It's used to overlay + * waypoints precisely in the elevation profile chart. + */ + private double length = 0; + + /** + * Status announcer executer. + */ + private PeriodicTaskExecuter executer; + private TaskExecuterManager signalManager; + private SplitManager splitManager; + + /** + * The interval in milliseconds that we have requested to be notfied of gps + * readings. + */ + private long currentRecordingInterval = 0; + + /** + * The policy used to decide how often we should request gps updates. + */ + private LocationListenerPolicy locationListenerPolicy = + new AbsoluteLocationListenerPolicy(0); + + /** + * Task invoked by a timer periodically to make sure the location listener is + * still registered. + */ + private TimerTask checkLocationListener = new TimerTask() { + @Override + public void run() { + if (!onCreateWasCalled) { + Log.e(MyTracksConstants.TAG, + "TrackRecordingService is running, but onCreate not called."); + } + if (isRecording) { + handler.post(new Runnable() { + public void run() { + Log.d(MyTracksConstants.TAG, + "Re-registering location listener with TrackRecordingService."); + unregisterLocationListener(); + registerLocationListener(); + } + }); + } else { + Log.w(MyTracksConstants.TAG, + "Track recording service is paused. That should not be."); + } + } + }; + + /** + * This timer invokes periodically the checkLocationListener timer task. + */ + private final Timer timer = new Timer(); + + /** + * Is the phone currently moving? + */ + private boolean isMoving = true; + + /** + * Is the service currently recording a track? + */ + private boolean isRecording = false; + + /** + * Last good location the service has received from the location listener + */ + private Location lastLocation = null; + + /** + * Last valid location (i.e. not a marker) that was recorded. + */ + private Location lastValidLocation = null; + + /** + * The frequency of status announcements. + */ + private int announcementFrequency = -1; + + private static boolean mTTSAvailable; + + /* establish whether the "new" class is available to us */ + static { + try { + SafeStatusAnnouncerTask.checkAvailable(); + mTTSAvailable = true; + } catch (Throwable t) { + mTTSAvailable = false; + } + } + + /* + * Utility functions + */ + + /** + * Inserts a new location in the track points db and updates the corresponding + * track in the track db. + * + * @param recordingTrack the track that is currently being recorded + * @param location the location to be inserted + * @param lastRecordedLocation the last recorded location before this one (or + * null if none) + * @param lastRecordedLocationId the id of the last recorded location (or -1 + * if none) + * @param trackId the id of the track + * @return true if successful. False if SQLite3 threw an exception. + */ + private boolean insertLocation(Track recordingTrack, Location location, + Location lastRecordedLocation, long lastRecordedLocationId, + long trackId) { + + // Keep track of length along recorded track (needed when a waypoint is + // inserted): + if (MyTracksUtils.isValidLocation(location)) { + if (lastValidLocation != null) { + length += location.distanceTo(lastValidLocation); + } + lastValidLocation = location; + } + + // Insert the new location: + try { + Uri pointUri = providerUtils.insertTrackPoint(location, trackId); + int pointId = Integer.parseInt(pointUri.getLastPathSegment()); + + // Update the current track: + if (lastRecordedLocation != null + && lastRecordedLocation.getLatitude() < 90) { + ContentValues values = new ContentValues(); + if (recordingTrack.getStartId() < 0) { + values.put(TracksColumns.STARTID, pointId); + recordingTrack.setStartId(pointId); + } + values.put(TracksColumns.STOPID, pointId); + values.put(TracksColumns.STOPTIME, System.currentTimeMillis()); + values.put(TracksColumns.NUMPOINTS, + recordingTrack.getNumberOfPoints() + 1); + values.put(TracksColumns.MINLAT, stats.getBottom()); + values.put(TracksColumns.MAXLAT, stats.getTop()); + values.put(TracksColumns.MINLON, stats.getLeft()); + values.put(TracksColumns.MAXLON, stats.getRight()); + values.put(TracksColumns.TOTALDISTANCE, stats.getTotalDistance()); + values.put(TracksColumns.TOTALTIME, stats.getTotalTime()); + values.put(TracksColumns.MOVINGTIME, stats.getMovingTime()); + values.put(TracksColumns.AVGSPEED, stats.getAverageSpeed()); + values.put(TracksColumns.AVGMOVINGSPEED, stats.getAverageMovingSpeed()); + values.put(TracksColumns.MAXSPEED, stats.getMaxSpeed()); + values.put(TracksColumns.MINELEVATION, stats.getMinElevation()); + values.put(TracksColumns.MAXELEVATION, stats.getMaxElevation()); + values.put(TracksColumns.ELEVATIONGAIN, stats.getTotalElevationGain()); + values.put(TracksColumns.MINGRADE, stats.getMinGrade()); + values.put(TracksColumns.MAXGRADE, stats.getMaxGrade()); + getContentResolver().update(TracksColumns.CONTENT_URI, + values, "_id=" + recordingTrack.getId(), null); + updateCurrentWaypoint(); + } + } catch (SQLiteException e) { + // Insert failed, most likely because of SqlLite error code 5 + // (SQLite_BUSY). This is expected to happen extremely rarely (if our + // listener gets invoked twice at about the same time). + Log.w(MyTracksConstants.TAG, + "Caught SQLiteException: " + e.getMessage(), e); + return false; + } + splitManager.updateSplits(); + return true; + } + + private void updateCurrentWaypoint() { + if (currentWaypointId >= 0) { + ContentValues values = new ContentValues(); + values.put(WaypointsColumns.STARTTIME, waypointStats.getStartTime()); + values.put(WaypointsColumns.LENGTH, length); + values.put(WaypointsColumns.DURATION, + System.currentTimeMillis() - stats.getStartTime()); + values.put(WaypointsColumns.TOTALDISTANCE, + waypointStats.getTotalDistance()); + values.put(WaypointsColumns.TOTALTIME, waypointStats.getTotalTime()); + values.put(WaypointsColumns.MOVINGTIME, waypointStats.getMovingTime()); + values.put(WaypointsColumns.AVGSPEED, waypointStats.getAverageSpeed()); + values.put(WaypointsColumns.AVGMOVINGSPEED, + waypointStats.getAverageMovingSpeed()); + values.put(WaypointsColumns.MAXSPEED, waypointStats.getMaxSpeed()); + values.put(WaypointsColumns.MINELEVATION, + waypointStats.getMinElevation()); + values.put(WaypointsColumns.MAXELEVATION, + waypointStats.getMaxElevation()); + values.put(WaypointsColumns.ELEVATIONGAIN, + waypointStats.getTotalElevationGain()); + values.put(WaypointsColumns.MINGRADE, waypointStats.getMinGrade()); + values.put(WaypointsColumns.MAXGRADE, waypointStats.getMaxGrade()); + getContentResolver().update(WaypointsColumns.CONTENT_URI, + values, "_id=" + currentWaypointId, null); + } + } + + /** + * Tries to acquire a partial wake lock if not already acquired. Logs errors + * and gives up trying in case the wake lock cannot be acquired. + */ + public void acquireWakeLock() { + try { + PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE); + if (pm == null) { + Log.e(MyTracksConstants.TAG, + "TrackRecordingService: Power manager not found!"); + return; + } + if (wakeLock == null) { + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, + MyTracksConstants.TAG); + if (wakeLock == null) { + Log.e(MyTracksConstants.TAG, + "TrackRecordingService: Could not create wake lock (null)."); + return; + } + } + if (!wakeLock.isHeld()) { + wakeLock.acquire(); + if (!wakeLock.isHeld()) { + Log.e(MyTracksConstants.TAG, + "TrackRecordingService: Could not acquire wake lock."); + } + } + } catch (RuntimeException e) { + Log.e(MyTracksConstants.TAG, + "TrackRecordingService: Caught unexpected exception: " + + e.getMessage(), e); + } + } + + /** + * Shows the notification message and icon in the notification bar. + */ + public void showNotification() { + if (isRecording) { + Notification notification = new Notification( + R.drawable.arrow_320, null /* tickerText */, + System.currentTimeMillis()); + PendingIntent contentIntent = PendingIntent.getActivity( + this, 0 /* requestCode */, new Intent(this, MyTracks.class), + 0 /* flags */); + notification.setLatestEventInfo(this, getString(R.string.app_name), + getString(R.string.recording_your_track), contentIntent); + notification.flags += Notification.FLAG_NO_CLEAR; + notificationManager.notify(1, notification); + } else { + notificationManager.cancelAll(); + } + } + + public void registerLocationListener() { + if (locationManager == null) { + Log.e(MyTracksConstants.TAG, + "TrackRecordingService: Do not have any location manager."); + return; + } + Log.d(MyTracksConstants.TAG, + "Preparing to register location listener w/ TrackRecordingService..."); + try { + long desiredInterval = locationListenerPolicy.getDesiredPollingInterval(); + locationManager.requestLocationUpdates( + MyTracksConstants.GPS_PROVIDER, desiredInterval, + locationListenerPolicy.getMinDistance(), + // , 0 /* minDistance, get all updates to properly time pauses */ + TrackRecordingService.this); + currentRecordingInterval = desiredInterval; + Log.d(MyTracksConstants.TAG, + "...location listener now registered w/ TrackRecordingService @ " + + currentRecordingInterval); + } catch (RuntimeException e) { + Log.e(MyTracksConstants.TAG, + "Could not register location listener: " + e.getMessage(), e); + } + } + + public void unregisterLocationListener() { + if (locationManager == null) { + Log.e(MyTracksConstants.TAG, + "TrackRecordingService: Do not have any location manager."); + return; + } + locationManager.removeUpdates(this); + Log.d(MyTracksConstants.TAG, + "Location listener now unregistered w/ TrackRecordingService."); + } + + private void restoreStats() { + if (recordingTrackId < 0) { + return; + } + + Track track = providerUtils.getTrack(recordingTrackId); + if (track == null) { + return; + } + + stats = new TripStatistics(track.getStartTime()); + if (executer != null) { + executer.scheduleTask(announcementFrequency * 60000); + } + + signalManager.restore(); + splitManager.restore(); + length = 0; + lastValidLocation = null; + + Waypoint waypoint = providerUtils.getFirstWaypoint(recordingTrackId); + if (waypoint != null) { + currentWaypointId = waypoint.getId(); + waypointStats = new TripStatistics(waypoint); + } else { + // This should never happen, but we got to do something so life goes on: + waypointStats = new TripStatistics(track.getStartTime()); + currentWaypointId = -1; + } + + Cursor cursor = null; + try { + cursor = providerUtils.getLocationsCursor( + recordingTrackId, -1, MyTracksConstants.MAX_LOADED_TRACK_POINTS, + true); + if (cursor != null) { + if (cursor.moveToLast()) { + do { + Location location = providerUtils.createLocation(cursor); + if (MyTracksUtils.isValidLocation(location)) { + stats.addLocation(location, location.getTime()); + if (lastValidLocation != null) { + length += location.distanceTo(lastValidLocation); + } + lastValidLocation = location; + } + } while (cursor.moveToPrevious()); + } + stats.setMovingTime(track.getMovingTime()); + stats.pauseAt(track.getStopTime()); + stats.resume(); + } else { + Log.e(MyTracksConstants.TAG, "Could not get track points cursor."); + } + } catch (RuntimeException e) { + Log.e(MyTracksConstants.TAG, "Error while restoring track.", e); + } finally { + if (cursor != null) { + cursor.close(); + } + } + + splitManager.calculateNextSplit(); + } + + /* + * Location listener implementation: ================================= + */ + + @Override + public void onLocationChanged(Location location) { + Log.d(MyTracksConstants.TAG, "TrackRecordingService.onLocationChanged"); + + try { + // Don't record if the service has been asked to pause recording: + if (!isRecording) { + Log.w(MyTracksConstants.TAG, + "Not recording because recording has been paused."); + return; + } + + // This should never happen, but just in case (we really don't want the + // service + // to crash): + if (location == null) { + Log.w(MyTracksConstants.TAG, + "Location changed, but location is null."); + return; + } + + // Don't record if the accuracy is too bad: + if (location.getAccuracy() > minRequiredAccuracy) { + Log.d(MyTracksConstants.TAG, + "Not recording. Bad accuracy."); + return; + } + + // At least one track must be available for appending points: + Track recordingTrack = providerUtils.getTrack(recordingTrackId); + if (recordingTrack == null) { + Log.d(MyTracksConstants.TAG, + "Not recording. No track to append to available."); + return; + } + + if (MyTracksUtils.isValidLocation(location)) { + long now = System.currentTimeMillis(); + stats.addLocation(location, now); + waypointStats.addLocation(location, now); + } + + // Update the idle time if needed. + locationListenerPolicy.updateIdleTime(stats.getIdleTime()); + if (currentRecordingInterval + != locationListenerPolicy.getDesiredPollingInterval()) { + registerLocationListener(); + } + + Location lastRecordedLocation = providerUtils.getLastLocation(); + long lastRecordedLocationId = + providerUtils.getLastLocationId(recordingTrackId); + double distanceToLastRecorded = Double.POSITIVE_INFINITY; + if (lastRecordedLocation != null) { + distanceToLastRecorded = location.distanceTo(lastRecordedLocation); + } + double distanceToLast = Double.POSITIVE_INFINITY; + if (lastLocation != null) { + distanceToLast = location.distanceTo(lastLocation); + } + + // If the user has been stationary for two recording just record the first + // two and ignore the rest. This code will only have an effect if the + // maxRecordingDistance = 0 + if (distanceToLast == 0) { + if (isMoving) { + Log.d(MyTracksConstants.TAG, "Found two identical locations."); + isMoving = false; + if (lastLocation != null && lastRecordedLocation != null + && !lastRecordedLocation.equals(lastLocation)) { + // Need to write the last location. This will happen when + // lastRecordedLocation.distance(lastLocation) < + // minRecordingDistance + if (!insertLocation(recordingTrack, lastLocation, + lastRecordedLocation, lastRecordedLocationId, + recordingTrackId)) { + return; + } + lastRecordedLocationId++; + } + } else { + Log.d(MyTracksConstants.TAG, + "Not recording. More than two identical locations."); + } + } else if (distanceToLastRecorded > minRecordingDistance) { + if (lastLocation != null && !isMoving) { + // Last location was the last stationary location. Need to go back and + // add it. + if (!insertLocation(recordingTrack, lastLocation, + lastRecordedLocation, lastRecordedLocationId, recordingTrackId)) { + return; + } + lastRecordedLocationId++; + isMoving = true; + } + + // If separation from last recorded point is too large insert a + // separator + // to indicate end of a segment: + boolean startNewSegment = + lastRecordedLocation != null + && lastRecordedLocation.getLatitude() < 90 + && distanceToLastRecorded > maxRecordingDistance + && recordingTrack.getStartId() >= 0; + if (startNewSegment) { + // Insert a separator point to indicate start of new track: + Log.d(MyTracksConstants.TAG, "Inserting a separator."); + Location separator = new Location(MyTracksConstants.GPS_PROVIDER); + separator.setLongitude(0); + separator.setLatitude(100); + separator.setTime(lastRecordedLocation.getTime()); + providerUtils.insertTrackPoint(separator, recordingTrackId); + } + + if (!insertLocation(recordingTrack, location, lastRecordedLocation, + lastRecordedLocationId, recordingTrackId)) { + return; + } + } else { + Log.d(MyTracksConstants.TAG, String.format( + "Not recording. Distance to last recorded point (%f m) is less than" + + " %d m.", distanceToLastRecorded, minRecordingDistance)); + } + } catch (Error e) { + // Probably important enough to rethrow. + Log.e(MyTracksConstants.TAG, "Error in onLocationChanged", e); + throw e; + } catch (RuntimeException e) { + // Safe usually to trap exceptions. + Log.e(MyTracksConstants.TAG, + "Trapping exception in onLocationChanged", e); + throw e; + } + lastLocation = location; + } + + @Override + public void onProviderDisabled(String provider) { + // Do nothing + } + + @Override + public void onProviderEnabled(String provider) { + // Do nothing + } + + @Override + public void onStatusChanged(String provider, int status, Bundle extras) { + // Do nothing + } + + /* + * SharedPreferencesChangeListener interface implementation. Note that + * services don't currently receive this event (Android platform limitation). + * This should be called from an activity whenever settings change. + */ + + /** + * Notifies that preferences have changed. + * Call this with key == null to update all preferences in one call. + * + * @param key the key that changed (may be null to update all preferences) + */ + public void onSharedPreferenceChanged(String key) { + Log.d(MyTracksConstants.TAG, + "TrackRecordingService.onSharedPreferenceChanged"); + SharedPreferences sharedPreferences = + getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + if (sharedPreferences == null) { + Log.w(MyTracksConstants.TAG, + "TrackRecordingService: Couldn't get shared preferences."); + return; + } + + if (key == null || key.equals(MyTracksSettings.MIN_RECORDING_DISTANCE)) { + minRecordingDistance = sharedPreferences.getInt( + MyTracksSettings.MIN_RECORDING_DISTANCE, + MyTracksSettings.DEFAULT_MIN_RECORDING_DISTANCE); + Log.d(MyTracksConstants.TAG, + "TrackRecordingService: minRecordingDistance = " + + minRecordingDistance); + } + if (key == null || key.equals(MyTracksSettings.MAX_RECORDING_DISTANCE)) { + maxRecordingDistance = sharedPreferences.getInt( + MyTracksSettings.MAX_RECORDING_DISTANCE, + MyTracksSettings.DEFAULT_MAX_RECORDING_DISTANCE); + } + if (key == null || key.equals(MyTracksSettings.MIN_RECORDING_INTERVAL)) { + minRecordingInterval = sharedPreferences.getInt( + MyTracksSettings.MIN_RECORDING_INTERVAL, + MyTracksSettings.DEFAULT_MIN_RECORDING_INTERVAL); + switch (minRecordingInterval) { + case -2: + // Battery Miser + // min: 30 seconds + // max: 5 minutes + // minDist: 5 meters Choose battery life over moving time accuracy. + locationListenerPolicy = + new AdaptiveLocationListenerPolicy(30000, 300000, 5); + break; + case -1: + // High Accuracy + // min: 1 second + // max: 30 seconds + // minDist: 0 meters get all updates to properly measure moving time. + locationListenerPolicy = + new AdaptiveLocationListenerPolicy(1000, 30000, 0); + break; + default: + locationListenerPolicy = + new AbsoluteLocationListenerPolicy(minRecordingInterval * 1000); + } + } + if (key == null || key.equals(MyTracksSettings.MIN_REQUIRED_ACCURACY)) { + minRequiredAccuracy = sharedPreferences.getInt( + MyTracksSettings.MIN_REQUIRED_ACCURACY, + MyTracksSettings.DEFAULT_MIN_REQUIRED_ACCURACY); + } + if (key == null || key.equals(MyTracksSettings.RECORDING_TRACK)) { + recordingTrackId = + sharedPreferences.getLong(MyTracksSettings.RECORDING_TRACK, -1); + } + if (key == null || key.equals(MyTracksSettings.ANNOUNCEMENT_FREQUENCY)) { + announcementFrequency = + sharedPreferences.getInt(MyTracksSettings.ANNOUNCEMENT_FREQUENCY, -1); + if (mTTSAvailable) { + if (announcementFrequency == -1) { + if (executer != null) { + executer.shutdown(); + executer = null; + } + } else { + if (executer == null) { + SafeStatusAnnouncerTask announcer = + new SafeStatusAnnouncerTask(this); + executer = new PeriodicTaskExecuter(announcer, this); + } + executer.scheduleTask(announcementFrequency * 60000); + } + } + } + if (key == null || key.equals(MyTracksSettings.SPLIT_FREQUENCY)) { + splitManager.setSplitFrequency( + sharedPreferences.getInt(MyTracksSettings.SPLIT_FREQUENCY, 0)); + } + if (key == null || key.equals(MyTracksSettings.SIGNAL_SAMPLING_FREQUENCY)) { + signalManager.setFrequency(sharedPreferences.getInt( + MyTracksSettings.SIGNAL_SAMPLING_FREQUENCY, -1), this); + } + if (key == null || key.equals(MyTracksSettings.METRIC_UNITS)) { + splitManager.setMetricUnits( + sharedPreferences.getBoolean(MyTracksSettings.METRIC_UNITS, true)); + } + + if (isRecording) { + registerLocationListener(); + } + } + + /* + * Application lifetime events: ============================ + */ + + @Override + public void onCreate() { + Log.d(MyTracksConstants.TAG, "TrackRecordingService.onCreate"); + super.onCreate(); + onCreateWasCalled = true; + setForeground(true); + providerUtils = MyTracksProviderUtils.Factory.get(this); + notificationManager = + (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + locationManager = + (LocationManager) getSystemService(Context.LOCATION_SERVICE); + splitManager = new SplitManager(this); + try { + signalManager = + new TaskExecuterManager(-1, new SignalStrengthTaskModern(this), this); + Log.d(MyTracksConstants.TAG, + "TrackRecordingService using modern signal strength api."); + } catch (LinkageError e) { + Log.w(MyTracksConstants.TAG, + "TrackRecordingService could not load modern signal strength.", e); + signalManager = + new TaskExecuterManager(-1, new SignalStrengthTask(this), this); + } + onSharedPreferenceChanged(null); + restoreStats(); + registerLocationListener(); + acquireWakeLock(); + /** + * After 5 min, check every minute that location listener still is + * registered and spit out additional debugging info to the logs: + */ + timer.schedule(checkLocationListener, 1000 * 60 * 5, 1000 * 60); + isRecording = true; + if (mTTSAvailable && (announcementFrequency != -1)) { + if (executer == null) { + SafeStatusAnnouncerTask announcer = new SafeStatusAnnouncerTask(this); + executer = new PeriodicTaskExecuter(announcer, this); + } + } + } + + @Override + public void onDestroy() { + Log.d(MyTracksConstants.TAG, "TrackRecordingService.onDestroy"); + if (wakeLock != null && wakeLock.isHeld()) { + wakeLock.release(); + } + isRecording = false; + showNotification(); + unregisterLocationListener(); + if (executer != null) { + executer.shutdown(); + } + splitManager.shutdown(); + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { + Log.d(MyTracksConstants.TAG, "TrackRecordingService.onBind"); + return binder; + } + + @Override + public boolean onUnbind(Intent intent) { + Log.d(MyTracksConstants.TAG, "TrackRecordingService.onUnbind"); + return super.onUnbind(intent); + } + + @Override + public boolean stopService(Intent name) { + Log.d(MyTracksConstants.TAG, "TrackRecordingService.stopService"); + unregisterLocationListener(); + return super.stopService(name); + } + + public long insertWaypointMarker(Waypoint waypoint) { + if (waypoint.getLocation() != null) { + waypoint.setLength(length); + waypoint.setDuration( + waypoint.getLocation().getTime() - stats.getStartTime()); + Uri uri = providerUtils.insertWaypoint(waypoint); + return Long.parseLong(uri.getLastPathSegment()); + } + return -1; + } + + /** + * Inserts a statistics marker. A statistics marker holds the stats for the + * last segment up to this marker. + * + * @param location the location where to insert + * @return the unique id of the inserted marker + */ + public long insertStatisticsMarker(Location location) { + Waypoint waypoint = new Waypoint(); + waypoint.setTrackId(recordingTrackId); + waypoint.setType(Waypoint.TYPE_STATISTICS); + waypoint.setName(TrackRecordingService.this.getString(R.string.statistics)); + waypointStats.fillStatisticsForWaypoint(waypoint); + StringUtils utils = new StringUtils(TrackRecordingService.this); + waypoint.setDescription(utils.generateWaypointDescription(waypoint)); + waypoint.setLocation(location); + waypoint.setIcon(STATISTICS_ICON_URL); + waypoint.setLength(length); + final long time = System.currentTimeMillis(); + waypoint.setDuration(time - stats.getStartTime()); + waypoint.setStartTime(waypointStats.getStartTime()); + waypoint.setStartId(providerUtils.getLastLocationId(recordingTrackId)); + Uri uri = providerUtils.insertWaypoint(waypoint); + waypointStats = new TripStatistics(time); + updateCurrentWaypoint(); + return Long.parseLong(uri.getLastPathSegment()); + } + + /** + * The ITrackRecordingService is defined through IDL. + */ + private final ITrackRecordingService.Stub binder = + new ITrackRecordingService.Stub() { + @Override + public boolean isRecording() { + return isRecording; + } + + @Override + public long getRecordingTrackId() { + return recordingTrackId; + } + + @Override + public boolean hasRecorded() { + return providerUtils.getLastTrackId() >= 0; + } + + @Override + public long startNewTrack() { + Log.d(MyTracksConstants.TAG, "TrackRecordingService.startNewTrack"); + Track track = new Track(); + track.setName("new"); + track.setStartTime(System.currentTimeMillis()); + track.setStartId(-1); + Uri trackUri = providerUtils.insertTrack(track); + long trackId = Long.parseLong(trackUri.getLastPathSegment()); + track.setId(trackId); + track.setName(String.format(getString(R.string.new_track), trackId)); + providerUtils.updateTrack(track); + recordingTrackId = trackId; + currentWaypointId = insertStatisticsMarker(null); + isRecording = true; + isMoving = true; + stats = new TripStatistics(track.getStartTime()); + if (announcementFrequency != -1 && executer != null) { + executer.scheduleTask(announcementFrequency * 60000); + } + length = 0; + showNotification(); + registerLocationListener(); + splitManager.restore(); + signalManager.restore(); + return trackId; + } + + /** + * Insert the given waypoint marker. Users can insert waypoint markers + * to tag locations with a name, description, category etc. + * + * @param waypoint a waypoint + * @return the unique id of the inserted marker + */ + @Override + public long insertWaypointMarker(Waypoint waypoint) { + return TrackRecordingService.this.insertWaypointMarker(waypoint); + } + + /** + * Insert a statistics marker. A statistics marker holds the stats for + * the last segment up to this marker. + * + * @param location the location where to insert + * @return the unique id of the inserted marker + */ + @Override + public long insertStatisticsMarker(Location location) { + return TrackRecordingService.this.insertStatisticsMarker(location); + } + + @Override + public void endCurrentTrack() { + Log.d(MyTracksConstants.TAG, "TrackRecordingService.endCurrentTrack"); + isRecording = false; + Track recordingTrack = providerUtils.getTrack(recordingTrackId); + if (recordingTrack != null) { + recordingTrack.setStopTime(System.currentTimeMillis()); + recordingTrack.setTotalTime( + recordingTrack.getStopTime() - recordingTrack.getStartTime()); + long lastRecordedLocationId = + providerUtils.getLastLocationId(recordingTrackId); + ContentValues values = new ContentValues(); + if (lastRecordedLocationId >= 0 + && recordingTrack.getStopId() >= 0) { + values.put(TracksColumns.STOPID, lastRecordedLocationId); + } + values.put(TracksColumns.STOPTIME, recordingTrack.getStopTime()); + values.put(TracksColumns.TOTALTIME, recordingTrack.getTotalTime()); + getContentResolver().update(TracksColumns.CONTENT_URI, values, + "_id=" + recordingTrack.getId(), null); + } + showNotification(); + recordingTrackId = -1; + } + + @Override + public void deleteAllTracks() { + endCurrentTrack(); + providerUtils.deleteAllTracks(); + } + + @Override + public void recordLocation(Location loc) { + onLocationChanged(loc); + } + + @Override + public void sharedPreferenceChanged(String key) { + Log.d(MyTracksConstants.TAG, + "TrackRecordingService.sharedPrefereneChange"); + onSharedPreferenceChanged(key); + } + }; + + TripStatistics getTripStatistics() { + return stats; + } + + Location getLastLocation() { + return lastLocation; + } + + long getRecordingTrackId() { + return recordingTrackId; + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/stats/DoubleBuffer.java b/MyTracks/src/com/google/android/apps/mytracks/stats/DoubleBuffer.java new file mode 100755 index 000000000..356664617 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/stats/DoubleBuffer.java @@ -0,0 +1,143 @@ +/* + * Copyright 2009 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.stats; + +/** + * This class maintains a buffer of doubles. This buffer is a convenient class + * for storing a series of doubles and calculating information about them. This + * is a FIFO buffer. + * + * @author Sandor Dornbush + */ +public class DoubleBuffer { + + /** + * The location that the next write will occur at. + */ + private int index; + + /** + * The sliding buffer of doubles. + */ + private final double[] buffer; + + /** + * Have all of the slots in the buffer been filled? + */ + private boolean isFull; + + /** + * Creates a buffer with size elements. + * + * @param size the number of elements in the buffer + * @throws IllegalArgumentException if the size is not a positive value + */ + public DoubleBuffer(int size) { + if (size < 1) { + throw new IllegalArgumentException("The buffer size must be positive."); + } + buffer = new double[size]; + reset(); + } + + /** + * Adds a double to the buffer. If the buffer is full the oldest element is + * overwritten. + * + * @param d the double to add + */ + public void setNext(double d) { + if (index == buffer.length) { + index = 0; + } + buffer[index] = d; + index++; + if (index == buffer.length) { + isFull = true; + } + } + + /** + * Are all of the entries in the buffer used? + */ + public boolean isFull() { + return isFull; + } + + /** + * Resets the buffer to the initial state. + */ + public void reset() { + index = 0; + isFull = false; + } + + /** + * Gets the average of values from the buffer. + * + * @return The average of the buffer + */ + public double getAverage() { + int numberOfEntries = isFull ? buffer.length : index; + if (numberOfEntries == 0) { + return 0; + } + + double sum = 0; + for (int i = 0; i < numberOfEntries; i++) { + sum += buffer[i]; + } + return sum / numberOfEntries; + } + + /** + * Gets the average and standard deviation of the buffer. + * + * @return An array of two elements - the first is the average, and the second + * is the variance + */ + public double[] getAverageAndVariance() { + int numberOfEntries = isFull ? buffer.length : index; + if (numberOfEntries == 0) { + return new double[]{0, 0}; + } + + double sum = 0; + double sumSquares = 0; + for (int i = 0; i < numberOfEntries; i++) { + sum += buffer[i]; + sumSquares += Math.pow(buffer[i], 2); + } + + double average = sum / numberOfEntries; + return new double[]{average, + sumSquares / numberOfEntries - Math.pow(average, 2)}; + } + + @Override + public String toString() { + StringBuffer stringBuffer = new StringBuffer("Full: "); + stringBuffer.append(isFull); + stringBuffer.append("\n"); + for (int i = 0; i < buffer.length; i++) { + stringBuffer.append((i == index) ? "<<" : "["); + stringBuffer.append(buffer[i]); + stringBuffer.append((i == index) ? ">> " : "] "); + } + return stringBuffer.toString(); + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/stats/ExtremityMonitor.java b/MyTracks/src/com/google/android/apps/mytracks/stats/ExtremityMonitor.java new file mode 100644 index 000000000..8fdfeead4 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/stats/ExtremityMonitor.java @@ -0,0 +1,93 @@ +/* + * Copyright 2009 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.stats; + +/** + * A helper class that tracks a minimum and a maximum of a variable. + * + * @author Sandor Dornbush + */ +public class ExtremityMonitor { + + /** + * The smallest value seen so far. + */ + private double min; + + /** + * The largest value seen so far. + */ + private double max; + + public ExtremityMonitor() { + reset(); + } + + /** + * Updates the min and the max with the new value. + * + * @param value the new value for the monitor + * @return true if an extremity was found + */ + public boolean update(double value) { + boolean changed = false; + if (value < min) { + min = value; + changed = true; + } + if (value > max) { + max = value; + changed = true; + } + return changed; + } + + /** + * Gets the minimum value seen. + * + * @return The minimum value passed into the update() function + */ + public double getMin() { + return min; + } + + /** + * Gets the maximum value seen. + * + * @return The maximum value passed into the update() function + */ + public double getMax() { + return max; + } + + /** + * Resets this object to it's initial state where the min and max are unknown. + */ + public void reset() { + min = Double.POSITIVE_INFINITY; + max = Double.NEGATIVE_INFINITY; + } + + /** + * Sets the minimum and maximum values. + * Useful when restoring state. + */ + public void set(double min, double max) { + this.min = min; + this.max = max; + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/stats/TripStatistics.java b/MyTracks/src/com/google/android/apps/mytracks/stats/TripStatistics.java new file mode 100644 index 000000000..a07d7b77e --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/stats/TripStatistics.java @@ -0,0 +1,565 @@ +/* + * Copyright 2009 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.stats; + +import com.google.android.apps.mytracks.MyTracksConstants; +import com.google.android.apps.mytracks.content.Track; +import com.google.android.apps.mytracks.content.Waypoint; + +import android.location.Location; +import android.util.Log; + +/** + * Statistics keeper for a trip. + * + * @author Sandor Dornbush + * @author Rodrigo Damazio + */ +public class TripStatistics { + /** + * Statistical data about the trip, which can be displayed to the user. + */ + private final TripStatisticsData data = new TripStatisticsData(); + + /** + * The last location that the gps reported. + */ + private Location lastLocation = null; + + /** + * The last location that contributed to the stats. It is also the last + * location the user was found to be moving. + */ + private Location lastMovingLocation = null; + + /** + * Is the trip currently paused? + * All trips start paused. + */ + private boolean paused = true; + + /** + * A buffer of the last speed readings in meters/second. + */ + private final DoubleBuffer speedBuffer = + new DoubleBuffer(MyTracksConstants.SPEED_SMOOTHING_FACTOR); + + /** + * A buffer of the recent elevation readings in meters. + */ + private final DoubleBuffer elevationBuffer = + new DoubleBuffer(MyTracksConstants.ELEVATION_SMOOTHING_FACTOR); + + /** + * A buffer of the distance between recent gps readings in meters. + */ + private final DoubleBuffer distanceBuffer = + new DoubleBuffer(MyTracksConstants.DISTANCE_SMOOTHING_FACTOR); + + /** + * A buffer of the recent grade calculations. + */ + private final DoubleBuffer gradeBuffer = + new DoubleBuffer(MyTracksConstants.GRADE_SMOOTHING_FACTOR); + + /** + * The total number of locations in this trip. + */ + private long totalLocations = 0; + + /** + * Creates a new trip starting at the current system time. + */ + public TripStatistics() { + data.startTime = System.currentTimeMillis(); + } + + /** + * Creates a new trip at the given start time. + * + * @param startTime the time that the trip started + */ + public TripStatistics(long startTime) { + data.startTime = startTime; + } + + /** + * Creates a new trip using the waypoint for start time and other information. + * + * @param waypoint the waypoint to get starting information from + */ + public TripStatistics(Waypoint waypoint) { + data.startTime = waypoint.getStartTime(); + data.totalTime = waypoint.getTotalTime(); + data.movingTime = waypoint.getMovingTime(); + data.totalDistance = waypoint.getTotalDistance(); + data.totalElevationGain = waypoint.getTotalElevationGain(); + data.maxSpeed = waypoint.getMaxSpeed(); + } + + /** + * Adds a location to the current trip. This will update all of the internal + * variables with this new location. + * + * @param currentLocation the current gps location + * @param systemTime the time used for calculation of totalTime. This should + * be the phone's time (not GPS time) + * @return true if the person is moving + */ + public boolean addLocation(Location currentLocation, long systemTime) { + totalLocations++; + + double elevationDifference = updateElevation(currentLocation.getAltitude()); + + // Update the "instant" values: + data.totalTime = systemTime - data.startTime; + data.currentSpeed = currentLocation.getSpeed(); + + // This was the 1st location added, remember it and do nothing else: + if (lastLocation == null) { + lastLocation = currentLocation; + lastMovingLocation = currentLocation; + return false; + } + + updateBounds(currentLocation); + + // Don't do anything if we didn't move since last fix: + double distance = lastLocation.distanceTo(currentLocation); + if (distance < MyTracksConstants.MAX_NO_MOVEMENT_DISTANCE + && data.currentSpeed < MyTracksConstants.MAX_NO_MOVEMENT_SPEED) { + lastLocation = currentLocation; + return false; + } + + data.totalDistance += lastMovingLocation.distanceTo(currentLocation); + updateSpeed(currentLocation.getTime(), data.currentSpeed, + lastLocation.getTime(), lastLocation.getSpeed()); + + updateGrade(distance, elevationDifference); + lastLocation = currentLocation; + lastMovingLocation = currentLocation; + return true; + } + + /** + * Updates the track's bounding box to include the given location. + */ + private void updateBounds(Location location) { + data.latitudeExtremities.update(location.getLatitude()); + data.longitudeExtremities.update(location.getLongitude()); + } + + /** + * Updates the elevation measurements. + * + * @param elevation the current elevation + */ + // @VisibleForTesting + double updateElevation(double elevation) { + double oldSmoothedElevation = getSmoothedElevation(); + elevationBuffer.setNext(elevation); + double smoothedElevation = getSmoothedElevation(); + data.elevationExtremities.update(smoothedElevation); + double elevationDifference = elevationBuffer.isFull() + ? smoothedElevation - oldSmoothedElevation + : 0.0; + if (elevationDifference > 0) { + data.totalElevationGain += elevationDifference; + } + return elevationDifference; + } + + /** + * Updates the speed measurements. + * + * @param updateTime the time of the speed update + * @param speed the current speed + * @param lastLocationTime the time of the last speed update + * @param lastLocationSpeed the speed of the last update + */ + // @VisibleForTesting + void updateSpeed(long updateTime, double speed, long lastLocationTime, + double lastLocationSpeed) { + // We are now sure the user is moving. + long timeDifference = updateTime - lastLocationTime; + if (timeDifference < 0) { + Log.e(MyTracksConstants.TAG, + "Found negative time change: " + timeDifference); + } + data.movingTime += timeDifference; + + if (isValidSpeed(updateTime, speed, lastLocationTime, lastLocationSpeed, + speedBuffer)) { + speedBuffer.setNext(speed); + if (speed > data.maxSpeed) { + data.maxSpeed = speed; + } + double movingSpeed = getAverageMovingSpeed(); + if (speedBuffer.isFull() && (movingSpeed > data.maxSpeed)) { + data.maxSpeed = movingSpeed; + } + } else { + Log.d(MyTracksConstants.TAG, + "TripStatistics ignoring big change: Raw Speed: " + speed + + " old: " + lastLocationSpeed + " [" + toString() + "]"); + } + } + + /** + * Checks to see if this is a valid speed. + * + * @param updateTime The time at the current reading + * @param speed The current speed + * @param lastLocationTime The time at the last location + * @param lastLocationSpeed Speed at the last location + * @param speedBuffer A buffer of recent readings + * @return True if this is likely a valid speed + */ + public static boolean isValidSpeed(long updateTime, double speed, + long lastLocationTime, double lastLocationSpeed, + DoubleBuffer speedBuffer) { + + // We don't want to count 0 towards the speed. + if (speed == 0) { + return false; + } + // We are now sure the user is moving. + long timeDifference = updateTime - lastLocationTime; + + // There are a lot of noisy speed readings. + // Do the cheapest checks first, most expensive last. + // The following code will ignore unlikely to be real readings. + // - 128 m/s seems to be an internal android error code. + if (Math.abs(speed - 128) < 1) { + return false; + } + + // Another check for a spurious reading. See if the path seems physically + // likely. Ignore any speeds that imply accelaration greater than 2g's + // Really who can accelerate faster? + double speedDifference = Math.abs(lastLocationSpeed - speed); + if (speedDifference > MyTracksConstants.MAX_ACCELERATION * timeDifference) { + return false; + } + + // There are three additional checks if the reading gets this far: + // - Only use the speed if the buffer is full + // - Check that the current speed is less than 10x the recent smoothed speed + // - Double check that the current speed does not imply crazy acceleration + double smoothedSpeed = speedBuffer.getAverage(); + double smoothedDiff = Math.abs(smoothedSpeed - speed); + return !speedBuffer.isFull() || + (speed < smoothedSpeed * 10 + && smoothedDiff < MyTracksConstants.MAX_ACCELERATION * timeDifference); + } + + /** + * Updates the grade measurements. + * + * @param distance the distance the user just traveled + * @param elevationDifference the elevation difference between the current + * reading and the previous reading + */ + // @VisibleForTesting + void updateGrade(double distance, double elevationDifference) { + distanceBuffer.setNext(distance); + double smoothedDistance = distanceBuffer.getAverage(); + + // With the error in the altitude measurement it is dangerous to divide + // by anything less than 5. + if (!elevationBuffer.isFull() || !distanceBuffer.isFull() + || smoothedDistance < 5.0) { + return; + } + data.currentGrade = elevationDifference / smoothedDistance; + gradeBuffer.setNext(data.currentGrade); + data.gradeExtremities.update(gradeBuffer.getAverage()); + } + + + /** + * Gets the current elevation smoothed over several readings. The elevation + * data is very noisy so it is better to use the smoothed elevation than the + * raw elevation for many tasks. + * + * @return The elevation smoothed over several readings + */ + public double getSmoothedElevation() { + return elevationBuffer.getAverage(); + } + + /** + * Pauses the track at the current time. + */ + public void pause() { + pauseAt(System.currentTimeMillis()); + } + + /** + * Pauses the track at the give time. + * + * @param time the time to pause at + */ + public void pauseAt(long time) { + data.totalTime = time - data.startTime; + lastLocation = null; // Make sure the counter restarts. + paused = true; + } + + /** + * Resumes the current track. + */ + public void resume() { + // TODO: The total time is bogus after this, it will include the paused time + paused = false; + } + + /** + * Gets the total time that this track has been active. + * + * @return The total number of milliseconds the track was active + */ + public long getTotalTime() { + return paused + ? data.totalTime + : System.currentTimeMillis() - data.startTime; + } + + /** + * Gets the moving time. + * + * @return The total number of milliseconds the user was moving + */ + public long getMovingTime() { + return data.movingTime; + } + + /** + * Sets the moving time. + * + * @parm movingTime The total number of milliseconds the user was moving + */ + public void setMovingTime(long movingTime) { + data.movingTime = movingTime; + } + + /** + * Gets the total distance the user traveled. + * + * @return The total distance traveled in meters + */ + public double getTotalDistance() { + return data.totalDistance; + } + + /** + * Gets the the average speed the user traveled. + * + * @return The average speed in m/s + */ + public double getAverageSpeed() { + return getTotalDistance() / ((double) getTotalTime() / 1000); + } + + /** + * Gets the the average speed the user traveled when they were actively + * moving. + * + * @return The average moving speed in m/s + */ + public double getAverageMovingSpeed() { + return getTotalDistance() / ((double) getMovingTime() / 1000); + } + + /** + * Gets the the maximum speed for this track. + * + * @return The maximum speed in m/s. + */ + public double getMaxSpeed() { + return data.maxSpeed; + } + + /** + * Gets the the current speed for this track. + * + * @return The current speed in m/s + */ + public double getCurrentSpeed() { + return data.currentSpeed; + } + + @Override + public String toString() { + return "TripStatistics:" + " Start Time: " + data.startTime + + " Total Time: " + data.totalTime + " Moving Time: " + data.movingTime + + " Total Distance: " + data.totalDistance + + " Elevation Gain: " + data.totalElevationGain + + " Min Elevation: " + getMinElevation() + + " Max Elevation: " + getMaxElevation() + + " Average Speed: " + getAverageMovingSpeed() + + " Min Grade: " + getMinGrade() + " Max Grade: " + getMaxGrade() + + " Total Locations: " + totalLocations; + } + + /** + * Gets the minimum elevation seen on this trip. This is calculated from the + * smoothed elevation so this can actually be more than the current elevation. + * + * @return The smallest elevation reading for this trip + */ + public double getMinElevation() { + return data.elevationExtremities.getMin(); + } + + /** + * Gets the maximum elevation seen on this trip. This is calculated from the + * smoothed elevation so this can actually be less than the current elevation. + * + * @return The largest elevation reading for this trip + */ + public double getMaxElevation() { + return data.elevationExtremities.getMax(); + } + + /** + * Gets the total elevation gain for this trip. This is calculated as the sum + * of all positive differences in the smoothed elevation. + * + * @return The elevation gain in meters for this trip + */ + public double getTotalElevationGain() { + return data.totalElevationGain; + } + + /** + * Gets the time that this track started. + * + * @return The number of milliseconds since epoch to the time when this track + * started + */ + public long getStartTime() { + return data.startTime; + } + + /** + * Gets the current grade. + * + * @return The current grade + */ + public double getGrade() { + return data.currentGrade; + } + + /** + * Gets the maximum grade for this trip. + * + * @return The maximum grade for this trip + */ + public double getMaxGrade() { + return data.gradeExtremities.getMax(); + } + + /** + * Gets the minimum grade for this trip. + * + * @return The minimum grade for this trip + */ + public double getMinGrade() { + return data.gradeExtremities.getMin(); + } + + /** + * Returns the amount of time the user has been idle or 0 if they are moving. + */ + public long getIdleTime() { + return lastLocation.getTime() - lastMovingLocation.getTime(); + } + + /** + + * Returns the leftmost position (lowest longitude) of the track. + */ + public int getLeft() { + return (int) (data.longitudeExtremities.getMin() * 1E6); + } + + /** + * Returns the rightmost position (highest longitude) of the track. + */ + public int getRight() { + return (int) (data.longitudeExtremities.getMax() * 1E6); + } + + /** + * Returns the bottommost position (lowest latitude) of the track. + */ + public int getBottom() { + return (int) (data.latitudeExtremities.getMin() * 1E6); + } + + /** + * Returns the topmost position (highest latitude) of the track. + */ + public int getTop() { + return (int) (data.latitudeExtremities.getMax() * 1E6); + } + + public TripStatisticsData getData() { + return data; + } + + /** + * Fills the given track with statistics about itself, calculated by this + * statitics class. + */ + public void fillStatisticsForTrack(Track track) { + track.setTotalDistance(getTotalDistance()); + track.setTotalTime(getTotalTime()); + track.setMovingTime(getMovingTime()); + track.setAverageSpeed(getAverageSpeed()); + track.setAverageMovingSpeed(getAverageMovingSpeed()); + track.setMaxSpeed(getMaxSpeed()); + track.setMinElevation(getMinElevation()); + track.setMaxElevation(getMaxElevation()); + track.setTotalElevationGain(getTotalElevationGain()); + track.setMinGrade(getMinGrade()); + track.setMaxGrade(getMaxGrade()); + + track.setBounds(getLeft(), getTop(), getRight(), getBottom()); + } + + /** + * Write all of the statistics fields to the waypoint. + * @param waypoint The waypoint to write the trip statistics to + */ + public void fillStatisticsForWaypoint(Waypoint waypoint) { + waypoint.setTotalDistance(getTotalDistance()); + waypoint.setTotalTime(getTotalTime()); + waypoint.setMovingTime(getMovingTime()); + waypoint.setAverageSpeed(getAverageSpeed()); + waypoint.setAverageMovingSpeed(getAverageMovingSpeed()); + waypoint.setMaxSpeed(getMaxSpeed()); + waypoint.setMinElevation(getMinElevation()); + waypoint.setMaxElevation(getMaxElevation()); + waypoint.setTotalElevationGain(getTotalElevationGain()); + waypoint.setMinGrade(getMinGrade()); + waypoint.setMaxGrade(getMaxGrade()); + } +} + diff --git a/MyTracks/src/com/google/android/apps/mytracks/stats/TripStatisticsData.aidl b/MyTracks/src/com/google/android/apps/mytracks/stats/TripStatisticsData.aidl new file mode 100644 index 000000000..94e71fd63 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/stats/TripStatisticsData.aidl @@ -0,0 +1,2 @@ +package com.google.android.apps.mytracks.stats; +parcelable TripStatisticsData; \ No newline at end of file diff --git a/MyTracks/src/com/google/android/apps/mytracks/stats/TripStatisticsData.java b/MyTracks/src/com/google/android/apps/mytracks/stats/TripStatisticsData.java new file mode 100644 index 000000000..f795e6689 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/stats/TripStatisticsData.java @@ -0,0 +1,309 @@ +/* + * 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.stats; + +import android.os.Parcel; +import android.os.Parcelable; + +/** + * Statistical data about a trip. + * The data in this class should be filled out by {@link TripStatistics}. + * + * TODO: Remove delegate methods from TripStatistics + * (detail is getTotalTime) + * TODO: Make Waypoint and Track use this instead of having the same fields + * + * @author Rodrigo Damazio + */ +public class TripStatisticsData implements Parcelable { + + /** + * The start time for the trip. This is system time which might not match gps + * time. + */ + long startTime; + + /** + * The total time that we believe the user was traveling in milliseconds. + */ + long movingTime; + + /** + * The total time of the trip in milliseconds. + * This is only updated when new points are received, so it may be stale. + */ + long totalTime; + + /** + * The total distance in meters that the user traveled on this trip. + */ + double totalDistance; + + /** + * The total elevation gained on this trip in meters. + */ + double totalElevationGain; + + /** + * The maximum speed in meters/second reported that we believe to be a valid + * speed. + */ + double maxSpeed; + + /** + * The current speed in meters/second as reported by the gps. + */ + double currentSpeed; + + /** + * The current grade. This value is very noisy and not reported to the user. + */ + double currentGrade; + + /** + * The min and max latitude values seen in this trip. + */ + final ExtremityMonitor latitudeExtremities = new ExtremityMonitor(); + + /** + * The min and max longitude values seen in this trip. + */ + final ExtremityMonitor longitudeExtremities = new ExtremityMonitor(); + + /** + * The min and max elevation seen on this trip in meters. + */ + final ExtremityMonitor elevationExtremities = new ExtremityMonitor(); + + /** + * The minimum and maximum grade calculations on this trip. + */ + final ExtremityMonitor gradeExtremities = new ExtremityMonitor(); + + /** + * Gets the time that this track started. + * + * @return The number of milliseconds since epoch to the time when this track + * started + */ + public long getStartTime() { + return startTime; + } + + /** + * Gets the total time that this track has been active. + * + * @return The total number of milliseconds the track was active + */ + public long getTotalTime() { + return totalTime; + } + + /** + * Gets the total distance the user traveled. + * + * @return The total distance traveled in meters + */ + public double getTotalDistance() { + return totalDistance; + } + + /** + * Gets the the maximum speed for this track. + * + * @return The maximum speed in m/s. + */ + public double getMaxSpeed() { + return maxSpeed; + } + + /** + * Gets the the current speed for this track. + * + * @return The current speed in m/s + */ + public double getCurrentSpeed() { + return currentSpeed; + } + + /** + * Gets the moving time. + * + * @return The total number of milliseconds the user was moving + */ + public long getMovingTime() { + return movingTime; + } + + /** + * Gets the total elevation gain for this trip. This is calculated as the sum + * of all positive differences in the smoothed elevation. + * + * @return The elevation gain in meters for this trip + */ + public double getTotalElevationGain() { + return totalElevationGain; + } + + /** + * Gets the current grade. + * + * @return The current grade + */ + public double getCurrentGrade() { + return currentGrade; + } + + /** + * Returns the leftmost position (lowest longitude) of the track. + */ + public int getLeft() { + return (int) (longitudeExtremities.getMin() * 1E6); + } + + /** + * Returns the rightmost position (highest longitude) of the track. + */ + public int getRight() { + return (int) (longitudeExtremities.getMax() * 1E6); + } + + /** + * Returns the bottommost position (lowest latitude) of the track. + */ + public int getBottom() { + return (int) (latitudeExtremities.getMin() * 1E6); + } + + /** + * Returns the topmost position (highest latitude) of the track. + */ + public int getTop() { + return (int) (latitudeExtremities.getMax() * 1E6); + } + + /** + * Gets the minimum elevation seen on this trip. This is calculated from the + * smoothed elevation so this can actually be more than the current elevation. + * + * @return The smallest elevation reading for this trip + */ + public double getMinElevation() { + return elevationExtremities.getMin(); + } + + /** + * Gets the maximum elevation seen on this trip. This is calculated from the + * smoothed elevation so this can actually be less than the current elevation. + * + * @return The largest elevation reading for this trip + */ + public double getMaxElevation() { + return elevationExtremities.getMax(); + } + + /** + * Gets the maximum grade for this trip. + * + * @return The maximum grade for this trip + */ + public double getMaxGrade() { + return gradeExtremities.getMax(); + } + + /** + * Gets the minimum grade for this trip. + * + * @return The minimum grade for this trip + */ + public double getMinGrade() { + return gradeExtremities.getMin(); + } + + // Parcelable interface and creator + + /** + * Creator of statistics data from parcels. + */ + public static class Creator + implements Parcelable.Creator { + + @Override + public TripStatisticsData createFromParcel(Parcel source) { + TripStatisticsData data = new TripStatisticsData(); + + data.startTime = source.readLong(); + data.movingTime = source.readLong(); + data.totalTime = source.readLong(); + data.totalDistance = source.readDouble(); + data.totalElevationGain = source.readDouble(); + data.maxSpeed = source.readDouble(); + data.currentSpeed = source.readDouble(); + data.currentGrade = source.readDouble(); + + double minLat = source.readDouble(); + double maxLat = source.readDouble(); + data.latitudeExtremities.set(minLat, maxLat); + + double minLong = source.readDouble(); + double maxLong = source.readDouble(); + data.longitudeExtremities.set(minLong, maxLong); + + double minElev = source.readDouble(); + double maxElev = source.readDouble(); + data.elevationExtremities.set(minElev, maxElev); + + double minGrade = source.readDouble(); + double maxGrade = source.readDouble(); + data.gradeExtremities.set(minGrade, maxGrade); + + return data; + } + + @Override + public TripStatisticsData[] newArray(int size) { + return new TripStatisticsData[size]; + } + } + + public static final Creator CREATOR = new Creator(); + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(startTime); + dest.writeLong(movingTime); + dest.writeLong(totalTime); + dest.writeDouble(totalDistance); + dest.writeDouble(totalElevationGain); + dest.writeDouble(maxSpeed); + dest.writeDouble(currentSpeed); + dest.writeDouble(currentGrade); + + dest.writeDouble(latitudeExtremities.getMin()); + dest.writeDouble(latitudeExtremities.getMax()); + dest.writeDouble(longitudeExtremities.getMin()); + dest.writeDouble(longitudeExtremities.getMax()); + dest.writeDouble(elevationExtremities.getMin()); + dest.writeDouble(elevationExtremities.getMax()); + dest.writeDouble(gradeExtremities.getMin()); + dest.writeDouble(gradeExtremities.getMax()); + } +} \ No newline at end of file diff --git a/MyTracks/src/com/google/android/apps/mytracks/util/ChartURLGenerator.java b/MyTracks/src/com/google/android/apps/mytracks/util/ChartURLGenerator.java new file mode 100644 index 000000000..649ba57d0 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/util/ChartURLGenerator.java @@ -0,0 +1,173 @@ +/* + * Copyright 2009 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 com.google.android.apps.mytracks.MyTracksSettings; +import com.google.android.apps.mytracks.content.Track; +import com.google.android.maps.mytracks.R; + +import android.content.Context; +import android.content.SharedPreferences; + +import java.util.Vector; + +/** + * This class will generate google chart server url's. + * + * @author Sandor Dornbush + */ +public class ChartURLGenerator { + + private static final String CHARTS_BASE_URL = + "http://chart.apis.google.com/chart?"; + + private ChartURLGenerator() { + } + + /** + * Gets a chart of a track. + * + * @param distances An array of distance measurements + * @param elevations A matching array of elevation measurements + * @param track The track for this chart + * @param context The current appplication context + */ + public static String getChartUrl(Vector distances, + Vector elevations, Track track, Context context) { + SharedPreferences preferences = + context.getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + boolean metricUnits = true; + if (preferences != null) { + metricUnits = preferences.getBoolean(MyTracksSettings.METRIC_UNITS, true); + } + + return getChartUrl(distances, elevations, track, + context.getString(R.string.elevation_label), metricUnits, true); + } + + /** + * Gets a chart of a track. + * This form is for testing without contexts. + * + * @param distances An array of distance measurements + * @param elevations A matching array of elevation measurements + * @param track The track for this chart + * @param title The title for the chart + * @param metricUnits Should the data be displayed in metric units + * @param escapePipe Should the pipe character be escaped + */ + public static String getChartUrl( + Vector distances, Vector elevations, + Track track, String title, boolean metricUnits, boolean escapePipe) { + if (distances == null || elevations == null || track == null) { + return null; + } + + if (distances.size() != elevations.size()) { + return null; + } + + // Round it up. + double effectiveMaxY = metricUnits + ? track.getMaxElevation() + : track.getMaxElevation() * UnitConversions.M_TO_FT; + effectiveMaxY = ((int) (effectiveMaxY / 100)) * 100 + 100; + // Round it down. + double effectiveMinY = 0; + double minElevation = metricUnits + ? track.getMinElevation() + : track.getMinElevation() * UnitConversions.M_TO_FT; + + effectiveMinY = ((int) (minElevation / 100)) * 100; + if (track.getMinElevation() < 0) { + effectiveMinY -= 100; + } + double ySpread = effectiveMaxY - effectiveMinY; + + StringBuilder sb = new StringBuilder(CHARTS_BASE_URL); + sb.append("&chs=600x350"); + sb.append("&cht=lxy"); + + // Title + sb.append("&chtt="); + sb.append(title); + + // Labels + sb.append("&chxt=x,y"); + double distKM = track.getTotalDistance() / 1000.0; + double distDisplay = + metricUnits ? distKM : (distKM * UnitConversions.KM_TO_MI); + int xInterval = ((int) (distDisplay / 6)); + int yInterval = ((int) (ySpread / 600)) * 100; + if (yInterval < 100) { + yInterval = 25; + } + // Range + sb.append("&chxr=0,0,"); + sb.append((int) distDisplay); + sb.append(','); + sb.append(xInterval); + + // This is an ugly hack. We really want a pipe but we must double escape it. + sb.append(escapePipe ? "%257C" : "|"); + sb.append("1,"); + sb.append(effectiveMinY); + sb.append(','); + sb.append(effectiveMaxY); + sb.append(','); + sb.append(yInterval); + + // Line color + sb.append("&chco=009A00"); + + // Fill + sb.append("&chm=B,00AA00,0,0,0"); + + // Grid lines + double desiredGrids = ySpread / yInterval; + sb.append("&chg=100000,"); + sb.append(100.0 / desiredGrids); + sb.append(",1,0"); + + // Data + sb.append("&chd=e:"); + for (int i = 0; i < distances.size(); i++) { + int normalized = + (int) (getNormalizedDistance(distances.elementAt(i), track) * 4095); + sb.append(ChartsExtendedEncoder.getEncodedValue(normalized)); + } + + sb.append(ChartsExtendedEncoder.getSeparator()); + for (int i = 0; i < elevations.size(); i++) { + int normalized = + (int) (getNormalizedElevation( + elevations.elementAt(i), effectiveMinY, ySpread) * 4095); + sb.append(ChartsExtendedEncoder.getEncodedValue(normalized)); + } + + return sb.toString(); + } + + protected static double getNormalizedDistance(double d, Track track) { + return d / track.getTotalDistance(); + } + + protected static double getNormalizedElevation( + double d, double effectiveMinY, double ySpread) { + return (d - effectiveMinY) / ySpread; + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/util/ChartsExtendedEncoder.java b/MyTracks/src/com/google/android/apps/mytracks/util/ChartsExtendedEncoder.java new file mode 100644 index 000000000..0785b5a66 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/util/ChartsExtendedEncoder.java @@ -0,0 +1,51 @@ +/* + * Copyright 2009 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; + +/** + * The singleton class representing the extended encoding of chart data. + */ +public class ChartsExtendedEncoder { + + // ChartServer data encoding in extended mode + private static final String CHARTSERVER_EXTENDED_ENCODING_SEPARATOR = ","; + private static final String CHARTSERVER_EXTENDED_ENCODING = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-."; + private static final int CHARTSERVER_EXTENDED_SINGLE_CHARACTER_VALUES = + CHARTSERVER_EXTENDED_ENCODING.length(); + private static final String MISSING_POINT_EXTENDED_ENCODING = "__"; + + private ChartsExtendedEncoder() { } + + public static String getEncodedValue(int scaled) { + int index1 = scaled / CHARTSERVER_EXTENDED_SINGLE_CHARACTER_VALUES; + int index2 = scaled % CHARTSERVER_EXTENDED_SINGLE_CHARACTER_VALUES; + + if (index1 >= CHARTSERVER_EXTENDED_SINGLE_CHARACTER_VALUES) { + return MISSING_POINT_EXTENDED_ENCODING; + } + if (index2 >= CHARTSERVER_EXTENDED_SINGLE_CHARACTER_VALUES) { + return MISSING_POINT_EXTENDED_ENCODING; + } + + return String.valueOf(CHARTSERVER_EXTENDED_ENCODING.charAt(index1)) + + String.valueOf(CHARTSERVER_EXTENDED_ENCODING.charAt(index2)); + } + + public static String getSeparator() { + return CHARTSERVER_EXTENDED_ENCODING_SEPARATOR; + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/util/MyTracksUtils.java b/MyTracks/src/com/google/android/apps/mytracks/util/MyTracksUtils.java index 501c68f96..8f21bb72a 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/util/MyTracksUtils.java +++ b/MyTracks/src/com/google/android/apps/mytracks/util/MyTracksUtils.java @@ -1,12 +1,12 @@ /* * Copyright 2008 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 @@ -18,6 +18,8 @@ package com.google.android.apps.mytracks.util; import com.google.android.apps.mytracks.MyTracksConstants; import com.google.android.apps.mytracks.content.Track; +import com.google.android.maps.GeoPoint; + import android.location.Location; import android.util.Log; @@ -27,28 +29,224 @@ import java.util.Stack; /** * Utility class for decimating tracks at a given level of precision. - * + * * @author Leif Hendrik Wilden */ public class MyTracksUtils { + /** + * Computes the distance on the two sphere between the point c0 and the line + * segment c1 to c2. + * + * @param c0 the first coordinate + * @param c1 the beginning of the line segment + * @param c2 the end of the lone segment + * @return the distance in m (assuming spherical earth) + */ + public static double distance( + final Location c0, final Location c1, final Location c2) { + if (c1.equals(c2)) { + return c2.distanceTo(c0); + } + + final double s0lat = c0.getLatitude() * UnitConversions.TO_RADIANS; + final double s0lng = c0.getLongitude() * UnitConversions.TO_RADIANS; + final double s1lat = c1.getLatitude() * UnitConversions.TO_RADIANS; + final double s1lng = c1.getLongitude() * UnitConversions.TO_RADIANS; + final double s2lat = c2.getLatitude() * UnitConversions.TO_RADIANS; + final double s2lng = c2.getLongitude() * UnitConversions.TO_RADIANS; + + double s2s1lat = s2lat - s1lat; + double s2s1lng = s2lng - s1lng; + final double u = + ((s0lat - s1lat) * s2s1lat + (s0lng - s1lng) * s2s1lng) + / (s2s1lat * s2s1lat + s2s1lng * s2s1lng); + if (u <= 0) { + return c0.distanceTo(c1); + } + if (u >= 1) { + return c0.distanceTo(c2); + } + Location sa = new Location(""); + sa.setLatitude(c0.getLatitude() - c1.getLatitude()); + sa.setLongitude(c0.getLongitude() - c1.getLongitude()); + Location sb = new Location(""); + sb.setLatitude(u * (c2.getLatitude() - c1.getLatitude())); + sb.setLongitude(u * (c2.getLongitude() - c1.getLongitude())); + return sa.distanceTo(sb); + } + + /** + * Decimates the given locations for a given zoom level. This uses a + * Douglas-Peucker decimation algorithm. + * + * @param tolerance in meters + * @param locations input + * @param decimated output + */ + public static void decimate(double tolerance, ArrayList locations, + ArrayList decimated) { + final int n = locations.size(); + if (n < 1) { + return; + } + int idx; + int maxIdx = 0; + Stack stack = new Stack(); + double[] dists = new double[n]; + dists[0] = 1; + dists[n - 1] = 1; + double maxDist; + double dist = 0.0; + int[] current; + + if (n > 2) { + int[] stackVal = new int[] {0, (n - 1)}; + stack.push(stackVal); + while (stack.size() > 0) { + current = stack.pop(); + maxDist = 0; + for (idx = current[0] + 1; idx < current[1]; ++idx) { + dist = MyTracksUtils.distance( + locations.get(idx), + locations.get(current[0]), + locations.get(current[1])); + if (dist > maxDist) { + maxDist = dist; + maxIdx = idx; + } + } + if (maxDist > tolerance) { + dists[maxIdx] = maxDist; + int[] stackValCurMax = {current[0], maxIdx}; + stack.push(stackValCurMax); + int[] stackValMaxCur = {maxIdx, current[1]}; + stack.push(stackValMaxCur); + } + } + } + + int i = 0; + idx = 0; + decimated.clear(); + for (Location l : locations) { + if (dists[idx] != 0) { + decimated.add(l); + i++; + } + idx++; + } + Log.d(MyTracksConstants.TAG, "Decimating " + n + " points to " + i + + " w/ tolerance = " + tolerance); + } + + /** + * Decimates the given track for the given precision. + * + * @param track a track + * @param precision desired precision in meters + */ + public static void decimate(Track track, double precision) { + ArrayList decimated = new ArrayList(); + decimate(precision, track.getLocations(), decimated); + track.setLocations(decimated); + } + + /** + * Limits number of points by dropping any points beyond the given number of + * points. Note: That'll actually discard points. + * + * @param track a track + * @param numberOfPoints maximum number of points + */ + public static void cut(Track track, int numberOfPoints) { + ArrayList locations = track.getLocations(); + while (locations.size() > numberOfPoints) { + locations.remove(locations.size() - 1); + } + } + + /** + * Splits a track in multiple tracks where each piece has less or equal than + * maxPoints. + * + * @param track the track to split + * @param maxPoints maximum number of points for each piece + * @return a list of one or more track pieces + */ + public static ArrayList split(Track track, int maxPoints) { + ArrayList result = new ArrayList(); + final int nTotal = track.getLocations().size(); + int n = 0; + Track piece = null; + do { + piece = new Track(); + piece.setId(track.getId()); + piece.setName(track.getName()); + piece.setDescription(track.getDescription()); + piece.setCategory(track.getCategory()); + List pieceLocations = piece.getLocations(); + for (int i = n; i < nTotal && pieceLocations.size() < maxPoints; i++) { + piece.addLocation(track.getLocations().get(i)); + } + int nPointsPiece = pieceLocations.size(); + if (nPointsPiece >= 2) { + piece.setStartTime(pieceLocations.get(0).getTime()); + piece.setStopTime(pieceLocations.get(nPointsPiece - 1).getTime()); + result.add(piece); + } + n += (pieceLocations.size() - 1); + } while (n < nTotal && piece != null && piece.getLocations().size() > 1); + + return result; + } + + /** + * Test if a given GeoPoint is valid, i.e. within physical bounds. + * + * @param geoPoint the point to be tested + * @return true, if it is a physical location on earth. + */ + public static boolean isValidGeoPoint(GeoPoint geoPoint) { + return Math.abs(geoPoint.getLatitudeE6()) < 90E6 + && Math.abs(geoPoint.getLongitudeE6()) <= 180E6; + } + /** * Checks if a given location is a valid (i.e. physically possible) location * on Earth. Note: The special separator locations (which have latitude = * 100) will not qualify as valid. Neither will locations with lat=0 and lng=0 * as these are most likely "bad" measurements which often cause trouble. - * + * * @param location the location to test - * @return true if the location is a valid location + * @return true if the location is a valid location. */ public static boolean isValidLocation(Location location) { return location != null && Math.abs(location.getLatitude()) <= 90 && Math.abs(location.getLongitude()) <= 180; } + /** + * Gets a location from a GeoPoint. + * + * @param p a GeoPoint + * @return the corresponding location + */ + public static Location getLocation(GeoPoint p) { + Location result = new Location(""); + result.setLatitude(p.getLatitudeE6() / 1.0E6); + result.setLongitude(p.getLongitudeE6() / 1.0E6); + return result; + } + + public static GeoPoint getGeoPoint(Location location) { + return new GeoPoint((int) (location.getLatitude() * 1E6), + (int) (location.getLongitude() * 1E6)); + } + /** * This is a utility class w/ only static memebers. */ - private MyTracksUtils() { + protected MyTracksUtils() { } } diff --git a/MyTracks/src/com/google/android/apps/mytracks/util/ResourceUtils.java b/MyTracks/src/com/google/android/apps/mytracks/util/ResourceUtils.java new file mode 100644 index 000000000..884f4b95a --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/util/ResourceUtils.java @@ -0,0 +1,84 @@ +/* + * Copyright 2008 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 android.content.Context; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; + +/** + * Utility functions for android resources. + * + * @author Sandor Dornbush + */ +public class ResourceUtils { + public static CharSequence readFile(Context activity, int id) { + BufferedReader in = null; + try { + in = new BufferedReader(new InputStreamReader( + activity.getResources().openRawResource(id))); + String line; + StringBuilder buffer = new StringBuilder(); + while ((line = in.readLine()) != null) { + buffer.append(line).append('\n'); + } + return buffer; + } catch (IOException e) { + return ""; + } finally { + if (in != null) { + try { + in.close(); + } catch (IOException e) { + // Ignore + } + } + } + } + + public static void readBinaryFileToOutputStream( + Context activity, int id, OutputStream os) { + BufferedInputStream in = null; + try { + in = new BufferedInputStream( + activity.getResources().openRawResource(id)); + BufferedOutputStream out = new BufferedOutputStream(os); + int b; + while ((b = in.read()) != -1) { + out.write(b); + } + out.flush(); + } catch (IOException e) { + return; + } finally { + if (in != null) { + try { + in.close(); + } catch (IOException e) { + // Ignore + } + } + } + } + + private ResourceUtils() { + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/util/StringUtils.java b/MyTracks/src/com/google/android/apps/mytracks/util/StringUtils.java new file mode 100644 index 000000000..47c3b4e62 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/util/StringUtils.java @@ -0,0 +1,399 @@ +/* + * Copyright 2008 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 com.google.android.apps.mytracks.MyTracksSettings; +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.maps.mytracks.R; + +import android.content.Context; +import android.content.SharedPreferences; + +import java.util.Date; +import java.util.Vector; + +/** + * Various string manipulation methods. + * + * @author Sandor Dornbush + * @author Rodrigo Damazio + */ +public class StringUtils implements DescriptionGenerator { + + private final Context context; + + /** + * Formats a number of milliseconds as a string. + * + * @param time - A period of time in milliseconds. + * @return A string of the format M:SS, MM:SS or HH:MM:SS + */ + public static String formatTime(long time) { + return formatTimeInternal(time, false); + } + + /** + * Formats a number of milliseconds as a string. To be used when we need the + * hours to be shown even when it is zero, e.g. exporting data to a + * spreadsheet. + * + * @param time - A period of time in milliseconds + * @return A string of the format HH:MM:SS even if time is less than 1 hour + */ + public static String formatTimeAlwaysShowingHours(long time) { + return formatTimeInternal(time, true); + } + + /** + * Formats the given text as a CDATA element to be used in a XML file. This + * includes adding the starting and ending CDATA tags. Please notice that this + * may result in multiple consecutive CDATA tags. + * + * @param unescaped the unescaped text to be formatted + * @return the formatted text, inside one or more CDATA tags + */ + public static String stringAsCData(String unescaped) { + // "]]>" needs to be broken into multiple CDATA segments, like: + // "Foo]]>Bar" becomes "Bar]]>" + // (the end of the first CDATA has the "]]", the other has ">") + String escaped = unescaped.replaceAll("]]>", "]]]]>"); + return ""; + } + + /** + * Formats a number of milliseconds as a string. + * + * @param time - A period of time in milliseconds + * @param alwaysShowHours - Whether to display 00 hours if time is less than 1 + * hour + * @return A string of the format HH:MM:SS + */ + private static String formatTimeInternal(long time, boolean alwaysShowHours) { + int[] parts = getTimeParts(time); + StringBuilder builder = new StringBuilder(); + if (parts[2] > 0 || alwaysShowHours) { + builder.append(parts[2]); + builder.append(':'); + if (parts[1] <= 9) { + builder.append("0"); + } + } + + builder.append(parts[1]); + builder.append(':'); + if (parts[0] <= 9) { + builder.append("0"); + } + builder.append(parts[0]); + + return builder.toString(); + } + + /** + * Gets the time as an array of parts. + */ + public static int[] getTimeParts(long time) { + if (time < 0) { + int[] parts = getTimeParts(time * -1); + parts[0] *= -1; + parts[1] *= -1; + parts[2] *= -1; + return parts; + } + int[] parts = new int[3]; + + long seconds = time / 1000; + parts[0] = (int) (seconds % 60); + int tmp = (int) (seconds / 60); + parts[1] = tmp % 60; + parts[2] = tmp / 60; + + return parts; + } + + public StringUtils(Context context) { + this.context = context; + } + + public String formatTimeLong(long time) { + int[] parts = getTimeParts(time); + String secLabel = + context.getString(parts[0] == 1 ? R.string.second : R.string.seconds); + String minLabel = + context.getString(parts[1] == 1 ? R.string.minute : R.string.minutes); + String hourLabel = + context.getString(parts[2] == 1 ? R.string.hour : R.string.hours); + + StringBuilder sb = new StringBuilder(); + if (parts[2] != 0) { + sb.append(parts[2]); + sb.append(" "); + sb.append(hourLabel); + sb.append(" "); + sb.append(parts[1]); + sb.append(minLabel); + } else { + sb.append(parts[1]); + sb.append(" "); + sb.append(minLabel); + sb.append(" "); + sb.append(parts[0]); + sb.append(secLabel); + } + return sb.toString(); + } + + /** + * Generates a description for a track (with information about the + * statistics). + * + * @param track the track + * @return a track description + */ + public String generateTrackDescription(Track track, Vector distances, + Vector elevations) { + boolean displaySpeed = true; + SharedPreferences preferences = + context.getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0); + if (preferences != null) { + displaySpeed = + preferences.getBoolean(MyTracksSettings.REPORT_SPEED, true); + } + + final double distanceInKm = track.getTotalDistance() / 1000; + final double distanceInMiles = distanceInKm * UnitConversions.KM_TO_MI; + final long minElevationInMeters = Math.round(track.getMinElevation()); + final long minElevationInFeet = + Math.round(track.getMinElevation() * UnitConversions.M_TO_FT); + final long maxElevationInMeters = Math.round(track.getMaxElevation()); + final long maxElevationInFeet = + Math.round(track.getMaxElevation() * UnitConversions.M_TO_FT); + final long elevationGainInMeters = + Math.round(track.getTotalElevationGain()); + final long elevationGainInFeet = + Math.round(track.getTotalElevationGain() * UnitConversions.M_TO_FT); + + long minGrade = 0; + long maxGrade = 0; + double trackMaxGrade = track.getMaxGrade(); + double trackMinGrade = track.getMinGrade(); + if (!Double.isNaN(trackMaxGrade) + && !Double.isInfinite(trackMaxGrade)) { + maxGrade = Math.round(trackMaxGrade * 100); + } + if (!Double.isNaN(trackMinGrade) && !Double.isInfinite(trackMinGrade)) { + minGrade = Math.round(trackMinGrade * 100); + } + + String category = context.getString(R.string.unknown); + String trackCategory = track.getCategory(); + if (trackCategory != null && trackCategory.length() > 0) { + category = trackCategory; + } + + String averageSpeed = + getSpeedString(track.getAverageSpeed(), + R.string.average_speed_label, + R.string.average_pace_label, + displaySpeed); + + String averageMovingSpeed = + getSpeedString(track.getAverageMovingSpeed(), + R.string.average_moving_speed_label, + R.string.average_moving_pace_label, + displaySpeed); + + String maxSpeed = + getSpeedString(track.getMaxSpeed(), + R.string.max_speed_label, + R.string.min_pace_label, + displaySpeed); + + return String.format("%s

" + + "%s: %.2f %s (%.1f %s)
" + + "%s: %s
" + + "%s: %s
" + + "%s %s %s" + + "%s: %d %s (%d %s)
" + + "%s: %d %s (%d %s)
" + + "%s: %d %s (%d %s)
" + + "%s: %d %%
" + + "%s: %d %%
" + + "%s: %tc
" + + "%s: %s
" + + "", + + // Line 1 + context.getString(R.string.my_map_link), + + // Line 2 + context.getString(R.string.total_distance_label), + distanceInKm, context.getString(R.string.kilometer), + distanceInMiles, context.getString(R.string.mile), + + // Line 3 + context.getString(R.string.total_time_label), + StringUtils.formatTime(track.getTotalTime()), + + // Line 4 + context.getString(R.string.moving_time_label), + StringUtils.formatTime(track.getMovingTime()), + + // Line 5 + averageSpeed, averageMovingSpeed, maxSpeed, + + // Line 6 + context.getString(R.string.min_elevation_label), + minElevationInMeters, context.getString(R.string.meter), + minElevationInFeet, context.getString(R.string.feet), + + // Line 7 + context.getString(R.string.max_elevation_label), + maxElevationInMeters, context.getString(R.string.meter), + maxElevationInFeet, context.getString(R.string.feet), + + // Line 8 + context.getString(R.string.elevation_gain_label), + elevationGainInMeters, context.getString(R.string.meter), + elevationGainInFeet, context.getString(R.string.feet), + + // Line 9 + context.getString(R.string.max_grade_label), maxGrade, + + // Line 10 + context.getString(R.string.min_grade_label), minGrade, + + // Line 11 + context.getString(R.string.recorded_date), + new Date(track.getStartTime()), + + // Line 12 + context.getString(R.string.category), category, + + // Line 13 + ChartURLGenerator.getChartUrl(distances, elevations, track, context)); + } + + private String getSpeedString(double speed, int speedLabel, int paceLabel, + boolean displaySpeed) { + double speedInKph = speed * 3.6; + double speedInMph = speedInKph * UnitConversions.KMH_TO_MPH; + if (displaySpeed) { + return String.format("%s: %.2f %s (%.1f %s)
", + context.getString(speedLabel), + speedInKph, context.getString(R.string.kilometer_per_hour), + speedInMph, context.getString(R.string.mile_per_hour)); + } else { + double paceInKm; + double paceInMi; + if (speed == 0) { + paceInKm = 0.0; + paceInMi = 0.0; + } else { + paceInKm = 60.0 / speedInKph; + paceInMi = 60.0 / speedInMph; + } + return String.format("%s: %.2f %s (%.1f %s)
", + context.getString(paceLabel), + paceInKm, context.getString(R.string.min_per_kilometer), + paceInMi, context.getString(R.string.min_per_mile)); + } + } + + /** + * Generates a description for a waypoint (with information about the + * statistics). + * + * @return a track description + */ + public String generateWaypointDescription(Waypoint waypoint) { + final double distanceInKm = waypoint.getTotalDistance() / 1000; + final double distanceInMiles = distanceInKm * UnitConversions.KM_TO_MI; + final double averageSpeedInKmh = waypoint.getAverageSpeed() * 3.6; + final double averageSpeedInMph = + averageSpeedInKmh * UnitConversions.KMH_TO_MPH; + final double movingSpeedInKmh = waypoint.getAverageMovingSpeed() * 3.6; + final double movingSpeedInMph = + movingSpeedInKmh * UnitConversions.KMH_TO_MPH; + final double maxSpeedInKmh = waypoint.getMaxSpeed() * 3.6; + final double maxSpeedInMph = maxSpeedInKmh * UnitConversions.KMH_TO_MPH; + final long minElevationInMeters = Math.round(waypoint.getMinElevation()); + final long minElevationInFeet = + Math.round(waypoint.getMinElevation() * UnitConversions.M_TO_FT); + final long maxElevationInMeters = Math.round(waypoint.getMaxElevation()); + final long maxElevationInFeet = + Math.round(waypoint.getMaxElevation() * UnitConversions.M_TO_FT); + final long elevationGainInMeters = + Math.round(waypoint.getTotalElevationGain()); + final long elevationGainInFeet = Math.round(waypoint.getTotalElevationGain() + * UnitConversions.M_TO_FT); + long theMinGrade = 0; + long theMaxGrade = 0; + if (!Double.isNaN(waypoint.getMaxGrade()) && + !Double.isInfinite(waypoint.getMaxGrade())) { + theMaxGrade = Math.round(waypoint.getMaxGrade() * 100); + } + if (!Double.isNaN(waypoint.getMinGrade()) && + !Double.isInfinite(waypoint.getMinGrade())) { + theMinGrade = Math.round(waypoint.getMinGrade() * 100); + } + final String percent = "%"; + + return String.format( + "%s: %.2f %s (%.1f %s)\n" + + "%s: %s\n" + + "%s: %s\n" + + "%s: %.2f %s (%.1f %s)\n" + + "%s: %.2f %s (%.1f %s)\n" + + "%s: %.2f %s (%.1f %s)\n" + + "%s: %d %s (%d %s)\n" + + "%s: %d %s (%d %s)\n" + + "%s: %d %s (%d %s)\n" + + "%s: %d %s\n" + + "%s: %d %s\n", + context.getString(R.string.distance_label), + distanceInKm, context.getString(R.string.kilometer), + distanceInMiles, context.getString(R.string.mile), + context.getString(R.string.time_label), + StringUtils.formatTime(waypoint.getTotalTime()), + context.getString(R.string.moving_time_label), + StringUtils.formatTime(waypoint.getMovingTime()), + context.getString(R.string.average_speed_label), + averageSpeedInKmh, context.getString(R.string.kilometer_per_hour), + averageSpeedInMph, context.getString(R.string.mile_per_hour), + context.getString(R.string.average_moving_speed_label), + movingSpeedInKmh, context.getString(R.string.kilometer_per_hour), + movingSpeedInMph, context.getString(R.string.mile_per_hour), + context.getString(R.string.max_speed_label), + maxSpeedInKmh, context.getString(R.string.kilometer_per_hour), + maxSpeedInMph, context.getString(R.string.mile_per_hour), + context.getString(R.string.min_elevation_label), + minElevationInMeters, context.getString(R.string.meter), + minElevationInFeet, context.getString(R.string.feet), + context.getString(R.string.max_elevation_label), + maxElevationInMeters, context.getString(R.string.meter), + maxElevationInFeet, context.getString(R.string.feet), + context.getString(R.string.elevation_gain_label), + elevationGainInMeters, context.getString(R.string.meter), + elevationGainInFeet, context.getString(R.string.feet), + context.getString(R.string.max_grade_label), + theMaxGrade, percent, + context.getString(R.string.min_grade_label), + theMinGrade, percent); + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/util/UnitConversions.java b/MyTracks/src/com/google/android/apps/mytracks/util/UnitConversions.java index 99cb104b6..c524ca917 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/util/UnitConversions.java +++ b/MyTracks/src/com/google/android/apps/mytracks/util/UnitConversions.java @@ -1,12 +1,12 @@ /* * Copyright 2008 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 @@ -17,7 +17,7 @@ package com.google.android.apps.mytracks.util; /** * Unit conversion constants. - * + * * @author Sandor Dornbush */ public abstract class UnitConversions { @@ -29,6 +29,6 @@ public abstract class UnitConversions { public static final double KMH_TO_MPH = 1000 * M_TO_FT / MI_TO_FEET; public static final double TO_RADIANS = Math.PI / 180.0; - private UnitConversions() { + protected UnitConversions() { } } diff --git a/MyTracks/src/com/google/wireless/gdata/docs/DocumentsClient.java b/MyTracks/src/com/google/wireless/gdata/docs/DocumentsClient.java new file mode 100644 index 000000000..2b22dd3bc --- /dev/null +++ b/MyTracks/src/com/google/wireless/gdata/docs/DocumentsClient.java @@ -0,0 +1,46 @@ +/* + * Copyright 2009 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.wireless.gdata.docs; + +import com.google.wireless.gdata.client.GDataClient; +import com.google.wireless.gdata.client.GDataParserFactory; +import com.google.wireless.gdata.client.GDataServiceClient; + +/** + * GDataServiceClient for accessing Google Documents. This is not a full + * implementation. + */ +public class DocumentsClient extends GDataServiceClient { + /** The name of the service, dictated to be 'wise' by the protocol. */ + private static final String SERVICE = "writely"; + + /** + * Creates a new DocumentsClient. + * + * @param client The GDataClient that should be used to authenticate requests, + * retrieve feeds, etc + * @param parserFactory The GDataParserFactory that should be used to obtain + * GDataParsers used by this client + */ + public DocumentsClient(GDataClient client, GDataParserFactory parserFactory) { + super(client, parserFactory); + } + + @Override + public String getServiceName() { + return SERVICE; + } +} diff --git a/MyTracks/src/com/google/wireless/gdata/docs/SpreadsheetsClient.java b/MyTracks/src/com/google/wireless/gdata/docs/SpreadsheetsClient.java new file mode 100755 index 000000000..aaeef5252 --- /dev/null +++ b/MyTracks/src/com/google/wireless/gdata/docs/SpreadsheetsClient.java @@ -0,0 +1,270 @@ +/* + * Copyright 2007 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.wireless.gdata.docs; + +import com.google.wireless.gdata.client.GDataClient; +import com.google.wireless.gdata.client.GDataParserFactory; +import com.google.wireless.gdata.client.GDataServiceClient; +import com.google.wireless.gdata.client.HttpException; +import com.google.wireless.gdata.data.Entry; +import com.google.wireless.gdata.data.StringUtils; +import com.google.wireless.gdata.parser.GDataParser; +import com.google.wireless.gdata.parser.ParseException; +import com.google.wireless.gdata.serializer.GDataSerializer; +import com.google.wireless.gdata2.client.AuthenticationException; + +import java.io.IOException; +import java.io.InputStream; + +/** + * GDataServiceClient for accessing Google Spreadsheets. This client can access + * and parse all of the Spreadsheets feed types: Spreadsheets feed, Worksheets + * feed, List feed, and Cells feed. Read operations are supported on all feed + * types, but only the List and Cells feeds support write operations. (This is a + * limitation of the protocol, not this API. Such write access may be added to + * the protocol in the future, requiring changes to this implementation.) + * + * Only 'private' visibility and 'full' projections are currently supported. + */ +public class SpreadsheetsClient extends GDataServiceClient { + /** The name of the service, dictated to be 'wise' by the protocol. */ + private static final String SERVICE = "wise"; + + /** Standard base feed url for spreadsheets. */ + public static final String SPREADSHEETS_BASE_FEED_URL = + "http://spreadsheets.google.com/feeds/spreadsheets/private/full"; + + /** + * Represents an entry in a GData Spreadsheets meta-feed. + */ + public static class SpreadsheetEntry extends Entry { } + + /** + * Represents an entry in a GData Worksheets meta-feed. + */ + public static class WorksheetEntry extends Entry { } + + /** + * Creates a new SpreadsheetsClient. Uses the standard base URL for + * spreadsheets feeds. + * + * @param client The GDataClient that should be used to authenticate requests, + * retrieve feeds, etc + */ + public SpreadsheetsClient(GDataClient client, + GDataParserFactory spreadsheetFactory) { + super(client, spreadsheetFactory); + } + + @Override + public String getServiceName() { + return SERVICE; + } + + /** + * Returns a parser for the specified feed type. + * + * @param feedEntryClass the Class of entry type that will be parsed, which + * lets this method figure out which parser to create + * @param feedUri the URI of the feed to be fetched and parsed + * @param authToken the current authToken to use for the request + * @return a parser for the indicated feed + * @throws AuthenticationException if the authToken is not valid + * @throws ParseException if the response from the server could not be parsed + */ + private GDataParser getParserForTypedFeed( + Class feedEntryClass, String feedUri, String authToken) + throws AuthenticationException, ParseException, IOException { + GDataClient gDataClient = getGDataClient(); + GDataParserFactory gDataParserFactory = getGDataParserFactory(); + + try { + InputStream is = gDataClient.getFeedAsStream(feedUri, authToken); + return gDataParserFactory.createParser(feedEntryClass, is); + } catch (HttpException e) { + convertHttpExceptionForReads("Could not fetch parser feed.", e); + return null; // never reached + } + } + + /** + * Converts an HTTP exception that happened while reading into the equivalent + * local exception. + */ + public void convertHttpExceptionForReads(String message, HttpException cause) + throws AuthenticationException, IOException { + switch (cause.getStatusCode()) { + case HttpException.SC_FORBIDDEN: + case HttpException.SC_UNAUTHORIZED: + throw new AuthenticationException(message, cause); + case HttpException.SC_GONE: + default: + throw new IOException(message + ": " + cause.getMessage()); + } + } + + @Override + public Entry createEntry(String feedUri, String authToken, Entry entry) + throws ParseException, IOException { + + GDataParserFactory factory = getGDataParserFactory(); + GDataSerializer serializer = factory.createSerializer(entry); + + InputStream is; + try { + is = getGDataClient().createEntry(feedUri, authToken, serializer); + } catch (HttpException e) { + convertHttpExceptionForWrites(entry.getClass(), + "Could not update entry.", e); + return null; // never reached. + } + + GDataParser parser = factory.createParser(entry.getClass(), is); + try { + return parser.parseStandaloneEntry(); + } finally { + parser.close(); + } + } + + /** + * Fetches a GDataParser for the indicated feed. The parser can be used to + * access the contents of URI. WARNING: because we cannot reliably infer the + * feed type from the URI alone, this method assumes the default feed type! + * This is probably NOT what you want. Please use the getParserFor[Type]Feed + * methods. + * + * @param feedEntryClass + * @param feedUri the URI of the feed to be fetched and parsed + * @param authToken the current authToken to use for the request + * @return a parser for the indicated feed + * @throws ParseException if the response from the server could not be parsed + */ + @SuppressWarnings("unchecked") + @Override + public GDataParser getParserForFeed( + Class feedEntryClass, String feedUri, String authToken) + throws ParseException, IOException { + try { + return getParserForTypedFeed(SpreadsheetEntry.class, feedUri, authToken); + } catch (AuthenticationException e) { + throw new IOException("Authentication Failure: " + e.getMessage()); + } + } + + /** + * Returns a parser for a Worksheets meta-feed. + * + * @param feedUri the URI of the feed to be fetched and parsed + * @param authToken the current authToken to use for the request + * @return a parser for the indicated feed + * @throws AuthenticationException if the authToken is not valid + * @throws ParseException if the response from the server could not be parsed + */ + public GDataParser getParserForWorksheetsFeed( + String feedUri, String authToken) + throws AuthenticationException, ParseException, IOException { + return getParserForTypedFeed(WorksheetEntry.class, feedUri, authToken); + } + + /** + * Updates an entry. The URI to be updated is taken from entry. + * Note that only entries in List and Cells feeds can be updated, so + * entry must be of the corresponding type; other types will + * result in an exception. + * + * @param entry the entry to be updated; must include its URI + * @param authToken the current authToken to be used for the operation + * @return An Entry containing the re-parsed version of the entry returned by + * the server in response to the update + * @throws ParseException if the server returned an error, if the server's + * response was unparseable (unlikely), or if entry is of + * a read-only type + * @throws IOException on network error + */ + @Override + public Entry updateEntry(Entry entry, String authToken) + throws ParseException, IOException { + GDataParserFactory factory = getGDataParserFactory(); + GDataSerializer serializer = factory.createSerializer(entry); + + String editUri = entry.getEditUri(); + if (StringUtils.isEmpty(editUri)) { + throw new ParseException("No edit URI -- cannot update."); + } + + InputStream is; + try { + is = getGDataClient().updateEntry(editUri, authToken, serializer); + } catch (HttpException e) { + convertHttpExceptionForWrites(entry.getClass(), + "Could not update entry.", e); + return null; // never reached + } + + GDataParser parser = factory.createParser(entry.getClass(), is); + try { + return parser.parseStandaloneEntry(); + } finally { + parser.close(); + } + } + + /** + * Converts an HTTP exception which happened while writing to the equivalent + * local exception. + */ + @SuppressWarnings("unchecked") + private void convertHttpExceptionForWrites( + Class entryClass, String message, HttpException cause) + throws ParseException, IOException { + switch (cause.getStatusCode()) { + case HttpException.SC_CONFLICT: + Entry entry = null; + if (entryClass != null) { + InputStream is = cause.getResponseStream(); + if (is != null) { + entry = parseEntry(entryClass, cause.getResponseStream()); + } + } + throw new IOException(message); + case HttpException.SC_BAD_REQUEST: + throw new ParseException(message + ": " + cause); + case HttpException.SC_FORBIDDEN: + case HttpException.SC_UNAUTHORIZED: + throw new IOException(message); + default: + throw new IOException(message + ": " + cause.getMessage()); + } + } + + /** + * Parses one entry from the input stream. + */ + @SuppressWarnings("unchecked") + private Entry parseEntry(Class entryClass, InputStream is) + throws ParseException, IOException { + GDataParser parser = null; + try { + parser = getGDataParserFactory().createParser(entryClass, is); + return parser.parseStandaloneEntry(); + } finally { + if (parser != null) { + parser.close(); + } + } + } +} diff --git a/MyTracks/src/com/google/wireless/gdata/maps/MapFeatureEntry.java b/MyTracks/src/com/google/wireless/gdata/maps/MapFeatureEntry.java new file mode 100644 index 000000000..4114f86d1 --- /dev/null +++ b/MyTracks/src/com/google/wireless/gdata/maps/MapFeatureEntry.java @@ -0,0 +1,50 @@ +/* + * 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.wireless.gdata.maps; + +import com.google.wireless.gdata.data.Entry; + +import java.util.HashMap; +import java.util.Map; + +/** + * GData entry for a map feature. + */ +public class MapFeatureEntry extends Entry { + + private String mPrivacy = null; + private Map mAttributes = new HashMap(); + + public void setPrivacy(String privacy) { + mPrivacy = privacy; + } + + public String getPrivacy() { + return mPrivacy; + } + + public void setAttribute(String name, String value) { + mAttributes.put(name, value); + } + + public void removeAttribute(String name) { + mAttributes.remove(name); + } + + public Map getAllAttributes() { + return mAttributes; + } +} diff --git a/MyTracks/src/com/google/wireless/gdata/maps/MapsClient.java b/MyTracks/src/com/google/wireless/gdata/maps/MapsClient.java new file mode 100644 index 000000000..dcf0b45e2 --- /dev/null +++ b/MyTracks/src/com/google/wireless/gdata/maps/MapsClient.java @@ -0,0 +1,113 @@ +/* + * 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.wireless.gdata.maps; + +import com.google.wireless.gdata.client.GDataClient; +import com.google.wireless.gdata.client.GDataParserFactory; +import com.google.wireless.gdata.client.GDataServiceClient; + +import android.util.Log; + +/** + * Client to talk to Google Maps via GData. + */ +public class MapsClient extends GDataServiceClient { + public static final String SERVICE = "local"; + + private static final boolean DEBUG = false; + public static final boolean LOG_COMMUNICATION = false; + + private static final String MAPS_BASE_FEED_URL = + "http://maps.google.com/maps/feeds/"; + private static final String MAPS_MAP_FEED_PATH = "maps/default/full"; + private static final String MAPS_FEATURE_FEED_PATH_BEFORE_MAPID = "features/"; + private static final String MAPS_FEATURE_FEED_PATH_AFTER_MAPID = "/full"; + private static final String MAPS_VERSION_FEED_PATH_FORMAT = + "%smaps/%s/versions/%s/full/%s"; + + private static final String MAP_ENTRY_ID_BEFORE_USER_ID = "maps/feeds/maps/"; + private static final String MAP_ENTRY_ID_BETWEEN_USER_ID_AND_MAP_ID = "/"; + private static final String V2_ONLY_PARAM = "?v=2.0"; + + public MapsClient(GDataClient dataClient, + GDataParserFactory dataParserFactory) { + super(dataClient, dataParserFactory); + } + + @Override + public String getServiceName() { + return SERVICE; + } + + public static String getMapsFeed() { + if (DEBUG) { + Log.d("Maps Client", "Requesting map feed:"); + } + return MAPS_BASE_FEED_URL + MAPS_MAP_FEED_PATH + V2_ONLY_PARAM; + } + + public static String getFeaturesFeed(String mapid) { + StringBuilder feed = new StringBuilder(); + feed.append(MAPS_BASE_FEED_URL); + feed.append(MAPS_FEATURE_FEED_PATH_BEFORE_MAPID); + feed.append(mapid); + feed.append(MAPS_FEATURE_FEED_PATH_AFTER_MAPID); + feed.append(V2_ONLY_PARAM); + return feed.toString(); + } + + public static String getMapIdFromMapEntryId(String entryId) { + String userId = null; + String mapId = null; + if (DEBUG) { + Log.d("Maps GData Client", "Getting mapid from entry id: " + entryId); + } + int userIdStart = + entryId.indexOf(MAP_ENTRY_ID_BEFORE_USER_ID) + + MAP_ENTRY_ID_BEFORE_USER_ID.length(); + int userIdEnd = + entryId.indexOf(MAP_ENTRY_ID_BETWEEN_USER_ID_AND_MAP_ID, userIdStart); + if (userIdStart >= 0 && userIdEnd < entryId.length() + && userIdStart <= userIdEnd) { + userId = entryId.substring(userIdStart, userIdEnd); + } + int mapIdStart = + entryId.indexOf(MAP_ENTRY_ID_BETWEEN_USER_ID_AND_MAP_ID, userIdEnd) + + MAP_ENTRY_ID_BETWEEN_USER_ID_AND_MAP_ID.length(); + if (mapIdStart >= 0 && mapIdStart < entryId.length()) { + mapId = entryId.substring(mapIdStart); + } + if (userId == null) { + userId = ""; + } + if (mapId == null) { + mapId = ""; + } + if (DEBUG) { + Log.d("Maps GData Client", "Got user id: " + userId); + Log.d("Maps GData Client", "Got map id: " + mapId); + } + return userId + "." + mapId; + } + + public static String getVersionFeed(String versionUserId, + String versionClient, String currentVersion) { + return String.format(MAPS_VERSION_FEED_PATH_FORMAT, + MAPS_BASE_FEED_URL, versionUserId, + versionClient, currentVersion); + } + +} diff --git a/MyTracks/src/com/google/wireless/gdata/maps/xml/XmlMapsGDataParser.java b/MyTracks/src/com/google/wireless/gdata/maps/xml/XmlMapsGDataParser.java new file mode 100644 index 000000000..e7fe6e7e6 --- /dev/null +++ b/MyTracks/src/com/google/wireless/gdata/maps/xml/XmlMapsGDataParser.java @@ -0,0 +1,178 @@ +/* + * Copyright 2009 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.wireless.gdata.maps.xml; + +import com.google.wireless.gdata.data.Entry; +import com.google.wireless.gdata.data.Feed; +import com.google.wireless.gdata.data.XmlUtils; +import com.google.wireless.gdata.maps.MapFeatureEntry; +import com.google.wireless.gdata.parser.ParseException; +import com.google.wireless.gdata.parser.xml.XmlGDataParser; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Parser for XML gdata maps data. + */ +public class XmlMapsGDataParser extends XmlGDataParser { + + public XmlMapsGDataParser(InputStream is, XmlPullParser xpp) + throws ParseException { + super(is, xpp); + } + + @Override + protected Feed createFeed() { + return new Feed(); + } + + @Override + protected Entry createEntry() { + return new MapFeatureEntry(); + } + + @Override + protected void handleExtraElementInFeed(Feed feed) { + // Do nothing + } + + @Override + protected void handleExtraLinkInEntry( + String rel, String type, String href, Entry entry) + throws XmlPullParserException, IOException { + if (!(entry instanceof MapFeatureEntry)) { + throw new IllegalArgumentException("Expected MapFeatureEntry!"); + } + if (rel.endsWith("#view")) { + return; + } + super.handleExtraLinkInEntry(rel, type, href, entry); + } + + /** + * Parses the current entry in the XML document. Assumes that the parser is + * currently pointing just after an <entry>. + * + * @param plainEntry The entry that will be filled. + * @throws XmlPullParserException Thrown if the XML cannot be parsed. + * @throws IOException Thrown if the underlying inputstream cannot be read. + */ + @Override + protected void handleEntry(Entry plainEntry) + throws XmlPullParserException, IOException, ParseException { + XmlPullParser parser = getParser(); + if (!(plainEntry instanceof MapFeatureEntry)) { + throw new IllegalArgumentException("Expected MapFeatureEntry!"); + } + MapFeatureEntry entry = (MapFeatureEntry) plainEntry; + + int eventType = parser.getEventType(); + entry.setPrivacy("public"); + while (eventType != XmlPullParser.END_DOCUMENT) { + switch (eventType) { + case XmlPullParser.START_TAG: + String name = parser.getName(); + if ("entry".equals(name)) { + // stop parsing here. + return; + } else if ("id".equals(name)) { + entry.setId(XmlUtils.extractChildText(parser)); + } else if ("title".equals(name)) { + entry.setTitle(XmlUtils.extractChildText(parser)); + } else if ("link".equals(name)) { + String rel = parser.getAttributeValue(null /* ns */, "rel"); + String type = parser.getAttributeValue(null /* ns */, "type"); + String href = parser.getAttributeValue(null /* ns */, "href"); + if ("edit".equals(rel)) { + entry.setEditUri(href); + } else if ("alternate".equals(rel) && "text/html".equals(type)) { + entry.setHtmlUri(href); + } else { + handleExtraLinkInEntry(rel, type, href, entry); + } + } else if ("summary".equals(name)) { + entry.setSummary(XmlUtils.extractChildText(parser)); + } else if ("content".equals(name)) { + StringBuilder contentBuilder = new StringBuilder(); + int parentDepth = parser.getDepth(); + while (parser.getEventType() != XmlPullParser.END_DOCUMENT) { + int etype = parser.next(); + switch (etype) { + case XmlPullParser.START_TAG: + contentBuilder.append('<'); + contentBuilder.append(parser.getName()); + contentBuilder.append('>'); + break; + case XmlPullParser.TEXT: + contentBuilder.append(""); + break; + case XmlPullParser.END_TAG: + if (parser.getDepth() > parentDepth) { + contentBuilder.append("'); + } + break; + } + if (etype == XmlPullParser.END_TAG + && parser.getDepth() == parentDepth) { + break; + } + } + entry.setContent(contentBuilder.toString()); + } else if ("category".equals(name)) { + String category = parser.getAttributeValue(null /* ns */, "term"); + if (category != null && category.length() > 0) { + entry.setCategory(category); + } + String categoryScheme = + parser.getAttributeValue(null /* ns */, "scheme"); + if (categoryScheme != null && category.length() > 0) { + entry.setCategoryScheme(categoryScheme); + } + } else if ("published".equals(name)) { + entry.setPublicationDate(XmlUtils.extractChildText(parser)); + } else if ("updated".equals(name)) { + entry.setUpdateDate(XmlUtils.extractChildText(parser)); + } else if ("deleted".equals(name)) { + entry.setDeleted(true); + } else if ("draft".equals(name)) { + String draft = XmlUtils.extractChildText(parser); + entry.setPrivacy("yes".equals(draft) ? "unlisted" : "public"); + } else if ("customProperty".equals(name)) { + String attrName = parser.getAttributeValue(null, "name"); + String attrValue = XmlUtils.extractChildText(parser); + entry.setAttribute(attrName, attrValue); + } else if ("deleted".equals(name)) { + entry.setDeleted(true); + } else { + handleExtraElementInEntry(entry); + } + break; + default: + break; + } + + eventType = parser.next(); + } + } +} diff --git a/MyTracks/src/com/google/wireless/gdata/maps/xml/XmlMapsGDataParserFactory.java b/MyTracks/src/com/google/wireless/gdata/maps/xml/XmlMapsGDataParserFactory.java new file mode 100644 index 000000000..ef2f72b6b --- /dev/null +++ b/MyTracks/src/com/google/wireless/gdata/maps/xml/XmlMapsGDataParserFactory.java @@ -0,0 +1,110 @@ +/* + * 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.wireless.gdata.maps.xml; + +import com.google.wireless.gdata.client.GDataParserFactory; +import com.google.wireless.gdata.data.Entry; +import com.google.wireless.gdata.maps.MapFeatureEntry; +import com.google.wireless.gdata.maps.MapsClient; +import com.google.wireless.gdata.parser.GDataParser; +import com.google.wireless.gdata.parser.ParseException; +import com.google.wireless.gdata.parser.xml.XmlGDataParser; +import com.google.wireless.gdata.parser.xml.XmlParserFactory; +import com.google.wireless.gdata.serializer.GDataSerializer; +import com.google.wireless.gdata.serializer.xml.XmlEntryGDataSerializer; + +import android.util.Log; + +import org.xmlpull.v1.XmlPullParserException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * Factory of Xml parsers for gdata maps data. + */ +public class XmlMapsGDataParserFactory implements GDataParserFactory { + private XmlParserFactory xmlFactory; + + public XmlMapsGDataParserFactory(XmlParserFactory xmlFactory) { + this.xmlFactory = xmlFactory; + } + + @Override + public GDataParser createParser(InputStream is) throws ParseException { + is = maybeLogCommunication(is); + try { + return new XmlGDataParser(is, xmlFactory.createParser()); + } catch (XmlPullParserException e) { + e.printStackTrace(); + return null; + } + } + + @SuppressWarnings("unchecked") + @Override + public GDataParser createParser(Class cls, InputStream is) + throws ParseException { + is = maybeLogCommunication(is); + try { + return createParserForClass(cls, is); + } catch (XmlPullParserException e) { + e.printStackTrace(); + return null; + } + } + + private InputStream maybeLogCommunication(InputStream is) + throws ParseException { + if (MapsClient.LOG_COMMUNICATION) { + StringBuilder builder = new StringBuilder(); + byte[] buffer = new byte[2048]; + try { + for (int n = is.read(buffer); n >= 0; n = is.read(buffer)) { + String part = new String(buffer, 0, n); + builder.append(part); + Log.d("Response part", part); + } + } catch (IOException e) { + throw new ParseException("Could not read stream", e); + } + String whole = builder.toString(); + Log.d("Response", whole); + is = new ByteArrayInputStream(whole.getBytes()); + } + return is; + } + + private GDataParser createParserForClass( + Class cls, InputStream is) + throws ParseException, XmlPullParserException { + if (cls == MapFeatureEntry.class) { + return new XmlMapsGDataParser(is, xmlFactory.createParser()); + } else { + return new XmlGDataParser(is, xmlFactory.createParser()); + } + } + + @Override + public GDataSerializer createSerializer(Entry en) { + if (en.getClass() == MapFeatureEntry.class) { + return new XmlMapsGDataSerializer(xmlFactory, en); + } else { + return new XmlEntryGDataSerializer(xmlFactory, en); + } + } +} diff --git a/MyTracks/src/com/google/wireless/gdata/maps/xml/XmlMapsGDataSerializer.java b/MyTracks/src/com/google/wireless/gdata/maps/xml/XmlMapsGDataSerializer.java new file mode 100644 index 000000000..7001a0f6b --- /dev/null +++ b/MyTracks/src/com/google/wireless/gdata/maps/xml/XmlMapsGDataSerializer.java @@ -0,0 +1,277 @@ +/* + * 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.wireless.gdata.maps.xml; + +import com.google.wireless.gdata.data.Entry; +import com.google.wireless.gdata.data.StringUtils; +import com.google.wireless.gdata.maps.MapFeatureEntry; +import com.google.wireless.gdata.maps.MapsClient; +import com.google.wireless.gdata.parser.ParseException; +import com.google.wireless.gdata.parser.xml.XmlGDataParser; +import com.google.wireless.gdata.parser.xml.XmlParserFactory; +import com.google.wireless.gdata.serializer.xml.XmlEntryGDataSerializer; + +import android.util.Log; + +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlSerializer; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Map; + +/** + * Serializer of maps data for GData. + */ +public class XmlMapsGDataSerializer extends XmlEntryGDataSerializer { + + private static final String APP_NAMESPACE = "http://www.w3.org/2007/app"; + + private MapFeatureEntry entry; + private XmlParserFactory factory; + private OutputStream stream; + + public XmlMapsGDataSerializer(XmlParserFactory factory, Entry entry) { + super(factory, entry); + + this.factory = factory; + this.entry = (MapFeatureEntry) entry; + } + + @Override + public void serialize(OutputStream out, int format) + throws IOException, ParseException { + XmlSerializer serializer = null; + try { + serializer = factory.createSerializer(); + } catch (XmlPullParserException e) { + throw new ParseException("Unable to create XmlSerializer.", e); + } + + // TODO: make the output compact + + ByteArrayOutputStream printStream; + + if (MapsClient.LOG_COMMUNICATION) { + printStream = new ByteArrayOutputStream(); + + serializer.setOutput(printStream, "UTF-8"); + } else { + serializer.setOutput(out, "UTF-8"); + } + + serializer.startDocument("UTF-8", Boolean.FALSE); + + declareEntryNamespaces(serializer); + serializer.startTag(XmlGDataParser.NAMESPACE_ATOM_URI, "entry"); + + stream = MapsClient.LOG_COMMUNICATION ? printStream : out; + serializeEntryContents(serializer, format); + + serializer.endTag(XmlGDataParser.NAMESPACE_ATOM_URI, "entry"); + serializer.endDocument(); + serializer.flush(); + + if (MapsClient.LOG_COMMUNICATION) { + Log.d("Request", printStream.toString()); + out.write(printStream.toByteArray()); + stream = out; + } + } + + private final void declareEntryNamespaces(XmlSerializer serializer) + throws IOException { + serializer.setPrefix( + "" /* default ns */, XmlGDataParser.NAMESPACE_ATOM_URI); + serializer.setPrefix( + XmlGDataParser.NAMESPACE_GD, XmlGDataParser.NAMESPACE_GD_URI); + declareExtraEntryNamespaces(serializer); + } + + private final void serializeEntryContents(XmlSerializer serializer, + int format) throws IOException { + if (format != FORMAT_CREATE) { + serializeId(serializer, entry.getId()); + } + + serializeTitle(serializer, entry.getTitle()); + + if (format != FORMAT_CREATE) { + serializeLink(serializer, + "edit" /* rel */, entry.getEditUri(), null /* type */); + serializeLink(serializer, + "alternate" /* rel */, entry.getHtmlUri(), "text/html" /* type */); + } + + serializeSummary(serializer, entry.getSummary()); + serializeContent(serializer, entry.getContent()); + serializeAuthor(serializer, entry.getAuthor(), entry.getEmail()); + serializeCategory(serializer, + entry.getCategory(), entry.getCategoryScheme()); + + if (format == FORMAT_FULL) { + serializePublicationDate(serializer, entry.getPublicationDate()); + } + + if (format != FORMAT_CREATE) { + serializeUpdateDate(serializer, entry.getUpdateDate()); + } + + serializeExtraEntryContents(serializer, format); + } + + private static void serializeId(XmlSerializer serializer, String id) + throws IOException { + if (StringUtils.isEmpty(id)) { + return; + } + serializer.startTag(null /* ns */, "id"); + serializer.text(id); + serializer.endTag(null /* ns */, "id"); + } + + private static void serializeTitle(XmlSerializer serializer, String title) + throws IOException { + if (StringUtils.isEmpty(title)) { + return; + } + serializer.startTag(null /* ns */, "title"); + serializer.text(title); + serializer.endTag(null /* ns */, "title"); + } + + public static void serializeLink(XmlSerializer serializer, String rel, + String href, String type) throws IOException { + if (StringUtils.isEmpty(href)) { + return; + } + serializer.startTag(null /* ns */, "link"); + serializer.attribute(null /* ns */, "rel", rel); + serializer.attribute(null /* ns */, "href", href); + if (!StringUtils.isEmpty(type)) { + serializer.attribute(null /* ns */, "type", type); + } + serializer.endTag(null /* ns */, "link"); + } + + private static void serializeSummary(XmlSerializer serializer, String summary) + throws IOException { + if (StringUtils.isEmpty(summary)) { + return; + } + serializer.startTag(null /* ns */, "summary"); + serializer.text(summary); + serializer.endTag(null /* ns */, "summary"); + } + + private void serializeContent(XmlSerializer serializer, String content) + throws IOException { + if (content == null) { + return; + } + serializer.startTag(null /* ns */, "content"); + if (content.contains("")) { + serializer.attribute( + null /* ns */, "type", "application/vnd.google-earth.kml+xml"); + serializer.flush(); + stream.write(content.getBytes()); + } else { + serializer.text(content); + } + serializer.endTag(null /* ns */, "content"); + } + + private static void serializeAuthor(XmlSerializer serializer, String author, + String email) throws IOException { + if (StringUtils.isEmpty(author) || StringUtils.isEmpty(email)) { + return; + } + serializer.startTag(null /* ns */, "author"); + serializer.startTag(null /* ns */, "name"); + serializer.text(author); + serializer.endTag(null /* ns */, "name"); + serializer.startTag(null /* ns */, "email"); + serializer.text(email); + serializer.endTag(null /* ns */, "email"); + serializer.endTag(null /* ns */, "author"); + } + + private static void serializeCategory(XmlSerializer serializer, + String category, String categoryScheme) throws IOException { + if (StringUtils.isEmpty(category) && StringUtils.isEmpty(categoryScheme)) { + return; + } + serializer.startTag(null /* ns */, "category"); + if (!StringUtils.isEmpty(category)) { + serializer.attribute(null /* ns */, "term", category); + } + if (!StringUtils.isEmpty(categoryScheme)) { + serializer.attribute(null /* ns */, "scheme", categoryScheme); + } + serializer.endTag(null /* ns */, "category"); + } + + private static void serializePublicationDate(XmlSerializer serializer, + String publicationDate) throws IOException { + if (StringUtils.isEmpty(publicationDate)) { + return; + } + serializer.startTag(null /* ns */, "published"); + serializer.text(publicationDate); + serializer.endTag(null /* ns */, "published"); + } + + private static void serializeUpdateDate(XmlSerializer serializer, + String updateDate) throws IOException { + if (StringUtils.isEmpty(updateDate)) { + return; + } + serializer.startTag(null /* ns */, "updated"); + serializer.text(updateDate); + serializer.endTag(null /* ns */, "updated"); + } + + @Override + protected void serializeExtraEntryContents(XmlSerializer serializer, + int format) throws IOException { + Map attrs = entry.getAllAttributes(); + for (Map.Entry attr : attrs.entrySet()) { + serializer.startTag("http://schemas.google.com/g/2005", "customProperty"); + serializer.attribute(null, "name", attr.getKey()); + serializer.text(attr.getValue()); + serializer.endTag("http://schemas.google.com/g/2005", "customProperty"); + } + String privacy = entry.getPrivacy(); + if (!StringUtils.isEmpty(privacy)) { + serializer.setPrefix("app", APP_NAMESPACE); + if ("public".equals(privacy)) { + serializer.startTag(APP_NAMESPACE, "control"); + serializer.startTag(APP_NAMESPACE, "draft"); + serializer.text("no"); + serializer.endTag(APP_NAMESPACE, "draft"); + serializer.endTag(APP_NAMESPACE, "control"); + } + if ("unlisted".equals(privacy)) { + serializer.startTag(APP_NAMESPACE, "control"); + serializer.startTag(APP_NAMESPACE, "draft"); + serializer.text("yes"); + serializer.endTag(APP_NAMESPACE, "draft"); + serializer.endTag(APP_NAMESPACE, "control"); + } + } + } +}