/* * 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.Constants; import com.google.android.apps.mytracks.TrackDetailActivity; import com.google.android.apps.mytracks.TrackListActivity; import com.google.android.apps.mytracks.content.DescriptionGeneratorImpl; import com.google.android.apps.mytracks.content.MyTracksLocation; import com.google.android.apps.mytracks.content.MyTracksProvider; import com.google.android.apps.mytracks.content.MyTracksProviderUtils; import com.google.android.apps.mytracks.content.MyTracksProviderUtils.LocationIterator; import com.google.android.apps.mytracks.content.Sensor; import com.google.android.apps.mytracks.content.Sensor.SensorDataSet; import com.google.android.apps.mytracks.content.Track; import com.google.android.apps.mytracks.content.Waypoint; import com.google.android.apps.mytracks.content.Waypoint.WaypointType; import com.google.android.apps.mytracks.content.WaypointCreationRequest; import com.google.android.apps.mytracks.services.sensors.SensorManager; import com.google.android.apps.mytracks.services.sensors.SensorManagerFactory; import com.google.android.apps.mytracks.services.tasks.AnnouncementPeriodicTaskFactory; import com.google.android.apps.mytracks.services.tasks.PeriodicTaskExecutor; import com.google.android.apps.mytracks.services.tasks.SplitPeriodicTaskFactory; import com.google.android.apps.mytracks.stats.TripStatistics; import com.google.android.apps.mytracks.stats.TripStatisticsUpdater; import com.google.android.apps.mytracks.util.CalorieUtils; import com.google.android.apps.mytracks.util.CalorieUtils.ActivityType; import com.google.android.apps.mytracks.util.IntentUtils; import com.google.android.apps.mytracks.util.LocationUtils; import com.google.android.apps.mytracks.util.PreferencesUtils; import com.google.android.apps.mytracks.util.SystemUtils; import com.google.android.apps.mytracks.util.TrackIconUtils; import com.google.android.apps.mytracks.util.TrackNameUtils; import com.google.android.apps.mytracks.util.UnitConversions; import com.google.android.gms.common.ConnectionResult; import com.google.android.gms.common.GooglePlayServicesClient.ConnectionCallbacks; import com.google.android.gms.common.GooglePlayServicesClient.OnConnectionFailedListener; import com.google.android.gms.location.ActivityRecognitionClient; import com.google.android.gms.location.DetectedActivity; import com.google.android.gms.location.LocationListener; import com.google.android.maps.mytracks.R; import com.google.common.annotations.VisibleForTesting; import android.app.PendingIntent; import android.app.Service; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.SharedPreferences.OnSharedPreferenceChangeListener; import android.database.sqlite.SQLiteException; import android.location.Location; import android.location.LocationManager; import android.net.Uri; import android.os.Binder; import android.os.Bundle; import android.os.Handler; import android.os.IBinder; import android.os.PowerManager.WakeLock; import android.os.Process; import android.support.v4.app.NotificationCompat; import android.support.v4.app.TaskStackBuilder; import android.util.Log; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** * A background service that registers a location listener and records track * points. Track points are saved to the {@link MyTracksProvider}. * * @author Leif Hendrik Wilden */ public class TrackRecordingService extends Service { /** * The name of extra intent property to indicate whether we want to resume a * previously recorded track. */ public static final String RESUME_TRACK_EXTRA_NAME = "com.google.android.apps.mytracks.RESUME_TRACK"; public static final double PAUSE_LATITUDE = 100.0; public static final double RESUME_LATITUDE = 200.0; /** * Anything faster than that (in meters per second) will be considered moving. */ public static final double MAX_NO_MOVEMENT_SPEED = 0.224; private static final String TAG = TrackRecordingService.class.getSimpleName(); // 1 second in milliseconds private static final long ONE_SECOND = (long) UnitConversions.S_TO_MS; // 1 minute in milliseconds private static final long ONE_MINUTE = (long) (UnitConversions.MIN_TO_S * UnitConversions.S_TO_MS); @VisibleForTesting static final int MAX_AUTO_RESUME_TRACK_RETRY_ATTEMPTS = 3; // The following variables are set in onCreate: private ExecutorService executorService; private Context context; private MyTracksProviderUtils myTracksProviderUtils; private Handler handler; private MyTracksLocationManager myTracksLocationManager; private PendingIntent activityRecognitionPendingIntent; private ActivityRecognitionClient activityRecognitionClient; private PeriodicTaskExecutor voiceExecutor; private PeriodicTaskExecutor splitExecutor; private SharedPreferences sharedPreferences; private long recordingTrackId; private boolean recordingTrackPaused; private LocationListenerPolicy locationListenerPolicy; private int recordingDistanceInterval; private int maxRecordingDistance; private int recordingGpsAccuracy; private int autoResumeTrackTimeout; private long currentRecordingInterval; private double weight; // The following variables are set when recording: private TripStatisticsUpdater trackTripStatisticsUpdater; private TripStatisticsUpdater markerTripStatisticsUpdater; private WakeLock wakeLock; private SensorManager sensorManager; private Location lastLocation; private boolean currentSegmentHasLocation; private boolean isIdle; // true if idle private ServiceBinder binder = new ServiceBinder(this); /* * Note that sharedPreferenceChangeListener cannot be an anonymous inner * class. Anonymous inner class will get garbage collected. */ private final OnSharedPreferenceChangeListener sharedPreferenceChangeListener = new OnSharedPreferenceChangeListener() { @Override public void onSharedPreferenceChanged(SharedPreferences preferences, String key) { if (key == null || key.equals(PreferencesUtils.getKey(context, R.string.recording_track_id_key))) { long trackId = PreferencesUtils.getLong(context, R.string.recording_track_id_key); /* * Only through the TrackRecordingService can one stop a recording * and set the recordingTrackId to -1L. */ if (trackId != PreferencesUtils.RECORDING_TRACK_ID_DEFAULT) { recordingTrackId = trackId; } } if (key == null || key.equals( PreferencesUtils.getKey(context, R.string.recording_track_paused_key))) { recordingTrackPaused = PreferencesUtils.getBoolean(context, R.string.recording_track_paused_key, PreferencesUtils.RECORDING_TRACK_PAUSED_DEFAULT); } if (key == null || key.equals(PreferencesUtils.getKey(context, R.string.stats_units_key))) { boolean metricUnits = PreferencesUtils.isMetricUnits(context); voiceExecutor.setMetricUnits(metricUnits); splitExecutor.setMetricUnits(metricUnits); } if (key == null || key.equals(PreferencesUtils.getKey(context, R.string.voice_frequency_key))) { voiceExecutor.setTaskFrequency(PreferencesUtils.getInt( context, R.string.voice_frequency_key, PreferencesUtils.VOICE_FREQUENCY_DEFAULT)); } if (key == null || key.equals(PreferencesUtils.getKey(context, R.string.split_frequency_key))) { splitExecutor.setTaskFrequency(PreferencesUtils.getInt( context, R.string.split_frequency_key, PreferencesUtils.SPLIT_FREQUENCY_DEFAULT)); } if (key == null || key.equals( PreferencesUtils.getKey(context, R.string.min_recording_interval_key))) { int minRecordingInterval = PreferencesUtils.getInt(context, R.string.min_recording_interval_key, PreferencesUtils.MIN_RECORDING_INTERVAL_DEFAULT); switch (minRecordingInterval) { case PreferencesUtils.MIN_RECORDING_INTERVAL_ADAPT_BATTERY_LIFE: // Choose battery life over moving time accuracy. locationListenerPolicy = new AdaptiveLocationListenerPolicy( 30 * ONE_SECOND, 5 * ONE_MINUTE, 5); break; case PreferencesUtils.MIN_RECORDING_INTERVAL_ADAPT_ACCURACY: // Get all the updates. locationListenerPolicy = new AdaptiveLocationListenerPolicy( ONE_SECOND, 30 * ONE_SECOND, 0); break; default: locationListenerPolicy = new AbsoluteLocationListenerPolicy( minRecordingInterval * ONE_SECOND); } } if (key == null || key.equals( PreferencesUtils.getKey(context, R.string.recording_distance_interval_key))) { recordingDistanceInterval = PreferencesUtils.getInt(context, R.string.recording_distance_interval_key, PreferencesUtils.RECORDING_DISTANCE_INTERVAL_DEFAULT); } if (key == null || key.equals( PreferencesUtils.getKey(context, R.string.max_recording_distance_key))) { maxRecordingDistance = PreferencesUtils.getInt(context, R.string.max_recording_distance_key, PreferencesUtils.MAX_RECORDING_DISTANCE_DEFAULT); } if (key == null || key.equals( PreferencesUtils.getKey(context, R.string.recording_gps_accuracy_key))) { recordingGpsAccuracy = PreferencesUtils.getInt(context, R.string.recording_gps_accuracy_key, PreferencesUtils.RECORDING_GPS_ACCURACY_DEFAULT); } if (key == null || key.equals( PreferencesUtils.getKey(context, R.string.auto_resume_track_timeout_key))) { autoResumeTrackTimeout = PreferencesUtils.getInt(context, R.string.auto_resume_track_timeout_key, PreferencesUtils.AUTO_RESUME_TRACK_TIMEOUT_DEFAULT); } if (key == null || key.equals(PreferencesUtils.getKey(context, R.string.weight_key))) { weight = PreferencesUtils.getFloat( context, R.string.weight_key, PreferencesUtils.getDefaultWeight(context)); } } }; private LocationListener locationListener = new LocationListener() { @Override public void onLocationChanged(final Location location) { if (myTracksLocationManager == null || executorService == null || !myTracksLocationManager.isAllowed() || executorService.isShutdown() || executorService.isTerminated()) { return; } executorService.submit(new Runnable() { @Override public void run() { onLocationChangedAsync(location); } }); } }; private final ConnectionCallbacks activityRecognitionCallbacks = new ConnectionCallbacks() { @Override public void onDisconnected() {} @Override public void onConnected(Bundle bundle) { activityRecognitionClient.requestActivityUpdates( ONE_MINUTE, activityRecognitionPendingIntent); } }; private final OnConnectionFailedListener activityRecognitionFailedListener = new OnConnectionFailedListener() { @Override public void onConnectionFailed(ConnectionResult connectionResult) {} }; private final Runnable registerLocationRunnable = new Runnable() { @Override public void run() { if (isRecording() && !isPaused()) { registerLocationListener(); } handler.postDelayed(this, ONE_MINUTE); } }; /* * Note that this service, through the AndroidManifest.xml, is configured to * allow both MyTracks and third party apps to invoke it. For the onCreate * callback, we cannot tell whether the caller is MyTracks or a third party * app, thus it cannot start/stop a recording or write/update MyTracks * database. */ @Override public void onCreate() { super.onCreate(); executorService = Executors.newSingleThreadExecutor(); context = this; myTracksProviderUtils = MyTracksProviderUtils.Factory.get(this); handler = new Handler(); myTracksLocationManager = new MyTracksLocationManager(this, handler.getLooper(), true); activityRecognitionPendingIntent = PendingIntent.getService(context, 0, new Intent(context, ActivityRecognitionIntentService.class), PendingIntent.FLAG_UPDATE_CURRENT); activityRecognitionClient = new ActivityRecognitionClient( context, activityRecognitionCallbacks, activityRecognitionFailedListener); activityRecognitionClient.connect(); voiceExecutor = new PeriodicTaskExecutor(this, new AnnouncementPeriodicTaskFactory()); splitExecutor = new PeriodicTaskExecutor(this, new SplitPeriodicTaskFactory()); sharedPreferences = getSharedPreferences(Constants.SETTINGS_NAME, Context.MODE_PRIVATE); sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener); // onSharedPreferenceChanged might not set recordingTrackId. recordingTrackId = PreferencesUtils.RECORDING_TRACK_ID_DEFAULT; // Require voiceExecutor and splitExecutor to be created. sharedPreferenceChangeListener.onSharedPreferenceChanged(sharedPreferences, null); handler.post(registerLocationRunnable); /* * Try to restart the previous recording track in case the service has been * restarted by the system, which can sometimes happen. */ Track track = myTracksProviderUtils.getTrack(recordingTrackId); if (track != null) { restartTrack(track); } else { if (isRecording()) { Log.w(TAG, "track is null, but recordingTrackId not -1L. " + recordingTrackId); updateRecordingState(PreferencesUtils.RECORDING_TRACK_ID_DEFAULT, true); } showNotification(false); } } /* * Note that this service, through the AndroidManifest.xml, is configured to * allow both MyTracks and third party apps to invoke it. For the onStart * callback, we cannot tell whether the caller is MyTracks or a third party * app, thus it cannot start/stop a recording or write/update MyTracks * database. */ @Override public void onStart(Intent intent, int startId) { handleStartCommand(intent, startId); } /* * Note that this service, through the AndroidManifest.xml, is configured to * allow both MyTracks and third party apps to invoke it. For the * onStartCommand callback, we cannot tell whether the caller is MyTracks or a * third party app, thus it cannot start/stop a recording or write/update * MyTracks database. */ @Override public int onStartCommand(Intent intent, int flags, int startId) { handleStartCommand(intent, startId); return START_STICKY; } @Override public IBinder onBind(Intent intent) { return binder; } @Override public void onDestroy() { if (sensorManager != null) { SensorManagerFactory.releaseSystemSensorManager(); sensorManager = null; } // Reverse order from onCreate showNotification(false); handler.removeCallbacks(registerLocationRunnable); unregisterLocationListener(); // unregister sharedPreferences before shutting down splitExecutor and voiceExecutor sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener); try { splitExecutor.shutdown(); } finally { splitExecutor = null; } try { voiceExecutor.shutdown(); } finally { voiceExecutor = null; } if (activityRecognitionClient.isConnected()) { activityRecognitionClient.removeActivityUpdates(activityRecognitionPendingIntent); } activityRecognitionClient.disconnect(); activityRecognitionPendingIntent.cancel(); myTracksLocationManager.close(); myTracksLocationManager = null; myTracksProviderUtils = null; binder.detachFromService(); binder = null; // This should be the next to last operation releaseWakeLock(); /* * Shutdown the executorService last to avoid sending events to a dead * executor. */ executorService.shutdown(); super.onDestroy(); } /** * Returns true if the service is recording. */ public boolean isRecording() { return recordingTrackId != PreferencesUtils.RECORDING_TRACK_ID_DEFAULT; } /** * Returns true if the current recording is paused. */ public boolean isPaused() { return recordingTrackPaused; } /** * Gets the trip statistics. */ public TripStatistics getTripStatistics() { if (trackTripStatisticsUpdater == null) { return null; } return trackTripStatisticsUpdater.getTripStatistics(); } /** * Inserts a waypoint. * * @param waypointCreationRequest the waypoint creation request * @return the waypoint id */ public long insertWaypoint(WaypointCreationRequest waypointCreationRequest) { if (!isRecording() || isPaused()) { return -1L; } WaypointType waypointType = waypointCreationRequest.getType(); boolean isStatistics = waypointType == WaypointType.STATISTICS; // Get name String name; if (waypointCreationRequest.getName() != null) { name = waypointCreationRequest.getName(); } else { int nextWaypointNumber = myTracksProviderUtils.getNextWaypointNumber( recordingTrackId, waypointType); if (nextWaypointNumber == -1) { nextWaypointNumber = 0; } name = getString( isStatistics ? R.string.marker_split_name_format : R.string.marker_name_format, nextWaypointNumber); } // Get category String category = waypointCreationRequest.getCategory() != null ? waypointCreationRequest .getCategory() : ""; // Get tripStatistics, description, and icon TripStatistics tripStatistics; String description; String icon; if (isStatistics) { long now = System.currentTimeMillis(); markerTripStatisticsUpdater.updateTime(now); tripStatistics = markerTripStatisticsUpdater.getTripStatistics(); markerTripStatisticsUpdater = new TripStatisticsUpdater(now); description = new DescriptionGeneratorImpl(this).generateWaypointDescription(tripStatistics); icon = getString(R.string.marker_statistics_icon_url); } else { tripStatistics = null; description = waypointCreationRequest.getDescription() != null ? waypointCreationRequest .getDescription() : ""; icon = getString(R.string.marker_waypoint_icon_url); } // Get length and duration double length; long duration; Location location = getLastValidTrackPointInCurrentSegment(recordingTrackId); if (location != null && trackTripStatisticsUpdater != null) { TripStatistics stats = trackTripStatisticsUpdater.getTripStatistics(); length = stats.getTotalDistance(); duration = stats.getTotalTime(); } else { if (!waypointCreationRequest.isTrackStatistics()) { return -1L; } // For track statistics, make it an impossible location location = new Location(""); location.setLatitude(100); location.setLongitude(180); length = 0.0; duration = 0L; } String photoUrl = waypointCreationRequest.getPhotoUrl() != null ? waypointCreationRequest .getPhotoUrl() : ""; // Insert waypoint Waypoint waypoint = new Waypoint(name, description, category, icon, recordingTrackId, waypointType, length, duration, -1L, -1L, location, tripStatistics, photoUrl); Uri uri = myTracksProviderUtils.insertWaypoint(waypoint); return Long.parseLong(uri.getLastPathSegment()); } /** * Starts the service as a foreground service. * * @param pendingIntent the notification pending intent * @param messageId the notification message id */ @VisibleForTesting protected void startForegroundService(PendingIntent pendingIntent, int messageId) { NotificationCompat.Builder builder = new NotificationCompat.Builder(this).setContentIntent( pendingIntent).setContentText(getString(messageId)) .setContentTitle(getString(R.string.my_tracks_app_name)).setOngoing(true) .setSmallIcon(R.drawable.ic_stat_notify_recording).setWhen(System.currentTimeMillis()); startForeground(1, builder.build()); } /** * Stops the service as a foreground service. */ @VisibleForTesting protected void stopForegroundService() { stopForeground(true); } /** * Handles start command. * * @param intent the intent * @param startId the start id */ private void handleStartCommand(Intent intent, int startId) { // Check if the service is called to resume track (from phone reboot) if (intent != null && intent.getBooleanExtra(RESUME_TRACK_EXTRA_NAME, false)) { if (!shouldResumeTrack()) { Log.i(TAG, "Stop resume track."); updateRecordingState(PreferencesUtils.RECORDING_TRACK_ID_DEFAULT, true); stopSelfResult(startId); return; } } } /** * Returns true if should resume. */ private boolean shouldResumeTrack() { Track track = myTracksProviderUtils.getTrack(recordingTrackId); if (track == null) { Log.d(TAG, "Not resuming. Track is null."); return false; } int retries = PreferencesUtils.getInt(this, R.string.auto_resume_track_current_retry_key, PreferencesUtils.AUTO_RESUME_TRACK_CURRENT_RETRY_DEFAULT); if (retries >= MAX_AUTO_RESUME_TRACK_RETRY_ATTEMPTS) { Log.d(TAG, "Not resuming. Exceeded maximum retry attempts."); return false; } PreferencesUtils.setInt(this, R.string.auto_resume_track_current_retry_key, retries + 1); if (autoResumeTrackTimeout == PreferencesUtils.AUTO_RESUME_TRACK_TIMEOUT_NEVER) { Log.d(TAG, "Not resuming. Auto-resume track timeout set to never."); return false; } else if (autoResumeTrackTimeout == PreferencesUtils.AUTO_RESUME_TRACK_TIMEOUT_ALWAYS) { Log.d(TAG, "Resuming. Auto-resume track timeout set to always."); return true; } if (track.getTripStatistics() == null) { Log.d(TAG, "Not resuming. No trip statistics."); return false; } long stopTime = track.getTripStatistics().getStopTime(); return stopTime > 0 && (System.currentTimeMillis() - stopTime) <= autoResumeTrackTimeout * ONE_MINUTE; } /** * Starts a new track. * * @return the track id */ private long startNewTrack() { if (isRecording()) { Log.d(TAG, "Ignore startNewTrack. Already recording."); return -1L; } long now = System.currentTimeMillis(); trackTripStatisticsUpdater = new TripStatisticsUpdater(now); markerTripStatisticsUpdater = new TripStatisticsUpdater(now); // Insert a track Track track = new Track(); Uri uri = myTracksProviderUtils.insertTrack(track); long trackId = Long.parseLong(uri.getLastPathSegment()); // Update shared preferences updateRecordingState(trackId, false); PreferencesUtils.setInt(this, R.string.auto_resume_track_current_retry_key, 0); PreferencesUtils.setInt(this, R.string.activity_recognition_type_key, PreferencesUtils.ACTIVITY_RECOGNITION_TYPE_DEFAULT); // Update database track.setId(trackId); track.setName(TrackNameUtils.getTrackName(this, trackId, now, null)); String category = PreferencesUtils.getString( this, R.string.default_activity_key, PreferencesUtils.DEFAULT_ACTIVITY_DEFAULT); track.setCategory(category); track.setIcon(TrackIconUtils.getIconValue(this, category)); track.setTripStatistics(trackTripStatisticsUpdater.getTripStatistics()); myTracksProviderUtils.updateTrack(track); insertWaypoint(WaypointCreationRequest.DEFAULT_START_TRACK); startRecording(true); return trackId; } /** * Restart a track. * * @param track the track */ private void restartTrack(Track track) { Log.d(TAG, "Restarting track: " + track.getId()); TripStatistics tripStatistics = track.getTripStatistics(); trackTripStatisticsUpdater = new TripStatisticsUpdater(tripStatistics.getStartTime()); long markerStartTime; Waypoint waypoint = myTracksProviderUtils.getLastWaypoint( recordingTrackId, WaypointType.STATISTICS); if (waypoint != null && waypoint.getTripStatistics() != null) { markerStartTime = waypoint.getTripStatistics().getStopTime(); } else { markerStartTime = tripStatistics.getStartTime(); } markerTripStatisticsUpdater = new TripStatisticsUpdater(markerStartTime); ActivityType activityType = CalorieUtils.getActivityType(context, track.getCategory()); LocationIterator locationIterator = null; try { locationIterator = myTracksProviderUtils.getTrackPointLocationIterator( track.getId(), -1L, false, MyTracksProviderUtils.DEFAULT_LOCATION_FACTORY); while (locationIterator.hasNext()) { Location location = locationIterator.next(); trackTripStatisticsUpdater.addLocation( location, recordingDistanceInterval, true, activityType, weight); if (location.getTime() > markerStartTime) { markerTripStatisticsUpdater.addLocation( location, recordingDistanceInterval, true, activityType, weight); } } } catch (RuntimeException e) { Log.e(TAG, "RuntimeException", e); } finally { if (locationIterator != null) { locationIterator.close(); } } startRecording(true); } /** * Resumes current track. */ private void resumeCurrentTrack() { if (!isRecording() || !isPaused()) { Log.d(TAG, "Ignore resumeCurrentTrack. Not recording or not paused."); return; } // Update shared preferences recordingTrackPaused = false; PreferencesUtils.setBoolean(this, R.string.recording_track_paused_key, false); // Update database Track track = myTracksProviderUtils.getTrack(recordingTrackId); if (track != null) { Location resume = new Location(LocationManager.GPS_PROVIDER); resume.setLongitude(0); resume.setLatitude(RESUME_LATITUDE); resume.setTime(System.currentTimeMillis()); insertLocation(track, resume, null); } startRecording(false); } /** * Common code for starting a new track, resuming a track, or restarting after * phone reboot. * * @param trackStarted true if track is started, false if track is resumed */ private void startRecording(boolean trackStarted) { // Update instance variables sensorManager = SensorManagerFactory.getSystemSensorManager(this); lastLocation = null; currentSegmentHasLocation = false; isIdle = false; startGps(); sendTrackBroadcast(trackStarted ? R.string.track_started_broadcast_action : R.string.track_resumed_broadcast_action, recordingTrackId); // Restore periodic tasks voiceExecutor.restore(); splitExecutor.restore(); } /** * Starts gps. */ private void startGps() { wakeLock = SystemUtils.acquireWakeLock(this, wakeLock); registerLocationListener(); showNotification(true); } /** * Ends the current track. */ private void endCurrentTrack() { if (!isRecording()) { Log.d(TAG, "Ignore endCurrentTrack. Not recording."); return; } // Need to remember the recordingTrackId before setting it to -1L long trackId = recordingTrackId; boolean paused = recordingTrackPaused; // Update shared preferences updateRecordingState(PreferencesUtils.RECORDING_TRACK_ID_DEFAULT, true); // Update database Track track = myTracksProviderUtils.getTrack(trackId); if (track != null) { // If not paused, add the last location if (!paused) { insertLocation(track, lastLocation, getLastValidTrackPointInCurrentSegment(trackId)); // Update the recording track time updateRecordingTrack(track, myTracksProviderUtils.getLastTrackPointId(trackId), false); } String trackName = TrackNameUtils.getTrackName(this, trackId, track.getTripStatistics().getStartTime(), myTracksProviderUtils.getFirstValidTrackPoint(trackId)); if (trackName != null && !trackName.equals(track.getName())) { track.setName(trackName); myTracksProviderUtils.updateTrack(track); } if (track.getCategory().equals(PreferencesUtils.DEFAULT_ACTIVITY_DEFAULT)) { int activityRecognitionType = PreferencesUtils.getInt(this, R.string.activity_recognition_type_key, PreferencesUtils.ACTIVITY_RECOGNITION_TYPE_DEFAULT); if (activityRecognitionType != PreferencesUtils.ACTIVITY_RECOGNITION_TYPE_DEFAULT) { String iconValue = null; switch (activityRecognitionType) { case DetectedActivity.IN_VEHICLE: iconValue = TrackIconUtils.DRIVE; break; case DetectedActivity.ON_BICYCLE: iconValue = TrackIconUtils.BIKE; break; case DetectedActivity.ON_FOOT: iconValue = TrackIconUtils.WALK; break; default: break; } if (iconValue != null) { track.setIcon(iconValue); track.setCategory(getString(TrackIconUtils.getIconActivityType(iconValue))); myTracksProviderUtils.updateTrack(track); CalorieUtils.updateTrackCalorie(context, track); } } } } endRecording(true, trackId); } /** * Pauses the current track. */ private void pauseCurrentTrack() { if (!isRecording() || isPaused()) { Log.d(TAG, "Ignore pauseCurrentTrack. Not recording or paused."); return; } // Update shared preferences recordingTrackPaused = true; PreferencesUtils.setBoolean(this, R.string.recording_track_paused_key, true); // Update database Track track = myTracksProviderUtils.getTrack(recordingTrackId); if (track != null) { insertLocation(track, lastLocation, getLastValidTrackPointInCurrentSegment(track.getId())); Location pause = new Location(LocationManager.GPS_PROVIDER); pause.setLongitude(0); pause.setLatitude(PAUSE_LATITUDE); pause.setTime(System.currentTimeMillis()); insertLocation(track, pause, null); } endRecording(false, recordingTrackId); } /** * Common code for ending a track or pausing a track. * * @param trackStopped true if track is stopped, false if track is paused * @param trackId the track id */ private void endRecording(boolean trackStopped, long trackId) { // Shutdown periodic tasks voiceExecutor.shutdown(); splitExecutor.shutdown(); // Update instance variables if (sensorManager != null) { SensorManagerFactory.releaseSystemSensorManager(); sensorManager = null; } lastLocation = null; sendTrackBroadcast(trackStopped ? R.string.track_stopped_broadcast_action : R.string.track_paused_broadcast_action, trackId); stopGps(trackStopped); } /** * Stops gps. * * @param stop true to stop self */ private void stopGps(boolean stop) { unregisterLocationListener(); showNotification(false); releaseWakeLock(); if (stop) { stopSelf(); } } /** * Gets the last valid track point in the current segment. Returns null if not * available. * * @param trackId the track id */ private Location getLastValidTrackPointInCurrentSegment(long trackId) { if (!currentSegmentHasLocation) { return null; } return myTracksProviderUtils.getLastValidTrackPoint(trackId); } /** * Updates the recording states. * * @param trackId the recording track id * @param paused true if the recording is paused */ private void updateRecordingState(long trackId, boolean paused) { recordingTrackId = trackId; PreferencesUtils.setLong(this, R.string.recording_track_id_key, trackId); recordingTrackPaused = paused; PreferencesUtils.setBoolean(this, R.string.recording_track_paused_key, recordingTrackPaused); } /** * Called when location changed. * * @param location the location */ private void onLocationChangedAsync(Location location) { try { if (!isRecording() || isPaused()) { Log.w(TAG, "Ignore onLocationChangedAsync. Not recording or paused."); return; } Track track = myTracksProviderUtils.getTrack(recordingTrackId); if (track == null) { Log.w(TAG, "Ignore onLocationChangedAsync. No track."); return; } if (!LocationUtils.isValidLocation(location)) { Log.w(TAG, "Ignore onLocationChangedAsync. location is invalid."); return; } if (!location.hasAccuracy() || location.getAccuracy() >= recordingGpsAccuracy) { Log.d(TAG, "Ignore onLocationChangedAsync. Poor accuracy."); return; } // Fix for phones that do not set the time field if (location.getTime() == 0L) { location.setTime(System.currentTimeMillis()); } Location lastValidTrackPoint = getLastValidTrackPointInCurrentSegment(track.getId()); long idleTime = 0L; if (lastValidTrackPoint != null && location.getTime() > lastValidTrackPoint.getTime()) { idleTime = location.getTime() - lastValidTrackPoint.getTime(); } locationListenerPolicy.updateIdleTime(idleTime); if (currentRecordingInterval != locationListenerPolicy.getDesiredPollingInterval()) { registerLocationListener(); } SensorDataSet sensorDataSet = getSensorDataSet(); if (sensorDataSet != null) { location = new MyTracksLocation(location, sensorDataSet); } // Always insert the first segment location if (!currentSegmentHasLocation) { insertLocation(track, location, null); currentSegmentHasLocation = true; lastLocation = location; return; } if (!LocationUtils.isValidLocation(lastValidTrackPoint)) { /* * Should not happen. The current segment should have a location. Just * insert the current location. */ insertLocation(track, location, null); lastLocation = location; return; } double distanceToLastTrackLocation = location.distanceTo(lastValidTrackPoint); if (distanceToLastTrackLocation > maxRecordingDistance) { insertLocation(track, lastLocation, lastValidTrackPoint); Location pause = new Location(LocationManager.GPS_PROVIDER); pause.setLongitude(0); pause.setLatitude(PAUSE_LATITUDE); pause.setTime(lastLocation.getTime()); insertLocation(track, pause, null); insertLocation(track, location, null); isIdle = false; } else if (sensorDataSet != null || distanceToLastTrackLocation >= recordingDistanceInterval) { insertLocation(track, lastLocation, lastValidTrackPoint); insertLocation(track, location, null); isIdle = false; } else if (!isIdle && location.hasSpeed() && location.getSpeed() < MAX_NO_MOVEMENT_SPEED) { insertLocation(track, lastLocation, lastValidTrackPoint); insertLocation(track, location, null); isIdle = true; } else if (isIdle && location.hasSpeed() && location.getSpeed() >= MAX_NO_MOVEMENT_SPEED) { insertLocation(track, lastLocation, lastValidTrackPoint); insertLocation(track, location, null); isIdle = false; } else { Log.d(TAG, "Not recording location, idle"); } lastLocation = location; } catch (Error e) { Log.e(TAG, "Error in onLocationChangedAsync", e); throw e; } catch (RuntimeException e) { Log.e(TAG, "RuntimeException in onLocationChangedAsync", e); throw e; } } /** * Inserts a location. * * @param track the track * @param location the location * @param lastValidTrackPoint the last valid track point, can be null */ private void insertLocation(Track track, Location location, Location lastValidTrackPoint) { if (location == null) { Log.w(TAG, "Ignore insertLocation. loation is null."); return; } // Do not insert if inserted already if (lastValidTrackPoint != null && lastValidTrackPoint.getTime() == location.getTime()) { Log.w(TAG, "Ignore insertLocation. location time same as last valid track point time."); return; } try { Uri uri = myTracksProviderUtils.insertTrackPoint(location, track.getId()); long trackPointId = Long.parseLong(uri.getLastPathSegment()); ActivityType activityType = CalorieUtils.getActivityType(context, track.getCategory()); trackTripStatisticsUpdater.addLocation( location, recordingDistanceInterval, true, activityType, weight); markerTripStatisticsUpdater.addLocation( location, recordingDistanceInterval, true, activityType, weight); updateRecordingTrack(track, trackPointId, LocationUtils.isValidLocation(location)); } 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(TAG, "SQLiteException", e); } voiceExecutor.update(); splitExecutor.update(); sendTrackBroadcast(R.string.track_update_broadcast_action, track.getId()); } /** * Updates the recording track time. Also updates the startId and the stopId. * Increase the number of points if it is a new and valid track point. * * @param track the track * @param lastTrackPointId the last track point id * @param increaseNumberOfPoints true to increase the number of points */ private void updateRecordingTrack( Track track, long lastTrackPointId, boolean increaseNumberOfPoints) { if (lastTrackPointId >= 0) { if (track.getStartId() < 0) { track.setStartId(lastTrackPointId); } track.setStopId(lastTrackPointId); } if (increaseNumberOfPoints) { track.setNumberOfPoints(track.getNumberOfPoints() + 1); } trackTripStatisticsUpdater.updateTime(System.currentTimeMillis()); track.setTripStatistics(trackTripStatisticsUpdater.getTripStatistics()); myTracksProviderUtils.updateTrack(track); } private SensorDataSet getSensorDataSet() { if (sensorManager == null || !sensorManager.isEnabled() || !sensorManager.isSensorDataSetValid()) { return null; } return sensorManager.getSensorDataSet(); } /** * Registers the location listener. */ private void registerLocationListener() { if (myTracksLocationManager == null) { Log.e(TAG, "locationManager is null."); return; } try { long interval = locationListenerPolicy.getDesiredPollingInterval(); myTracksLocationManager.requestLocationUpdates( interval, locationListenerPolicy.getMinDistance(), locationListener); currentRecordingInterval = interval; } catch (RuntimeException e) { Log.e(TAG, "Could not register location listener.", e); } } /** * Unregisters the location manager. */ private void unregisterLocationListener() { if (myTracksLocationManager == null) { Log.e(TAG, "locationManager is null."); return; } myTracksLocationManager.removeLocationUpdates(locationListener); } /** * Releases the wake lock. */ private void releaseWakeLock() { if (wakeLock != null && wakeLock.isHeld()) { wakeLock.release(); wakeLock = null; } } /** * Shows the notification. * * @param isGpsStarted true if GPS is started */ private void showNotification(boolean isGpsStarted) { if (isRecording()) { if (isPaused()) { stopForegroundService(); } else { Intent intent = IntentUtils.newIntent(this, TrackDetailActivity.class) .putExtra(TrackDetailActivity.EXTRA_TRACK_ID, recordingTrackId); PendingIntent pendingIntent = TaskStackBuilder.create(this) .addParentStack(TrackDetailActivity.class).addNextIntent(intent) .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT); startForegroundService(pendingIntent, R.string.track_record_notification); } return; } else { // Not recording if (isGpsStarted) { Intent intent = IntentUtils.newIntent(this, TrackListActivity.class); PendingIntent pendingIntent = TaskStackBuilder.create(this) .addNextIntent(intent).getPendingIntent(0, 0); startForegroundService(pendingIntent, R.string.gps_starting); } else { stopForegroundService(); } } } /** * Sends track broadcast. * * @param actionId the intent action id * @param trackId the track id */ private void sendTrackBroadcast(int actionId, long trackId) { Intent intent = new Intent().setAction(getString(actionId)) .putExtra(getString(R.string.track_id_broadcast_extra), trackId); sendBroadcast(intent, getString(R.string.permission_notification_value)); if (PreferencesUtils.getBoolean( this, R.string.allow_access_key, PreferencesUtils.ALLOW_ACCESS_DEFAULT)) { sendBroadcast(intent, getString(R.string.broadcast_notifications_permission)); } } /** * TODO: There is a bug in Android that leaks Binder instances. This bug is * especially visible if we have a non-static class, as there is no way to * nullify reference to the outer class (the service). A workaround is to use * a static class and explicitly clear service and detach it from the * underlying Binder. With this approach, we minimize the leak to 24 bytes per * each service instance. For more details, see the following bug: * http://code.google.com/p/android/issues/detail?id=6426. */ private static class ServiceBinder extends ITrackRecordingService.Stub { private TrackRecordingService trackRecordingService; private DeathRecipient deathRecipient; public ServiceBinder(TrackRecordingService trackRecordingService) { this.trackRecordingService = trackRecordingService; } @Override public boolean isBinderAlive() { return trackRecordingService != null; } @Override public boolean pingBinder() { return isBinderAlive(); } @Override public void linkToDeath(DeathRecipient recipient, int flags) { deathRecipient = recipient; } @Override public boolean unlinkToDeath(DeathRecipient recipient, int flags) { if (!isBinderAlive()) { return false; } deathRecipient = null; return true; } @Override public void startGps() { if (!canAccess()) { return; } if (!trackRecordingService.isRecording()) { trackRecordingService.startGps(); } } public void stopGps() { if (!canAccess()) { return; } if (!trackRecordingService.isRecording()) { trackRecordingService.stopGps(true); } } @Override public long startNewTrack() { if (!canAccess()) { return -1L; } return trackRecordingService.startNewTrack(); } @Override public void pauseCurrentTrack() { if (!canAccess()) { return; } trackRecordingService.pauseCurrentTrack(); } @Override public void resumeCurrentTrack() { if (!canAccess()) { return; } trackRecordingService.resumeCurrentTrack(); } @Override public void endCurrentTrack() { if (!canAccess()) { return; } trackRecordingService.endCurrentTrack(); } @Override public boolean isRecording() { if (!canAccess()) { return false; } return trackRecordingService.isRecording(); } @Override public boolean isPaused() { if (!canAccess()) { return false; } return trackRecordingService.isPaused(); } @Override public long getRecordingTrackId() { if (!canAccess()) { return -1L; } return trackRecordingService.recordingTrackId; } @Override public long getTotalTime() { if (!canAccess()) { return 0; } TripStatisticsUpdater updater = trackRecordingService.trackTripStatisticsUpdater; if (updater == null) { return 0; } if (!trackRecordingService.isPaused()) { updater.updateTime(System.currentTimeMillis()); } return updater.getTripStatistics().getTotalTime(); } @Override public long insertWaypoint(WaypointCreationRequest waypointCreationRequest) { if (!canAccess()) { return -1L; } return trackRecordingService.insertWaypoint(waypointCreationRequest); } @Override public void insertTrackPoint(Location location) { if (!canAccess()) { return; } trackRecordingService.locationListener.onLocationChanged(location); } @Override public byte[] getSensorData() { if (!canAccess()) { return null; } if (trackRecordingService.sensorManager == null) { Log.d(TAG, "sensorManager is null."); return null; } if (trackRecordingService.sensorManager.getSensorDataSet() == null) { Log.d(TAG, "Sensor data set is null."); return null; } return trackRecordingService.sensorManager.getSensorDataSet().toByteArray(); } @Override public int getSensorState() { if (!canAccess()) { return Sensor.SensorState.NONE.getNumber(); } if (trackRecordingService.sensorManager == null) { Log.d(TAG, "sensorManager is null."); return Sensor.SensorState.NONE.getNumber(); } return trackRecordingService.sensorManager.getSensorState().getNumber(); } /** * Returns true if the RPC caller is from the same application or if the * "Allow access" setting indicates that another app can invoke this * service's RPCs. */ private boolean canAccess() { // As a precondition for access, must check if the service is available. if (trackRecordingService == null) { throw new IllegalStateException("The track recording service has been detached!"); } if (Process.myPid() == Binder.getCallingPid()) { return true; } else { return PreferencesUtils.getBoolean(trackRecordingService, R.string.allow_access_key, PreferencesUtils.ALLOW_ACCESS_DEFAULT); } } /** * Detaches from the track recording service. Clears the reference to the * outer class to minimize the leak. */ private void detachFromService() { trackRecordingService = null; attachInterface(null, null); if (deathRecipient != null) { deathRecipient.binderDied(); } } @Override public void updateCalorie() { if (!canAccess()) { return; } trackRecordingService.updateCalorie(); } } /** * Updates the calorie of current recording track after the current track is * edited by user. */ public void updateCalorie() { if (executorService == null || executorService.isShutdown() || executorService.isTerminated()) { return; } executorService.submit(new Runnable() { @Override public void run() { if (!isRecording()) { Log.w(TAG, "Ignore updateCalorie. Not recording."); return; } Track track = myTracksProviderUtils.getTrack(recordingTrackId); if (track == null) { Log.w(TAG, "Ignore updateCalorie. No track."); return; } double[] calories = CalorieUtils.updateTrackCalorie(context, track); // Update track statistics trackTripStatisticsUpdater.updateCalorie(calories[0]); // Update marker statistics markerTripStatisticsUpdater.updateCalorie(calories[1]); } }); } }