diff --git a/app/src/main/java/com/wytehat/btlogger/MapActivity.java b/app/src/main/java/com/wytehat/btlogger/MapActivity.java index f0fcc46..55be9d6 100644 --- a/app/src/main/java/com/wytehat/btlogger/MapActivity.java +++ b/app/src/main/java/com/wytehat/btlogger/MapActivity.java @@ -16,6 +16,8 @@ import android.location.LocationListener; import android.location.LocationManager; import android.net.Uri; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; import android.view.View; import android.webkit.WebView; import android.webkit.WebViewClient; @@ -34,6 +36,8 @@ import java.util.Date; import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; public class MapActivity extends Activity implements SensorEventListener { @@ -50,8 +54,9 @@ implements SensorEventListener { private LinearLayout filterPanel; - private Spinner categorySpinner; - private Spinner deviceSpinner; + private Spinner categorySpinner; + private Spinner deviceSpinner; + private Spinner resolutionSpinner; private CheckBox showAll; @@ -70,15 +75,54 @@ implements SensorEventListener { private final ArrayList deviceLabels = new ArrayList(); - private String categoryFilter = "Trackers / Tags"; - private String deviceFilter = "All"; + private final ArrayList resolutionLabels = + new ArrayList(); - private boolean rebuildingCategories; - private boolean rebuildingDevices; - private boolean loadingMap; + /* + * Time-bucket widths for the trail sampler. + * 0 = every stored point (still capped by TrackPointSampler). + */ + private static final long[] RESOLUTION_VALUES = { + 0L, + 15L * 60L * 1000L, + 60L * 60L * 1000L, + 120L * 60L * 1000L + }; - private long historyFrom; - private long historyTo; + private static final String[] RESOLUTION_LABELS = { + "All points", + "Every 15 min", + "Every 1 hour", + "Every 2 hours" + }; + + private volatile String categoryFilter = "Trackers / Tags"; + private volatile String deviceFilter = "All"; + + private boolean rebuildingCategories; + private boolean rebuildingDevices; + private boolean rebuildingResolution = true; + private volatile boolean loadingMap; + + private volatile long historyFrom; + private volatile long historyTo; + + /* + * Selected trail resolution, read by the background loader. + */ + private volatile long sampleInterval = RESOLUTION_VALUES[0]; + + /* + * Map data (SQLite reads + HTML generation) is built off the main + * thread; only the WebView load and the spinner refresh run on it. + */ + private final ExecutorService mapExecutor = + Executors.newSingleThreadExecutor(); + + private final Handler ui = + new Handler(Looper.getMainLooper()); + + private volatile boolean destroyed; private LocationManager locationManager; private SensorManager sensorManager; @@ -269,14 +313,53 @@ implements SensorEventListener { deviceSpinner.setAdapter(deviceAdapter); - filterPanel.addView( - deviceSpinner, - compactParams() - ); + filterPanel.addView( + deviceSpinner, + compactParams() + ); - /* - * Time. - */ + /* + * Trail resolution (downsampling). + */ + TextView resolutionLabel = + smallLabel("Trail detail"); + + filterPanel.addView(resolutionLabel); + + resolutionSpinner = new Spinner(this); + + for (int i = 0; + i < RESOLUTION_LABELS.length; + i++) { + + resolutionLabels.add( + RESOLUTION_LABELS[i] + ); + } + + ArrayAdapter resolutionAdapter = + new ArrayAdapter( + this, + android.R.layout.simple_spinner_item, + resolutionLabels + ); + + resolutionAdapter.setDropDownViewResource( + android.R.layout.simple_spinner_dropdown_item + ); + + resolutionSpinner.setAdapter( + resolutionAdapter + ); + + filterPanel.addView( + resolutionSpinner, + compactParams() + ); + + /* + * Time. + */ TextView timeLabel = smallLabel("Location history"); @@ -567,7 +650,51 @@ implements SensorEventListener { } ); - fromButton.setOnClickListener( + /* + * Trail resolution: applied immediately so the user can compare + * densities without reopening the panel. + */ + resolutionSpinner.setOnItemSelectedListener( + new AdapterView.OnItemSelectedListener() { + + @Override + public void onItemSelected( + AdapterView parent, + View view, + int position, + long id) { + + if (rebuildingResolution) { + return; + } + + if (position < 0 || + position >= + RESOLUTION_VALUES.length) { + + return; + } + + if (sampleInterval == + RESOLUTION_VALUES[position]) { + + return; + } + + sampleInterval = + RESOLUTION_VALUES[position]; + + reloadMap(); + } + + @Override + public void onNothingSelected( + AdapterView parent) { + } + } + ); + + fromButton.setOnClickListener( new View.OnClickListener() { @Override @@ -679,9 +806,11 @@ implements SensorEventListener { } ); - updateTimeButtons(); + updateTimeButtons(); - rebuildDeviceFilter(); + rebuildingResolution = false; + + rebuildDeviceFilter(); /* * The initial map is deliberately: @@ -891,12 +1020,28 @@ implements SensorEventListener { ); } - private void rebuildDeviceFilter() { + private void rebuildDeviceFilter() { - List available = - getMapDevices(); + rebuildDeviceFilter( + getMapDevices( + showAll.isChecked() + ) + ); + } - ArrayList nextAddresses = + /* + * Main thread only. Accepts an already loaded device list so the + * background loader does not repeat the query. + */ + private void rebuildDeviceFilter( + List available) { + + if (available == null) { + available = + new ArrayList(); + } + + ArrayList nextAddresses = new ArrayList(); ArrayList nextLabels = @@ -993,10 +1138,19 @@ implements SensorEventListener { rebuildingDevices = false; } - private List getMapDevices() { + /* + * Safe to call from a worker thread: the caller passes the checkbox + * state in instead of reading the view here. + */ + private List getMapDevices( + boolean includeAll) { - List all = - db.all(); + TrackerDatabase database = db; + + List all = + database == null + ? null + : database.all(); ArrayList result = new ArrayList(); @@ -1020,17 +1174,17 @@ implements SensorEventListener { * Normal map: * only explicitly map-enabled devices. */ - if (!showAll.isChecked() && - record.mapEnabled != 1) { + if (!includeAll && + record.mapEnabled != 1) { continue; } /* - * Normal map excludes unknown entries. - */ - if (!showAll.isChecked() && - isUnknown(record)) { + * Normal map excludes unknown entries. + */ + if (!includeAll && + isUnknown(record)) { continue; } @@ -1211,82 +1365,175 @@ implements SensorEventListener { return 0; } - private void reloadMap() { + private void reloadMap() { - if (loadingMap) { - return; - } + if (loadingMap) { + return; + } - loadingMap = true; - mapReady = false; + loadingMap = true; + mapReady = false; - try { + /* + * View state is snapshotted on the main thread; everything after + * this point (SQLite reads, trail sampling, HTML generation) runs + * on the worker. + */ + final boolean includeAll = + showAll.isChecked(); - List available = - getMapDevices(); + mapExecutor.execute( + new Runnable() { - refreshMapCategories( - available - ); + @Override + public void run() { - /* - * Rebuild device list after category - * changes. - */ - rebuildDeviceFilter(); + List available = null; + String html = null; - ArrayList rows = - new ArrayList(); + try { - for (int i = 0; - i < available.size(); - i++) { + available = + getMapDevices(includeAll); - DeviceRecord record = - available.get(i); + String device = deviceFilter; - if (record == null || - !record.hasLocation) { - continue; - } + /* + * The selected device may have gone away since + * the last load; fall back to showing all. + */ + if (!"All".equals(device) && + !containsAddress( + available, + device + )) { - if (!categoryMatches(record)) { - continue; - } + device = "All"; + } - if (!"All".equals(deviceFilter) && - !deviceFilter.equals( - record.address - )) { - continue; - } + ArrayList rows = + new ArrayList(); - rows.add(record); - } + for (int i = 0; + i < available.size(); + i++) { - String html = - buildHtml( - rows, - focus - ); + DeviceRecord record = + available.get(i); - web.loadDataWithBaseURL( - "https://unpkg.com/", - html, - "text/html", - "UTF-8", - null - ); + if (record == null || + !record.hasLocation) { + continue; + } - } finally { + if (!categoryMatches(record)) { + continue; + } - loadingMap = false; - } - } + if (!"All".equals(device) && + !device.equals( + record.address + )) { + continue; + } - private String buildHtml( - List rows, - String selectedAddress) { + rows.add(record); + } + + html = + buildHtml( + rows, + focus, + device + ); + + } catch (Exception ignored) { + } + + final List loaded = + available; + + final String content = html; + + ui.post( + new Runnable() { + + @Override + public void run() { + + try { + + if (destroyed || + web == null) { + return; + } + + refreshMapCategories( + loaded + ); + + /* + * Rebuild device list after + * category changes. + */ + rebuildDeviceFilter( + loaded + ); + + if (content != null) { + + web.loadDataWithBaseURL( + "https://unpkg.com/", + content, + "text/html", + "UTF-8", + null + ); + } + + } finally { + + loadingMap = false; + } + } + } + ); + } + } + ); + } + + private boolean containsAddress( + List records, + String address) { + + if (records == null || + address == null) { + + return false; + } + + for (int i = 0; + i < records.size(); + i++) { + + DeviceRecord record = + records.get(i); + + if (record != null && + address.equals(record.address)) { + + return true; + } + } + + return false; + } + + private String buildHtml( + List rows, + String selectedAddress, + String device) { StringBuilder script = new StringBuilder(); @@ -1295,29 +1542,44 @@ implements SensorEventListener { * Historical data is loaded ONLY for a * specifically selected device. */ - if (!"All".equals(deviceFilter)) { + if (!"All".equals(device)) { - for (int i = 0; - i < rows.size(); - i++) { + for (int i = 0; + i < rows.size(); + i++) { - DeviceRecord record = - rows.get(i); + DeviceRecord record = + rows.get(i); - if (!deviceFilter.equals( - record.address - )) { - continue; - } + if (!device.equals( + record.address + )) { + continue; + } - List history = - db.history( - record.address, - historyFrom, - historyTo - ); + TrackerDatabase database = db; - appendHistory( + List history = + database == null + ? null + : database.history( + record.address, + historyFrom, + historyTo + ); + + /* + * Dense histories are downsampled here, on the + * worker thread, so the WebView never receives + * thousands of markers. + */ + history = + TrackPointSampler.sample( + history, + sampleInterval + ); + + appendHistory( script, record, history, @@ -2332,11 +2594,18 @@ implements SensorEventListener { } @Override - protected void onDestroy() { + protected void onDestroy() { - try { + destroyed = true; - if (web != null) { + try { + mapExecutor.shutdownNow(); + } catch (Exception ignored) { + } + + try { + + if (web != null) { web.stopLoading(); web.destroy(); web = null; diff --git a/app/src/main/java/com/wytehat/btlogger/TrackPointSampler.java b/app/src/main/java/com/wytehat/btlogger/TrackPointSampler.java new file mode 100644 index 0000000..346984d --- /dev/null +++ b/app/src/main/java/com/wytehat/btlogger/TrackPointSampler.java @@ -0,0 +1,132 @@ +package com.wytehat.btlogger; + +import java.util.ArrayList; +import java.util.List; + +/** + * Downsamples dense GPS history before it is turned into map markers. + * + * Time-bucket sampling: the timeline is cut into fixed buckets and one + * representative point (the most accurate fix) survives per bucket. The very + * first and last fixes are always kept so the trail keeps its real endpoints. + * + * A hard ceiling is applied afterwards - even "All Points" cannot hand the + * WebView an unbounded number of circle markers. + */ +final class TrackPointSampler { + + /** Ceiling on rendered points, applied regardless of the chosen interval. */ + static final int MAX_RENDERED_POINTS = 750; + + private TrackPointSampler() { + } + + /** + * @param bucketMillis bucket width, or 0 for "All Points" (cap only). + */ + static List sample(List points, long bucketMillis) { + + if (points == null || points.size() < 3) { + return points; + } + + List reduced = + bucketMillis > 0 + ? bucket(points, bucketMillis) + : points; + + return cap(reduced); + } + + private static List bucket(List points, long bucketMillis) { + + ArrayList result = new ArrayList(); + + LocationPoint best = null; + long currentBucket = 0; + + for (int i = 0; i < points.size(); i++) { + + LocationPoint point = points.get(i); + + if (point == null) { + continue; + } + + long bucketIndex = point.timestamp / bucketMillis; + + if (best == null) { + best = point; + currentBucket = bucketIndex; + continue; + } + + if (bucketIndex != currentBucket) { + result.add(best); + best = point; + currentBucket = bucketIndex; + continue; + } + + if (moreAccurate(point, best)) { + best = point; + } + } + + if (best != null) { + result.add(best); + } + + /* + * Always terminate on the newest fix - that is the one the user cares + * about when reading a trail. + */ + LocationPoint last = points.get(points.size() - 1); + + if (last != null && + (result.isEmpty() || + result.get(result.size() - 1) != last)) { + + result.add(last); + } + + return result; + } + + private static List cap(List points) { + + int size = points.size(); + + if (size <= MAX_RENDERED_POINTS) { + return points; + } + + int stride = (size + MAX_RENDERED_POINTS - 1) / MAX_RENDERED_POINTS; + + ArrayList result = new ArrayList(); + + for (int i = 0; i < size; i += stride) { + result.add(points.get(i)); + } + + LocationPoint last = points.get(size - 1); + + if (result.isEmpty() || + result.get(result.size() - 1) != last) { + + result.add(last); + } + + return result; + } + + private static boolean moreAccurate(LocationPoint candidate, LocationPoint current) { + + if (candidate.accuracy <= 0) { + return false; + } + + return current.accuracy <= 0 || + candidate.accuracy < current.accuracy; + } +} diff --git a/app/src/main/java/com/wytehat/btlogger/TrackerDatabase.java b/app/src/main/java/com/wytehat/btlogger/TrackerDatabase.java index e5c2e04..74bce15 100644 --- a/app/src/main/java/com/wytehat/btlogger/TrackerDatabase.java +++ b/app/src/main/java/com/wytehat/btlogger/TrackerDatabase.java @@ -17,10 +17,25 @@ public class TrackerDatabase extends SQLiteOpenHelper { super(context, DB_NAME, null, DB_VERSION); } - public List history(String address, long historyFrom, long historyTo) + public synchronized List history(String address, long historyFrom, long historyTo) { - // TODO: Implement this method - return null; + ArrayList result = new ArrayList(); + if (address == null) return result; + Cursor cursor = getReadableDatabase().query("location_history", null, + "address=? AND logged_at>=? AND logged_at<=?", + new String[] { address, String.valueOf(historyFrom), String.valueOf(historyTo) }, + null, null, "logged_at ASC"); + try { + while (cursor.moveToNext()) { + LocationPoint point = new LocationPoint(); + point.latitude = cursor.getDouble(cursor.getColumnIndex("latitude")); + point.longitude = cursor.getDouble(cursor.getColumnIndex("longitude")); + point.accuracy = cursor.getFloat(cursor.getColumnIndex("accuracy")); + point.timestamp = cursor.getLong(cursor.getColumnIndex("logged_at")); + result.add(point); + } + } finally { cursor.close(); } + return result; } public void onCreate(SQLiteDatabase db) {