/* * 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.stats.TripStatistics; 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(); TripStatistics segmentStats = segment.getStatistics(); TripStatistics trackStats = track.getStatistics(); segment.setId(track.getId()); segment.setName(track.getName()); segment.setDescription(/* track.getDescription() */ ""); segment.setCategory(track.getCategory()); segmentStats.setStartTime(trackStats.getStartTime()); segmentStats.setStopTime(trackStats.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 (segmentStats.getStartTime() < 0) { segmentStats.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) { TripStatistics segmentStats = segment.getStatistics(); if (segmentStats.getStopTime() < 0 && segment.getLocations().size() > 0) { segmentStats.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(context.getString(R.string.metric_units_key), 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( context.getString(R.string.default_map_public_key), 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); myMapsFeature.setDescription(isStart ? "" : track.getDescription()); 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()); } myMapsFeature.setDescription(wpt.getDescription().replaceAll("\n", "
")); return myMapsFeature; } }