/* * Copyright (c) 2013, Will Szumski * Copyright (c) 2013, Doug Szumski * * This file is part of Cyclismo. * * Cyclismo is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Cyclismo is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Cyclismo. If not, see <http://www.gnu.org/licenses/>. */ /* * 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 org.cowboycoders.cyclismo.services; import android.app.Notification; 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.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.Binder; import android.os.Bundle; import android.os.Handler; import android.os.IBinder; import android.os.PowerManager; import android.os.PowerManager.WakeLock; import android.os.Process; import android.os.RemoteException; import android.support.v4.app.NotificationCompat; import android.support.v4.app.TaskStackBuilder; import android.util.Log; import com.google.common.annotations.VisibleForTesting; import org.cowboycoders.cyclismo.Constants; import org.cowboycoders.cyclismo.R; import org.cowboycoders.cyclismo.TrackDetailActivity; import org.cowboycoders.cyclismo.content.DescriptionGeneratorImpl; import org.cowboycoders.cyclismo.content.MyTracksLocation; import org.cowboycoders.cyclismo.content.MyTracksProvider; import org.cowboycoders.cyclismo.content.MyTracksProviderUtils; import org.cowboycoders.cyclismo.content.Sensor; import org.cowboycoders.cyclismo.content.Sensor.SensorDataSet; import org.cowboycoders.cyclismo.content.Track; import org.cowboycoders.cyclismo.content.Waypoint; import org.cowboycoders.cyclismo.content.WaypointCreationRequest; import org.cowboycoders.cyclismo.content.WaypointCreationRequest.WaypointType; import org.cowboycoders.cyclismo.services.sensors.SensorManager; import org.cowboycoders.cyclismo.services.sensors.SensorManagerFactory; import org.cowboycoders.cyclismo.services.tasks.AnnouncementPeriodicTaskFactory; import org.cowboycoders.cyclismo.services.tasks.PeriodicTaskExecutor; import org.cowboycoders.cyclismo.services.tasks.SplitPeriodicTaskFactory; import org.cowboycoders.cyclismo.stats.TripStatistics; import org.cowboycoders.cyclismo.stats.TripStatisticsUpdater; import org.cowboycoders.cyclismo.util.IntentUtils; import org.cowboycoders.cyclismo.util.LocationUtils; import org.cowboycoders.cyclismo.util.PreferencesUtils; import org.cowboycoders.cyclismo.util.TrackIconUtils; import org.cowboycoders.cyclismo.util.TrackNameUtils; import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.locks.ReentrantLock; import static org.cowboycoders.cyclismo.Constants.RESUME_TRACK_EXTRA_NAME; /** * 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 { private static final String TAG = TrackRecordingService.class.getSimpleName(); public static final double PAUSE_LATITUDE = 100.0; public static final double RESUME_LATITUDE = 200.0; // One second in milliseconds private static final long ONE_SECOND = 1000; // One minute in milliseconds private static final long ONE_MINUTE = 60 * ONE_SECOND; @VisibleForTesting static final int MAX_AUTO_RESUME_TRACK_RETRY_ATTEMPTS = 3; // The following variables are set in onCreate: private Context context; private MyTracksProviderUtils myTracksProviderUtils; private MyTracksLocationManager myTracksLocationManager; private PeriodicTaskExecutor voiceExecutor; private PeriodicTaskExecutor splitExecutor; private ExecutorService executorService; private SharedPreferences sharedPreferences; private long recordingTrackId; private boolean recordingTrackPaused; private LocationListenerPolicy locationListenerPolicy; private int minRecordingDistance; private int maxRecordingDistance; private int minRequiredAccuracy; private int autoResumeTrackTimeout; private long currentRecordingInterval; // 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; // Timer to periodically invoke checkLocationListener private final Timer timer = new Timer(); // Handler for the timer to post a runnable to the main thread private final Handler handler = new Handler(); private ServiceBinder binder = new ServiceBinder(this); private final ReentrantLock lastLocationFutureLock = new ReentrantLock(); private Future<?> lastLocationFuture; /* * 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.metric_units_key))) { boolean metricUnits = PreferencesUtils.getBoolean( context, R.string.metric_units_key, PreferencesUtils.METRIC_UNITS_DEFAULT); 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.min_recording_distance_key))) { minRecordingDistance = PreferencesUtils.getInt(context, R.string.min_recording_distance_key, PreferencesUtils.MIN_RECORDING_DISTANCE_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.min_required_accuracy_key))) { minRequiredAccuracy = PreferencesUtils.getInt(context, R.string.min_required_accuracy_key, PreferencesUtils.MIN_REQUIRED_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); } } }; private LocationListener locationListener = new LocationListener() { @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 } @Override public void onLocationChanged(final Location location) { if (myTracksLocationManager == null || executorService == null || executorService.isShutdown() || executorService.isTerminated()) { return; } lastLocationFutureLock.lock(); try { lastLocationFuture = executorService.submit(new Runnable() { @Override public void run() { onLocationChangedAsync(location); } }); } finally { lastLocationFutureLock.unlock(); } } }; private TimerTask checkLocationListener = new TimerTask() { @Override public void run() { if (isRecording() && !isPaused()) { handler.post(new Runnable() { public void run() { registerLocationListener(); } }); } } }; /* * 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(); context = this; myTracksProviderUtils = MyTracksProviderUtils.Factory.get(this); myTracksLocationManager = new MyTracksLocationManager(this); voiceExecutor = new PeriodicTaskExecutor(this, new AnnouncementPeriodicTaskFactory()); splitExecutor = new PeriodicTaskExecutor(this, new SplitPeriodicTaskFactory()); executorService = Executors.newSingleThreadExecutor(); sharedPreferences = getSharedPreferences(Constants.SETTINGS_NAME, Context.MODE_PRIVATE); sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener); // onSharedPreferenceChanged might not set recordingTrackId. recordingTrackId = PreferencesUtils.RECORDING_TRACK_ID_DEFAULT; // Require announcementExecutor and splitExecutor to be created. sharedPreferenceChangeListener.onSharedPreferenceChanged(sharedPreferences, null); timer.schedule(checkLocationListener, 0, ONE_MINUTE); /* * 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(); } } /* * 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() { showNotification(); sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener); checkLocationListener.cancel(); checkLocationListener = null; timer.cancel(); timer.purge(); unregisterLocationListener(); try { voiceExecutor.shutdown(); } finally { voiceExecutor = null; } try { splitExecutor.shutdown(); } finally { splitExecutor = null; } if (sensorManager != null) { SensorManagerFactory.releaseSystemSensorManager(); sensorManager = null; } // Make sure we have no indirect references to this service. myTracksProviderUtils = null; myTracksLocationManager.close(); myTracksLocationManager = null; binder.detachFromService(); binder = null; // This should be the next to last operation releaseWakeLock(); /* * Shutdown the executor service 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; } boolean isStatistics = waypointCreationRequest.getType() == WaypointType.STATISTICS; String name; if (waypointCreationRequest.getName() != null) { name = waypointCreationRequest.getName(); } else { int nextWaypointNumber = myTracksProviderUtils.getNextWaypointNumber( recordingTrackId, isStatistics); if (nextWaypointNumber == -1) { nextWaypointNumber = 0; } name = getString( isStatistics ? R.string.marker_split_name_format : R.string.marker_name_format, nextWaypointNumber); } TripStatistics tripStatistics; String description; if (isStatistics) { long now = System.currentTimeMillis(); markerTripStatisticsUpdater.updateTime(now); tripStatistics = markerTripStatisticsUpdater.getTripStatistics(); markerTripStatisticsUpdater = new TripStatisticsUpdater(now); description = new DescriptionGeneratorImpl(this).generateWaypointDescription(tripStatistics); } else { tripStatistics = null; description = waypointCreationRequest.getDescription() != null ? waypointCreationRequest .getDescription() : ""; } String category = waypointCreationRequest.getCategory() != null ? waypointCreationRequest .getCategory() : ""; String icon = getString( isStatistics ? R.string.marker_statistics_icon_url : R.string.marker_waypoint_icon_url); int type = isStatistics ? Waypoint.TYPE_STATISTICS : Waypoint.TYPE_WAYPOINT; long duration; double length; lastLocationFutureLock.lock(); try { if (lastLocationFuture != null) { // Block until the last asynchronous location update is complete lastLocationFuture.get(); } } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } finally { lastLocationFutureLock.unlock(); } 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; duration = 0; } Waypoint waypoint = new Waypoint(name, description, category, icon, recordingTrackId, type, length, duration, -1L, -1L, location, tripStatistics); Uri uri = myTracksProviderUtils.insertWaypoint(waypoint); return Long.parseLong(uri.getLastPathSegment()); } /** * Starts the service as a foreground service. * * @param notification the notification for the foreground service */ @VisibleForTesting protected void startForegroundService(Notification notification) { startForeground(1, notification); } /** * 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); long currentUserId = PreferencesUtils.getLong(this, R.string.settings_select_user_current_selection_key); // Insert a track Track track = new Track(); track.setOwner(currentUserId); 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); // Update database track.setId(trackId); track.setName(TrackNameUtils.getTrackName(this, trackId, now, null)); track.setCategory(PreferencesUtils.getString( this, R.string.default_activity_key, PreferencesUtils.getDefaultActivityDefault(this))); track.setIcon((TrackIconUtils.getIconValue(this, track.getCategory()))); 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.getLastStatisticsWaypoint(recordingTrackId); if (waypoint != null && waypoint.getTripStatistics() != null) { markerStartTime = waypoint.getTripStatistics().getStopTime(); } else { markerStartTime = tripStatistics.getStartTime(); } markerTripStatisticsUpdater = new TripStatisticsUpdater(markerStartTime); Cursor cursor = null; try { // TODO: how to handle very long track. cursor = myTracksProviderUtils.getTrackPointCursor( recordingTrackId, -1, Constants.MAX_LOADED_TRACK_POINTS, true); if (cursor == null) { Log.e(TAG, "Cursor is null."); } else { if (cursor.moveToLast()) { do { Location location = myTracksProviderUtils.createTrackPoint(cursor); trackTripStatisticsUpdater.addLocation(location, minRecordingDistance); if (location.getTime() > markerStartTime) { markerTripStatisticsUpdater.addLocation(location, minRecordingDistance); } } while (cursor.moveToPrevious()); } } } catch (RuntimeException e) { Log.e(TAG, "RuntimeException", e); } finally { if (cursor != null) { cursor.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) { acquireWakeLock(); // Update instance variables sensorManager = SensorManagerFactory.getSystemSensorManager(this); lastLocation = null; currentSegmentHasLocation = false; // Register notifications registerLocationListener(); // Send notifications showNotification(); sendTrackBroadcast(trackStarted ? R.string.track_started_broadcast_action : R.string.track_resumed_broadcast_action, recordingTrackId); // Restore periodic tasks voiceExecutor.restore(); splitExecutor.restore(); } /** * 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 && !paused) { insertLocation(track, lastLocation, getLastValidTrackPointInCurrentSegment(trackId)); updateRecordingTrack(track, myTracksProviderUtils.getLastTrackPointId(trackId), false); } endRecording(true, trackId); stopSelf(); } /** * Gets the last valid track point in the current segment. Returns null if not available. * This may happen, for example, when the asynchronous location update hasn't finished and there * is no prior location. * * @param trackId the track id */ private Location getLastValidTrackPointInCurrentSegment(long trackId) { if (!currentSegmentHasLocation) { return null; } return myTracksProviderUtils.getLastValidTrackPoint(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; // Unregister notifications unregisterLocationListener(); // Send notifications showNotification(); sendTrackBroadcast(trackStopped ? R.string.track_stopped_broadcast_action : R.string.track_paused_broadcast_action, trackId); releaseWakeLock(); } /** * 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.getAccuracy() > minRequiredAccuracy) { Log.d(TAG, "Ignore onLocationChangedAsync. Poor accuracy."); return; } Location lastValidTrackPoint = getLastValidTrackPointInCurrentSegment(track.getId()); long idleTime = lastValidTrackPoint != null ? location.getTime() - lastValidTrackPoint.getTime() : 0L; 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 < minRecordingDistance + Constants.RECORDING_DISTANCE_ACCURACY && sensorDataSet == null) { Log.d(TAG, "Not recording location due to min recording distance."); } else if (distanceToLastTrackLocation > maxRecordingDistance - Constants.RECORDING_DISTANCE_ACCURACY) { 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); } else { /* * (distanceToLastTrackLocation >= minRecordingDistance || * hasSensorData) && distanceToLastTrackLocation <= maxRecordingDistance */ insertLocation(track, lastLocation, lastValidTrackPoint); insertLocation(track, location, null); } 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. Location is null."); return; } Log.d(TAG, "insertLocation, location: " + location.toString()); if (lastValidTrackPoint != null) { Log.d(TAG, "insertLocation, lastValidTrackPoint" + lastValidTrackPoint.toString()); } // 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; } Log.d(TAG, "insertLocation, location: " + location.toString()); if (lastValidTrackPoint != null) { Log.d(TAG, "insertLocation, lastValidLocation: " + lastValidTrackPoint.toString()); } try { Uri uri = myTracksProviderUtils.insertTrackPoint(location, track.getId()); long trackPointId = Long.parseLong(uri.getLastPathSegment()); trackTripStatisticsUpdater.addLocation(location, minRecordingDistance); markerTripStatisticsUpdater.addLocation(location, minRecordingDistance); 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()); } private void updateRecordingTrack( Track track, long trackPointId, boolean isTrackPointNewAndValid) { if (trackPointId >= 0) { if (track.getStartId() < 0) { track.setStartId(trackPointId); } track.setStopId(trackPointId); } if (isTrackPointNewAndValid) { 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() { unregisterLocationListener(); if (myTracksLocationManager == null) { Log.e(TAG, "locationManager is null."); return; } try { long interval = locationListenerPolicy.getDesiredPollingInterval(); myTracksLocationManager.requestLocationUpdates(SimulatedLocationProvider.NAME, 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.removeUpdates(locationListener); } /** * Acquires the wake lock. */ private void acquireWakeLock() { try { PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE); if (powerManager == null) { Log.e(TAG, "powerManager is null."); return; } if (wakeLock == null) { wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG); if (wakeLock == null) { Log.e(TAG, "wakeLock is null."); return; } } if (!wakeLock.isHeld()) { wakeLock.acquire(); if (!wakeLock.isHeld()) { Log.e(TAG, "Unable to hold wakeLock."); } } } catch (RuntimeException e) { Log.e(TAG, "Caught unexpected exception", e); } } /** * Releases the wake lock. */ private void releaseWakeLock() { if (wakeLock != null && wakeLock.isHeld()) { wakeLock.release(); wakeLock = null; } } /** * Shows the notification. */ private void showNotification() { if (isRecording() && !isPaused()) { Long courseId = PreferencesUtils.getLong(this, R.string.recording_course_track_id_key); Intent intent = IntentUtils.newIntent(this, TrackDetailActivity.class) .putExtra(TrackDetailActivity.EXTRA_TRACK_ID, recordingTrackId) .putExtra(TrackDetailActivity.EXTRA_USE_COURSE_PROVIDER, false) .putExtra(TrackDetailActivity.EXTRA_COURSE_TRACK_ID, courseId); TaskStackBuilder taskStackBuilder = TaskStackBuilder.create(this); taskStackBuilder.addNextIntent(intent); NotificationCompat.Builder builder = new NotificationCompat.Builder(this).setContentIntent( taskStackBuilder.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)) .setContentText(getString(R.string.track_record_notification)) .setContentTitle(getString(R.string.my_tracks_app_name)).setOngoing(true) .setSmallIcon(R.drawable.my_tracks_notification_icon).setWhen(System.currentTimeMillis()); startForegroundService(builder.build()); } 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 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 TripStatistics getTripStatistics() throws RemoteException { if (!canAccess()) { return null; } TripStatisticsUpdater updater = trackRecordingService.trackTripStatisticsUpdater; if (updater == null) { return null; } if (!trackRecordingService.isPaused()) { updater.updateTime(System.currentTimeMillis()); } return updater.getTripStatistics(); } @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(); } } } }