Files
OpenTracks/MyTracks/src/com/google/android/apps/mytracks/ChartActivity.java
T
2010-12-10 14:30:29 -02:00

662 lines
21 KiB
Java

/*
* Copyright 2009 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 com.google.android.apps.mytracks.ChartView.Mode;
import com.google.android.apps.mytracks.content.MyTracksLocation;
import com.google.android.apps.mytracks.content.MyTracksProviderUtils;
import com.google.android.apps.mytracks.content.Sensor;
import com.google.android.apps.mytracks.content.Track;
import com.google.android.apps.mytracks.content.TrackPointsColumns;
import com.google.android.apps.mytracks.content.Waypoint;
import com.google.android.apps.mytracks.content.WaypointsColumns;
import com.google.android.apps.mytracks.content.Sensor.SensorDataSet;
import com.google.android.apps.mytracks.services.StatusAnnouncerFactory;
import com.google.android.apps.mytracks.stats.DoubleBuffer;
import com.google.android.apps.mytracks.stats.TripStatisticsBuilder;
import com.google.android.apps.mytracks.util.ApiFeatures;
import com.google.android.apps.mytracks.util.MyTracksUtils;
import com.google.android.apps.mytracks.util.UnitConversions;
import com.google.android.maps.mytracks.R;
import android.app.Activity;
import android.content.SharedPreferences;
import android.database.ContentObserver;
import android.database.Cursor;
import android.location.Location;
import android.os.Bundle;
import android.os.Handler;
import android.os.HandlerThread;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewGroup.LayoutParams;
import android.view.Window;
import android.widget.LinearLayout;
import android.widget.ZoomControls;
import java.util.ArrayList;
/**
* An activity that displays a chart from the track point provider.
*
* @author Sandor Dornbush
*/
public class ChartActivity extends Activity implements
SharedPreferences.OnSharedPreferenceChangeListener {
private double profileLength = 0;
private boolean metricUnits = true;
private boolean reportSpeed = true;
/**
* The track id that is displayed.
*/
private long selectedTrackId = -1;
/**
* Id of the last location that was seen when reading tracks from the
* provider. This is used to determine which locations are new compared to the
* last time the chart was updated.
*/
private long lastSeenLocationId = -1;
private long startTime = -1;
private Location lastLocation;
/**
* The id of the track currently being recorded.
*/
private long recordingTrackId = -1;
private final DoubleBuffer elevationBuffer =
new DoubleBuffer(MyTracksConstants.ELEVATION_SMOOTHING_FACTOR);
private final DoubleBuffer speedBuffer =
new DoubleBuffer(MyTracksConstants.SPEED_SMOOTHING_FACTOR);
private Mode mode = Mode.BY_DISTANCE;
/**
* Utilities to deal with the database.
*/
private MyTracksProviderUtils providerUtils;
/*
* UI elements:
*/
private ChartView cv;
private MenuItem chartSettingsMenuItem;
private LinearLayout busyPane;
private ZoomControls zoomControls;
/** Handler for callbacks to the UI thread */
private final Handler uiHandler = new Handler();
/**
* A runnable that can be posted to the UI thread. It will remove the spinner
* (if any), enable/disable zoom controls and orange pointer as appropriate
* and redraw.
*/
private final Runnable updateChart = new Runnable() {
@Override
public void run() {
busyPane.setVisibility(View.GONE);
zoomControls.setIsZoomInEnabled(cv.canZoomIn());
zoomControls.setIsZoomOutEnabled(cv.canZoomOut());
cv.setShowPointer(selectedTrackIsRecording());
cv.invalidate();
}
};
/**
* A runnable that can be posted to the UI thread. It will show the spinner.
*/
private final Runnable showSpinner = new Runnable() {
@Override
public void run() {
busyPane.setVisibility(View.VISIBLE);
}
};
/**
* An observer for the tracks provider. Will listen to new track points being
* added and update the chart if necessary.
*/
private ContentObserver observer;
/**
* An observer for the waypoints provider. Will listen to new way points being
* added and update the chart if necessary.
*/
private ContentObserver waypointObserver;
/**
* A thread with a looper. Post to updateTrackHandler to execute Runnables on
* this thread.
*/
private final HandlerThread updateTrackThread =
new HandlerThread("updateTrackThread");
/** Handler for updateTrackThread */
private Handler updateTrackHandler;
/**
* A runnable that update the profile from the provider.
*/
private final Runnable updateTrackRunnable = new Runnable() {
@Override
public void run() {
Log.d(MyTracksConstants.TAG, "MyTracks: Updating chart.");
Track track = providerUtils.getTrack(recordingTrackId);
if (track == null) {
Log.w(MyTracksConstants.TAG, "MyTracks: track not found");
return;
}
Cursor cursor = null;
try {
cursor = providerUtils.getLocationsCursor(recordingTrackId,
lastSeenLocationId + 1,
MyTracksConstants.MAX_DISPLAYED_TRACK_POINTS - cv.getData().size(),
true);
if (cursor != null) {
if (cursor.moveToLast()) {
final int idColumnIdx =
cursor.getColumnIndexOrThrow(TrackPointsColumns._ID);
ArrayList<double[]> data = new ArrayList<double[]>();
// Need two locations so we can keep track of the last location.
Location location = new MyTracksLocation("");
do {
lastSeenLocationId = cursor.getLong(idColumnIdx);
providerUtils.fillLocation(cursor, location);
if (MyTracksUtils.isValidLocation(location)) {
double[] point = new double[6];
location = getDataPoint(location, track, point);
data.add(point);
}
} while (cursor.moveToPrevious());
cv.addDataPoints(data);
}
}
} catch (RuntimeException e) {
Log.w(MyTracksConstants.TAG, "Caught an unexpected exception.", e);
} finally {
if (cursor != null) {
cursor.close();
}
uiHandler.post(new Runnable() {
public void run() {
cv.invalidate();
}
});
}
}
};
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences,
String key) {
if (key != null) {
if (key.equals(getString(R.string.selected_track_key))) {
selectedTrackId =
sharedPreferences.getLong(getString(R.string.selected_track_key),
-1);
readProfileAsync();
} else if (key.equals(getString(R.string.metric_units_key))) {
metricUnits =
sharedPreferences.getBoolean(getString(R.string.metric_units_key),
true);
cv.setMetricUnits(metricUnits);
readProfileAsync();
} else if (key.equals(getString(R.string.report_speed_key))) {
reportSpeed =
sharedPreferences.getBoolean(getString(R.string.report_speed_key),
true);
cv.setReportSpeed(reportSpeed, this);
readProfileAsync();
} else if (key.equals(getString(R.string.recording_track_key))) {
recordingTrackId =
sharedPreferences.getLong(getString(R.string.recording_track_key),
-1);
runOnUiThread(updateChart);
}
}
}
@Override
protected void onCreate(Bundle savedInstanceState) {
Log.w(MyTracksConstants.TAG, "ChartActivity.onCreate");
super.onCreate(savedInstanceState);
MyTracks.getInstance().setChartActivity(this);
providerUtils = MyTracksProviderUtils.Factory.get(this);
// The volume we want to control is the Text-To-Speech volume
int volumeStream =
new StatusAnnouncerFactory(ApiFeatures.getInstance()).getVolumeStream();
setVolumeControlStream(volumeStream);
requestWindowFeature(Window.FEATURE_NO_TITLE);
setContentView(R.layout.mytracks_elevation);
ViewGroup layout = (ViewGroup) findViewById(R.id.elevation_chart);
cv = new ChartView(this);
cv.setMode(this.mode);
LayoutParams params =
new LayoutParams(LayoutParams.FILL_PARENT, LayoutParams.FILL_PARENT);
layout.addView(cv, params);
SharedPreferences preferences =
getSharedPreferences(MyTracksSettings.SETTINGS_NAME, 0);
if (preferences != null) {
selectedTrackId =
preferences.getLong(getString(R.string.selected_track_key), -1);
recordingTrackId =
preferences.getLong(getString(R.string.recording_track_key), -1);
metricUnits = preferences.getBoolean(getString(R.string.metric_units_key),
true);
cv.setMetricUnits(metricUnits);
reportSpeed = preferences.getBoolean(getString(R.string.report_speed_key),
true);
cv.setReportSpeed(reportSpeed, this);
preferences.registerOnSharedPreferenceChangeListener(this);
}
busyPane = (LinearLayout) findViewById(R.id.elevation_busypane);
zoomControls = (ZoomControls) findViewById(R.id.elevation_zoom);
zoomControls.setOnZoomInClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
zoomIn();
}
});
zoomControls.setOnZoomOutClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
zoomOut();
}
});
updateTrackThread.start();
updateTrackHandler = new Handler(updateTrackThread.getLooper());
// Register observer for the track point provider:
Handler contentHandler = new Handler();
observer = new ContentObserver(contentHandler) {
@Override
public void onChange(boolean selfChange) {
Log.d(MyTracksConstants.TAG, "ChartActivity: ContentObserver.onChange");
// Check for any new locations and append them to the currently
// recording track.
if (recordingTrackId < 0) {
// No track is being recorded. We should not be here.
return;
}
if (selectedTrackId != recordingTrackId) {
// No track, or one other than the recording track is selected, don't
// bother.
return;
}
// Update can potentially be lengthy, put it in its own thread:
updateTrackHandler.post(updateTrackRunnable);
super.onChange(selfChange);
}
};
waypointObserver = new ContentObserver(contentHandler) {
@Override
public void onChange(boolean selfChange) {
Log.d(MyTracksConstants.TAG,
"MyTracksMap: ContentObserver.onChange waypoints");
if (selectedTrackId < 0) {
return;
}
Thread t = new Thread() {
@Override
public void run() {
readWaypoints();
ChartActivity.this.runOnUiThread(new Runnable() {
@Override
public void run() {
cv.invalidate();
}
});
}
};
t.start();
super.onChange(selfChange);
}
};
readProfileAsync();
}
@Override
protected void onPause() {
super.onPause();
unregisterContentObservers();
}
@Override
protected void onResume() {
super.onResume();
// Make sure any updates that might have happened are propagated to this
// activity:
observer.onChange(false);
waypointObserver.onChange(false);
registerContentObservers();
}
/**
* Register the content observer for the map overlay.
*/
private void registerContentObservers() {
getContentResolver().registerContentObserver(TrackPointsColumns.CONTENT_URI,
false/* notifyForDescendents */, observer);
getContentResolver().registerContentObserver(WaypointsColumns.CONTENT_URI,
false/* notifyForDescendents */, waypointObserver);
}
/**
* Unregister the content observer for the map overlay.
*/
private void unregisterContentObservers() {
getContentResolver().unregisterContentObserver(observer);
getContentResolver().unregisterContentObserver(waypointObserver);
}
private boolean selectedTrackIsRecording() {
return selectedTrackId == recordingTrackId;
}
private void zoomIn() {
cv.zoomIn();
zoomControls.setIsZoomInEnabled(cv.canZoomIn());
zoomControls.setIsZoomOutEnabled(cv.canZoomOut());
}
private void zoomOut() {
cv.zoomOut();
zoomControls.setIsZoomInEnabled(cv.canZoomIn());
zoomControls.setIsZoomOutEnabled(cv.canZoomOut());
}
public void setMode(Mode newMode) {
if (this.mode != newMode) {
this.mode = newMode;
cv.setMode(this.mode);
readProfileAsync();
}
}
public Mode getMode() {
return mode;
}
public void setSeriesEnabled(int index, boolean enabled) {
cv.getChartValueSeries(index).setEnabled(enabled);
runOnUiThread(updateChart);
}
public boolean isSeriesEnabled(int index) {
return cv.getChartValueSeries(index).isEnabled();
}
private void readWaypoints() {
if (selectedTrackId < 0) {
return;
}
Cursor cursor = null;
cv.clearWaypoints();
try {
// We will silently drop extra waypoints to make the app responsive.
cursor =
providerUtils.getWaypointsCursor(selectedTrackId, 0,
MyTracksConstants.MAX_DISPLAYED_TRACK_POINTS);
if (cursor != null) {
if (cursor.moveToFirst()) {
do {
Waypoint wpt = providerUtils.createWaypoint(cursor);
cv.addWaypoint(wpt);
} while (cursor.moveToNext());
}
}
} catch (RuntimeException e) {
Log.w(MyTracksConstants.TAG, "Caught an unexpected exception.", e);
} finally {
if (cursor != null) {
cursor.close();
}
}
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
super.onCreateOptionsMenu(menu);
chartSettingsMenuItem =
menu.add(0, MyTracksConstants.MENU_CHART_SETTINGS, 0,
R.string.chart_settings);
chartSettingsMenuItem.setIcon(R.drawable.chart_settings);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case MyTracksConstants.MENU_CHART_SETTINGS:
MyTracks.getInstance().getDialogManager().showDialogSafely(
DialogManager.DIALOG_CHART_SETTINGS);
return true;
}
return super.onOptionsItemSelected(item);
}
/**
* Given a location, creates a new data point for the chart. A data point is
* an array double[3 or 6], where:
* data[0] = the time or distance
* data[1] = the elevation
* data[2] = the speed
* and possibly:
* data[3] = power
* data[4] = cadence
* data[5] = heart rate
*
* This must be called in order for each point.
*
* @param location the location to get data for (this method takes ownership of that location)
* @param track the track to get data from
* @param result the resulting point to fill out
* @return the previous location, now available for reuse
*/
private Location getDataPoint(Location location, Track track, double[] result) {
if (location instanceof MyTracksLocation &&
((MyTracksLocation) location).getSensorDataSet() != null) {
SensorDataSet sensorData = ((MyTracksLocation) location).getSensorDataSet();
if (sensorData.hasPower()
&& sensorData.getPower().getState() == Sensor.SensorState.SENDING
&& sensorData.getPower().hasValue()) {
result[3] = sensorData.getPower().getValue();
} else {
result[3] = Double.NaN;
}
if (sensorData.hasCadence()
&& sensorData.getCadence().getState() == Sensor.SensorState.SENDING
&& sensorData.getCadence().hasValue()) {
result[4] = sensorData.getCadence().getValue();
} else {
result[4] = Double.NaN;
}
if (sensorData.hasHeartRate()
&& sensorData.getHeartRate().getState() == Sensor.SensorState.SENDING
&& sensorData.getHeartRate().hasValue()) {
result[5] = sensorData.getHeartRate().getValue();
} else {
result[5] = Double.NaN;
}
} else {
result[3] = Double.NaN;
result[4] = Double.NaN;
result[5] = Double.NaN;
}
switch (mode) {
case BY_DISTANCE:
result[0] = profileLength;
if (lastLocation != null) {
double d = lastLocation.distanceTo(location);
if (metricUnits) {
profileLength += d / 1000.0;
} else {
profileLength += d * UnitConversions.KM_TO_MI / 1000.0;
}
}
break;
case BY_TIME:
if (startTime == -1) {
// Base case
startTime = location.getTime();
}
result[0] = (location.getTime() - startTime);
break;
default:
Log.w(MyTracksConstants.TAG, "ChartActivity unknown mode: " + mode);
}
elevationBuffer.setNext(metricUnits
? location.getAltitude()
: location.getAltitude() * UnitConversions.M_TO_FT);
result[1] = elevationBuffer.getAverage();
if (lastLocation == null) {
if (Math.abs(location.getSpeed() - 128) > 1) {
speedBuffer.setNext(location.getSpeed());
}
} else if (TripStatisticsBuilder.isValidSpeed(
location.getTime(), location.getSpeed(), lastLocation.getTime(),
lastLocation.getSpeed(), speedBuffer)
&& (location.getSpeed() <= track.getStatistics().getMaxSpeed())) {
speedBuffer.setNext(location.getSpeed());
}
result[2] = speedBuffer.getAverage() * 3.6;
if (!metricUnits) {
result[2] *= UnitConversions.KM_TO_MI;
}
if (!reportSpeed && (result[2] != 0)) {
// Format as hours per unit
result[2] = (60.0 / result[2]);
}
Location oldLastLocation = lastLocation;
lastLocation = location;
if (oldLastLocation == null) {
// No previous location, but return a blank one for reuse
return new MyTracksLocation("");
}
return oldLastLocation;
}
/**
* Sets the chart data points reading from the provider. This is non-blocking.
*/
private void readProfileAsync() {
cv.reset();
updateTrackHandler.post(new Runnable() {
public void run() {
runOnUiThread(showSpinner);
readProfile();
readWaypoints();
runOnUiThread(updateChart);
}
});
}
/**
* Reads the track profile from the provider. This is a blocking function and
* should not be run from the UI thread.
*/
private void readProfile() {
profileLength = 0;
lastLocation = null;
startTime = -1;
Cursor cursor = null;
if (selectedTrackId < 0) {
return;
}
Track track = providerUtils.getTrack(selectedTrackId);
if (track == null) {
return;
}
long lastLocationRead = track.getStartId();
long totalLocations = track.getStopId() - track.getStartId();
// Limit the number of chart readings. Ideally we would want around 1024.
int chartSamplingFrequency = Math.max(1, (int) (totalLocations / 1024.0));
int bufferSize = 1024;
try {
final ArrayList<double[]> theData = new ArrayList<double[]>();
int points = 0;
// Need two locations so we can keep track of the last location.
Location location = new MyTracksLocation("");
while (lastLocationRead < track.getStopId()) {
cursor = providerUtils.getLocationsCursor(
selectedTrackId, lastLocationRead, bufferSize, false);
if (cursor != null) {
elevationBuffer.reset();
speedBuffer.reset();
if (cursor.moveToFirst()) {
final int idColumnIdx =
cursor.getColumnIndexOrThrow(TrackPointsColumns._ID);
while (cursor.moveToNext()) {
points++;
providerUtils.fillLocation(cursor, location);
if (MyTracksUtils.isValidLocation(location)) {
lastLocationRead = lastSeenLocationId =
cursor.getLong(idColumnIdx);
// TODO Can we be smarter about choosing 3 or 6 entries?
double[] point = new double[6];
location = getDataPoint(location, track, point);
if (points % chartSamplingFrequency == 0) {
theData.add(point);
}
}
}
} else {
lastLocationRead += bufferSize;
}
} else {
lastLocationRead += bufferSize;
}
cursor.close();
cursor = null;
}
runOnUiThread(new Runnable() {
public void run() {
cv.setDataPoints(theData);
}
});
} catch (RuntimeException e) {
Log.w(MyTracksConstants.TAG, "Caught unexpected exception.", e);
} finally {
if (cursor != null) {
cursor.close();
}
}
}
}