forked from upstream-mirrors/OpenTracks
1096 lines
37 KiB
Java
1096 lines
37 KiB
Java
/*
|
|
* Copyright 2011 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.content;
|
|
|
|
import static com.google.android.apps.mytracks.Constants.DEFAULT_MIN_REQUIRED_ACCURACY;
|
|
import static com.google.android.apps.mytracks.Constants.MAX_DISPLAYED_WAYPOINTS_POINTS;
|
|
import static com.google.android.apps.mytracks.Constants.TARGET_DISPLAYED_TRACK_POINTS;
|
|
import static com.google.android.apps.mytracks.Constants.MAX_LOCATION_AGE_MS;
|
|
import static com.google.android.apps.mytracks.Constants.MAX_NETWORK_AGE_MS;
|
|
import static com.google.android.apps.mytracks.Constants.TAG;
|
|
|
|
import com.google.android.apps.mytracks.Constants;
|
|
import com.google.android.apps.mytracks.content.DataSourceManager.DataSourceListener;
|
|
import com.google.android.apps.mytracks.content.MyTracksProviderUtils.DoubleBufferedLocationFactory;
|
|
import com.google.android.apps.mytracks.content.MyTracksProviderUtils.LocationIterator;
|
|
import com.google.android.apps.mytracks.content.TrackDataListener.ProviderState;
|
|
import com.google.android.apps.mytracks.content.TrackDataListeners.ListenerRegistration;
|
|
import com.google.android.apps.mytracks.util.ApiFeatures;
|
|
import com.google.android.apps.mytracks.util.LocationUtils;
|
|
import com.google.android.maps.mytracks.R;
|
|
|
|
import android.content.Context;
|
|
import android.content.SharedPreferences;
|
|
import android.database.Cursor;
|
|
import android.hardware.GeomagneticField;
|
|
import android.location.Location;
|
|
import android.location.LocationManager;
|
|
import android.os.Handler;
|
|
import android.os.HandlerThread;
|
|
import android.util.Log;
|
|
import android.widget.Toast;
|
|
|
|
import java.util.Collections;
|
|
import java.util.EnumSet;
|
|
import java.util.Set;
|
|
|
|
/**
|
|
* Track data hub, which receives data (both live and recorded) from many
|
|
* different sources and distributes it to those interested after some standard
|
|
* processing.
|
|
*
|
|
* TODO: Simplify the threading model here, it's overly complex and it's not obvious why
|
|
* certain race conditions won't happen.
|
|
*
|
|
* @author Rodrigo Damazio
|
|
*/
|
|
public class TrackDataHub {
|
|
|
|
// Preference keys
|
|
private final String SELECTED_TRACK_KEY;
|
|
private final String RECORDING_TRACK_KEY;
|
|
private final String MIN_REQUIRED_ACCURACY_KEY;
|
|
private final String METRIC_UNITS_KEY;
|
|
private final String SPEED_REPORTING_KEY;
|
|
|
|
// Overridable constants
|
|
private final int targetNumPoints;
|
|
|
|
/** Types of data that we can expose. */
|
|
public static enum ListenerDataType {
|
|
/** Listen to when the selected track changes. */
|
|
SELECTED_TRACK_CHANGED,
|
|
|
|
/** Listen to when the tracks change. */
|
|
TRACK_UPDATES,
|
|
|
|
/** Listen to when the waypoints change. */
|
|
WAYPOINT_UPDATES,
|
|
|
|
/** Listen to when the current track points change. */
|
|
POINT_UPDATES,
|
|
|
|
/**
|
|
* Listen to sampled-out points.
|
|
* Listening to this without listening to {@link #SAMPLED_POINT_UPDATES}
|
|
* makes no sense and may yield unexpected results.
|
|
*/
|
|
SAMPLED_OUT_POINT_UPDATES,
|
|
|
|
/** Listen to updates to the current location. */
|
|
LOCATION_UPDATES,
|
|
|
|
/** Listen to updates to the current heading. */
|
|
COMPASS_UPDATES,
|
|
|
|
/** Listens to changes in display preferences. */
|
|
DISPLAY_PREFERENCES;
|
|
}
|
|
|
|
/** Listener which receives events from the system. */
|
|
private class HubDataSourceListener implements DataSourceListener {
|
|
@Override
|
|
public void notifyTrackUpdated() {
|
|
TrackDataHub.this.notifyTrackUpdated(getListenersFor(ListenerDataType.TRACK_UPDATES));
|
|
}
|
|
|
|
@Override
|
|
public void notifyWaypointUpdated() {
|
|
TrackDataHub.this.notifyWaypointUpdated(getListenersFor(ListenerDataType.WAYPOINT_UPDATES));
|
|
}
|
|
|
|
@Override
|
|
public void notifyPointsUpdated() {
|
|
TrackDataHub.this.notifyPointsUpdated(true, 0, 0,
|
|
getListenersFor(ListenerDataType.POINT_UPDATES),
|
|
getListenersFor(ListenerDataType.SAMPLED_OUT_POINT_UPDATES));
|
|
}
|
|
|
|
@Override
|
|
public void notifyPreferenceChanged(String key) {
|
|
TrackDataHub.this.notifyPreferenceChanged(key);
|
|
}
|
|
|
|
@Override
|
|
public void notifyLocationProviderEnabled(boolean enabled) {
|
|
hasProviderEnabled = enabled;
|
|
TrackDataHub.this.notifyFixType();
|
|
}
|
|
|
|
@Override
|
|
public void notifyLocationChanged(Location loc) {
|
|
TrackDataHub.this.notifyLocationChanged(loc,
|
|
getListenersFor(ListenerDataType.LOCATION_UPDATES));
|
|
}
|
|
|
|
@Override
|
|
public void notifyHeadingChanged(float heading) {
|
|
lastSeenMagneticHeading = heading;
|
|
maybeUpdateDeclination();
|
|
TrackDataHub.this.notifyHeadingChanged(getListenersFor(ListenerDataType.COMPASS_UPDATES));
|
|
}
|
|
}
|
|
|
|
// Application services
|
|
private final Context context;
|
|
private final MyTracksProviderUtils providerUtils;
|
|
private final SharedPreferences preferences;
|
|
|
|
// Get content notifications on the main thread, send listener callbacks in another.
|
|
// This ensures listener calls are serialized.
|
|
private final HandlerThread listenerHandlerThread;
|
|
private final Handler listenerHandler;
|
|
|
|
/** Manager for external listeners (those from activities). */
|
|
private final TrackDataListeners listeners;
|
|
|
|
/** Wrapper for interacting with system data managers. */
|
|
private final DataSourcesWrapper dataSources;
|
|
|
|
/** Manager for system data listener registrations. */
|
|
private final DataSourceManager dataSourceManager;
|
|
|
|
/** Condensed listener for system data listener events. */
|
|
private final DataSourceListener dataSourceListener = new HubDataSourceListener();
|
|
|
|
/** Whether we've been started. */
|
|
private boolean started;
|
|
|
|
// Cached preference values
|
|
private int minRequiredAccuracy;
|
|
private boolean useMetricUnits;
|
|
private boolean reportSpeed;
|
|
|
|
// Cached sensor readings
|
|
private float declination;
|
|
private long lastDeclinationUpdate;
|
|
private float lastSeenMagneticHeading;
|
|
|
|
// Cached GPS readings
|
|
private Location lastSeenLocation;
|
|
private boolean hasProviderEnabled = true;
|
|
private boolean hasFix;
|
|
private boolean hasGoodFix;
|
|
|
|
// Transient state about the selected track
|
|
private long selectedTrackId;
|
|
private long firstSeenLocationId;
|
|
private long lastSeenLocationId;
|
|
private int numLoadedPoints;
|
|
private int lastSamplingFrequency;
|
|
private DoubleBufferedLocationFactory locationFactory;
|
|
|
|
/**
|
|
* Default constructor.
|
|
*/
|
|
public TrackDataHub(Context ctx, MyTracksProviderUtils providerUtils) {
|
|
this(ctx, ctx.getSharedPreferences(Constants.SETTINGS_NAME, 0), providerUtils);
|
|
}
|
|
|
|
/**
|
|
* Intermediate constructor, used only for passing preferences into arguments.
|
|
*/
|
|
private TrackDataHub(Context ctx, SharedPreferences preferences,
|
|
MyTracksProviderUtils providerUtils) {
|
|
this(ctx, new DataSourcesWrapperImpl(ctx, preferences), new TrackDataListeners(),
|
|
preferences, providerUtils, TARGET_DISPLAYED_TRACK_POINTS);
|
|
}
|
|
|
|
/**
|
|
* Injection constructor.
|
|
*/
|
|
// @VisibleForTesting
|
|
TrackDataHub(Context ctx, DataSourcesWrapper dataSources, TrackDataListeners listeners,
|
|
SharedPreferences preferences, MyTracksProviderUtils providerUtils, int targetNumPoints) {
|
|
this.context = ctx;
|
|
this.listeners = listeners;
|
|
this.preferences = preferences;
|
|
this.providerUtils = providerUtils;
|
|
this.dataSources = dataSources;
|
|
this.targetNumPoints = targetNumPoints;
|
|
this.dataSourceManager = new DataSourceManager(dataSourceListener, dataSources);
|
|
this.locationFactory = new DoubleBufferedLocationFactory();
|
|
|
|
SELECTED_TRACK_KEY = context.getString(R.string.selected_track_key);
|
|
RECORDING_TRACK_KEY = context.getString(R.string.recording_track_key);
|
|
MIN_REQUIRED_ACCURACY_KEY = context.getString(R.string.min_required_accuracy_key);
|
|
METRIC_UNITS_KEY = context.getString(R.string.metric_units_key);
|
|
SPEED_REPORTING_KEY = context.getString(R.string.report_speed_key);
|
|
|
|
listenerHandlerThread = new HandlerThread("trackDataContentThread");
|
|
listenerHandlerThread.start();
|
|
listenerHandler = new Handler(listenerHandlerThread.getLooper());
|
|
|
|
resetState();
|
|
}
|
|
|
|
/**
|
|
* Starts listening to data sources and reporting the data to external
|
|
* listeners.
|
|
*/
|
|
public void start() {
|
|
Log.i(TAG, "TrackDataHub.start");
|
|
if (started) {
|
|
Log.w(TAG, "Already started, ignoring");
|
|
return;
|
|
}
|
|
started = true;
|
|
|
|
// This may or may not register internal listeners, depending on whether
|
|
// we already had external listeners.
|
|
dataSourceManager.updateAllListeners(getNeededListenerTypes());
|
|
loadSharedPreferences();
|
|
|
|
// If there were listeners already registered, make sure they become up-to-date.
|
|
loadDataForAllListeners();
|
|
}
|
|
|
|
private void loadSharedPreferences() {
|
|
selectedTrackId = preferences.getLong(SELECTED_TRACK_KEY, -1);
|
|
useMetricUnits = preferences.getBoolean(METRIC_UNITS_KEY, true);
|
|
reportSpeed = preferences.getBoolean(SPEED_REPORTING_KEY, true);
|
|
minRequiredAccuracy = preferences.getInt(MIN_REQUIRED_ACCURACY_KEY,
|
|
DEFAULT_MIN_REQUIRED_ACCURACY);
|
|
}
|
|
|
|
/**
|
|
* Stops listening to data sources and reporting the data to external
|
|
* listeners.
|
|
*/
|
|
public void stop() {
|
|
Log.i(TAG, "TrackDataHub.stop");
|
|
if (!started) {
|
|
Log.w(TAG, "Not started, ignoring");
|
|
return;
|
|
}
|
|
|
|
// Unregister internal listeners even if there are external listeners registered.
|
|
dataSourceManager.unregisterAllListeners();
|
|
|
|
started = false;
|
|
}
|
|
|
|
/** Permanently invalidates and throws away all resources used by this class. */
|
|
public void destroy() {
|
|
if (started) {
|
|
throw new IllegalStateException("Can only destroy the data hub after it's been stopped");
|
|
}
|
|
|
|
ApiFeatures.getInstance().getApiPlatformAdapter()
|
|
.stopHandlerThread(listenerHandlerThread);
|
|
}
|
|
|
|
/** Updates known magnetic declination if needed. */
|
|
private void maybeUpdateDeclination() {
|
|
if (lastSeenLocation == null) {
|
|
// We still don't know where we are.
|
|
return;
|
|
}
|
|
|
|
// Update the declination every hour
|
|
long now = System.currentTimeMillis();
|
|
if (now - lastDeclinationUpdate < 60 * 60 * 1000) {
|
|
return;
|
|
}
|
|
|
|
lastDeclinationUpdate = now;
|
|
long timestamp = lastSeenLocation.getTime();
|
|
if (timestamp == 0) {
|
|
// Hack for Samsung phones which don't populate the time field
|
|
timestamp = now;
|
|
}
|
|
|
|
declination = getDeclinationFor(lastSeenLocation, timestamp);
|
|
Log.i(TAG, "Updated magnetic declination to " + declination);
|
|
}
|
|
|
|
// @VisibleForTesting
|
|
protected float getDeclinationFor(Location location, long timestamp) {
|
|
GeomagneticField field = new GeomagneticField(
|
|
(float) location.getLatitude(),
|
|
(float) location.getLongitude(),
|
|
(float) location.getAltitude(),
|
|
timestamp);
|
|
return field.getDeclination();
|
|
}
|
|
|
|
/**
|
|
* Forces the current location to be updated and reported to all listeners.
|
|
* The reported location may be from the network provider if the GPS provider
|
|
* is not available or doesn't have a fix.
|
|
*/
|
|
public void forceUpdateLocation() {
|
|
Log.i(TAG, "Forcing location update");
|
|
|
|
Location loc = dataSources.getLastKnownLocation();
|
|
if (loc != null) {
|
|
notifyLocationChanged(loc, getListenersFor(ListenerDataType.LOCATION_UPDATES));
|
|
}
|
|
}
|
|
|
|
|
|
/** Returns the ID of the currently-selected track. */
|
|
public long getSelectedTrackId() {
|
|
if (!started) {
|
|
loadSharedPreferences();
|
|
}
|
|
return selectedTrackId;
|
|
}
|
|
|
|
/** Returns whether there's a track currently selected. */
|
|
public boolean isATrackSelected() {
|
|
return getSelectedTrackId() > 0;
|
|
}
|
|
|
|
/** Returns whether we're currently recording a track. */
|
|
public boolean isRecording() {
|
|
if (!started) {
|
|
loadSharedPreferences();
|
|
}
|
|
return preferences.getLong(RECORDING_TRACK_KEY, -1) > 0;
|
|
}
|
|
|
|
/** Returns whether the selected track is still being recorded. */
|
|
public boolean isRecordingSelected() {
|
|
if (!started) {
|
|
loadSharedPreferences();
|
|
}
|
|
long recordingTrackId = preferences.getLong(RECORDING_TRACK_KEY, -1);
|
|
return recordingTrackId > 0 && recordingTrackId == selectedTrackId;
|
|
}
|
|
|
|
/**
|
|
* Loads the given track and makes it the currently-selected one.
|
|
* It is ok to call this method before {@link start}, and in that case
|
|
* the data will only be passed to listeners when {@link start} is called.
|
|
*
|
|
* @param trackId the ID of the track to load
|
|
*/
|
|
public void loadTrack(long trackId) {
|
|
if (trackId == selectedTrackId) {
|
|
Log.w(TAG, "Not reloading track, id=" + trackId);
|
|
return;
|
|
}
|
|
|
|
// Save the selection to memory and flash.
|
|
ApiFeatures.getInstance().getApiPlatformAdapter().applyPreferenceChanges(
|
|
preferences.edit().putLong(SELECTED_TRACK_KEY, trackId));
|
|
selectedTrackId = trackId;
|
|
|
|
// Force it to reload data from the beginning.
|
|
Log.d(TAG, "Loading track");
|
|
resetState();
|
|
|
|
loadDataForAllListeners();
|
|
}
|
|
|
|
/**
|
|
* Resets the internal state of what data has already been loaded into listeners.
|
|
*/
|
|
private void resetState() {
|
|
firstSeenLocationId = -1;
|
|
lastSeenLocationId = -1;
|
|
numLoadedPoints = 0;
|
|
lastSamplingFrequency = -1;
|
|
}
|
|
|
|
/**
|
|
* Unloads the currently-selected track.
|
|
*/
|
|
public void unloadCurrentTrack() {
|
|
loadTrack(-1);
|
|
}
|
|
|
|
public void registerTrackDataListener(
|
|
TrackDataListener listener, EnumSet<ListenerDataType> dataTypes) {
|
|
synchronized (listeners) {
|
|
ListenerRegistration registration = listeners.registerTrackDataListener(listener, dataTypes);
|
|
|
|
// Don't load any data or start internal listeners if start() hasn't been
|
|
// called. When it is called, we'll do both things.
|
|
if (!started) return;
|
|
|
|
reloadDataForListener(registration);
|
|
|
|
dataSourceManager.updateAllListeners(getNeededListenerTypes());
|
|
}
|
|
}
|
|
|
|
public void unregisterTrackDataListener(TrackDataListener listener) {
|
|
synchronized (listeners) {
|
|
listeners.unregisterTrackDataListener(listener);
|
|
|
|
// Don't load any data or start internal listeners if start() hasn't been
|
|
// called. When it is called, we'll do both things.
|
|
if (!started) return;
|
|
|
|
dataSourceManager.updateAllListeners(getNeededListenerTypes());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reloads all track data received so far into the specified listeners.
|
|
*/
|
|
public void reloadDataForListener(TrackDataListener listener) {
|
|
ListenerRegistration registration;
|
|
synchronized (listeners) {
|
|
registration = listeners.getRegistration(listener);
|
|
reloadDataForListener(registration);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reloads all track data received so far into the specified listeners.
|
|
*
|
|
* Assumes it's called from a block that synchronizes on {@link #listeners}.
|
|
*/
|
|
private void reloadDataForListener(final ListenerRegistration registration) {
|
|
if (!started) {
|
|
Log.w(TAG, "Not started, not reloading");
|
|
return;
|
|
}
|
|
if (registration == null) {
|
|
return;
|
|
}
|
|
|
|
// If a listener happens to be added after this method but before the Runnable below is
|
|
// executed, it will have triggered a separate call to load data only up to the point this
|
|
// listener got to. This is ensured by being synchronized on listeners.
|
|
final boolean isOnlyListener = (listeners.getNumListeners() == 1);
|
|
|
|
runInListenerThread(new Runnable() {
|
|
@SuppressWarnings("unchecked")
|
|
@Override
|
|
public void run() {
|
|
// Reload everything if either it's a different track, or the track has been resampled
|
|
// (this also covers the case of a new registration).
|
|
boolean reloadAll = registration.lastTrackId != selectedTrackId ||
|
|
registration.lastSamplingFrequency != lastSamplingFrequency;
|
|
Log.d(TAG, "Doing a " + (reloadAll ? "full" : "partial") + " reload for " + registration);
|
|
|
|
TrackDataListener listener = registration.listener;
|
|
Set<TrackDataListener> listenerSet = Collections.singleton(listener);
|
|
|
|
if (registration.isInterestedIn(ListenerDataType.DISPLAY_PREFERENCES)) {
|
|
reloadAll |= listener.onUnitsChanged(useMetricUnits);
|
|
reloadAll |= listener.onReportSpeedChanged(reportSpeed);
|
|
}
|
|
|
|
if (reloadAll && registration.isInterestedIn(ListenerDataType.SELECTED_TRACK_CHANGED)) {
|
|
notifySelectedTrackChanged(selectedTrackId, listenerSet);
|
|
}
|
|
|
|
if (registration.isInterestedIn(ListenerDataType.TRACK_UPDATES)) {
|
|
notifyTrackUpdated(listenerSet);
|
|
}
|
|
|
|
boolean interestedInPoints =
|
|
registration.isInterestedIn(ListenerDataType.POINT_UPDATES);
|
|
boolean interestedInSampledOutPoints =
|
|
registration.isInterestedIn(ListenerDataType.SAMPLED_OUT_POINT_UPDATES);
|
|
if (interestedInPoints || interestedInSampledOutPoints) {
|
|
long minPointId = 0;
|
|
int previousNumPoints = 0;
|
|
|
|
if (reloadAll) {
|
|
// Clear existing points and send them all again
|
|
notifyPointsCleared(listenerSet);
|
|
} else {
|
|
// Send only new points
|
|
minPointId = registration.lastPointId + 1;
|
|
previousNumPoints = registration.numLoadedPoints;
|
|
}
|
|
|
|
// If this is the only listener we have registered, keep the state that we serve to it as
|
|
// a reference for other future listeners.
|
|
notifyPointsUpdated(isOnlyListener,
|
|
minPointId,
|
|
previousNumPoints,
|
|
listenerSet,
|
|
interestedInSampledOutPoints ? listenerSet : Collections.EMPTY_SET);
|
|
}
|
|
|
|
if (registration.isInterestedIn(ListenerDataType.WAYPOINT_UPDATES)) {
|
|
notifyWaypointUpdated(listenerSet);
|
|
}
|
|
|
|
if (registration.isInterestedIn(ListenerDataType.LOCATION_UPDATES)) {
|
|
if (lastSeenLocation != null) {
|
|
notifyLocationChanged(lastSeenLocation, true, listenerSet);
|
|
} else {
|
|
notifyFixType();
|
|
}
|
|
}
|
|
|
|
if (registration.isInterestedIn(ListenerDataType.COMPASS_UPDATES)) {
|
|
notifyHeadingChanged(listenerSet);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Reloads all track data received so far into the specified listeners.
|
|
*/
|
|
private void loadDataForAllListeners() {
|
|
if (!started) {
|
|
Log.w(TAG, "Not started, not reloading");
|
|
return;
|
|
}
|
|
synchronized (listeners) {
|
|
if (!listeners.hasListeners()) {
|
|
Log.d(TAG, "No listeners, not reloading");
|
|
return;
|
|
}
|
|
}
|
|
|
|
runInListenerThread(new Runnable() {
|
|
@Override
|
|
public void run() {
|
|
// Ignore the return values here, we're already sending the full data set anyway
|
|
for (TrackDataListener listener :
|
|
getListenersFor(ListenerDataType.DISPLAY_PREFERENCES)) {
|
|
listener.onUnitsChanged(useMetricUnits);
|
|
listener.onReportSpeedChanged(reportSpeed);
|
|
}
|
|
|
|
notifySelectedTrackChanged(selectedTrackId,
|
|
getListenersFor(ListenerDataType.SELECTED_TRACK_CHANGED));
|
|
|
|
notifyTrackUpdated(getListenersFor(ListenerDataType.TRACK_UPDATES));
|
|
|
|
Set<TrackDataListener> pointListeners =
|
|
getListenersFor(ListenerDataType.POINT_UPDATES);
|
|
Set<TrackDataListener> sampledOutPointListeners =
|
|
getListenersFor(ListenerDataType.SAMPLED_OUT_POINT_UPDATES);
|
|
notifyPointsCleared(pointListeners);
|
|
notifyPointsUpdated(true, 0, 0, pointListeners, sampledOutPointListeners);
|
|
|
|
notifyWaypointUpdated(getListenersFor(ListenerDataType.WAYPOINT_UPDATES));
|
|
|
|
if (lastSeenLocation != null) {
|
|
notifyLocationChanged(lastSeenLocation, true,
|
|
getListenersFor(ListenerDataType.LOCATION_UPDATES));
|
|
} else {
|
|
notifyFixType();
|
|
}
|
|
|
|
notifyHeadingChanged(getListenersFor(ListenerDataType.COMPASS_UPDATES));
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Called when a preference changes.
|
|
*
|
|
* @param key the key to the preference that changed
|
|
*/
|
|
private void notifyPreferenceChanged(String key) {
|
|
if (MIN_REQUIRED_ACCURACY_KEY.equals(key)) {
|
|
minRequiredAccuracy = preferences.getInt(MIN_REQUIRED_ACCURACY_KEY,
|
|
DEFAULT_MIN_REQUIRED_ACCURACY);
|
|
} else if (METRIC_UNITS_KEY.equals(key)) {
|
|
useMetricUnits = preferences.getBoolean(METRIC_UNITS_KEY, true);
|
|
notifyUnitsChanged();
|
|
} else if (SPEED_REPORTING_KEY.equals(key)) {
|
|
reportSpeed = preferences.getBoolean(SPEED_REPORTING_KEY, true);
|
|
notifySpeedReportingChanged();
|
|
}
|
|
}
|
|
|
|
/** Called when the speed/pace reporting preference changes. */
|
|
private void notifySpeedReportingChanged() {
|
|
if (!started) return;
|
|
|
|
runInListenerThread(new Runnable() {
|
|
@Override
|
|
public void run() {
|
|
Set<TrackDataListener> displayListeners =
|
|
getListenersFor(ListenerDataType.DISPLAY_PREFERENCES);
|
|
|
|
for (TrackDataListener listener : displayListeners) {
|
|
// TODO: Do the reloading just once for all interested listeners
|
|
if (listener.onReportSpeedChanged(reportSpeed)) {
|
|
synchronized (listeners) {
|
|
reloadDataForListener(listeners.getRegistration(listener));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Called when the metric units setting changes. */
|
|
private void notifyUnitsChanged() {
|
|
if (!started) return;
|
|
|
|
runInListenerThread(new Runnable() {
|
|
@Override
|
|
public void run() {
|
|
Set<TrackDataListener> displayListeners = getListenersFor(ListenerDataType.DISPLAY_PREFERENCES);
|
|
|
|
for (TrackDataListener listener : displayListeners) {
|
|
if (listener.onUnitsChanged(useMetricUnits)) {
|
|
synchronized (listeners) {
|
|
reloadDataForListener(listeners.getRegistration(listener));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Notifies about the current GPS fix state. */
|
|
private void notifyFixType() {
|
|
final TrackDataListener.ProviderState state;
|
|
if (!hasProviderEnabled) {
|
|
state = ProviderState.DISABLED;
|
|
|
|
// Give a global warning about this state.
|
|
Toast.makeText(context, R.string.error_no_gps_location_provider, Toast.LENGTH_LONG).show();
|
|
} else if (!hasFix) {
|
|
state = ProviderState.NO_FIX;
|
|
} else if (!hasGoodFix) {
|
|
state = ProviderState.BAD_FIX;
|
|
} else {
|
|
state = ProviderState.GOOD_FIX;
|
|
}
|
|
|
|
runInListenerThread(new Runnable() {
|
|
@Override
|
|
public void run() {
|
|
// Notify to everyone.
|
|
Log.d(TAG, "Notifying fix type: " + state);
|
|
for (TrackDataListener listener :
|
|
getListenersFor(ListenerDataType.LOCATION_UPDATES)) {
|
|
listener.onProviderStateChange(state);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Notifies the the current location has changed, without any filtering.
|
|
* If the state of GPS fix has changed, that will also be reported.
|
|
*
|
|
* @param location the current location
|
|
* @param listeners the listeners to notify
|
|
*/
|
|
private void notifyLocationChanged(Location location, Set<TrackDataListener> listeners) {
|
|
notifyLocationChanged(location, false, listeners);
|
|
}
|
|
|
|
/**
|
|
* Notifies that the current location has changed, without any filtering.
|
|
* If the state of GPS fix has changed, that will also be reported.
|
|
*
|
|
* @param location the current location
|
|
* @param forceUpdate whether to force the notifications to happen
|
|
* @param listeners the listeners to notify
|
|
*/
|
|
private void notifyLocationChanged(Location location, boolean forceUpdate,
|
|
final Set<TrackDataListener> listeners) {
|
|
if (location == null) return;
|
|
if (listeners.isEmpty()) return;
|
|
|
|
boolean isGpsLocation = location.getProvider().equals(LocationManager.GPS_PROVIDER);
|
|
|
|
boolean oldHasFix = hasFix;
|
|
boolean oldHasGoodFix = hasGoodFix;
|
|
|
|
long now = System.currentTimeMillis();
|
|
if (isGpsLocation) {
|
|
// We consider a good fix to be a recent one with reasonable accuracy.
|
|
hasFix = !isLocationOld(location, now, MAX_LOCATION_AGE_MS);
|
|
hasGoodFix = (location != null && location.getAccuracy() <= minRequiredAccuracy);
|
|
} else {
|
|
if (!isLocationOld(lastSeenLocation, now, MAX_LOCATION_AGE_MS)) {
|
|
// This is a network location, but we have a recent/valid GPS location, just ignore this.
|
|
return;
|
|
}
|
|
|
|
// We haven't gotten a GPS location in a while (or at all), assume we have no fix anymore.
|
|
hasFix = false;
|
|
hasGoodFix = false;
|
|
|
|
// If the network location is recent, we'll use that.
|
|
if (isLocationOld(location, now, MAX_NETWORK_AGE_MS)) {
|
|
// Alas, we have no clue where we are.
|
|
location = null;
|
|
}
|
|
}
|
|
|
|
if (hasFix != oldHasFix || hasGoodFix != oldHasGoodFix || forceUpdate) {
|
|
notifyFixType();
|
|
}
|
|
|
|
lastSeenLocation = location;
|
|
final Location finalLoc = location;
|
|
runInListenerThread(new Runnable() {
|
|
@Override
|
|
public void run() {
|
|
for (TrackDataListener listener : listeners) {
|
|
listener.onCurrentLocationChanged(finalLoc);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns true if the given location is either invalid or too old.
|
|
*
|
|
* @param location the location to test
|
|
* @param now the current timestamp in milliseconds
|
|
* @param maxAge the maximum age in milliseconds
|
|
* @return true if it's invalid or too old, false otherwise
|
|
*/
|
|
private static boolean isLocationOld(Location location, long now, long maxAge) {
|
|
return !LocationUtils.isValidLocation(location) || now - location.getTime() > maxAge;
|
|
}
|
|
|
|
/**
|
|
* Notifies that the current heading has changed.
|
|
*
|
|
* @param listeners the listeners to notify
|
|
*/
|
|
private void notifyHeadingChanged(final Set<TrackDataListener> listeners) {
|
|
if (listeners.isEmpty()) return;
|
|
|
|
runInListenerThread(new Runnable() {
|
|
@Override
|
|
public void run() {
|
|
float heading = lastSeenMagneticHeading + declination;
|
|
for (TrackDataListener listener : listeners) {
|
|
listener.onCurrentHeadingChanged(heading);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Notifies that a new track has been selected..
|
|
*
|
|
* @param track the new selected track
|
|
* @param listeners the listeners to notify
|
|
*/
|
|
private void notifySelectedTrackChanged(long trackId,
|
|
final Set<TrackDataListener> listeners) {
|
|
if (listeners.isEmpty()) return;
|
|
|
|
Log.i(TAG, "New track selected, id=" + trackId);
|
|
final Track track = providerUtils.getTrack(trackId);
|
|
|
|
runInListenerThread(new Runnable() {
|
|
@Override
|
|
public void run() {
|
|
for (TrackDataListener listener : listeners) {
|
|
listener.onSelectedTrackChanged(track, isRecordingSelected());
|
|
|
|
if (track != null) {
|
|
listener.onTrackUpdated(track);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Notifies that the currently-selected track's data has been updated.
|
|
*
|
|
* @param listeners the listeners to notify
|
|
*/
|
|
private void notifyTrackUpdated(final Set<TrackDataListener> listeners) {
|
|
if (listeners.isEmpty()) return;
|
|
|
|
final Track track = providerUtils.getTrack(selectedTrackId);
|
|
|
|
runInListenerThread(new Runnable() {
|
|
@Override
|
|
public void run() {
|
|
for (TrackDataListener listener : listeners) {
|
|
listener.onTrackUpdated(track);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Notifies that waypoints have been updated.
|
|
* We assume few waypoints, so we reload them all every time.
|
|
*
|
|
* @param listeners the listeners to notify
|
|
*/
|
|
private void notifyWaypointUpdated(final Set<TrackDataListener> listeners) {
|
|
if (listeners.isEmpty()) return;
|
|
|
|
// Always reload all the waypoints.
|
|
final Cursor cursor = providerUtils.getWaypointsCursor(
|
|
selectedTrackId, 0L, MAX_DISPLAYED_WAYPOINTS_POINTS);
|
|
|
|
runInListenerThread(new Runnable() {
|
|
@Override
|
|
public void run() {
|
|
Log.d(TAG, "Reloading waypoints");
|
|
for (TrackDataListener listener : listeners) {
|
|
listener.clearWaypoints();
|
|
}
|
|
|
|
try {
|
|
if (cursor != null && cursor.moveToFirst()) {
|
|
do {
|
|
Waypoint waypoint = providerUtils.createWaypoint(cursor);
|
|
if (!LocationUtils.isValidLocation(waypoint.getLocation())) {
|
|
continue;
|
|
}
|
|
|
|
for (TrackDataListener listener : listeners) {
|
|
listener.onNewWaypoint(waypoint);
|
|
}
|
|
} while (cursor.moveToNext());
|
|
}
|
|
} finally {
|
|
if (cursor != null) {
|
|
cursor.close();
|
|
}
|
|
}
|
|
|
|
for (TrackDataListener listener : listeners) {
|
|
listener.onNewWaypointsDone();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Tells listeners to clear the current list of points.
|
|
*
|
|
* @param listeners the listeners to notify
|
|
*/
|
|
private void notifyPointsCleared(final Set<TrackDataListener> listeners) {
|
|
if (listeners.isEmpty()) return;
|
|
|
|
runInListenerThread(new Runnable() {
|
|
@Override
|
|
public void run() {
|
|
for (TrackDataListener listener : listeners) {
|
|
listener.clearTrackPoints();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Notifies the given listeners about track points in the given ID range.
|
|
*
|
|
* @param keepState whether to load and save state about the already-notified points.
|
|
* If true, only new points are reported.
|
|
* If false, then the whole track will be loaded, without affecting the state.
|
|
* @param minPointId the first point ID to notify, inclusive, or 0 to determine from
|
|
* internal state
|
|
* @param previousNumPoints the number of points to assume were previously loaded for
|
|
* these listeners, or 0 to assume it's the kept state
|
|
*/
|
|
private void notifyPointsUpdated(final boolean keepState,
|
|
final long minPointId, final int previousNumPoints,
|
|
final Set<TrackDataListener> sampledListeners,
|
|
final Set<TrackDataListener> sampledOutListeners) {
|
|
if (sampledListeners.isEmpty() && sampledOutListeners.isEmpty()) return;
|
|
|
|
runInListenerThread(new Runnable() {
|
|
@Override
|
|
public void run() {
|
|
notifyPointsUpdatedSync(keepState, minPointId, previousNumPoints, sampledListeners, sampledOutListeners);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Synchronous version of the above method.
|
|
*/
|
|
private void notifyPointsUpdatedSync(boolean keepState,
|
|
long minPointId, int previousNumPoints,
|
|
Set<TrackDataListener> sampledListeners,
|
|
Set<TrackDataListener> sampledOutListeners) {
|
|
// If we're loading state, start from after the last seen point up to the last recorded one
|
|
// (all new points)
|
|
// If we're not loading state, then notify about all the previously-seen points.
|
|
if (minPointId <= 0) {
|
|
minPointId = keepState ? lastSeenLocationId + 1 : 0;
|
|
}
|
|
long maxPointId = keepState ? -1 : lastSeenLocationId;
|
|
|
|
// TODO: Move (re)sampling to a separate class.
|
|
if (numLoadedPoints >= targetNumPoints) {
|
|
// We're about to exceed the maximum desired number of points, so reload
|
|
// the whole track with fewer points (the sampling frequency will be
|
|
// lower). We do this for every listener even if we were loading just for
|
|
// a few of them (why miss the oportunity?).
|
|
|
|
Log.i(TAG, "Resampling point set after " + numLoadedPoints + " points.");
|
|
resetState();
|
|
synchronized (listeners) {
|
|
sampledListeners = getListenersFor(ListenerDataType.POINT_UPDATES);
|
|
sampledOutListeners = getListenersFor(ListenerDataType.SAMPLED_OUT_POINT_UPDATES);
|
|
}
|
|
maxPointId = -1;
|
|
minPointId = 0;
|
|
previousNumPoints = 0;
|
|
keepState = true;
|
|
|
|
for (TrackDataListener listener : sampledListeners) {
|
|
listener.clearTrackPoints();
|
|
}
|
|
}
|
|
|
|
// Keep the originally selected track ID so we can stop if it changes.
|
|
long currentSelectedTrackId = selectedTrackId;
|
|
|
|
// If we're ignoring state, start from the beginning of the track
|
|
int localNumLoadedPoints = previousNumPoints;
|
|
if (previousNumPoints <= 0) {
|
|
localNumLoadedPoints = keepState ? numLoadedPoints : 0;
|
|
}
|
|
long localFirstSeenLocationId = keepState ? firstSeenLocationId : -1;
|
|
long localLastSeenLocationId = minPointId;
|
|
long lastStoredLocationId = providerUtils.getLastLocationId(currentSelectedTrackId);
|
|
int pointSamplingFrequency = -1;
|
|
|
|
LocationIterator it = providerUtils.getLocationIterator(
|
|
currentSelectedTrackId, minPointId, false, locationFactory);
|
|
|
|
while (it.hasNext()) {
|
|
if (currentSelectedTrackId != selectedTrackId) {
|
|
// The selected track changed beneath us, stop.
|
|
break;
|
|
}
|
|
|
|
Location location = it.next();
|
|
long locationId = it.getLocationId();
|
|
|
|
// If past the last wanted point, stop.
|
|
// This happens when adding a new listener after data has already been loaded,
|
|
// in which case we only want to bring that listener up to the point where the others
|
|
// were. In case it does happen, we should be wasting few points (only the ones not
|
|
// yet notified to other listeners).
|
|
if (maxPointId > 0 && locationId > maxPointId) {
|
|
break;
|
|
}
|
|
|
|
if (localFirstSeenLocationId == -1) {
|
|
// This was our first point, keep its ID
|
|
localFirstSeenLocationId = locationId;
|
|
}
|
|
|
|
if (pointSamplingFrequency == -1) {
|
|
// Now we already have at least one point, calculate the sampling
|
|
// frequency.
|
|
// It should be noted that a non-obvious consequence of this sampling is that
|
|
// no matter how many points we get in the newest batch, we'll never exceed
|
|
// MAX_DISPLAYED_TRACK_POINTS = 2 * TARGET_DISPLAYED_TRACK_POINTS before resampling.
|
|
long numTotalPoints = lastStoredLocationId - localFirstSeenLocationId;
|
|
pointSamplingFrequency = (int) (1 + numTotalPoints / targetNumPoints);
|
|
}
|
|
|
|
notifyNewPoint(location, locationId, lastStoredLocationId,
|
|
localNumLoadedPoints, pointSamplingFrequency, sampledListeners, sampledOutListeners);
|
|
|
|
localNumLoadedPoints++;
|
|
localLastSeenLocationId = locationId;
|
|
}
|
|
it.close();
|
|
|
|
if (keepState) {
|
|
numLoadedPoints = localNumLoadedPoints;
|
|
firstSeenLocationId = localFirstSeenLocationId;
|
|
lastSeenLocationId = localLastSeenLocationId;
|
|
}
|
|
|
|
// Always keep the sampling frequency - if it changes we'll do a full reload above anyway.
|
|
lastSamplingFrequency = pointSamplingFrequency;
|
|
|
|
for (TrackDataListener listener : sampledListeners) {
|
|
listener.onNewTrackPointsDone();
|
|
|
|
// Update the listener state
|
|
ListenerRegistration registration = listeners.getRegistration(listener);
|
|
if (registration != null) {
|
|
registration.lastTrackId = currentSelectedTrackId;
|
|
registration.lastPointId = localLastSeenLocationId;
|
|
registration.lastSamplingFrequency = pointSamplingFrequency;
|
|
registration.numLoadedPoints = localNumLoadedPoints;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void notifyNewPoint(Location location,
|
|
long locationId,
|
|
long lastStoredLocationId,
|
|
int numLoadedPoints,
|
|
int pointSamplingFrequency,
|
|
Set<TrackDataListener> sampledListeners,
|
|
Set<TrackDataListener> sampledOutListeners) {
|
|
boolean isValid = LocationUtils.isValidLocation(location);
|
|
if (!isValid) {
|
|
// Invalid points are segment splits - report those separately.
|
|
// TODO: Always send last valid point before and first valid point after a split
|
|
for (TrackDataListener listener : sampledListeners) {
|
|
listener.onSegmentSplit();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Include a point if it fits one of the following criteria:
|
|
// - Has the mod for the sampling frequency (includes first point).
|
|
// - Is the last point and we are not recording this track.
|
|
boolean recordingSelected = isRecordingSelected();
|
|
boolean includeInSample =
|
|
(numLoadedPoints % pointSamplingFrequency == 0 ||
|
|
(!recordingSelected && locationId == lastStoredLocationId));
|
|
|
|
if (!includeInSample) {
|
|
for (TrackDataListener listener : sampledOutListeners) {
|
|
listener.onSampledOutTrackPoint(location);
|
|
}
|
|
} else {
|
|
// Point is valid and included in sample.
|
|
for (TrackDataListener listener : sampledListeners) {
|
|
// No need to allocate a new location (we can safely reuse the existing).
|
|
listener.onNewTrackPoint(location);
|
|
}
|
|
}
|
|
}
|
|
|
|
// @VisibleForTesting
|
|
protected void runInListenerThread(Runnable runnable) {
|
|
listenerHandler.post(runnable);
|
|
}
|
|
|
|
private Set<TrackDataListener> getListenersFor(ListenerDataType type) {
|
|
synchronized (listeners) {
|
|
return listeners.getListenersFor(type);
|
|
}
|
|
}
|
|
|
|
private EnumSet<ListenerDataType> getNeededListenerTypes() {
|
|
EnumSet<ListenerDataType> neededTypes = listeners.getAllRegisteredTypes();
|
|
|
|
// We always want preference updates.
|
|
neededTypes.add(ListenerDataType.DISPLAY_PREFERENCES);
|
|
|
|
return neededTypes;
|
|
}
|
|
}
|