/** AirCasting - Share your Air! Copyright (C) 2011-2012 HabitatMap, Inc. This program 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. This program 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 this program. If not, see <http://www.gnu.org/licenses/>. You can contact the authors by email at <info@habitatmap.org> */ package pl.llp.aircasting.view.presenter; import pl.llp.aircasting.activity.ApplicationState; import pl.llp.aircasting.activity.events.SessionChangeEvent; import pl.llp.aircasting.android.Logger; import pl.llp.aircasting.event.ui.ViewStreamEvent; import pl.llp.aircasting.helper.SettingsHelper; import pl.llp.aircasting.model.Measurement; import pl.llp.aircasting.model.MeasurementStream; import pl.llp.aircasting.model.Sensor; import pl.llp.aircasting.model.SensorManager; import pl.llp.aircasting.model.SessionManager; import pl.llp.aircasting.model.events.MeasurementEvent; import pl.llp.aircasting.sensor.builtin.SimpleAudioReader; import android.content.SharedPreferences; import com.google.common.base.Function; import com.google.common.base.Stopwatch; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableListMultimap; import com.google.common.collect.Lists; import com.google.common.eventbus.EventBus; import com.google.common.eventbus.Subscribe; import com.google.inject.Inject; import com.google.inject.Singleton; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.TreeMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; import static com.google.common.collect.Iterables.getLast; import static com.google.common.collect.Lists.newArrayList; import static com.google.common.collect.Lists.newCopyOnWriteArrayList; import static com.google.common.collect.Lists.newLinkedList; import static com.google.common.collect.Multimaps.index; import static java.util.Collections.sort; @Singleton public class MeasurementPresenter implements SharedPreferences.OnSharedPreferenceChangeListener { private static final long MIN_ZOOM = 120000; private static final long SCROLL_TIMEOUT = 1000; private static final int INITIAL_MAX_NUMBER_OF_FIXED_SESSION_MEASUREMENTS = 1440; @Inject SessionManager sessionManager; @Inject SettingsHelper settingsHelper; @Inject SharedPreferences preferences; @Inject EventBus eventBus; @Inject SensorManager sensorManager; @Inject MeasurementAggregator aggregator; private CopyOnWriteArrayList<Measurement> fullView = null; private int measurementsSize; private int anchor; private long visibleMilliseconds = MIN_ZOOM; private long lastScrolled; private Date timeAnchor; private final CopyOnWriteArrayList<Measurement> timelineView = new CopyOnWriteArrayList<Measurement>(); private List<Listener> listeners = newArrayList(); @Inject private ApplicationState state; public void setSensor(Sensor sensor) { this.sensor = sensor; } private Sensor sensor = SimpleAudioReader.getSensor(); @Inject public void init() { preferences.registerOnSharedPreferenceChangeListener(this); eventBus.register(this); } @Subscribe public synchronized void onEvent(MeasurementEvent event) { onMeasurement(event); } private void onMeasurement(MeasurementEvent event) { if (!state.recording().isRecording()) return; if (!event.getSensor().equals(this.sensor)) return; Measurement measurement = event.getMeasurement(); prepareFullView(); updateFullView(measurement); if (anchor == 0) { updateTimelineView(); } notifyListeners(); } private synchronized void updateTimelineView() { Stopwatch stopwatch = new Stopwatch().start(); Measurement measurement = aggregator.getAverage(); if (aggregator.isComposite()) { if (!timelineView.isEmpty()) { timelineView.remove(timelineView.size() - 1); } timelineView.add(measurement); Logger.logGraphPerformance("updateTimelineView step 0 took " + stopwatch.elapsed(TimeUnit.MILLISECONDS)); } else { long firstToDisplay = measurement.getTime().getTime() - visibleMilliseconds; while (!timelineView.isEmpty() && firstToDisplay >= timelineView.get(0).getTime().getTime()) { timelineView.remove(0); } Logger.logGraphPerformance("updateTimelineView step 1 took " + stopwatch.elapsed(TimeUnit.MILLISECONDS)); measurementsSize += 1; timelineView.add(measurement); } Logger.logGraphPerformance("updateTimelineView step n took " + stopwatch.elapsed(TimeUnit.MILLISECONDS)); } private void updateFullView(Measurement measurement) { boolean newBucket = isNewBucket(measurement); if (newBucket) { if (!aggregator.isEmpty()) { for (Listener listener : listeners) { listener.onAveragedMeasurement(aggregator.getAverage()); } } aggregator.reset(); } else { fullView.remove(fullView.size() - 1); } aggregator.add(measurement); fullView.add(aggregator.getAverage()); } private boolean isNewBucket(Measurement measurement) { if (fullView.isEmpty()) return true; Measurement last = getLast(fullView); long b1 = bucketBySecond(last); long b2 = bucketBySecond(measurement); return b1 != b2; } @Subscribe public synchronized void onEvent(SessionChangeEvent event) { this.sensor = sensorManager.getVisibleSensor(); reset(); anchor = 0; } @Subscribe public synchronized void onEvent(ViewStreamEvent event) { this.sensor = event.getSensor(); reset(); } private synchronized void reset() { fullView = null; timelineView.clear(); notifyListeners(); } private CopyOnWriteArrayList<Measurement> prepareFullView() { if (fullView != null) return fullView; Stopwatch stopwatch = new Stopwatch().start(); String sensorName = sensor.getSensorName(); MeasurementStream stream = sessionManager.getMeasurementStream(sensorName); Iterable<Measurement> measurements; if (stream == null) { measurements = newArrayList(); } else { // To avoid app crashes, in case of larger sessions, we simply limit the number of initially loaded measurements // when user opens the graph with fixed session (since fixed sessions are often much longer). if(sessionManager.getSession().isFixed()) measurements = stream.getLastMeasurements(INITIAL_MAX_NUMBER_OF_FIXED_SESSION_MEASUREMENTS); else measurements = stream.getMeasurements(); } ImmutableListMultimap<Long, Measurement> forAveraging = index(measurements, new Function<Measurement, Long>() { @Override public Long apply(Measurement measurement) { return measurement.getSecond() / settingsHelper.getAveragingTime(); } }); Logger.logGraphPerformance("prepareFullView step 1 took " + stopwatch.elapsed(TimeUnit.MILLISECONDS)); ArrayList <Long> times = newArrayList(forAveraging.keySet()); sort(times); Logger.logGraphPerformance("prepareFullView step 2 took " + stopwatch.elapsed(TimeUnit.MILLISECONDS)); List<Measurement> mobileMeasurements = newLinkedList(); for (Long time : times) { ImmutableList<Measurement> chunk = forAveraging.get(time); mobileMeasurements.add(average(chunk)); } Logger.logGraphPerformance("prepareFullView step 3 took " + stopwatch.elapsed(TimeUnit.MILLISECONDS)); CopyOnWriteArrayList<Measurement> result = Lists.newCopyOnWriteArrayList(mobileMeasurements); Logger.logGraphPerformance("prepareFullView step n took " + stopwatch.elapsed(TimeUnit.MILLISECONDS)); fullView = result; return result; } private long bucketBySecond(Measurement measurement) { return measurement.getSecond() / settingsHelper.getAveragingTime(); } private Measurement average(ImmutableList<Measurement> measurements) { aggregator.reset(); for (Measurement measurement : measurements) { aggregator.add(measurement); } return aggregator.getAverage(); } public synchronized List<Measurement> getTimelineView() { if (state.recording().isShowingASession()) { prepareTimelineView(); return timelineView; } else { return newArrayList(); } } private int timeToAnchor(Date d, List<Measurement> measurements) { if (measurements.isEmpty()) return 0; int position = 0; for (int i = 1; i < measurements.size(); i++) { if (Math.abs(d.getTime() - measurements.get(i).getTime().getTime()) < Math.abs(d.getTime() - measurements.get(position).getTime().getTime())) { position = i; } } return measurements.size() - 1 - position; } protected synchronized void prepareTimelineView() { if (!timelineView.isEmpty()) { return; } Stopwatch stopwatch = new Stopwatch().start(); final List<Measurement> measurements = getFullView(); if (anchor != 0 && new Date().getTime() - lastScrolled > SCROLL_TIMEOUT) { anchor = timeToAnchor(timeAnchor, measurements); } int position = measurements.size() - 1 - anchor; final long lastMeasurementTime = measurements.isEmpty() ? 0 : measurements.get(position).getTime().getTime(); timelineView.clear(); TreeMap<Long, Measurement> measurementsMap = new TreeMap<Long, Measurement>(); for (Measurement m : measurements) { measurementsMap.put(m.getTime().getTime(), m); } // +1 because subMap parameters are (inclusive, exclusive) timelineView.addAll(measurementsMap.subMap(lastMeasurementTime - visibleMilliseconds, lastMeasurementTime + 1).values()); measurementsSize = measurements.size(); Logger.logGraphPerformance("prepareTimelineView for [" + timelineView.size() + "] took " + stopwatch.elapsed(TimeUnit.MILLISECONDS)); } public void registerListener(Listener listener) { listeners.add(listener); } public void unregisterListener(Listener listener) { listeners.remove(listener); } public boolean canZoomIn() { return visibleMilliseconds > MIN_ZOOM; } public synchronized boolean canZoomOut() { prepareTimelineView(); return timelineView.size() < measurementsSize; } public void zoomIn() { if (canZoomIn()) { anchor += timelineView.size() / 4; fixAnchor(); setZoom(visibleMilliseconds / 2); } } public synchronized void zoomOut() { if (canZoomOut()) { anchor -= timelineView.size() / 2; fixAnchor(); setZoom(visibleMilliseconds * 2); } } synchronized void setZoom(long zoom) { this.visibleMilliseconds = zoom; timelineView.clear(); notifyListeners(); } private synchronized void fixAnchor() { if (anchor > measurementsSize - timelineView.size()) { anchor = measurementsSize - timelineView.size(); } if (anchor < 0) { anchor = 0; } int position = fullView.size() - 1 - anchor; timeAnchor = fullView.isEmpty() ? new Date() : fullView.get(position).getTime(); } public synchronized void scroll(double scrollAmount) { lastScrolled = new Date().getTime(); prepareTimelineView(); anchor -= scrollAmount * timelineView.size(); fixAnchor(); timelineView.clear(); notifyListeners(); } public List<Measurement> getFullView() { if (state.recording().isJustShowingCurrentValues()) { fullView = newCopyOnWriteArrayList(); } else { fullView = prepareFullView(); } return fullView; } public synchronized boolean canScrollRight() { return anchor != 0; } public synchronized boolean canScrollLeft() { prepareTimelineView(); return anchor < measurementsSize - timelineView.size(); } @Override public synchronized void onSharedPreferenceChanged(SharedPreferences preferences, String key) { if (key.equals(SettingsHelper.AVERAGING_TIME)) { reset(); } } public interface Listener { void onViewUpdated(); void onAveragedMeasurement(Measurement measurement); } private void notifyListeners() { for (Listener listener : listeners) { listener.onViewUpdated(); } } }