Files
OpenTracks/MyTracks/src/com/google/android/apps/mytracks/TrackDataHub.java
T
2011-03-21 13:43:34 -07:00

1012 lines
34 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;
import static com.google.android.apps.mytracks.Constants.TAG;
import com.google.android.apps.mytracks.TrackDataListener.ProviderState;
import com.google.android.apps.mytracks.content.MyTracksProviderUtils;
import com.google.android.apps.mytracks.content.MyTracksProviderUtils.LocationIterator;
import com.google.android.apps.mytracks.content.Track;
import com.google.android.apps.mytracks.content.TrackPointsColumns;
import com.google.android.apps.mytracks.content.TracksColumns;
import com.google.android.apps.mytracks.content.Waypoint;
import com.google.android.apps.mytracks.content.WaypointsColumns;
import com.google.android.apps.mytracks.services.TrackRecordingService;
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.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
import android.database.ContentObserver;
import android.database.Cursor;
import android.hardware.GeomagneticField;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.location.LocationProvider;
import android.os.Bundle;
import android.os.Handler;
import android.os.HandlerThread;
import android.util.Log;
import android.widget.Toast;
import java.util.LinkedHashSet;
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.
*
* @author Rodrigo Damazio
*/
public class TrackDataHub {
private static final long MAX_LOCATION_AGE_MS = 60 * 1000; // 1 minute
private static final long MAX_NETWORK_AGE_MS = 1000 * 60 * 10; // 10 minutes
// 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;
// Application services
private final Context context;
private final MyTracksProviderUtils providerUtils;
// System services
private final SensorManager sensorManager;
private final LocationManager locationManager;
private final SharedPreferences sharedPreferences;
private final ContentResolver contentResolver;
// Internal listeners (to receive data from the system)
private final ContentObserver pointObserver;
private final ContentObserver waypointObserver;
private final ContentObserver trackObserver;
private final LocationListener locationListener;
private final OnSharedPreferenceChangeListener preferenceListener;
private final SensorEventListener compassListener;
// External listeners (to pass data to activities)
private final Set<TrackDataListener> registeredListeners =
new LinkedHashSet<TrackDataListener>();
// 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;
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;
private Location lastSeenLocation;
private boolean hasFix;
private boolean hasGoodFix;
// Transient state about the selected track
private long firstSeenLocationId;
private long lastSeenLocationId;
private long selectedTrackId;
private long recordingTrackId;
private int numLoadedPoints;
private boolean hasProviderEnabled;
/** Callback for when the tracks table is updated. */
private class TrackObserverCallback implements Runnable {
@Override
public void run() {
notifyTrackUpdated(getRegisteredListenerArray());
}
}
/** Callback for when the waypoints table is updated. */
private class WaypointObserverCallback implements Runnable {
@Override
public void run() {
notifyWaypointUpdated(getRegisteredListenerArray());
}
}
/** Callback for when the points table is updated. */
private class PointObserverCallback implements Runnable {
@Override
public void run() {
notifyPointsUpdated(true, getRegisteredListenerArray());
}
}
/** Listener for when preferences change. */
private class HubSharedPreferenceListener implements OnSharedPreferenceChangeListener {
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
notifyPreferenceChanged(key);
}
}
/**
* Generic content observer which will call a given {@link Runnable} in the
* given handler if the content has changed and we're recording the selected
* track.
*/
private class TrackContentObserver extends ContentObserver {
private final Runnable callback;
public TrackContentObserver(Handler contentHandler, Runnable callback) {
super(contentHandler);
this.callback = callback;
}
@Override
public void onChange(boolean selfChange) {
Log.v(TAG, "TrackContentObserver.onChange");
// We want to filter only updates from the selected track, but since
// we can't see what the update is, we'll let two cases pass:
// 1 - The point(s) was(ere) changed because it's a recording track
// (and thus we care about it if the recording is the selected one)
// 2 - The point(s) was(ere) changed because it's syncing a track
// (and thus there will be no new points for the selected one)
if (!isRecordingSelected()) {
return;
}
// Update can potentially be lengthy, put it in its own thread:
listenerHandler.post(callback);
}
}
/** Listener for the current location (independent from track data). */
private class CurrentLocationListener implements
LocationListener {
@Override
public void onStatusChanged(String provider, int status, Bundle extras) {
if (!LocationManager.GPS_PROVIDER.equals(provider)) return;
hasProviderEnabled = (status == LocationProvider.AVAILABLE);
notifyFixType();
}
@Override
public void onProviderEnabled(String provider) {
if (!LocationManager.GPS_PROVIDER.equals(provider)) return;
hasProviderEnabled = true;
notifyFixType();
}
@Override
public void onProviderDisabled(String provider) {
if (!LocationManager.GPS_PROVIDER.equals(provider)) return;
hasProviderEnabled = false;
notifyFixType();
}
@Override
public void onLocationChanged(Location location) {
notifyLocationChanged(location);
}
}
/** Listener for compass readings. */
private class CompassListener implements
SensorEventListener {
@Override
public void onSensorChanged(SensorEvent event) {
lastSeenMagneticHeading = event.values[0];
maybeUpdateDeclination();
notifyHeadingChanged(getRegisteredListenerArray());
}
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
// Do nothing
}
}
public TrackDataHub(Context ctx, MyTracksProviderUtils providerUtils) {
this.context = ctx;
this.providerUtils = providerUtils;
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());
sharedPreferences = ctx.getSharedPreferences(Constants.SETTINGS_NAME, 0);
preferenceListener = new HubSharedPreferenceListener();
sensorManager = (SensorManager) ctx.getSystemService(Context.SENSOR_SERVICE);
locationManager =
(LocationManager) ctx.getSystemService(Context.LOCATION_SERVICE);
contentResolver = ctx.getContentResolver();
Handler contentHandler = new Handler();
pointObserver = new TrackContentObserver(contentHandler, new PointObserverCallback());
waypointObserver = new TrackContentObserver(contentHandler, new WaypointObserverCallback());
trackObserver = new TrackContentObserver(contentHandler, new TrackObserverCallback());
compassListener = new CompassListener();
locationListener = new CurrentLocationListener();
}
/**
* 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;
sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceListener);
selectedTrackId = sharedPreferences.getLong(SELECTED_TRACK_KEY, -1);
recordingTrackId = sharedPreferences.getLong(RECORDING_TRACK_KEY, -1);
useMetricUnits = sharedPreferences.getBoolean(METRIC_UNITS_KEY, true);
reportSpeed = sharedPreferences.getBoolean(SPEED_REPORTING_KEY, true);
minRequiredAccuracy = sharedPreferences.getInt(MIN_REQUIRED_ACCURACY_KEY,
Constants.DEFAULT_MIN_REQUIRED_ACCURACY);
if (recordingTrackId > 0) {
Intent startIntent = new Intent(context, TrackRecordingService.class);
context.startService(startIntent);
}
// This may or may not register internal listeners, depending on whether
// we already had external listeners.
updateInternalListeners();
// If there were listeners already registered, make sure they become up-to-date.
// TODO: This should really only send new data (in a start-stop-start cycle).
reloadDataFor(getRegisteredListenerArray());
}
/**
* 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;
}
sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceListener);
// Unregister internal listeners even if there are external listeners registered.
unregisterInternalListeners();
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");
}
listenerHandlerThread.quit();
}
/** Updates known magnetic declination if needed. */
private void maybeUpdateDeclination() {
if (lastSeenLocation == null) {
// We still don't know where we are.
return;
}
// Update the variation 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;
}
GeomagneticField field = new GeomagneticField(
(float) lastSeenLocation.getLatitude(),
(float) lastSeenLocation.getLongitude(),
(float) lastSeenLocation.getAltitude(),
timestamp);
declination = 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() {
checkStarted();
// TODO: Let's look at more advanced algorithms to determine the best
// current location.
if (locationManager == null) {
return;
}
final long now = System.currentTimeMillis();
Location loc = locationManager.getLastKnownLocation(
Constants.GPS_PROVIDER);
if (loc == null || loc.getTime() < now - MAX_LOCATION_AGE_MS) {
// We don't have a recent GPS fix, just use cell towers if available
loc = locationManager.getLastKnownLocation(
LocationManager.NETWORK_PROVIDER);
if (loc == null || loc.getTime() < now - MAX_NETWORK_AGE_MS) {
// We don't have a recent cell tower location, let the user know:
Toast.makeText(context, context.getString(R.string.status_no_location),
Toast.LENGTH_LONG).show();
return;
} else {
// Let the user know we have only an approximate location:
Toast.makeText(context, context.getString(R.string.status_approximate_location),
Toast.LENGTH_LONG).show();
}
}
notifyLocationChanged(loc, getRegisteredListenerArray());
}
/** Returns the ID of the currently-selected track. */
public long getSelectedTrackId() {
checkStarted();
return selectedTrackId;
}
/** Returns whether there's a track currently selected. */
public boolean isATrackSelected() {
checkStarted();
return selectedTrackId > 0;
}
/** Returns whether we're currently recording a track. */
public boolean isRecording() {
checkStarted();
return recordingTrackId > 0;
}
/** Returns whether the selected track is still being recorded. */
public boolean isRecordingSelected() {
checkStarted();
return isRecording() && 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(
sharedPreferences.edit().putLong(SELECTED_TRACK_KEY, trackId));
selectedTrackId = trackId;
// Force it to reload data from the beginning.
firstSeenLocationId = -1;
lastSeenLocationId = -1;
numLoadedPoints = 0;
reloadDataFor(getRegisteredListenerArray());
}
/**
* Unloads the currently-selected track.
*/
public void unloadCurrentTrack() {
loadTrack(-1);
}
/**
* Registers a listener to send data to.
* 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 listener the listener to register
*/
public void registerTrackDataListener(final TrackDataListener listener) {
Log.d(TAG, "Registered track data listener: " + listener);
synchronized (registeredListeners) {
registeredListeners.add(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;
reloadDataFor(listener);
}
updateInternalListeners();
}
/**
* Unregisters a listener to send data to.
*
* @param listener the listener to unregister
*/
public void unregisterTrackDataListener(TrackDataListener listener) {
Log.d(TAG, "Unregistered track data listener: " + listener);
synchronized (registeredListeners) {
registeredListeners.remove(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;
}
updateInternalListeners();
}
/** Updates the internal (sensor, position, etc) listeners. */
private void updateInternalListeners() {
boolean hasListeners;
synchronized (registeredListeners) {
hasListeners = registeredListeners.isEmpty();
}
if (hasListeners) {
unregisterInternalListeners();
} else {
registerInternalListeners();
}
}
/** Registers all internal (sensor, position, etc.) listeners. */
private void registerInternalListeners() {
// Listen to data provider
contentResolver.registerContentObserver(
TracksColumns.CONTENT_URI, false, trackObserver);
contentResolver.registerContentObserver(
WaypointsColumns.CONTENT_URI, false, waypointObserver);
contentResolver.registerContentObserver(
TrackPointsColumns.CONTENT_URI, false, pointObserver);
// Listen to compass
Sensor compass = sensorManager.getDefaultSensor(Sensor.TYPE_ORIENTATION);
if (compass != null) {
Log.d(Constants.TAG,
"TrackDataHub: Now registering sensor listeners.");
sensorManager.registerListener(compassListener, compass, SensorManager.SENSOR_DELAY_UI);
}
// Listen to GPS
LocationProvider gpsProvider =
locationManager.getProvider(Constants.GPS_PROVIDER);
if (gpsProvider == null) {
Toast.makeText(context, R.string.error_no_gps_location_provider, Toast.LENGTH_LONG).show();
hasProviderEnabled = false;
return;
} else {
Log.d(Constants.TAG, "TrackDataHub: Using location provider "
+ gpsProvider.getName());
}
locationManager.requestLocationUpdates(gpsProvider.getName(),
0 /*minTime*/, 0 /*minDist*/, locationListener);
hasProviderEnabled = locationManager.isProviderEnabled(Constants.GPS_PROVIDER);
// Listen to network location
try {
locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER,
1000 * 60 * 5 /*minTime*/, 0 /*minDist*/, locationListener);
} catch (RuntimeException e) {
// If anything at all goes wrong with getting a cell location do not
// abort. Cell location is not essential to this app.
Log.w(Constants.TAG,
"Could not register network location listener.");
}
}
/** Unregisters all internal (sensor, position, etc.) listeners. */
private void unregisterInternalListeners() {
locationManager.removeUpdates(locationListener);
sensorManager.unregisterListener(compassListener);
contentResolver.unregisterContentObserver(trackObserver);
contentResolver.unregisterContentObserver(waypointObserver);
contentResolver.unregisterContentObserver(pointObserver);
}
/**
* Reloads all track data received so far into the specified listeners.
*/
public void reloadDataFor(final TrackDataListener... listeners) {
if (listeners.length == 0) {
Log.d(TAG, "No listeners, not reloading");
return;
}
if (!started) {
Log.w(TAG, "Not started, not reloading");
return;
}
listenerHandler.post(new Runnable() {
@Override
public void run() {
Track track = providerUtils.getTrack(selectedTrackId);
// Ignore the return values here, we're already sending the full data set anyway
for (TrackDataListener listener : listeners) {
listener.onUnitsChanged(useMetricUnits);
listener.onReportSpeedChanged(reportSpeed);
}
notifySelectedTrackChanged(track, listeners);
notifyPointsCleared(listeners);
notifyPointsUpdated(false, listeners);
notifyWaypointUpdated(listeners);
if (lastSeenLocation != null) {
notifyLocationChanged(lastSeenLocation, true, listeners);
} else {
notifyFixType();
}
notifyHeadingChanged(listeners);
}
});
}
/**
* Called when a preference changes.
*
* @param key the key to the preference that changed
*/
private void notifyPreferenceChanged(String key) {
if (key.equals(RECORDING_TRACK_KEY)) {
recordingTrackId = sharedPreferences.getLong(RECORDING_TRACK_KEY, -1);
} else if (key.equals(MIN_REQUIRED_ACCURACY_KEY)) {
minRequiredAccuracy = sharedPreferences.getInt(RECORDING_TRACK_KEY,
Constants.DEFAULT_MIN_REQUIRED_ACCURACY);
} else if (key.equals(METRIC_UNITS_KEY)) {
useMetricUnits = sharedPreferences.getBoolean(METRIC_UNITS_KEY, true);
notifyUnitsChanged();
} else if (key.equals(SPEED_REPORTING_KEY)) {
reportSpeed = sharedPreferences.getBoolean(SPEED_REPORTING_KEY, true);
notifySpeedReportingChanged();
}
}
/** Called when the speed/pace reporting preference changes. */
private void notifySpeedReportingChanged() {
listenerHandler.post(new Runnable() {
@Override
public void run() {
TrackDataListener[] listeners = getRegisteredListenerArray();
boolean reloadData = false;
for (TrackDataListener listener : listeners) {
reloadData |= listener.onReportSpeedChanged(reportSpeed);
}
if (reloadData) {
reloadDataFor(listeners);
}
}
});
}
/** Called when the metric units setting changes. */
private void notifyUnitsChanged() {
listenerHandler.post(new Runnable() {
@Override
public void run() {
TrackDataListener[] listeners = getRegisteredListenerArray();
boolean reloadData = false;
for (TrackDataListener listener : listeners) {
reloadData |= listener.onUnitsChanged(useMetricUnits);
}
if (reloadData) {
reloadDataFor(listeners);
}
}
});
}
/** Notifies about the current GPS fix state. */
private void notifyFixType() {
final TrackDataListener.ProviderState state;
if (!hasProviderEnabled) {
state = ProviderState.DISABLED;
} else if (!hasFix) {
state = ProviderState.NO_FIX;
} else if (!hasGoodFix) {
state = ProviderState.BAD_FIX;
} else {
state = ProviderState.GOOD_FIX;
}
listenerHandler.post(new Runnable() {
@Override
public void run() {
for (TrackDataListener listener : registeredListeners) {
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, 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(final Location location, boolean forceUpdate, final TrackDataListener... listeners) {
if (location == null) return;
boolean isGpsLocation = location.getProvider().equals(LocationManager.GPS_PROVIDER);
boolean oldHasFix = hasFix;
boolean oldHasGoodFix = hasGoodFix;
// We consider a good fix to be a recent one with reasonable accuracy.
if (isGpsLocation) {
lastSeenLocation = location;
hasFix = (location != null && System.currentTimeMillis() - location.getTime() <= MAX_LOCATION_AGE_MS);
hasGoodFix = (location != null && location.getAccuracy() <= minRequiredAccuracy);
if (hasFix != oldHasFix || hasGoodFix != oldHasGoodFix || forceUpdate) {
notifyFixType();
}
}
listenerHandler.post(new Runnable() {
@Override
public void run() {
for (TrackDataListener listener : listeners) {
listener.onCurrentLocationChanged(location);
}
}
});
}
/**
* Notifies that the current heading has changed.
*
* @param listeners the listeners to notify
*/
private void notifyHeadingChanged(final TrackDataListener... listeners) {
listenerHandler.post(new Runnable() {
@Override
public void run() {
for (TrackDataListener listener : listeners) {
listener.onCurrentHeadingChanged(lastSeenMagneticHeading + declination);
}
}
});
}
/**
* Notifies that a new track has been selected..
*
* @param track the new selected track
* @param listeners the listeners to notify
*/
private void notifySelectedTrackChanged(final Track track, final TrackDataListener... listeners) {
Log.d(TAG, "New track selected, id=" + (track != null ? track.getId() : "none"));
listenerHandler.post(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 TrackDataListener... listeners) {
final Track track = providerUtils.getTrack(selectedTrackId);
listenerHandler.post(new Runnable() {
@Override
public void run() {
for (TrackDataListener listener : registeredListeners) {
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 TrackDataListener... listeners) {
// Always reload all the waypoints.
final Cursor cursor = providerUtils.getWaypointsCursor(
selectedTrackId, 0, Constants.MAX_DISPLAYED_WAYPOINTS_POINTS);
listenerHandler.post(new Runnable() {
@Override
public void run() {
for (TrackDataListener listener : registeredListeners) {
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 TrackDataListener... listeners) {
listenerHandler.post(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 minPointId the first point ID to notify, inclusive
* @param maxPointId the last poind ID to notify, inclusive
* @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 store.
* @param listeners the listeners to notify
*/
private void notifyPointsUpdated(final boolean keepState, final TrackDataListener... listeners) {
listenerHandler.post(new Runnable() {
@Override
public void run() {
notifyPointsUpdatedSync(keepState, listeners);
}
});
}
/**
* Asynchronous version of the above method.
*/
private void notifyPointsUpdatedSync(boolean keepState, TrackDataListener[] listeners) {
// 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.
long minPointId = keepState ? lastSeenLocationId + 1 : 0;
long maxPointId = keepState ? -1 : lastSeenLocationId;
if (numLoadedPoints >= Constants.MAX_DISPLAYED_TRACK_POINTS) {
// We're about to exceed the maximum allowed 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?).
firstSeenLocationId = -1;
lastSeenLocationId = -1;
numLoadedPoints = 0;
listeners = getRegisteredListenerArray();
maxPointId = -1;
minPointId = 0;
keepState = true;
for (TrackDataListener listener : listeners) {
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 = keepState ? numLoadedPoints : 0;
long localFirstSeenLocationId = keepState ? firstSeenLocationId : 0;
long localLastSeenLocationId = minPointId;
long lastStoredLocationId = providerUtils.getLastLocationId(currentSelectedTrackId);
int pointSamplingFrequency = -1;
// Create a double-buffering location provider.
MyTracksProviderUtils.DoubleBufferedLocationFactory locationFactory =
new MyTracksProviderUtils.DoubleBufferedLocationFactory();
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.
long numTotalPoints = lastStoredLocationId - localFirstSeenLocationId;
pointSamplingFrequency =
(int) (1 + numTotalPoints / Constants.TARGET_DISPLAYED_TRACK_POINTS);
}
notifyNewPoint(location, locationId, lastStoredLocationId,
localNumLoadedPoints, pointSamplingFrequency, listeners);
localNumLoadedPoints++;
localLastSeenLocationId = locationId;
}
it.close();
if (keepState) {
numLoadedPoints = localNumLoadedPoints;
firstSeenLocationId = localFirstSeenLocationId;
lastSeenLocationId = localLastSeenLocationId;
}
for (TrackDataListener listener : listeners) {
listener.onNewTrackPointsDone();
}
}
private void notifyNewPoint(Location location,
long locationId,
long lastStoredLocationId,
int numLoadedPoints,
int pointSamplingFrequency,
TrackDataListener[] listeners) {
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 : listeners) {
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 includeInSample =
(numLoadedPoints % pointSamplingFrequency == 0 ||
(!isRecordingSelected() && locationId == lastStoredLocationId));
if (!includeInSample) {
for (TrackDataListener listener : listeners) {
listener.onSampledOutTrackPoint(location);
}
return;
}
// Point is valid and included in sample.
for (TrackDataListener listener : listeners) {
// No need to allocate a new location (we can safely reuse the existing).
listener.onNewTrackPoint(location);
}
}
/** Returns an array with all the currently-registered listeners. */
private TrackDataListener[] getRegisteredListenerArray() {
synchronized (registeredListeners) {
TrackDataListener listenerArray[] = new TrackDataListener[registeredListeners.size()];
return registeredListeners.toArray(listenerArray);
}
}
/** Verifies that {@link #start} has been called. */
private void checkStarted() {
if (!started) {
throw new IllegalStateException("TrackDataHub has not been started.");
}
}
}