diff --git a/app/src/main/java/com/wytehat/btlogger/HistoryNormalizer.java b/app/src/main/java/com/wytehat/btlogger/HistoryNormalizer.java new file mode 100644 index 0000000..450b549 --- /dev/null +++ b/app/src/main/java/com/wytehat/btlogger/HistoryNormalizer.java @@ -0,0 +1,667 @@ +package com.wytehat.btlogger; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; + +/** + * GPS history cleanup for the map screen. + * + * Recorded history is noisy: the same fix gets logged twice, two fixes claim + * the same timestamp from different places, and a bad fix occasionally puts a + * device a kilometre away and back within a second. This walks a device's + * history in time order and drops the points that cannot be real, keeping a + * Decision for every removal so the map and the CSV export can show what was + * taken out and why. + * + * One aggressiveness dial (0 = very light, 100 = very aggressive) drives every + * threshold; see the getters below for what each end of the range means. + * + * Split out of MapActivity, which owns the UI, not this. + */ +public class HistoryNormalizer { + + public static final int DEFAULT_AGGRESSIVENESS = 50; + + private static final double MIN_MAX_SPEED_KMH = 20.0; + private static final double MAX_MAX_SPEED_KMH = 250.0; + + private static final double MIN_DUPLICATE_DISTANCE_METERS = 0.75; + private static final double MAX_DUPLICATE_DISTANCE_METERS = 5.0; + + private static final double MIN_SAME_TIME_DISTANCE_METERS = 10.0; + private static final double MAX_SAME_TIME_DISTANCE_METERS = 50.0; + + private static final double MIN_ACCURACY_MULTIPLIER = 0.75; + private static final double MAX_ACCURACY_MULTIPLIER = 3.0; + + private static final double EARTH_RADIUS_METERS = 6371008.8; + + /** A point that normalization removed, and the reasoning behind it. */ + public static class Decision { + + public LocationPoint point; + public LocationPoint previousAccepted; + + public String reason; + + public double distanceMeters; + public double elapsedSeconds; + public double speedKmh; + public double threshold; + + public Decision( + LocationPoint point, + LocationPoint previousAccepted, + String reason, + double distanceMeters, + double elapsedSeconds, + double speedKmh, + double threshold) { + + this.point = point; + this.previousAccepted = previousAccepted; + this.reason = reason; + this.distanceMeters = distanceMeters; + this.elapsedSeconds = elapsedSeconds; + this.speedKmh = speedKmh; + this.threshold = threshold; + } + } + + /* 0..100. */ + private int aggressiveness = DEFAULT_AGGRESSIVENESS; + + private final HashMap> historyCache = + new HashMap>(); + + /* Points removed by normalization, kept so they can be drawn/exported. */ + private final HashMap> decisionCache = + new HashMap>(); + + private final HashSet duplicateKeys = new HashSet(); + private final HashSet rejectedKeys = new HashSet(); + + // ------------------------------------------------------------------ + // Settings + // ------------------------------------------------------------------ + + public int getAggressiveness() { + return aggressiveness; + } + + /** Clamps to 0..100 and drops cached results, which are now stale. */ + public void setAggressiveness(int value) { + + int clamped = value; + + if (clamped < 0) clamped = 0; + if (clamped > 100) clamped = 100; + + if (clamped == aggressiveness) return; + + aggressiveness = clamped; + + clear(); + } + + public String levelName() { + return levelName(aggressiveness); + } + + public static String levelName(int aggressiveness) { + + if (aggressiveness <= 20) return "Very Light"; + if (aggressiveness <= 40) return "Light"; + if (aggressiveness <= 60) return "Normal"; + if (aggressiveness <= 80) return "Strong"; + + return "Very Aggressive"; + } + + /** + * Light normalization allows up to 250 km/h, very aggressive bottoms out + * at 20 km/h. An ordinary 5 m/s walk (~18 km/h) survives every setting. + */ + public double maxSpeedKmh() { + return scale(MIN_MAX_SPEED_KMH, MAX_MAX_SPEED_KMH); + } + + public double maxSpeedMps() { + return maxSpeedKmh() / 3.6; + } + + public double duplicateDistance() { + return scale( + MIN_DUPLICATE_DISTANCE_METERS, + MAX_DUPLICATE_DISTANCE_METERS); + } + + public double sameTimeDistance() { + return scale( + MIN_SAME_TIME_DISTANCE_METERS, + MAX_SAME_TIME_DISTANCE_METERS); + } + + public double accuracyMultiplier() { + return scale(MIN_ACCURACY_MULTIPLIER, MAX_ACCURACY_MULTIPLIER); + } + + /** Maps aggressiveness 0..100 onto max..min of a threshold range. */ + private double scale(double minimum, double maximum) { + + double fraction = aggressiveness / 100.0; + + return maximum - ((maximum - minimum) * fraction); + } + + // ------------------------------------------------------------------ + // Cached results + // ------------------------------------------------------------------ + + public void clear() { + historyCache.clear(); + decisionCache.clear(); + duplicateKeys.clear(); + rejectedKeys.clear(); + } + + /** + * Normalizes every distinct device in the list up front, so the map and + * the export summary can read counts without re-running the engine. + */ + public void prepare(TrackerDatabase db, List rows) { + + if (db == null || rows == null) return; + + HashSet seen = new HashSet(); + + for (int i = 0; i < rows.size(); i++) { + + DeviceRecord record = rows.get(i); + + if (record == null) continue; + + if (record.address == null || record.address.trim().length() == 0) { + continue; + } + + if (seen.contains(record.address)) continue; + + seen.add(record.address); + + historyOf(db, record.address); + } + } + + /** + * Normalized history for one device, running the engine (and caching the + * result) the first time it is asked for. + */ + public List historyOf(TrackerDatabase db, String address) { + + List cached = historyCache.get(address); + + if (cached != null) return cached; + + List normalized = + normalize(address, db.history(address, 0L)); + + historyCache.put(address, normalized); + + return normalized; + } + + /** Removed points for one device; never null. */ + public List decisionsOf(String address) { + + List result = decisionCache.get(address); + + return result == null + ? new ArrayList() + : result; + } + + public int duplicateCount() { + return duplicateKeys.size(); + } + + public int rejectedCount() { + return rejectedKeys.size(); + } + + /** Points kept across every device normalized so far. */ + public int retainedCount() { + + int retained = 0; + + for (List list : historyCache.values()) { + if (list != null) retained += list.size(); + } + + return retained; + } + + /** + * Points examined so far: the decision cache holds only removals and the + * history cache only survivors, so the raw total is the two combined. + */ + public int examinedCount() { + + int removed = 0; + + for (List list : decisionCache.values()) { + if (list != null) removed += list.size(); + } + + return removed + retainedCount(); + } + + // ------------------------------------------------------------------ + // Engine + // ------------------------------------------------------------------ + + public List normalize( + String deviceAddress, + List input) { + + ArrayList rows = new ArrayList(); + ArrayList decisions = new ArrayList(); + + if (input == null) { + + decisionCache.put(deviceAddress, decisions); + + return rows; + } + + for (int i = 0; i < input.size(); i++) { + + LocationPoint point = input.get(i); + + if (point != null) rows.add(point); + } + + Collections.sort(rows, new Comparator() { + + @Override + public int compare(LocationPoint a, LocationPoint b) { + + long ta = pointTime(a); + long tb = pointTime(b); + + if (ta < tb) return -1; + if (ta > tb) return 1; + + return 0; + } + }); + + /* + * Pass 1: drop points identical to one already seen. + */ + + ArrayList unique = new ArrayList(); + + HashSet exact = new HashSet(); + + for (int i = 0; i < rows.size(); i++) { + + LocationPoint point = rows.get(i); + + String key = buildKey(deviceAddress, point); + + if (exact.contains(key)) { + + duplicateKeys.add(key); + + decisions.add(new Decision( + point, + unique.size() > 0 + ? unique.get(unique.size() - 1) + : null, + "Exact duplicate", + 0, + 0, + 0, + duplicateDistance())); + + continue; + } + + exact.add(key); + unique.add(point); + } + + /* + * Pass 2: walk the timeline and judge each point against the last + * one that was trusted. + */ + + ArrayList normalized = new ArrayList(); + + LocationPoint previousAccepted = null; + + for (int i = 0; i < unique.size(); i++) { + + LocationPoint point = unique.get(i); + + long timestamp = pointTime(point); + + double lat = point.latitude; + double lon = point.longitude; + + double accuracy = pointDouble(point, "accuracy", -1); + + /* Nothing to compare against - keep it and move on. */ + + if (timestamp <= 0L) { + normalized.add(point); + continue; + } + + if (!isValidCoordinate(lat, lon)) { + normalized.add(point); + continue; + } + + if (previousAccepted == null) { + normalized.add(point); + previousAccepted = point; + continue; + } + + long previousTimestamp = pointTime(previousAccepted); + + double previousLat = previousAccepted.latitude; + double previousLon = previousAccepted.longitude; + + if (previousTimestamp <= 0L + || !isValidCoordinate(previousLat, previousLon)) { + + normalized.add(point); + previousAccepted = point; + continue; + } + + double elapsed = (timestamp - previousTimestamp) / 1000.0; + + double distance = + haversineMeters(previousLat, previousLon, lat, lon); + + if (elapsed < 0) { + normalized.add(point); + previousAccepted = point; + continue; + } + + /* + * ======================================================== + * SAME TIMESTAMP + * ======================================================== + */ + + if (elapsed == 0) { + + double previousAccuracy = + pointDouble(previousAccepted, "accuracy", 0); + + double allowedDistance = Math.max( + sameTimeDistance(), + (Math.max(accuracy, 0) + Math.max(previousAccuracy, 0)) + * accuracyMultiplier()); + + String key = buildKey(deviceAddress, point); + + if (distance <= allowedDistance) { + + duplicateKeys.add(key); + + decisions.add(new Decision( + point, + previousAccepted, + "Same timestamp / duplicate GPS fix", + distance, + elapsed, + 0, + allowedDistance)); + + continue; + } + + rejectedKeys.add(key); + + decisions.add(new Decision( + point, + previousAccepted, + "Conflicting GPS point at same timestamp", + distance, + elapsed, + 0, + allowedDistance)); + + continue; + } + + /* + * ======================================================== + * NORMAL MOVEMENT + * ======================================================== + */ + + double previousAccuracy = + pointDouble(previousAccepted, "accuracy", 0); + + double combinedAccuracy = Math.max(previousAccuracy, accuracy); + + double movementTolerance = Math.max( + duplicateDistance(), + combinedAccuracy * accuracyMultiplier()); + + if (distance <= movementTolerance) { + normalized.add(point); + previousAccepted = point; + continue; + } + + /* + * ======================================================== + * PHYSICAL SPEED TEST + * ======================================================== + */ + + double speedKmh = (distance / elapsed) * 3.6; + + double physicallyPossibleDistance = maxSpeedMps() * elapsed; + + if (distance > physicallyPossibleDistance) { + + rejectedKeys.add(buildKey(deviceAddress, point)); + + decisions.add(new Decision( + point, + previousAccepted, + "Impossible GPS movement", + distance, + elapsed, + speedKmh, + maxSpeedKmh())); + + /* + * IMPORTANT: + * + * Do not advance previousAccepted when a point is + * rejected. The next point is still compared against + * the last trustworthy point. + */ + continue; + } + + normalized.add(point); + previousAccepted = point; + } + + decisionCache.put(deviceAddress, decisions); + + return normalized; + } + + public static String buildKey(String deviceAddress, LocationPoint point) { + + if (point == null) return ""; + + return String.valueOf(deviceAddress) + + "|" + pointTime(point) + + "|" + Double.toString(point.latitude) + + "|" + Double.toString(point.longitude); + } + + public static boolean isValidCoordinate(double latitude, double longitude) { + + return !Double.isNaN(latitude) + && !Double.isNaN(longitude) + && !Double.isInfinite(latitude) + && !Double.isInfinite(longitude) + && latitude >= -90.0 + && latitude <= 90.0 + && longitude >= -180.0 + && longitude <= 180.0; + } + + public static double haversineMeters( + double lat1, + double lon1, + double lat2, + double lon2) { + + double rLat1 = Math.toRadians(lat1); + double rLon1 = Math.toRadians(lon1); + double rLat2 = Math.toRadians(lat2); + double rLon2 = Math.toRadians(lon2); + + double dLat = rLat2 - rLat1; + double dLon = rLon2 - rLon1; + + double a = + Math.sin(dLat / 2.0) * Math.sin(dLat / 2.0) + + Math.cos(rLat1) + * Math.cos(rLat2) + * Math.sin(dLon / 2.0) + * Math.sin(dLon / 2.0); + + double c = 2.0 * Math.atan2(Math.sqrt(a), Math.sqrt(1.0 - a)); + + return EARTH_RADIUS_METERS * c; + } + + // ------------------------------------------------------------------ + // LocationPoint reflection + // + // The point class has changed shape more than once (timestamp vs time + // vs recordedAt, and rssi/battery only exist on some builds), so these + // read whatever field is actually there instead of assuming. + // ------------------------------------------------------------------ + + private static final String[] TIME_FIELDS = { + "timestamp", + "time", + "recordedAt", + "createdAt", + "updatedAt", + "timeMillis", + "timestampMillis", + "recordedTime" + }; + + public static long pointTime(LocationPoint point) { + + if (point == null) return 0L; + + for (int i = 0; i < TIME_FIELDS.length; i++) { + + Object value = readField(point, TIME_FIELDS[i]); + + if (value == null) continue; + + if (value instanceof Number) { + return millis(((Number) value).longValue()); + } + + if (value instanceof Date) { + return ((Date) value).getTime(); + } + + if (value instanceof String) { + + try { + return millis(Long.parseLong((String) value)); + + } catch (Exception ignored) { + } + } + } + + return 0L; + } + + /** Promotes a seconds-since-epoch value to milliseconds. */ + private static long millis(long value) { + + return value > 0 && value < 100000000000L + ? value * 1000L + : value; + } + + public static double pointDouble( + LocationPoint point, + String name, + double fallback) { + + Object value = readField(point, name); + + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + + if (value instanceof String) { + + try { + return Double.parseDouble((String) value); + + } catch (Exception ignored) { + } + } + + return fallback; + } + + private static Object readField(LocationPoint point, String name) { + + if (point == null) return null; + + Class cls = point.getClass(); + + while (cls != null) { + + try { + + Field field = cls.getDeclaredField(name); + + field.setAccessible(true); + + return field.get(point); + + } catch (Exception ignored) { + + cls = cls.getSuperclass(); + } + } + + return null; + } +} diff --git a/app/src/main/java/com/wytehat/btlogger/MapActivity.java b/app/src/main/java/com/wytehat/btlogger/MapActivity.java index 37f3d90..ed005e8 100644 --- a/app/src/main/java/com/wytehat/btlogger/MapActivity.java +++ b/app/src/main/java/com/wytehat/btlogger/MapActivity.java @@ -1,2662 +1,2085 @@ package com.wytehat.btlogger; -import android.app.Activity; -import android.app.DatePickerDialog; -import android.app.TimePickerDialog; -import android.bluetooth.BluetoothAdapter; -import android.bluetooth.BluetoothDevice; -import android.content.Intent; -import android.graphics.Color; -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.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; -import android.widget.AdapterView; -import android.widget.ArrayAdapter; -import android.widget.Button; -import android.widget.CheckBox; -import android.widget.LinearLayout; -import android.widget.ScrollView; -import android.widget.Spinner; -import android.widget.TextView; - -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Calendar; -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; +import android.app.*; +import android.bluetooth.*; +import android.content.*; +import android.graphics.*; +import android.location.*; +import android.net.*; +import android.os.*; +import android.view.*; +import android.webkit.*; +import android.widget.*; +import com.wytehat.btlogger.*; +import java.text.*; +import java.util.*; public class MapActivity extends Activity -implements SensorEventListener { +implements LocationListener, android.hardware.SensorEventListener, +MapHtmlBuilder.DataSource { - private TrackerDatabase db; - private WebView web; +private TrackerDatabase db; +private WebView web; - private Button filterButton; - private Button fromButton; - private Button toButton; - private Button lastHourButton; - private Button todayButton; - private Button applyButton; +private LocationManager locationManager; +private android.hardware.SensorManager sensorManager; +private android.hardware.Sensor rotationSensor; - private LinearLayout filterPanel; +private Location phoneLocation; +private float phoneHeading; +private boolean mapReady; - private ScrollView filterScroll; - private Spinner categorySpinner; - private Spinner deviceSpinner; - private Spinner resolutionSpinner; - - private CheckBox showAll; +private LinearLayout filterPanel; +private TextView filterHeader; +private boolean filtersExpanded = false; - private ArrayAdapter categoryAdapter; - private ArrayAdapter deviceAdapter; +private Spinner deviceSpinner; +private Spinner categorySpinner; - private final ArrayList categoryValues = - new ArrayList(); +private Button startDateButton; +private Button endDateButton; +private Button startTimeButton; +private Button endTimeButton; - private final ArrayList categoryLabels = - new ArrayList(); +private CheckBox showHistory; +private CheckBox showAll; +private CheckBox showNormalized; +private CheckBox showChanges; - private final ArrayList deviceAddresses = - new ArrayList(); +private SeekBar normalizationSeekBar; +private TextView normalizationValueText; +private TextView normalizationStatsText; - private final ArrayList deviceLabels = - new ArrayList(); +private Button applyButton; +private Button resetButton; +private Button exportRawButton; +private Button exportNormalizedButton; - private final ArrayList resolutionLabels = - new ArrayList(); +private final ArrayList deviceValues = + new ArrayList(); - /* - * 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 final ArrayList deviceLabels = + new ArrayList(); - private static final String[] RESOLUTION_LABELS = { - "All points", - "Every 15 min", - "Every 1 hour", - "Every 2 hours" - }; +private final ArrayList categoryValues = + new ArrayList(); - private volatile String categoryFilter = "Trackers / Tags"; - private volatile String deviceFilter = "All"; +private final ArrayList categoryLabels = + new ArrayList(); + +private ArrayAdapter deviceAdapter; +private ArrayAdapter categoryAdapter; + +private boolean rebuildingDevices; +private boolean rebuildingCategories; - private boolean rebuildingCategories; - private boolean rebuildingDevices; - private boolean rebuildingResolution = true; - private volatile boolean loadingMap; +private String selectedDevice = "All"; +private String categoryFilter = "All"; - private volatile long historyFrom; - private volatile long historyTo; +private Calendar filterStart; +private Calendar filterEnd; - /* - * Selected trail resolution, read by the background loader. - */ - private volatile long sampleInterval = RESOLUTION_VALUES[0]; +private boolean normalizedMode; - /* - * 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(); +/* + * If true, the map also draws the points normalization removed. + * Backs the "showChanges" checkbox, the same way normalizedMode + * backs "showNormalized". + */ +private boolean showNormalizationChanges = true; + +private final HistoryNormalizer normalizer = + new HistoryNormalizer(); + +private MapExporter exporter; + +private static final String[] CATEGORY_ORDER = { + "Trackers / Tags", + "Audio", + "Wearables", + "Phones", + "Computers", + "Vehicles", + "Other" +}; + +@Override +protected void onCreate(Bundle state) { + super.onCreate(state); + + db = new TrackerDatabase(this); + + exporter = + new MapExporter( + this, + db, + normalizer + ); + + android.content.SharedPreferences settings = + getSharedPreferences( + "map_settings", + 0 + ); + + normalizedMode = + settings.getBoolean( + "normalized_mode", + false + ); + + showNormalizationChanges = + settings.getBoolean( + "show_normalization_changes", + true + ); + + normalizer.setAggressiveness( + settings.getInt( + "normalization_aggressiveness", + HistoryNormalizer.DEFAULT_AGGRESSIVENESS + ) + ); + + filterEnd = Calendar.getInstance(); + + filterStart = Calendar.getInstance(); + + filterStart.setTimeInMillis( + System.currentTimeMillis() + - (60L * 60L * 1000L) + ); + + LinearLayout root = + new LinearLayout(this); + + root.setOrientation( + LinearLayout.VERTICAL + ); + + root.setBackgroundColor( + Color.WHITE + ); + + root.addView( + NavigationHelper.createBar( + this, + 2, + "Tracked Item Map" + ) + ); + + filterHeader = + new TextView(this); + + filterHeader.setText( + "Map Filters ▼" + ); + + filterHeader.setTextSize(16); + filterHeader.setTextColor(Color.DKGRAY); + + filterHeader.setGravity( + Gravity.CENTER_VERTICAL + ); + + filterHeader.setPadding( + dp(14), + dp(10), + dp(14), + dp(10) + ); + + filterHeader.setBackgroundColor( + Color.rgb(238, 238, 238) + ); + + root.addView( + filterHeader, + new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + dp(48) + ) + ); + + filterPanel = + new LinearLayout(this); + + filterPanel.setOrientation( + LinearLayout.VERTICAL + ); + + filterPanel.setPadding( + dp(10), + dp(6), + dp(10), + dp(8) + ); + + filterPanel.setBackgroundColor( + Color.WHITE + ); + + buildFilterPanel(); + + root.addView( + filterPanel, + new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + ); + + filterHeader.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View view) { + + setFiltersExpanded( + !filtersExpanded + ); + } + } + ); + + setFiltersExpanded(false); + + web = new WebView(this); + + web.getSettings().setJavaScriptEnabled(true); + web.getSettings().setAllowFileAccess(true); + + web.setWebViewClient( + new WebViewClient() { + + @Override + public void onPageFinished( + WebView view, + String url) { + + mapReady = true; + updatePhoneMarker(); + } + + @Override + public boolean shouldOverrideUrlLoading( + WebView view, + String url) { + + if (url != null && + url.startsWith("geo:")) { + + openMap(url); + return true; + } + + return false; + } + } + ); + + root.addView( + web, + new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + 0, + 1 + ) + ); + + setContentView(root); + + locationManager = + (LocationManager) + getSystemService( + LOCATION_SERVICE + ); + + sensorManager = + (android.hardware.SensorManager) + getSystemService( + SENSOR_SERVICE + ); + + if (sensorManager != null) { + + rotationSensor = + sensorManager.getDefaultSensor( + android.hardware.Sensor.TYPE_ROTATION_VECTOR + ); + } + + String focus = + getIntent().getStringExtra("focus"); + + if (focus != null && + focus.trim().length() > 0) { + + selectedDevice = focus; + } + + reloadMap(); +} + +private int dp(int value) { + + float density = + getResources() + .getDisplayMetrics() + .density; + + return Math.round( + value * density + ); +} + +/* + * ================================================================ + * FILTER UI + * ================================================================ + */ + +private void buildFilterPanel() { + + TextView deviceTitle = + filterLabel("Tracked Device"); + + filterPanel.addView(deviceTitle); + + deviceSpinner = + new Spinner(this); + + deviceAdapter = + new ArrayAdapter( + this, + android.R.layout.simple_spinner_item, + deviceLabels + ); + + deviceAdapter.setDropDownViewResource( + android.R.layout.simple_spinner_dropdown_item + ); + + deviceSpinner.setAdapter( + deviceAdapter + ); + + filterPanel.addView( + deviceSpinner, + new LinearLayout.LayoutParams( + -1, + -2 + ) + ); + + deviceSpinner.setOnItemSelectedListener( + new android.widget.AdapterView.OnItemSelectedListener() { + + @Override + public void onItemSelected( + android.widget.AdapterView parent, + View view, + int position, + long id) { + + if (!rebuildingDevices && + position >= 0 && + position < deviceValues.size()) { + + selectedDevice = + deviceValues.get(position); + } + } + + @Override + public void onNothingSelected( + android.widget.AdapterView parent) { + } + } + ); + + TextView categoryTitle = + filterLabel("Category"); + + filterPanel.addView(categoryTitle); + + categorySpinner = + new Spinner(this); + + categoryAdapter = + new ArrayAdapter( + this, + android.R.layout.simple_spinner_item, + categoryLabels + ); + + categoryAdapter.setDropDownViewResource( + android.R.layout.simple_spinner_dropdown_item + ); + + categorySpinner.setAdapter( + categoryAdapter + ); + + filterPanel.addView( + categorySpinner, + new LinearLayout.LayoutParams( + -1, + -2 + ) + ); + + categorySpinner.setOnItemSelectedListener( + new android.widget.AdapterView.OnItemSelectedListener() { + + @Override + public void onItemSelected( + android.widget.AdapterView parent, + View view, + int position, + long id) { + + if (!rebuildingCategories && + position >= 0 && + position < categoryValues.size()) { + + categoryFilter = + categoryValues.get(position); + } + } + + @Override + public void onNothingSelected( + android.widget.AdapterView parent) { + } + } + ); + + TextView rangeTitle = + filterLabel("Date and Time Range"); + + filterPanel.addView(rangeTitle); + + LinearLayout row1 = + new LinearLayout(this); + + row1.setOrientation( + LinearLayout.HORIZONTAL + ); + + startDateButton = + makeFilterButton("Start Date"); + + endDateButton = + makeFilterButton("End Date"); + + row1.addView( + startDateButton, + weightParams() + ); + + row1.addView( + endDateButton, + weightParams() + ); + + filterPanel.addView(row1); + + LinearLayout row2 = + new LinearLayout(this); + + row2.setOrientation( + LinearLayout.HORIZONTAL + ); + + startTimeButton = + makeFilterButton("Start Time"); + + endTimeButton = + makeFilterButton("End Time"); + + row2.addView( + startTimeButton, + weightParams() + ); + + row2.addView( + endTimeButton, + weightParams() + ); + + filterPanel.addView(row2); + + updateFilterButtonText(); + + startDateButton.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View v) { + showDatePicker(true); + } + } + ); + + endDateButton.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View v) { + showDatePicker(false); + } + } + ); + + startTimeButton.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View v) { + showTimePicker(true); + } + } + ); + + endTimeButton.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View v) { + showTimePicker(false); + } + } + ); + + showHistory = + new CheckBox(this); + + showHistory.setText( + "Show previous recorded points" + ); + + showHistory.setChecked(true); + + filterPanel.addView(showHistory); + + showAll = + new CheckBox(this); + + showAll.setText( + "Troubleshooting: show all scanned devices" + ); + + showAll.setChecked( + getSharedPreferences( + "map_settings", + 0 + ).getBoolean( + "show_all_scanned", + false + ) + ); + + filterPanel.addView(showAll); + + /* + * ============================================================ + * NORMALIZATION SECTION + * ============================================================ + */ + + TextView normalizationTitle = + filterLabel( + "GPS Normalization" + ); + + normalizationTitle.setTextSize(16); + normalizationTitle.setTypeface( + null, + android.graphics.Typeface.BOLD + ); + + filterPanel.addView( + normalizationTitle + ); + + showNormalized = + new CheckBox(this); + + showNormalized.setText( + "Apply normalization to map" + ); + + showNormalized.setChecked( + normalizedMode + ); + + filterPanel.addView( + showNormalized + ); + + showChanges = + new CheckBox(this); + + showChanges.setText( + "Show what normalization removed" + ); + + showChanges.setChecked( + showNormalizationChanges + ); + + filterPanel.addView( + showChanges + ); + + TextView aggressivenessLabel = + filterLabel( + "Normalization aggressiveness" + ); + + filterPanel.addView( + aggressivenessLabel + ); + + normalizationSeekBar = + new SeekBar(this); + + normalizationSeekBar.setMax(100); + + normalizationSeekBar.setProgress( + normalizer.getAggressiveness() + ); + + filterPanel.addView( + normalizationSeekBar, + new LinearLayout.LayoutParams( + -1, + dp(42) + ) + ); + + normalizationValueText = + new TextView(this); + + normalizationValueText.setTextSize(13); + normalizationValueText.setTextColor( + Color.DKGRAY + ); + + normalizationValueText.setPadding( + dp(4), + dp(0), + dp(4), + dp(6) + ); + + filterPanel.addView( + normalizationValueText + ); + + normalizationStatsText = + new TextView(this); + + normalizationStatsText.setTextSize(12); + normalizationStatsText.setTextColor( + Color.GRAY + ); + + normalizationStatsText.setPadding( + dp(4), + dp(2), + dp(4), + dp(8) + ); - private final Handler ui = - new Handler(Looper.getMainLooper()); + filterPanel.addView( + normalizationStatsText + ); - private volatile boolean destroyed; + updateNormalizationControls(); - private LocationManager locationManager; - private SensorManager sensorManager; - private Sensor rotationSensor; - - private Location phoneLocation; - private float phoneHeading; - - private boolean mapReady; - - private String focus; - - private static final String[] CATEGORY_ORDER = { - "Trackers / Tags", - "Audio", - "Wearables", - "Phones", - "Computers", - "Vehicles", - "Other" - }; - - private final LocationListener locationListener = - new LocationListener() { - - @Override - public void onStatusChanged(String provider, int status, Bundle extras) - { - // TODO: Implement this method - } - - public void onLocationChanged(Location location) { - if (location == null) { - return; - } - - phoneLocation = location; - - if (location.hasBearing() && - location.hasSpeed() && - location.getSpeed() > 0.8f) { - - phoneHeading = location.getBearing(); - } - - updatePhoneMarker(); - } - }; - - @Override - protected void onCreate(Bundle state) { - super.onCreate(state); - - db = new TrackerDatabase(this); - - long now = System.currentTimeMillis(); - - historyTo = now; - historyFrom = now - (60L * 60L * 1000L); - - focus = getIntent().getStringExtra("focus"); - - LinearLayout root = new LinearLayout(this); - root.setOrientation(LinearLayout.VERTICAL); - root.setBackgroundColor(Color.WHITE); - - root.addView( - NavigationHelper.createBar( - this, - 2, - "Tracked Item Map" - ) - ); - - /* - * Compact filter button. - */ - LinearLayout filterHeader = - new LinearLayout(this); - - filterHeader.setOrientation( - LinearLayout.HORIZONTAL - ); - - filterHeader.setGravity( - android.view.Gravity.CENTER_VERTICAL - ); - - filterHeader.setPadding( - dp(8), - dp(2), - dp(8), - dp(2) - ); - - filterButton = new Button(this); - filterButton.setText("☰ Filters"); - filterButton.setTextSize(13); - - filterHeader.addView( - filterButton, - new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ) - ); - - root.addView( - filterHeader, - new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ) - ); - - /* - * Everything below this point is collapsible. - */ - filterPanel = new LinearLayout(this); - filterPanel.setOrientation( - LinearLayout.VERTICAL - ); - - filterPanel.setPadding( - dp(8), - 0, - dp(8), - dp(4) - ); - - /* - * Category. - */ - TextView categoryLabel = - smallLabel("Category"); - - filterPanel.addView(categoryLabel); - - categorySpinner = new Spinner(this); - - categoryValues.add("Trackers / Tags"); - categoryLabels.add("Trackers / Tags"); - - categoryValues.add("All"); - - categoryLabels.add("All categories"); - - categoryAdapter = - new ArrayAdapter( - this, - android.R.layout.simple_spinner_item, - categoryLabels - ); - - categoryAdapter.setDropDownViewResource( - android.R.layout.simple_spinner_dropdown_item - ); - - categorySpinner.setAdapter(categoryAdapter); - - filterPanel.addView( - categorySpinner, - compactParams() - ); - - /* - * Device. - */ - TextView deviceLabel = - smallLabel("Device"); - - filterPanel.addView(deviceLabel); - - deviceSpinner = new Spinner(this); - - deviceAdapter = - new ArrayAdapter( - this, - android.R.layout.simple_spinner_item, - deviceLabels - ); - - deviceAdapter.setDropDownViewResource( - android.R.layout.simple_spinner_dropdown_item - ); - - deviceSpinner.setAdapter(deviceAdapter); - - filterPanel.addView( - deviceSpinner, - compactParams() - ); - - /* - * Trail resolution (downsampling). - */ - LinearLayout resolutionRow = - new LinearLayout(this); - - resolutionRow.setOrientation( - LinearLayout.HORIZONTAL - ); - - resolutionRow.setGravity( - android.view.Gravity.CENTER_VERTICAL - ); - - TextView resolutionLabel = - smallLabel("Trail detail"); - - resolutionRow.addView( - resolutionLabel, - new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ) - ); - 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 - ); - - resolutionRow.addView( - resolutionSpinner, - new LinearLayout.LayoutParams( - 0, - dp(38), - 1 - ) - ); - - filterPanel.addView(resolutionRow); - /* - * Time. - */ - TextView timeLabel = - smallLabel("Location history"); - - filterPanel.addView(timeLabel); - - LinearLayout timeRow = - new LinearLayout(this); - - timeRow.setOrientation( - LinearLayout.HORIZONTAL - ); - - fromButton = new Button(this); - toButton = new Button(this); - - fromButton.setTextSize(11); - toButton.setTextSize(11); - - timeRow.addView( - fromButton, - new LinearLayout.LayoutParams( - 0, - LinearLayout.LayoutParams.WRAP_CONTENT, - 1 - ) - ); - - timeRow.addView( - toButton, - new LinearLayout.LayoutParams( - 0, - LinearLayout.LayoutParams.WRAP_CONTENT, - 1 - ) - ); - - filterPanel.addView(timeRow); - - /* - * Quick time buttons. - */ - LinearLayout quickRow = - new LinearLayout(this); - - quickRow.setOrientation( - LinearLayout.HORIZONTAL - ); - - lastHourButton = new Button(this); - todayButton = new Button(this); - applyButton = new Button(this); - - lastHourButton.setText("1 Hour"); - todayButton.setText("Today"); - applyButton.setText("Apply"); - - lastHourButton.setTextSize(11); - todayButton.setTextSize(11); - applyButton.setTextSize(11); - - quickRow.addView( - lastHourButton, - new LinearLayout.LayoutParams( - 0, - LinearLayout.LayoutParams.WRAP_CONTENT, - 1 - ) - ); - - quickRow.addView( - todayButton, - new LinearLayout.LayoutParams( - 0, - LinearLayout.LayoutParams.WRAP_CONTENT, - 1 - ) - ); - - quickRow.addView( - applyButton, - new LinearLayout.LayoutParams( - 0, - LinearLayout.LayoutParams.WRAP_CONTENT, - 1 - ) - ); - - filterPanel.addView(quickRow); - - /* - * Troubleshooting switch. - */ - showAll = new CheckBox(this); - - showAll.setText( - "Show all scanned devices" - ); - - showAll.setTextSize(12); - - showAll.setChecked( - getSharedPreferences( - "map_settings", - 0 - ).getBoolean( - "show_all_scanned", - false - ) - ); - - filterPanel.addView(showAll); - - /* - * The panel is scrollable and bounded to a third of the - * screen so every control stays reachable on short screens. - */ - filterScroll = new ScrollView(this); - - filterScroll.setVisibility(View.GONE); - - filterScroll.addView( - filterPanel, - new android.widget.FrameLayout.LayoutParams( - android.widget.FrameLayout.LayoutParams.MATCH_PARENT, - android.widget.FrameLayout.LayoutParams.WRAP_CONTENT - ) - ); - - root.addView( - filterScroll, - new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - 0, - 1 - ) - ); - /* - * WebView map. - */ - web = new WebView(this); - - web.getSettings().setJavaScriptEnabled(true); - web.getSettings().setDomStorageEnabled(true); - web.getSettings().setAllowFileAccess(true); - web.getSettings().setAllowContentAccess(true); - web.setBackgroundColor(Color.WHITE); - - web.setWebViewClient( - new WebViewClient() { - - @Override - public void onPageFinished( - WebView view, - String url) { - - mapReady = true; - - updatePhoneMarker(); - } - - @Override - public boolean shouldOverrideUrlLoading( - WebView view, - String url) { - - if (url != null && - url.startsWith("geo:")) { - - openMap(url); - return true; - } - - return false; - } - } - ); - - root.addView( - web, - new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - 0, - 2 - ) - ); - - setContentView(root); - - locationManager = - (LocationManager) - getSystemService( - LOCATION_SERVICE - ); - - sensorManager = - (SensorManager) - getSystemService( - SENSOR_SERVICE - ); - - if (sensorManager != null) { - - rotationSensor = - sensorManager.getDefaultSensor( - Sensor.TYPE_ROTATION_VECTOR - ); - } - - /* - * Filter open/close. - */ - filterButton.setOnClickListener( - new View.OnClickListener() { - - @Override - public void onClick(View view) { - - filterScroll.setVisibility( - filterScroll.getVisibility() - == View.VISIBLE - ? View.GONE - : View.VISIBLE - ); - } - } - ); - - /* - * Category. - */ - categorySpinner.setOnItemSelectedListener( - new AdapterView.OnItemSelectedListener() { - - @Override - public void onItemSelected( - AdapterView parent, - View view, - int position, - long id) { - - if (rebuildingCategories) { - return; - } - - if (position < 0 || - position >= - categoryValues.size()) { - - return; - } - - categoryFilter = - categoryValues.get(position); - - rebuildDeviceFilter(); - } - - @Override - public void onNothingSelected( - AdapterView parent) { - } - } - ); - - /* - * Device. - */ - deviceSpinner.setOnItemSelectedListener( - new AdapterView.OnItemSelectedListener() { - - @Override - public void onItemSelected( - AdapterView parent, - View view, - int position, - long id) { - - if (rebuildingDevices) { - return; - } - - if (position < 0 || - position >= - deviceAddresses.size()) { - - return; - } - - deviceFilter = - deviceAddresses.get(position); - } - - @Override - public void onNothingSelected( - AdapterView parent) { - } - } - ); - - /* - * 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 - public void onClick(View view) { - pickDateTime(true); - } - } - ); - - toButton.setOnClickListener( - new View.OnClickListener() { - - @Override - public void onClick(View view) { - pickDateTime(false); - } - } - ); - - lastHourButton.setOnClickListener( - new View.OnClickListener() { - - @Override - public void onClick(View view) { - - long current = - System.currentTimeMillis(); - - historyTo = current; - - historyFrom = - current - - (60L * 60L * 1000L); - - updateTimeButtons(); - } - } - ); - - todayButton.setOnClickListener( - new View.OnClickListener() { - - @Override - public void onClick(View view) { - - historyFrom = - startOfToday(); - - historyTo = - System.currentTimeMillis(); - - updateTimeButtons(); - } - } - ); - - applyButton.setOnClickListener( - new View.OnClickListener() { + normalizationSeekBar.setOnSeekBarChangeListener( + new SeekBar.OnSeekBarChangeListener() { - @Override - public void onClick(View view) { + @Override + public void onProgressChanged( + SeekBar seekBar, + int progress, + boolean fromUser) { + + normalizer.setAggressiveness(progress); - if (historyTo < historyFrom) { + updateNormalizationControls(); - long temp = - historyFrom; + /* + * Rebuild immediately so the user can drag + * the slider and see the effect. + */ + if (fromUser && + normalizedMode) { - historyFrom = - historyTo; + reloadMap(); + } + } - historyTo = - temp; - } + @Override + public void onStartTrackingTouch( + SeekBar seekBar) { + } - rebuildDeviceFilter(); - reloadMap(); + @Override + public void onStopTrackingTouch( + SeekBar seekBar) { - /* - * Collapse after applying. - */ - filterScroll.setVisibility( - View.GONE - ); - } - } - ); - - showAll.setOnClickListener( - new View.OnClickListener() { - - @Override - public void onClick(View view) { - - getSharedPreferences( - "map_settings", - 0 - ).edit() - .putBoolean( - "show_all_scanned", - showAll.isChecked() - ) - .apply(); - - deviceFilter = "All"; - - rebuildDeviceFilter(); - reloadMap(); - } - } - ); - - updateTimeButtons(); - - rebuildingResolution = false; - - rebuildDeviceFilter(); - - /* - * The initial map is deliberately: - * - * Trackers / Tags - * Last hour - * All tracked devices - * - * No historical trail is loaded until an - * individual device is selected. - */ - reloadMap(); - } - - private LinearLayout.LayoutParams compactParams() { - - return new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - dp(42) - ); - } - - private TextView smallLabel(String text) { - - TextView label = new TextView(this); - - label.setText(text); - label.setTextSize(11); - label.setTextColor(Color.DKGRAY); - - label.setPadding( - dp(4), - dp(3), - dp(4), - 0 - ); - - return label; - } - - private void pickDateTime(final boolean from) { - - final Calendar current = - Calendar.getInstance(); - - current.setTimeInMillis( - from ? historyFrom : historyTo - ); - - DatePickerDialog dialog = - new DatePickerDialog( - this, - new DatePickerDialog.OnDateSetListener() { - - @Override - public void onDateSet( - android.widget.DatePicker picker, - int year, - int month, - int day) { - - final Calendar selected = - Calendar.getInstance(); - - selected.set( - year, - month, - day - ); - - TimePickerDialog timeDialog = - new TimePickerDialog( - MapActivity.this, - new TimePickerDialog.OnTimeSetListener() { - - @Override - public void onTimeSet( - android.widget.TimePicker picker, - int hour, - int minute) { - - selected.set( - Calendar.HOUR_OF_DAY, - hour - ); - - selected.set( - Calendar.MINUTE, - minute - ); - - selected.set( - Calendar.SECOND, - 0 - ); - - selected.set( - Calendar.MILLISECOND, - 0 - ); - - if (from) { - - historyFrom = - selected.getTimeInMillis(); - - } else { - - historyTo = - selected.getTimeInMillis(); - } - - updateTimeButtons(); - } - }, - current.get( - Calendar.HOUR_OF_DAY - ), - current.get( - Calendar.MINUTE - ), - false - ); - - timeDialog.show(); - } - }, - current.get(Calendar.YEAR), - current.get(Calendar.MONTH), - current.get(Calendar.DAY_OF_MONTH) - ); - - dialog.show(); - } - - private void updateTimeButtons() { - - SimpleDateFormat format = - new SimpleDateFormat( - "M/d/yy h:mm a", - Locale.US - ); - - fromButton.setText( - "From\n" + - format.format( - new Date(historyFrom) - ) - ); - - toButton.setText( - "To\n" + - format.format( - new Date(historyTo) - ) - ); - } - - private boolean isUnknown(DeviceRecord record) { - - if (record == null) { - return true; - } - - String name = - record.displayName(); - - if (name == null) { - return true; - } - - String value = - name.trim().toLowerCase( - Locale.US - ); - - if (value.length() == 0) { - return true; - } - - return value.equals("unknown") || - value.equals("unknown device") || - value.equals("unknown item") || - value.equals("unknown device name") || - value.equals("unnamed") || - value.equals("unnamed device") || - value.equals("manufacturer unknown") || - value.equals("device"); - } - - private boolean validAddress(DeviceRecord record) { - - return record != null && - record.address != null && - record.address.trim().length() > 0; - } - - private boolean categoryMatches( - DeviceRecord record) { - - if ("All".equals(categoryFilter)) { - return true; - } - - return categoryFilter.equals( - record.category - ); - } - - private void rebuildDeviceFilter() { - - rebuildDeviceFilter( - getMapDevices( - showAll.isChecked() - ) - ); - } - - /* - * 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 = - new ArrayList(); - - nextAddresses.add("All"); - nextLabels.add("All devices"); - - HashSet seen = - new HashSet(); - - for (int i = 0; - i < available.size(); - i++) { - - DeviceRecord record = - available.get(i); + saveNormalizationSettings(); - if (!validAddress(record)) { - continue; - } + if (normalizedMode) + reloadMap(); + } + } + ); - if (!record.hasLocation) { - continue; - } + /* + * ============================================================ + * NORMAL FILTER BUTTONS + * ============================================================ + */ - if (!categoryMatches(record)) { - continue; - } + LinearLayout buttonRow = + new LinearLayout(this); - if (!showAll.isChecked() && - isUnknown(record)) { - continue; - } + buttonRow.setOrientation( + LinearLayout.HORIZONTAL + ); - String address = - record.address.trim(); + applyButton = + new Button(this); - if (seen.contains(address)) { - continue; - } - - seen.add(address); + applyButton.setText( + "Apply Filters" + ); - String name = - record.displayName(); + resetButton = + new Button(this); - if (name == null || - name.trim().length() == 0) { - - name = "Unknown item"; - } + resetButton.setText( + "Reset" + ); - nextAddresses.add(address); + buttonRow.addView( + applyButton, + weightParams() + ); - nextLabels.add( - name + - " [" + - address + - "]" - ); - } + buttonRow.addView( + resetButton, + weightParams() + ); - int selected = - nextAddresses.indexOf( - deviceFilter - ); + filterPanel.addView(buttonRow); - if (selected < 0) { + LinearLayout exportRow = + new LinearLayout(this); - deviceFilter = "All"; - selected = 0; - } + exportRow.setOrientation( + LinearLayout.HORIZONTAL + ); - rebuildingDevices = true; + exportRawButton = + new Button(this); - deviceAddresses.clear(); - deviceAddresses.addAll( - nextAddresses - ); + exportRawButton.setText( + "Export Raw" + ); - deviceLabels.clear(); - deviceLabels.addAll( - nextLabels - ); + exportNormalizedButton = + new Button(this); - deviceAdapter.notifyDataSetChanged(); + exportNormalizedButton.setText( + "Export Normalized" + ); - deviceSpinner.setSelection( - selected, - false - ); + exportRow.addView( + exportRawButton, + weightParams() + ); - rebuildingDevices = false; - } + exportRow.addView( + exportNormalizedButton, + weightParams() + ); - /* - * 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) { + filterPanel.addView(exportRow); - TrackerDatabase database = db; + applyButton.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View v) { - List all = - database == null - ? null - : database.all(); + normalizeFilterRange(); - ArrayList result = - new ArrayList(); + normalizedMode = + showNormalized != null && + showNormalized.isChecked(); - if (all == null) { - return result; - } + showNormalizationChanges = + showChanges != null && + showChanges.isChecked(); - for (int i = 0; - i < all.size(); - i++) { + saveNormalizationSettings(); - DeviceRecord record = - all.get(i); + reloadMap(); - if (record == null) { - continue; - } + setFiltersExpanded(false); + } + } + ); - /* - * Normal map: - * only explicitly map-enabled devices. - */ - if (!includeAll && - record.mapEnabled != 1) { + resetButton.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View v) { - continue; - } + filterEnd = + Calendar.getInstance(); - /* - * Normal map excludes unknown entries. - */ - if (!includeAll && - isUnknown(record)) { + filterStart = + Calendar.getInstance(); - continue; - } + filterStart.setTimeInMillis( + filterEnd.getTimeInMillis() + - + (60L * 60L * 1000L) + ); - if (!record.hasLocation) { - continue; - } + selectedDevice = "All"; + categoryFilter = "All"; - result.add(record); - } - - return result; - } - - private void refreshMapCategories( - List rows) { - - int[] counts = - new int[CATEGORY_ORDER.length]; + normalizedMode = false; - int located = 0; - - if (rows != null) { - - for (int r = 0; - r < rows.size(); - r++) { - - DeviceRecord record = - rows.get(r); + normalizer.setAggressiveness( + HistoryNormalizer.DEFAULT_AGGRESSIVENESS + ); - if (record == null || - !record.hasLocation) { - continue; - } - - located++; - - String category = - record.category; - - if (category == null) { - category = "Other"; - } - - boolean found = false; - - for (int i = 0; - i < CATEGORY_ORDER.length; - i++) { - - if (CATEGORY_ORDER[i] - .equals(category)) { - - counts[i]++; - found = true; - break; - } - } + showNormalizationChanges = true; - if (!found) { - counts[ - CATEGORY_ORDER.length - 1 - ]++; - } - } - } - - ArrayList nextValues = - new ArrayList(); - - ArrayList nextLabels = - new ArrayList(); + if (showNormalized != null) + showNormalized.setChecked(false); - /* - * Keep Trackers first. - */ - nextValues.add("Trackers / Tags"); - nextLabels.add( - "Trackers / Tags (" + - countFor( - counts, - "Trackers / Tags" - ) + - ")" - ); + if (showChanges != null) + showChanges.setChecked(true); - for (int i = 0; - i < CATEGORY_ORDER.length; - i++) { + if (normalizationSeekBar != null) + normalizationSeekBar.setProgress( + normalizer.getAggressiveness() + ); - String category = - CATEGORY_ORDER[i]; + saveNormalizationSettings(); - if ("Trackers / Tags".equals( - category - )) { - continue; - } + updateNormalizationControls(); + updateFilterButtonText(); + + reloadMap(); + } + } + ); + + exportRawButton.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View v) { + + normalizeFilterRange(); + + exportFilteredData(false); + } + } + ); + + exportNormalizedButton.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View v) { + + normalizeFilterRange(); + + exportFilteredData(true); + } + } + ); + + showAll.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View view) { + + getSharedPreferences( + "map_settings", + 0 + ) + .edit() + .putBoolean( + "show_all_scanned", + showAll.isChecked() + ) + .apply(); + + reloadMap(); + } + } + ); + + showNormalized.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View view) { + + normalizedMode = + showNormalized.isChecked(); + + saveNormalizationSettings(); + + reloadMap(); + } + } + ); + + showChanges.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View view) { + + showNormalizationChanges = + view instanceof CheckBox && + ((CheckBox) view).isChecked(); + + saveNormalizationSettings(); + + if (normalizedMode) + reloadMap(); + } + } + ); +} + +/* + * ================================================================ + * NORMALIZATION SETTINGS + * ================================================================ + */ + +private void saveNormalizationSettings() { + + getSharedPreferences( + "map_settings", + 0 + ) + .edit() + .putBoolean( + "normalized_mode", + normalizedMode + ) + .putBoolean( + "show_normalization_changes", + showNormalizationChanges + ) + .putInt( + "normalization_aggressiveness", + normalizer.getAggressiveness() + ) + .apply(); +} + +private void updateNormalizationControls() { + + if (normalizationValueText == null) + return; + + normalizationValueText.setText( + "Level: " + + normalizer.levelName() + + " (" + + normalizer.getAggressiveness() + + "%)" + ); + + normalizationStatsText.setText( + "Current thresholds:\n" + + "Maximum movement speed: " + + MapFormat.number( + normalizer.maxSpeedKmh() + ) + + " km/h\n" + + "Duplicate tolerance: " + + MapFormat.number( + normalizer.duplicateDistance() + ) + + " m\n" + + "Same-time tolerance: " + + MapFormat.number( + normalizer.sameTimeDistance() + ) + + " m\n" + + "GPS accuracy multiplier: " + + MapFormat.number( + normalizer.accuracyMultiplier() + ) + ); +} + +/* + * ================================================================ + * FILTER UI HELPERS + * ================================================================ + */ + +private void setFiltersExpanded( + boolean expanded) { + + filtersExpanded = expanded; + + filterPanel.setVisibility( + expanded + ? View.VISIBLE + : View.GONE + ); + + filterHeader.setText( + expanded + ? "Map Filters ▲" + : "Map Filters ▼" + ); +} + +private TextView filterLabel( + String text) { + + TextView label = + new TextView(this); + + label.setText(text); + label.setTextSize(14); + label.setTextColor(Color.DKGRAY); + + label.setPadding( + dp(2), + dp(6), + dp(2), + dp(2) + ); + + return label; +} + +private Button makeFilterButton( + String text) { + + Button button = + new Button(this); + + button.setText(text); + button.setTextSize(13); + + return button; +} + +private LinearLayout.LayoutParams weightParams() { + + return new LinearLayout.LayoutParams( + 0, + dp(48), + 1 + ); +} + +private void showDatePicker( + final boolean start) { + + Calendar selected = + start + ? filterStart + : filterEnd; + + DatePickerDialog dialog = + new DatePickerDialog( + this, + new DatePickerDialog.OnDateSetListener() { + + @Override + public void onDateSet( + DatePicker view, + int year, + int month, + int day) { + + Calendar target = + start + ? filterStart + : filterEnd; + + target.set( + Calendar.YEAR, + year + ); + + target.set( + Calendar.MONTH, + month + ); + + target.set( + Calendar.DAY_OF_MONTH, + day + ); + + updateFilterButtonText(); + } + }, + selected.get(Calendar.YEAR), + selected.get(Calendar.MONTH), + selected.get(Calendar.DAY_OF_MONTH) + ); + + dialog.show(); +} + +private void showTimePicker( + final boolean start) { + + Calendar selected = + start + ? filterStart + : filterEnd; + + TimePickerDialog dialog = + new TimePickerDialog( + this, + new TimePickerDialog.OnTimeSetListener() { + + @Override + public void onTimeSet( + TimePicker view, + int hourOfDay, + int minute) { + + Calendar target = + start + ? filterStart + : filterEnd; + + target.set( + Calendar.HOUR_OF_DAY, + hourOfDay + ); + + target.set( + Calendar.MINUTE, + minute + ); + + target.set( + Calendar.SECOND, + 0 + ); + + target.set( + Calendar.MILLISECOND, + 0 + ); + + updateFilterButtonText(); + } + }, + selected.get(Calendar.HOUR_OF_DAY), + selected.get(Calendar.MINUTE), + false + ); + + dialog.show(); +} + +private void updateFilterButtonText() { + + if (startDateButton == null) + return; + + SimpleDateFormat dateFormat = + new SimpleDateFormat( + "MMM d, yyyy", + Locale.US + ); + + SimpleDateFormat timeFormat = + new SimpleDateFormat( + "h:mm a", + Locale.US + ); + + startDateButton.setText( + "From\n" + + dateFormat.format( + filterStart.getTime() + ) + ); + + endDateButton.setText( + "To\n" + + dateFormat.format( + filterEnd.getTime() + ) + ); + + startTimeButton.setText( + "From\n" + + timeFormat.format( + filterStart.getTime() + ) + ); + + endTimeButton.setText( + "To\n" + + timeFormat.format( + filterEnd.getTime() + ) + ); +} + +private void normalizeFilterRange() { + + if (filterEnd.getTimeInMillis() < + filterStart.getTimeInMillis()) { + + Calendar temp = + (Calendar) + filterStart.clone(); + + filterStart = + (Calendar) + filterEnd.clone(); + + filterEnd = + temp; + } +} + +/* + * ================================================================ + * MAP RELOAD + * ================================================================ + */ + +private void reloadMap() { + + mapReady = false; + + normalizer.clear(); + + List available = + showAll != null && + showAll.isChecked() + ? db.all() + : defaultMapItems(); + + refreshDeviceFilter(available); + refreshMapCategories(available); + + ArrayList rows = + new ArrayList(); + + for (int i = 0; + i < available.size(); + i++) { + + DeviceRecord record = + available.get(i); + + if (!record.hasLocation) + continue; + + if (!"All".equals(selectedDevice) && + !deviceMatches( + record, + selectedDevice + )) + continue; + + if (!"All".equals(categoryFilter) && + !categoryFilter.equals( + record.category + )) + continue; + + rows.add(record); + } + + if (normalizedMode) { + + normalizer.prepare( + db, + rows + ); + + updateNormalizationSummary(); + } + + String focus = + "All".equals(selectedDevice) + ? null + : selectedDevice; + + web.loadDataWithBaseURL( + "https://www.openstreetmap.org/", + MapHtmlBuilder.build( + rows, + focus, + htmlOptions(), + this + ), + "text/html", + "UTF-8", + null + ); +} + +private MapHtmlBuilder.Options htmlOptions() { + + MapHtmlBuilder.Options options = + new MapHtmlBuilder.Options(); + + options.normalizedMode = normalizedMode; + options.showRemovedPoints = showNormalizationChanges; - if (counts[i] > 0) { + options.showHistory = + showHistory != null && + showHistory.isChecked(); - nextValues.add(category); + options.aggressiveness = + normalizer.getAggressiveness(); - nextLabels.add( - category + - " (" + - counts[i] + - ")" - ); - } - } + return options; +} - nextValues.add("All"); - nextLabels.add( - "All categories (" + - located + - ")" - ); +private void updateNormalizationSummary() { - int selected = - nextValues.indexOf( - categoryFilter - ); - - if (selected < 0) { - - categoryFilter = - "Trackers / Tags"; - - selected = - nextValues.indexOf( - categoryFilter - ); - - if (selected < 0) { - selected = 0; - } - } - - rebuildingCategories = true; - - categoryValues.clear(); - categoryValues.addAll( - nextValues - ); - - categoryLabels.clear(); - categoryLabels.addAll( - nextLabels - ); - - categoryAdapter.notifyDataSetChanged(); - - categorySpinner.setSelection( - selected, - false - ); - - rebuildingCategories = false; - } - - private int countFor( - int[] counts, - String category) { - - for (int i = 0; - i < CATEGORY_ORDER.length; - i++) { - - if (CATEGORY_ORDER[i] - .equals(category)) { - - return counts[i]; - } - } - - return 0; - } + if (normalizationStatsText == null) + return; - private void reloadMap() { + normalizationStatsText.setText( + "Level: " + + normalizer.levelName() + + " (" + + normalizer.getAggressiveness() + + "%)\n" + + "Maximum movement speed: " + + MapFormat.number( + normalizer.maxSpeedKmh() + ) + + " km/h\n" + + "Raw points examined: " + + normalizer.examinedCount() + + "\n" + + "Points retained: " + + normalizer.retainedCount() + + "\n" + + "Duplicates suppressed: " + + normalizer.duplicateCount() + + "\n" + + "GPS jumps rejected: " + + normalizer.rejectedCount() + ); +} - if (loadingMap) { - return; - } +/* + * ================================================================ + * DEVICE FILTERS + * ================================================================ + */ - loadingMap = true; - mapReady = false; +private void refreshDeviceFilter( + List rows) { - /* - * 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(); + ArrayList nextValues = + new ArrayList(); - mapExecutor.execute( - new Runnable() { + ArrayList nextLabels = + new ArrayList(); - @Override - public void run() { - - List available = null; - String html = null; - - try { + nextValues.add("All"); + nextLabels.add("All Devices"); - available = - getMapDevices(includeAll); + HashSet seen = + new HashSet(); - String device = deviceFilter; + for (int i = 0; + i < rows.size(); + i++) { - /* - * The selected device may have gone away since - * the last load; fall back to showing all. - */ - if (!"All".equals(device) && - !containsAddress( - available, - device - )) { + DeviceRecord record = + rows.get(i); - device = "All"; - } + if (record == null) + continue; - ArrayList rows = - new ArrayList(); + String value = + record.address; - for (int i = 0; - i < available.size(); - i++) { + if (value == null || + value.trim().length() == 0) + continue; - DeviceRecord record = - available.get(i); + if (seen.contains(value)) + continue; - if (record == null || - !record.hasLocation) { - continue; - } + seen.add(value); - if (!categoryMatches(record)) { - continue; - } + nextValues.add(value); - if (!"All".equals(device) && - !device.equals( - record.address - )) { - continue; - } + String label = + record.displayName(); - rows.add(record); - } + if (label == null || + label.trim().length() == 0) + label = value; - html = - buildHtml( - rows, - focus, - device - ); + nextLabels.add(label); + } - } catch (Exception ignored) { - } + if (!nextValues.contains(selectedDevice)) + selectedDevice = "All"; - final List loaded = - available; + rebuildingDevices = true; - final String content = html; + deviceValues.clear(); + deviceValues.addAll(nextValues); - ui.post( - new Runnable() { + deviceLabels.clear(); + deviceLabels.addAll(nextLabels); - @Override - public void run() { + deviceAdapter.notifyDataSetChanged(); - 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(); - - /* - * Historical data is loaded ONLY for a - * specifically selected device. - */ - if (!"All".equals(device)) { - - for (int i = 0; - i < rows.size(); - i++) { - - DeviceRecord record = - rows.get(i); - - if (!device.equals( - record.address - )) { - continue; - } - - TrackerDatabase database = db; - - 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, - validColor(record.pinColor) - ); - - break; - } - } - - /* - * Current markers. - */ - for (int i = 0; - i < rows.size(); - i++) { - - DeviceRecord record = - rows.get(i); - - String color = - validColor(record.pinColor); - - StringBuilder details = - new StringBuilder(); - - if (record.photoUri != null && - record.photoUri.length() > 0) { - - try { - - if (new java.io.File( - record.photoUri - ).exists()) { - - details.append( - "" + - "
" - ); - } - - } catch (Exception ignored) { - } - } - - details.append("") - .append( - htmlText( - record.displayName() - ) - ) - .append("
"); - - details.append( - "GPS: ") - .append(record.latitude) - .append(", ") - .append(record.longitude) - .append(""); - - if (record.accuracy > 0) { - - details.append( - " (±" - ) - .append( - Math.round( - record.accuracy - ) - ) - .append(" m)"); - } - - if (hasValue(record.address)) { - - details.append( - "
Address: " - ) - .append( - htmlText( - record.address - ) - ); - } - - if (hasValue(record.category)) { - - details.append( - "
Category: " - ) - .append( - htmlText( - record.category - ) - ); - } - - if (hasValue(record.vendorName) && - !"Unknown".equalsIgnoreCase( - record.vendorName - ) && - !"Manufacturer unknown" - .equalsIgnoreCase( - record.vendorName - )) { - - details.append( - "
Manufacturer: " - ) - .append( - htmlText( - record.vendorName - ) - ); - } - - if (record.updatedAt > 0) { - - details.append( - "
Last seen: " - ) - .append( - htmlText( - date( - record.updatedAt - ) - ) - ); - } - - if (record.battery >= 0) { - - details.append( - "
Battery: " - ) - .append(record.battery) - .append("%"); - } - - if (record.rssi != 0 && - record.lastRssiAt > 0) { - - details.append( - "
RSSI: " - ) - .append(record.rssi) - .append(" dBm"); - } - - String status = - reason(record.reason); - - if (hasValue(status) && - !"Status unknown" - .equalsIgnoreCase( - status - )) { - - details.append( - "
Status: " - ) - .append( - htmlText(status) - ); - } - - boolean locked = - record.locationLocked == 1; - - int markerSize = - locked ? 10 : 22; - - int iconSize = - markerSize + 6; - - int anchor = - iconSize / 2; - - String markerColor = - locked - ? "#00A86B" - : color; - - script.append( - "var icon" - ) - .append(i) - .append( - "=L.divIcon({" + - "className:''," + - "html:\"
" + - "
\"," + - "iconSize:[" - ) - .append(iconSize) - .append(",") - .append(iconSize) - .append( - "],iconAnchor:[" - ) - .append(anchor) - .append(",") - .append(anchor) - .append( - "]});" - ); - - /* - * Every current point is clickable. - */ - script.append( - "var marker" - ) - .append(i) - .append( - "=L.marker([" - ) - .append(record.latitude) - .append(",") - .append(record.longitude) - .append( - "],{icon:icon" - ) - .append(i) - .append( - "}).addTo(map)" - ) - .append( - ".bindPopup('" - ) - .append( - js( - details.toString() - ) - ) - .append( - "',{maxWidth:320});" - ); - - script.append( - "bounds.push([" - ) - .append(record.latitude) - .append(",") - .append(record.longitude) - .append("]);"); - - if (record.address != null && - record.address.equals( - selectedAddress - )) { - - script.append( - "marker" - ) - .append(i) - .append( - ".openPopup();" - ); - - script.append( - "map.setView([" - ) - .append(record.latitude) - .append(",") - .append(record.longitude) - .append( - "],19);" - ); - } - } - - return "" + - "" + - "" + - - "" + - - "" + - - "" + - "" + - - "" + - - "
" + - - "" + - - "" + + for (int i = 0; + i < CATEGORY_ORDER.length; + i++) { - "" + - ""; - } + if (counts[i] > 0) { - private void appendHistory( - StringBuilder script, - DeviceRecord record, - List history, - String color) { + nextValues.add( + CATEGORY_ORDER[i] + ); - if (history == null || - history.size() == 0) { + nextLabels.add( + CATEGORY_ORDER[i] + + " (" + + counts[i] + + ")" + ); + } + } - return; - } + int selected = + nextValues.indexOf( + categoryFilter + ); - script.append( - "var trail=[" - ); + if (selected < 0) { - for (int i = 0; - i < history.size(); - i++) { - - LocationPoint point = - history.get(i); - - if (point == null) { - continue; - } - - if (i > 0) { - script.append(","); - } - - script.append("[") - .append(point.latitude) - .append(",") - .append(point.longitude) - .append("]"); - } - - script.append("];"); - - if (history.size() > 1) { - - script.append( - "L.polyline(trail," + - "{color:'" - ) - .append(color) - .append( - "',weight:4," + - "opacity:.65})" + - ".addTo(map);" - ); - } - - /* - * Every historical point gets its own - * clickable marker. - */ - for (int i = 0; - i < history.size(); - i++) { - - LocationPoint point = - history.get(i); - - if (point == null) { - continue; - } - - StringBuilder popup = - new StringBuilder(); - - popup.append( - "
" - ); - - popup.append("") - .append( - htmlText( - record.displayName() - ) - ) - .append("
"); - - popup.append( - "Historical location
" - ); - - popup.append( - "Time: " - ) - .append( - htmlText( - date( - point.timestamp - ) - ) - ) - .append("
"); - - popup.append( - "Latitude: " - ) - .append(point.latitude) - .append("
"); - - popup.append( - "Longitude: " - ) - .append(point.longitude) - .append("
"); - - if (point.accuracy > 0) { - - popup.append( - "Accuracy: ±" - ) - .append( - Math.round( - point.accuracy - ) - ) - .append(" m
"); - } - - popup.append( - "Open in Maps" - ); - - popup.append("
"); - - script.append( - "var historyPoint" - ) - .append(i) - .append( - "=L.circleMarker([" - ) - .append(point.latitude) - .append(",") - .append(point.longitude) - .append( - "],{" + - "radius:7," + - "color:'" - ) - .append(color) - .append( - "',fillColor:'" - ) - .append(color) - .append( - "',fillOpacity:.9," + - "weight:2})" + - ".addTo(map)" + - ".bindPopup('" - ) - .append( - js( - popup.toString() - ) - ) - .append( - "',{maxWidth:280});" - ); - - script.append( - "historyPoint" - ) - .append(i) - .append( - ".on('click',function(){" + - "this.openPopup();" + - "});" - ); - - script.append( - "bounds.push([" - ) - .append(point.latitude) - .append(",") - .append(point.longitude) - .append("]);" - ); - } - } - - @Override - protected void onResume() { - - super.onResume(); - - try { - - Location gps = - locationManager == null - ? null - : locationManager - .getLastKnownLocation( - LocationManager.GPS_PROVIDER - ); - - Location network = - locationManager == null - ? null - : locationManager - .getLastKnownLocation( - LocationManager.NETWORK_PROVIDER - ); - - phoneLocation = - gps != null - ? gps - : network; - - if (locationManager != null) { - - try { - - locationManager.requestLocationUpdates( - LocationManager.GPS_PROVIDER, - 1000L, - 1f, - locationListener - ); - - } catch (SecurityException ignored) { - } - - try { - - locationManager.requestLocationUpdates( - LocationManager.NETWORK_PROVIDER, - 1000L, - 1f, - locationListener - ); - - } catch (SecurityException ignored) { - } - } - - } catch (Exception ignored) { - } - - if (sensorManager != null && - rotationSensor != null) { - - sensorManager.registerListener( - this, - rotationSensor, - SensorManager.SENSOR_DELAY_UI - ); - } - - updatePhoneMarker(); - } - - @Override - protected void onPause() { - - try { - - if (locationManager != null) { - - locationManager.removeUpdates( - locationListener - ); - } + categoryFilter = "All"; + selected = 0; + } - } catch (Exception ignored) { - } + rebuildingCategories = true; - try { + categoryValues.clear(); + categoryValues.addAll(nextValues); - if (sensorManager != null) { + categoryLabels.clear(); + categoryLabels.addAll(nextLabels); - sensorManager.unregisterListener( - this - ); - } + categoryAdapter.notifyDataSetChanged(); - } catch (Exception ignored) { - } + categorySpinner.setSelection( + selected + ); - super.onPause(); - } + rebuildingCategories = false; +} - @Override - public void onSensorChanged( - SensorEvent event) { +private List defaultMapItems() { - if (event == null || - event.sensor == null) { + List all = + db.all(); - return; - } + ArrayList result = + new ArrayList(); - if (event.sensor.getType() != - Sensor.TYPE_ROTATION_VECTOR) { + HashSet paired = + new HashSet(); - return; - } + try { - float[] rotation = - new float[9]; + BluetoothAdapter adapter = + BluetoothAdapter.getDefaultAdapter(); - float[] orientation = - new float[3]; + if (adapter != null) { - SensorManager - .getRotationMatrixFromVector( - rotation, - event.values - ); + for (BluetoothDevice device : + adapter.getBondedDevices()) { - SensorManager.getOrientation( - rotation, - orientation - ); + paired.add( + device.getAddress() + ); + } + } - float compass = - (float) Math.toDegrees( - orientation[0] - ); + } catch (Exception ignored) { + } - if (compass < 0) { - compass += 360f; - } + for (int i = 0; + i < all.size(); + i++) { - if (phoneLocation == null || - !phoneLocation.hasSpeed() || - phoneLocation.getSpeed() <= 0.8f) { + DeviceRecord record = + all.get(i); - phoneHeading = compass; - } + if (record.mapEnabled == 1) + result.add(record); + } - updatePhoneMarker(); - } + return result; +} - @Override - public void onAccuracyChanged( - Sensor sensor, - int accuracy) { - } +/* + * ================================================================ + * NORMALIZED HISTORY + * ================================================================ + */ - private void updatePhoneMarker() { +@Override +public List historyFor( + DeviceRecord record) { - if (!mapReady || - web == null || - phoneLocation == null) { + if (record == null || + record.address == null) + return new ArrayList(); - return; - } + List history = + normalizedMode + ? normalizer.historyOf( + db, + record.address + ) + : db.history( + record.address, + 0L + ); - String javascript = - String.format( - Locale.US, - "javascript:updatePhone(" + - "%.7f,%.7f,%.1f)", - phoneLocation.getLatitude(), - phoneLocation.getLongitude(), - phoneHeading - ); + return filterHistoryByDate( + history + ); +} - web.loadUrl(javascript); - } +@Override +public List removedPointsFor( + String address) { - private void openMap(String uri) { + ArrayList filtered = + new ArrayList(); - Intent intent = - new Intent( - Intent.ACTION_VIEW, - Uri.parse(uri) - ); + for (HistoryNormalizer.Decision decision : + normalizer.decisionsOf(address)) { - intent.setPackage( - "com.google.android.apps.maps" - ); + if (decision == null || + decision.point == null) + continue; - try { + long time = + HistoryNormalizer.pointTime( + decision.point + ); - startActivity(intent); + if (time <= 0L || + isInsideFilter(time)) { - } catch (Exception error) { + filtered.add(decision); + } + } - intent.setPackage(null); + return filtered; +} + +private List filterHistoryByDate( + List history) { + + ArrayList result = + new ArrayList(); + + if (history == null) + return result; - try { - startActivity(intent); - } catch (Exception ignored) { - } - } - } + for (int i = 0; + i < history.size(); + i++) { - private long startOfToday() { + LocationPoint point = + history.get(i); + + if (point == null) + continue; + + long time = + HistoryNormalizer.pointTime(point); - Calendar calendar = - Calendar.getInstance(); + if (time <= 0L || + isInsideFilter(time)) { - calendar.set( - Calendar.HOUR_OF_DAY, - 0 - ); + result.add(point); + } + } - calendar.set( - Calendar.MINUTE, - 0 - ); + return result; +} + +private boolean isInsideFilter( + long time) { + + if (time <= 0L) + return true; + + return time >= + filterStart.getTimeInMillis() + && + time <= + filterEnd.getTimeInMillis(); +} + +/* + * ================================================================ + * EXPORT + * ================================================================ + */ - calendar.set( - Calendar.SECOND, - 0 - ); +private void exportFilteredData( + boolean normalized) { + + exporter.start( + exportDevices(), + normalized, + currentFilters() + ); +} - calendar.set( - Calendar.MILLISECOND, - 0 - ); +/* + * Every device the filter bar currently allows. + * + * Unlike the map, this keeps devices with no location fix -- their row + * still carries battery, RSSI and status worth exporting. + */ +private List exportDevices() { - return calendar.getTimeInMillis(); - } + List available = + showAll != null && + showAll.isChecked() + ? db.all() + : defaultMapItems(); - private String validColor(String value) { + ArrayList filtered = + new ArrayList(); - if (value != null && - value.matches( - "#[0-9a-fA-F]{6}" - )) { + for (int i = 0; + i < available.size(); + i++) { - return value; - } + DeviceRecord record = + available.get(i); - return "#E53935"; - } + if (record == null) + continue; - private String date(long time) { + if (!"All".equals(selectedDevice) && + !deviceMatches( + record, + selectedDevice + )) + continue; + + if (!"All".equals(categoryFilter) && + !categoryFilter.equals( + record.category + )) + continue; + + filtered.add(record); + } + + return filtered; +} + +private MapExporter.Filters currentFilters() { + + MapExporter.Filters filters = + new MapExporter.Filters(); + + filters.device = selectedDevice; + filters.category = categoryFilter; + + filters.startMillis = + filterStart.getTimeInMillis(); + + filters.endMillis = + filterEnd.getTimeInMillis(); + + filters.includeHistory = + showHistory != null && + showHistory.isChecked(); + + return filters; +} + +@Override +protected void onActivityResult( + int requestCode, + int resultCode, + Intent data) { + + super.onActivityResult( + requestCode, + resultCode, + data + ); + + if (resultCode != RESULT_OK || + data == null || + data.getData() == null) { + + return; + } + + boolean normalized = + requestCode == + MapExporter.REQUEST_EXPORT_NORMALIZED; + + if (!normalized && + requestCode != + MapExporter.REQUEST_EXPORT_RAW) { + + return; + } + + exporter.write( + data.getData(), + exportDevices(), + normalized, + currentFilters() + ); +} - if (time <= 0) { - return "Unknown"; - } +/* + * ================================================================ + * PHONE LOCATION / SENSOR + * ================================================================ + */ - SimpleDateFormat format = - new SimpleDateFormat( - "M/d/yy h:mm:ss a", - Locale.US - ); +@Override +protected void onResume() { - return format.format( - new Date(time) - ); - } + super.onResume(); - private String reason(String value) { + try { - if (value == null) { - return "Status unknown"; - } + Location gps = + locationManager == null + ? null + : locationManager + .getLastKnownLocation( + LocationManager.GPS_PROVIDER + ); + + Location network = + locationManager == null + ? null + : locationManager + .getLastKnownLocation( + LocationManager.NETWORK_PROVIDER + ); + + phoneLocation = + gps == null + ? network + : gps; + + if (locationManager != null) { + + locationManager.requestLocationUpdates( + LocationManager.GPS_PROVIDER, + 1000L, + 1f, + this + ); + + locationManager.requestLocationUpdates( + LocationManager.NETWORK_PROVIDER, + 1000L, + 1f, + this + ); + } + + } catch (Exception ignored) { + } + + if (sensorManager != null && + rotationSensor != null) { + + sensorManager.registerListener( + this, + rotationSensor, + android.hardware.SensorManager.SENSOR_DELAY_UI + ); + } + + updatePhoneMarker(); +} + +@Override +protected void onPause() { + + try { + + if (locationManager != null) + locationManager.removeUpdates(this); + + } catch (Exception ignored) { + } + + try { + + if (sensorManager != null) + sensorManager.unregisterListener(this); + + } catch (Exception ignored) { + } + + super.onPause(); +} + +@Override +public void onLocationChanged( + Location location) { + + if (location == null) + return; + + phoneLocation = location; + + if (location.hasBearing() && + location.hasSpeed() && + location.getSpeed() > 0.8f) { + + phoneHeading = + location.getBearing(); + } + + updatePhoneMarker(); +} + +/* + * Do NOT add onProviderEnabled() or onProviderDisabled(). + * They caused the override compilation errors in the previous + * version. + */ +@Override +@SuppressWarnings("deprecation") +public void onStatusChanged( + String provider, + int status, + Bundle extras) { +} + +@Override +public void onSensorChanged( + android.hardware.SensorEvent event) { + + if (event == null || + event.sensor == null || + event.sensor.getType() != + android.hardware.Sensor.TYPE_ROTATION_VECTOR) + return; - if (value.indexOf("Range") >= 0) { - return "Out of range"; - } + float[] rotation = + new float[9]; - if (value.indexOf("Battery") >= 0 || - value.indexOf("Power") >= 0) { + float[] orientation = + new float[3]; + + android.hardware.SensorManager + .getRotationMatrixFromVector( + rotation, + event.values + ); - return "Possible dead battery"; - } + android.hardware.SensorManager + .getOrientation( + rotation, + orientation + ); - return value; - } + float compass = + (float) Math.toDegrees( + orientation[0] + ); - private boolean hasValue(String value) { + if (compass < 0) + compass += 360f; - return value != null && - value.trim().length() > 0; - } + if (phoneLocation == null || + !phoneLocation.hasSpeed() || + phoneLocation.getSpeed() <= 0.8f) { - private String js(String value) { + phoneHeading = compass; + } - if (value == null) { - return ""; - } + updatePhoneMarker(); +} - return value - .replace("\\", "\\\\") - .replace("'", "\\'") - .replace("\"", "\\\"") - .replace("\n", " ") - .replace("\r", " "); - } +@Override +public void onAccuracyChanged( + android.hardware.Sensor sensor, + int accuracy) { +} - private String htmlText(String value) { +private void updatePhoneMarker() { - if (value == null) { - return ""; - } + if (!mapReady || + web == null || + phoneLocation == null) + return; + + web.loadUrl( + String.format( + Locale.US, + "javascript:updatePhone(%.7f,%.7f,%.1f)", + phoneLocation.getLatitude(), + phoneLocation.getLongitude(), + phoneHeading + ) + ); +} + +/* + * ================================================================ + * MISC + * ================================================================ + */ + +private void openMap( + String uri) { + + Intent intent = + new Intent( + Intent.ACTION_VIEW, + Uri.parse(uri) + ); + + intent.setPackage( + "com.google.android.apps.maps" + ); + + try { + + startActivity(intent); + + } catch (Exception error) { + + intent.setPackage(null); - return value - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("'", "'") - .replace("\"", """); - } + try { - private int dp(int value) { + startActivity(intent); - return (int) ( - value * - getResources() - .getDisplayMetrics() - .density + - 0.5f - ); - } - - @Override - protected void onDestroy() { - - destroyed = true; - - try { - mapExecutor.shutdownNow(); - } catch (Exception ignored) { - } - - try { - - if (web != null) { - web.stopLoading(); - web.destroy(); - web = null; - } - - } catch (Exception ignored) { - } - - try { - - if (db != null) { - db.close(); - db = null; - } - - } catch (Exception ignored) { - } - - super.onDestroy(); - } - - } + } catch (Exception ignored) { + } + } +} +} \ No newline at end of file diff --git a/app/src/main/java/com/wytehat/btlogger/MapExporter.java b/app/src/main/java/com/wytehat/btlogger/MapExporter.java new file mode 100644 index 0000000..489df76 --- /dev/null +++ b/app/src/main/java/com/wytehat/btlogger/MapExporter.java @@ -0,0 +1,531 @@ +package com.wytehat.btlogger; + +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.ContentResolver; +import android.content.Intent; +import android.net.Uri; +import android.widget.Toast; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +/** + * CSV export for whatever the map is currently showing. + * + * Two flavours: RAW writes the history exactly as recorded, NORMALIZED runs + * it through {@link HistoryNormalizer} first and also writes out the points + * that were removed, so the file explains its own cleanup. + * + * Saving is a two-step dance with the system file picker - {@link #start} + * asks for a destination, the activity hands the chosen Uri back to + * {@link #write} from onActivityResult. + */ +public class MapExporter { + + public static final int REQUEST_EXPORT_RAW = 7401; + public static final int REQUEST_EXPORT_NORMALIZED = 7402; + + private static final String HEADER_ROW = + "Record Type,Device Name,Address,Category," + + "Manufacturer,Latitude,Longitude,Accuracy," + + "Battery,RSSI,Last Seen,Status," + + "Location Locked,Map Enabled,Show Trail,Photo URI"; + + /** Snapshot of the map's filter bar at the moment of export. */ + public static class Filters { + + public String device = "All"; + public String category = "All"; + + public long startMillis; + public long endMillis; + + public boolean includeHistory; + + /** Same rule the map uses: an undated point is never filtered out. */ + public boolean contains(long time) { + + if (time <= 0L) return true; + + return time >= startMillis && time <= endMillis; + } + } + + private final Activity activity; + private final TrackerDatabase db; + private final HistoryNormalizer normalizer; + + public MapExporter( + Activity activity, + TrackerDatabase db, + HistoryNormalizer normalizer) { + + this.activity = activity; + this.db = db; + this.normalizer = normalizer; + } + + /** + * Asks the system where to save. The caller passes the devices already + * matching the filter bar, so the file picker is never opened for an + * export that would come back empty. + * + * @return false if there was nothing to export, or no app to save with. + */ + public boolean start( + List devices, + boolean normalized, + Filters filters) { + + if (devices == null || devices.size() == 0) { + + toast("No database records match the current filters."); + + return false; + } + + if (normalized) { + + normalizer.clear(); + normalizer.prepare(db, devices); + } + + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("text/csv"); + + intent.putExtra( + Intent.EXTRA_TITLE, + buildFilename(filters, normalized)); + + try { + + activity.startActivityForResult( + intent, + normalized + ? REQUEST_EXPORT_NORMALIZED + : REQUEST_EXPORT_RAW); + + return true; + + } catch (ActivityNotFoundException error) { + + toast("No file-saving application is available."); + + return false; + } + } + + /** Writes the export to the destination the user picked. */ + public void write( + Uri uri, + List devices, + boolean normalized, + Filters filters) { + + OutputStream output = null; + BufferedWriter writer = null; + + try { + + ContentResolver resolver = activity.getContentResolver(); + + output = resolver.openOutputStream(uri); + + if (output == null) { + throw new IOException("Unable to open output file."); + } + + writer = new BufferedWriter( + new OutputStreamWriter(output, "UTF-8")); + + writeHeader(writer, normalized, filters); + + int deviceCount = 0; + int pointCount = 0; + + for (int i = 0; i < devices.size(); i++) { + + DeviceRecord record = devices.get(i); + + if (record == null) continue; + + writeDeviceRow(writer, record, normalized); + + deviceCount++; + + if (!filters.includeHistory) continue; + + if (record.address == null + || record.address.trim().length() == 0) { + + continue; + } + + pointCount += writeHistoryRows( + writer, + record, + normalized, + filters); + + if (normalized) { + writeRemovedRows(writer, record, filters); + } + } + + writeSummary(writer, normalized, deviceCount, pointCount); + + writer.flush(); + + toast(summaryMessage(normalized, deviceCount, pointCount)); + + } catch (Exception error) { + + toast("Export failed: " + error.getMessage()); + + } finally { + + try { + + if (writer != null) writer.close(); + else if (output != null) output.close(); + + } catch (Exception ignored) { + } + } + } + + // ------------------------------------------------------------------ + // Sections + // ------------------------------------------------------------------ + + private void writeHeader( + BufferedWriter writer, + boolean normalized, + Filters filters) throws IOException { + + line(writer, MapFormat.csv( + normalized + ? "BT Logger Normalized Database Export" + : "BT Logger Raw Database Export")); + + pair(writer, "Exported", MapFormat.date(System.currentTimeMillis())); + pair(writer, "Data Mode", normalized ? "Normalized" : "Raw"); + pair(writer, "Selected Device", filters.device); + pair(writer, "Category", filters.category); + pair(writer, "Start", MapFormat.date(filters.startMillis)); + pair(writer, "End", MapFormat.date(filters.endMillis)); + + pair( + writer, + "Historical Points", + filters.includeHistory ? "Included" : "Excluded"); + + if (normalized) { + + pair( + writer, + "Normalization Aggressiveness", + normalizer.getAggressiveness() + "%"); + + pair( + writer, + "Maximum Speed", + normalizer.maxSpeedKmh() + " km/h"); + + pair( + writer, + "Duplicate Distance", + normalizer.duplicateDistance() + " m"); + + pair( + writer, + "Same Time Distance", + normalizer.sameTimeDistance() + " m"); + + pair( + writer, + "Accuracy Multiplier", + Double.toString(normalizer.accuracyMultiplier())); + } + + writer.newLine(); + + line(writer, HEADER_ROW); + } + + private void writeDeviceRow( + BufferedWriter writer, + DeviceRecord record, + boolean normalized) throws IOException { + + double latitude = record.latitude; + double longitude = record.longitude; + + long time = record.updatedAt; + + /* + * In normalized mode the device's own last-known fix may be one of + * the points that got thrown out, so report the newest surviving + * point instead. + */ + if (normalized) { + + List history = + normalizer.historyOf(db, record.address); + + if (history.size() > 0) { + + LocationPoint latest = history.get(history.size() - 1); + + if (latest != null) { + + latitude = latest.latitude; + longitude = latest.longitude; + + long latestTime = HistoryNormalizer.pointTime(latest); + + if (latestTime > 0) time = latestTime; + } + } + } + + line(writer, + MapFormat.csv("DEVICE") + "," + + MapFormat.csv(record.displayName()) + "," + + MapFormat.csv(record.address) + "," + + MapFormat.csv(record.category) + "," + + MapFormat.csv(record.vendorName) + "," + + MapFormat.csv(Double.toString(latitude)) + "," + + MapFormat.csv(Double.toString(longitude)) + "," + + MapFormat.csv( + record.accuracy > 0 + ? Double.toString(record.accuracy) + : "") + "," + + MapFormat.csv( + record.battery >= 0 + ? Integer.toString(record.battery) + : "") + "," + + MapFormat.csv( + record.rssi != 0 + ? Integer.toString(record.rssi) + : "") + "," + + MapFormat.csv(time > 0 ? MapFormat.date(time) : "") + "," + + MapFormat.csv(MapFormat.reason(record.reason)) + "," + + MapFormat.csv(Integer.toString(record.locationLocked)) + "," + + MapFormat.csv(Integer.toString(record.mapEnabled)) + "," + + MapFormat.csv(Integer.toString(record.showTrail)) + "," + + MapFormat.csv(record.photoUri)); + } + + private int writeHistoryRows( + BufferedWriter writer, + DeviceRecord record, + boolean normalized, + Filters filters) throws IOException { + + List history = + normalized + ? normalizer.historyOf(db, record.address) + : db.history(record.address, 0L); + + int written = 0; + + for (int p = 0; p < history.size(); p++) { + + LocationPoint point = history.get(p); + + if (point == null) continue; + + long time = HistoryNormalizer.pointTime(point); + + if (time > 0L && !filters.contains(time)) continue; + + double accuracy = + HistoryNormalizer.pointDouble(point, "accuracy", -1); + + double rssi = HistoryNormalizer.pointDouble(point, "rssi", 0); + + double battery = + HistoryNormalizer.pointDouble(point, "battery", -1); + + line(writer, + MapFormat.csv( + normalized + ? "NORMALIZED_POINT" + : "HISTORICAL_POINT") + "," + + MapFormat.csv(record.displayName()) + "," + + MapFormat.csv(record.address) + "," + + MapFormat.csv(record.category) + "," + + MapFormat.csv(record.vendorName) + "," + + MapFormat.csv(Double.toString(point.latitude)) + "," + + MapFormat.csv(Double.toString(point.longitude)) + "," + + MapFormat.csv( + accuracy > 0 ? Double.toString(accuracy) : "") + "," + + MapFormat.csv( + battery >= 0 ? Double.toString(battery) : "") + "," + + MapFormat.csv( + rssi != 0 ? Double.toString(rssi) : "") + "," + + MapFormat.csv(time > 0 ? MapFormat.date(time) : "") + "," + + MapFormat.csv("") + "," + + MapFormat.csv("") + "," + + MapFormat.csv("") + "," + + MapFormat.csv("")); + + written++; + } + + return written; + } + + /** The points normalization threw away, with the reason in the Status column. */ + private void writeRemovedRows( + BufferedWriter writer, + DeviceRecord record, + Filters filters) throws IOException { + + List decisions = + normalizer.decisionsOf(record.address); + + for (HistoryNormalizer.Decision decision : decisions) { + + if (decision == null || decision.point == null) continue; + + long time = HistoryNormalizer.pointTime(decision.point); + + if (time > 0 && !filters.contains(time)) continue; + + line(writer, + MapFormat.csv("REMOVED_POINT") + "," + + MapFormat.csv(record.displayName()) + "," + + MapFormat.csv(record.address) + "," + + MapFormat.csv(record.category) + "," + + MapFormat.csv(record.vendorName) + "," + + MapFormat.csv( + Double.toString(decision.point.latitude)) + "," + + MapFormat.csv( + Double.toString(decision.point.longitude)) + "," + + MapFormat.csv("") + "," + + MapFormat.csv("") + "," + + MapFormat.csv("") + "," + + MapFormat.csv(time > 0 ? MapFormat.date(time) : "") + "," + + MapFormat.csv(decision.reason) + "," + + MapFormat.csv("") + "," + + MapFormat.csv("") + "," + + MapFormat.csv("")); + } + } + + private void writeSummary( + BufferedWriter writer, + boolean normalized, + int deviceCount, + int pointCount) throws IOException { + + writer.newLine(); + + line(writer, MapFormat.csv("Export Summary")); + + pair(writer, "Devices Exported", Integer.toString(deviceCount)); + + pair( + writer, + normalized + ? "Normalized Points Exported" + : "Historical Points Exported", + Integer.toString(pointCount)); + + if (normalized) { + + pair( + writer, + "Duplicates Removed", + Integer.toString(normalizer.duplicateCount())); + + pair( + writer, + "GPS Jumps Rejected", + Integer.toString(normalizer.rejectedCount())); + } + } + + private String summaryMessage( + boolean normalized, + int deviceCount, + int pointCount) { + + if (!normalized) { + + return "Exported " + + deviceCount + " device(s) and " + + pointCount + " historical point(s)."; + } + + return "Exported " + + deviceCount + " device(s), " + + pointCount + " normalized point(s), " + + normalizer.duplicateCount() + " duplicate(s) removed, and " + + normalizer.rejectedCount() + " GPS jump(s) rejected."; + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private String buildFilename(Filters filters, boolean normalized) { + + String devicePart = sanitizeFilename( + "All".equals(filters.device) + ? "all_devices" + : filters.device); + + String datePart = + new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) + .format(new Date()); + + return "btlogger_" + + devicePart + + "_" + + (normalized ? "normalized_" : "raw_") + + datePart + + ".csv"; + } + + private String sanitizeFilename(String value) { + + if (value == null || value.trim().length() == 0) { + return "export"; + } + + return value + .replaceAll("[\\\\/:*?\"<>|]", "_") + .replaceAll("\\s+", "_"); + } + + private void line(BufferedWriter writer, String text) throws IOException { + + writer.write(text); + writer.newLine(); + } + + private void pair( + BufferedWriter writer, + String label, + String value) throws IOException { + + line(writer, MapFormat.csv(label) + "," + MapFormat.csv(value)); + } + + private void toast(String message) { + + Toast.makeText(activity, message, Toast.LENGTH_LONG).show(); + } +} diff --git a/app/src/main/java/com/wytehat/btlogger/MapFormat.java b/app/src/main/java/com/wytehat/btlogger/MapFormat.java new file mode 100644 index 0000000..eeb3140 --- /dev/null +++ b/app/src/main/java/com/wytehat/btlogger/MapFormat.java @@ -0,0 +1,98 @@ +package com.wytehat.btlogger; + +import java.text.DateFormat; +import java.util.Date; +import java.util.Locale; + +/** + * Formatting and escaping helpers shared by the map screen, the HTML it + * feeds the WebView and the CSV exporter. + * + * These used to be private methods on MapActivity, which meant every piece + * of code that wanted to escape a popup string or stamp a timestamp had to + * live in that one class. + */ +public final class MapFormat { + + private MapFormat() { + } + + public static boolean hasValue(String value) { + return value != null && value.trim().length() > 0; + } + + /** Human readable timestamp, or "Unknown" for a missing/zero time. */ + public static String date(long time) { + return time <= 0 + ? "Unknown" + : DateFormat.getDateTimeInstance().format(new Date(time)); + } + + /** Turns a raw disconnect reason into something worth showing a user. */ + public static String reason(String value) { + + if (value == null) return "Status unknown"; + + if (value.indexOf("Range") >= 0) return "Out of range"; + + if (value.indexOf("Battery") >= 0 || value.indexOf("Power") >= 0) { + return "Possible dead battery"; + } + + return value; + } + + /** Falls back to the default pin red when a stored colour is unusable. */ + public static String validColor(String value) { + + if (value != null && value.matches("#[0-9a-fA-F]{6}")) { + return value; + } + + return "#E53935"; + } + + public static String number(double value) { + return String.format(Locale.US, "%.2f", value); + } + + public static String htmlText(String value) { + + if (value == null) return ""; + + return value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + /** Escapes a string for use inside a single-quoted JavaScript literal. */ + public static String js(String value) { + + if (value == null) return ""; + + return value + .replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\"", "\\\"") + .replace("\r", "\\r") + .replace("\n", "\\n") + .replace("
", "\\u2028") + .replace("
", "\\u2029"); + } + + /** Quotes a CSV field, doubling quotes and flattening newlines. */ + public static String csv(String value) { + + if (value == null) return "\"\""; + + return "\"" + + value + .replace("\"", "\"\"") + .replace("\r", " ") + .replace("\n", " ") + + "\""; + } +} diff --git a/app/src/main/java/com/wytehat/btlogger/MapHtmlBuilder.java b/app/src/main/java/com/wytehat/btlogger/MapHtmlBuilder.java new file mode 100644 index 0000000..e81cbfa --- /dev/null +++ b/app/src/main/java/com/wytehat/btlogger/MapHtmlBuilder.java @@ -0,0 +1,650 @@ +package com.wytehat.btlogger; + +import java.io.File; +import java.util.List; + +/** + * Builds the Leaflet page the map WebView loads: one marker per device, an + * optional trail of history points, and - in normalized mode - the points + * normalization removed, drawn in red (impossible jump) or amber (duplicate) + * with a dashed line back to the last point that was trusted. + * + * Everything the page needs is pushed in through {@link Options} and + * {@link DataSource}, so this knows nothing about the activity's widgets. + */ +public final class MapHtmlBuilder { + + /** Where the per-device history and removal lists come from. */ + public interface DataSource { + + /** History for this device, already date-filtered. */ + List historyFor(DeviceRecord record); + + /** Points normalization removed, already date-filtered. */ + List removedPointsFor(String address); + } + + /** What the filter bar currently says. */ + public static class Options { + + public boolean normalizedMode; + public boolean showRemovedPoints; + public boolean showHistory; + + public int aggressiveness = HistoryNormalizer.DEFAULT_AGGRESSIVENESS; + } + + private MapHtmlBuilder() { + } + + public static String build( + List rows, + String selectedAddress, + Options options, + DataSource data) { + + StringBuilder script = new StringBuilder(); + + for (int i = 0; i < rows.size(); i++) { + + DeviceRecord record = rows.get(i); + + if (!record.hasLocation) continue; + + String color = MapFormat.validColor(record.pinColor); + + List history = data.historyFor(record); + + if (options.normalizedMode && options.showRemovedPoints) { + appendRemovedPoints(script, record, options, data); + } + + double markerLatitude = record.latitude; + double markerLongitude = record.longitude; + + long markerTime = record.updatedAt; + + /* + * The device's stored fix may itself have been normalized away, + * so pin it to the newest surviving point instead. + */ + if (options.normalizedMode && history.size() > 0) { + + LocationPoint latest = history.get(history.size() - 1); + + if (latest != null) { + + markerLatitude = latest.latitude; + markerLongitude = latest.longitude; + + long normalizedTime = + HistoryNormalizer.pointTime(latest); + + if (normalizedTime > 0) markerTime = normalizedTime; + } + } + + if (options.showHistory && history.size() > 0) { + + if (record.showTrail == 1 && history.size() > 1) { + appendTrail(script, i, history, color); + } + + appendHistoryPoints(script, record, history, color, options); + } + + appendDeviceMarker( + script, + i, + record, + markerLatitude, + markerLongitude, + markerTime, + color, + selectedAddress, + options); + } + + return page(script.toString(), selectedAddress); + } + + // ------------------------------------------------------------------ + // Layers + // ------------------------------------------------------------------ + + private static void appendRemovedPoints( + StringBuilder script, + DeviceRecord record, + Options options, + DataSource data) { + + List decisions = + data.removedPointsFor(record.address); + + for (int d = 0; d < decisions.size(); d++) { + + HistoryNormalizer.Decision decision = decisions.get(d); + + if (decision == null || decision.point == null) continue; + + LocationPoint removed = decision.point; + + /* + * Red = rejected GPS jump + * Yellow = duplicate/suppressed point + */ + boolean jump = + decision.reason != null + && decision.reason.indexOf("Impossible") >= 0; + + String removedColor = jump ? "#E53935" : "#F9A825"; + + if (decision.previousAccepted != null) { + + script.append("L.polyline([[") + .append(decision.previousAccepted.latitude) + .append(',') + .append(decision.previousAccepted.longitude) + .append("],[") + .append(removed.latitude) + .append(',') + .append(removed.longitude) + .append("]],{color:'") + .append(removedColor) + .append("',weight:2,dashArray:'6,6',opacity:.8})") + .append(".addTo(map);"); + } + + script.append("L.circleMarker([") + .append(removed.latitude) + .append(',') + .append(removed.longitude) + .append("],{radius:8,color:'") + .append(removedColor) + .append("',fillColor:'") + .append(removedColor) + .append("',fillOpacity:.85,weight:3})") + .append(".addTo(map).bindPopup('") + .append(MapFormat.js(removedPopup(record, decision, options))) + .append("');"); + + script.append("bounds.push([") + .append(removed.latitude) + .append(',') + .append(removed.longitude) + .append("]);"); + } + } + + private static void appendTrail( + StringBuilder script, + int index, + List history, + String color) { + + script.append("var trail").append(index).append("=["); + + for (int p = 0; p < history.size(); p++) { + + if (p > 0) script.append(','); + + LocationPoint point = history.get(p); + + script.append('[') + .append(point.latitude) + .append(',') + .append(point.longitude) + .append(']'); + } + + script.append("];L.polyline(trail") + .append(index) + .append(",{color:'") + .append(color) + .append("',weight:4,opacity:.65}).addTo(map);"); + } + + private static void appendHistoryPoints( + StringBuilder script, + DeviceRecord record, + List history, + String color, + Options options) { + + for (int p = 0; p < history.size(); p++) { + + LocationPoint point = history.get(p); + + if (point == null) continue; + + script.append("L.circleMarker([") + .append(point.latitude) + .append(',') + .append(point.longitude) + .append("],{radius:5,color:'") + .append(color) + .append("',fillColor:'") + .append(color) + .append("',fillOpacity:.9,weight:2})") + .append(".addTo(map).bindPopup('") + .append(MapFormat.js(historyPopup(record, point, options))) + .append("');"); + } + } + + private static void appendDeviceMarker( + StringBuilder script, + int index, + DeviceRecord record, + double latitude, + double longitude, + long time, + String color, + String selectedAddress, + Options options) { + + boolean locked = record.locationLocked == 1; + + int markerSize = locked ? 10 : 22; + int iconSize = markerSize + 6; + int anchor = iconSize / 2; + + String markerColor = locked ? "#00A86B" : color; + + script.append("var icon").append(index) + .append("=L.divIcon({className:'',html:\"") + .append("
") + .append("
\",iconSize:[") + .append(iconSize) + .append(',') + .append(iconSize) + .append("],iconAnchor:[") + .append(anchor) + .append(',') + .append(anchor) + .append("]});"); + + script.append("var marker").append(index) + .append("=L.marker([") + .append(latitude) + .append(',') + .append(longitude) + .append("],{icon:icon") + .append(index) + .append("}).addTo(map).bindPopup('") + .append(MapFormat.js( + devicePopup(record, latitude, longitude, time, options))) + .append("');"); + + script.append("bounds.push([") + .append(latitude) + .append(',') + .append(longitude) + .append("]);"); + + if (record.address != null + && record.address.equals(selectedAddress)) { + + script.append("marker").append(index) + .append(".openPopup();map.setView([") + .append(latitude) + .append(',') + .append(longitude) + .append("],21);"); + } + } + + // ------------------------------------------------------------------ + // Popups + // ------------------------------------------------------------------ + + private static String devicePopup( + DeviceRecord record, + double latitude, + double longitude, + long time, + Options options) { + + StringBuilder html = new StringBuilder(); + + if (record.photoUri != null && new File(record.photoUri).exists()) { + + html.append("
"); + } + + html.append("") + .append(MapFormat.htmlText(record.displayName())) + .append("
"); + + html.append("GPS: ") + .append(latitude) + .append(", ") + .append(longitude) + .append(""); + + if (record.accuracy > 0) { + + html.append(" (±") + .append(Math.round(record.accuracy)) + .append(" m)"); + } + + if (MapFormat.hasValue(record.category) + && !"Other".equalsIgnoreCase(record.category)) { + + html.append("
Category: ") + .append(MapFormat.htmlText(record.category)); + } + + if (MapFormat.hasValue(record.vendorName) + && !"Unknown".equalsIgnoreCase(record.vendorName) + && !"Manufacturer unknown" + .equalsIgnoreCase(record.vendorName)) { + + html.append("
Manufacturer: ") + .append(MapFormat.htmlText(record.vendorName)); + } + + if (time > 0) { + + html.append("
Last seen: ") + .append(MapFormat.htmlText(MapFormat.date(time))); + } + + if (record.battery >= 0) { + + html.append("
Battery: ") + .append(record.battery) + .append("%"); + } + + if (record.rssi != 0 && record.lastRssiAt > 0) { + + html.append("
RSSI: ") + .append(record.rssi) + .append(" dBm"); + } + + String status = MapFormat.reason(record.reason); + + if (MapFormat.hasValue(status) + && !"Status unknown".equalsIgnoreCase(status)) { + + html.append("
Status: ") + .append(MapFormat.htmlText(status)); + } + + if (options.normalizedMode) { + + html.append("
View: Normalized"); + + html.append("
Normalization: ") + .append(MapFormat.htmlText( + HistoryNormalizer.levelName(options.aggressiveness))); + } + + return html.toString(); + } + + private static String removedPopup( + DeviceRecord record, + HistoryNormalizer.Decision decision, + Options options) { + + StringBuilder html = new StringBuilder(); + + html.append("") + .append(MapFormat.htmlText(record.displayName())) + .append(""); + + html.append("
") + .append(MapFormat.htmlText(decision.reason)) + .append(""); + + long time = HistoryNormalizer.pointTime(decision.point); + + if (time > 0) { + + html.append("
Date/time: ") + .append(MapFormat.htmlText(MapFormat.date(time))); + } + + html.append("
GPS: ") + .append(decision.point.latitude) + .append(", ") + .append(decision.point.longitude); + + html.append("
Distance from previous accepted point: ") + .append(MapFormat.number(decision.distanceMeters)) + .append(" m"); + + html.append("
Elapsed time: ") + .append(MapFormat.number(decision.elapsedSeconds)) + .append(" s"); + + if (decision.elapsedSeconds > 0) { + + html.append("
Calculated speed: ") + .append(MapFormat.number(decision.speedKmh)) + .append(" km/h"); + } + + if (decision.threshold > 0) { + + boolean jump = + decision.reason != null + && decision.reason.indexOf("Impossible") >= 0; + + if (jump) { + + html.append("
Allowed speed at current setting: ") + .append(MapFormat.number(decision.threshold)) + .append(" km/h"); + + } else { + + html.append("
Allowed distance: ") + .append(MapFormat.number(decision.threshold)) + .append(" m"); + } + } + + html.append("
Normalization strength: ") + .append(options.aggressiveness) + .append("%"); + + return html.toString(); + } + + private static String historyPopup( + DeviceRecord record, + LocationPoint point, + Options options) { + + StringBuilder html = new StringBuilder(); + + html.append("") + .append(MapFormat.htmlText(record.displayName())) + .append(""); + + html.append("
") + .append( + options.normalizedMode + ? "Normalized recorded point" + : "Previous recorded point") + .append(""); + + long time = HistoryNormalizer.pointTime(point); + + if (time > 0) { + + html.append("
Date/time: ") + .append(MapFormat.htmlText(MapFormat.date(time))); + } + + html.append("
GPS: ") + .append(point.latitude) + .append(", ") + .append(point.longitude); + + double accuracy = + HistoryNormalizer.pointDouble(point, "accuracy", -1); + + if (accuracy > 0) { + + html.append("
Accuracy: ±") + .append(Math.round(accuracy)) + .append(" m"); + } + + double rssi = HistoryNormalizer.pointDouble(point, "rssi", 0); + + if (rssi != 0) { + + html.append("
RSSI: ") + .append(Math.round(rssi)) + .append(" dBm"); + } + + double battery = HistoryNormalizer.pointDouble(point, "battery", -1); + + if (battery >= 0) { + + html.append("
Battery: ") + .append(Math.round(battery)) + .append("%"); + } + + return html.toString(); + } + + // ------------------------------------------------------------------ + // Page shell + // ------------------------------------------------------------------ + + private static String page(String script, String selectedAddress) { + + return "" + + "" + + + "" + + + "" + + + "
" + + + "" + + + ""; + } +}