refactor(map): split the map screen into four focused classes
MapActivity had grown to 4,800 lines and was editing itself into
corruption - the field block had picked up seventeen duplicate
declarations of showNormalizationChanges, and that name was being
used for both the CheckBox widget and the boolean state it toggles,
which is what the compiler was actually failing on.
Fix the collision the way the class already handles the same pattern
elsewhere (showNormalized/normalizedMode): the widget is showChanges,
the state stays showNormalizationChanges.
Then move the bulk out of the activity:
HistoryNormalizer the GPS cleanup engine, its caches, thresholds
and the LocationPoint reflection helpers
MapExporter CSV export, including the 695-line writer and
the file-picker round trip
MapHtmlBuilder the Leaflet page, markers, trails and popups
MapFormat date/reason/colour/HTML/JS/CSV escaping shared
by all three
The activity keeps the UI - onCreate, the filter panel, pickers and
sensors - and feeds the builders through a Filters snapshot, an
Options struct and a two-method DataSource interface, so none of the
extracted classes reach back into its widgets.
Behaviour is unchanged. Also drops pendingExportMode, which was
written in two places and never read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
00703e013c
commit
15baafc9be
@@ -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<String, List<LocationPoint>> historyCache =
|
||||
new HashMap<String, List<LocationPoint>>();
|
||||
|
||||
/* Points removed by normalization, kept so they can be drawn/exported. */
|
||||
private final HashMap<String, List<Decision>> decisionCache =
|
||||
new HashMap<String, List<Decision>>();
|
||||
|
||||
private final HashSet<String> duplicateKeys = new HashSet<String>();
|
||||
private final HashSet<String> rejectedKeys = new HashSet<String>();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 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<DeviceRecord> rows) {
|
||||
|
||||
if (db == null || rows == null) return;
|
||||
|
||||
HashSet<String> seen = new HashSet<String>();
|
||||
|
||||
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<LocationPoint> historyOf(TrackerDatabase db, String address) {
|
||||
|
||||
List<LocationPoint> cached = historyCache.get(address);
|
||||
|
||||
if (cached != null) return cached;
|
||||
|
||||
List<LocationPoint> normalized =
|
||||
normalize(address, db.history(address, 0L));
|
||||
|
||||
historyCache.put(address, normalized);
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Removed points for one device; never null. */
|
||||
public List<Decision> decisionsOf(String address) {
|
||||
|
||||
List<Decision> result = decisionCache.get(address);
|
||||
|
||||
return result == null
|
||||
? new ArrayList<Decision>()
|
||||
: 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<LocationPoint> 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<Decision> list : decisionCache.values()) {
|
||||
if (list != null) removed += list.size();
|
||||
}
|
||||
|
||||
return removed + retainedCount();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Engine
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public List<LocationPoint> normalize(
|
||||
String deviceAddress,
|
||||
List<LocationPoint> input) {
|
||||
|
||||
ArrayList<LocationPoint> rows = new ArrayList<LocationPoint>();
|
||||
ArrayList<Decision> decisions = new ArrayList<Decision>();
|
||||
|
||||
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<LocationPoint>() {
|
||||
|
||||
@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<LocationPoint> unique = new ArrayList<LocationPoint>();
|
||||
|
||||
HashSet<String> exact = new HashSet<String>();
|
||||
|
||||
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<LocationPoint> normalized = new ArrayList<LocationPoint>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<DeviceRecord> 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<DeviceRecord> 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<LocationPoint> 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<LocationPoint> 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<HistoryNormalizer.Decision> 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();
|
||||
}
|
||||
}
|
||||
@@ -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", " ") +
|
||||
"\"";
|
||||
}
|
||||
}
|
||||
@@ -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<LocationPoint> historyFor(DeviceRecord record);
|
||||
|
||||
/** Points normalization removed, already date-filtered. */
|
||||
List<HistoryNormalizer.Decision> 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<DeviceRecord> 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<LocationPoint> 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<HistoryNormalizer.Decision> 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<LocationPoint> 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<LocationPoint> 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("<div style='width:")
|
||||
.append(markerSize)
|
||||
.append("px;height:")
|
||||
.append(markerSize)
|
||||
.append("px;border-radius:50%;background:")
|
||||
.append(markerColor)
|
||||
.append(";border:2px solid white;box-shadow:0 1px 4px #333'>")
|
||||
.append("</div>\",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("<img src='file://")
|
||||
.append(MapFormat.htmlText(record.photoUri))
|
||||
.append("' style='width:72px;height:72px;")
|
||||
.append("object-fit:cover;border-radius:10px'><br>");
|
||||
}
|
||||
|
||||
html.append("<b>")
|
||||
.append(MapFormat.htmlText(record.displayName()))
|
||||
.append("</b><br>");
|
||||
|
||||
html.append("<a href='geo:")
|
||||
.append(latitude)
|
||||
.append(",")
|
||||
.append(longitude)
|
||||
.append("?q=")
|
||||
.append(latitude)
|
||||
.append(",")
|
||||
.append(longitude)
|
||||
.append("'>GPS: ")
|
||||
.append(latitude)
|
||||
.append(", ")
|
||||
.append(longitude)
|
||||
.append("</a>");
|
||||
|
||||
if (record.accuracy > 0) {
|
||||
|
||||
html.append(" (±")
|
||||
.append(Math.round(record.accuracy))
|
||||
.append(" m)");
|
||||
}
|
||||
|
||||
if (MapFormat.hasValue(record.category)
|
||||
&& !"Other".equalsIgnoreCase(record.category)) {
|
||||
|
||||
html.append("<br>Category: ")
|
||||
.append(MapFormat.htmlText(record.category));
|
||||
}
|
||||
|
||||
if (MapFormat.hasValue(record.vendorName)
|
||||
&& !"Unknown".equalsIgnoreCase(record.vendorName)
|
||||
&& !"Manufacturer unknown"
|
||||
.equalsIgnoreCase(record.vendorName)) {
|
||||
|
||||
html.append("<br>Manufacturer: ")
|
||||
.append(MapFormat.htmlText(record.vendorName));
|
||||
}
|
||||
|
||||
if (time > 0) {
|
||||
|
||||
html.append("<br>Last seen: ")
|
||||
.append(MapFormat.htmlText(MapFormat.date(time)));
|
||||
}
|
||||
|
||||
if (record.battery >= 0) {
|
||||
|
||||
html.append("<br>Battery: ")
|
||||
.append(record.battery)
|
||||
.append("%");
|
||||
}
|
||||
|
||||
if (record.rssi != 0 && record.lastRssiAt > 0) {
|
||||
|
||||
html.append("<br>RSSI: ")
|
||||
.append(record.rssi)
|
||||
.append(" dBm");
|
||||
}
|
||||
|
||||
String status = MapFormat.reason(record.reason);
|
||||
|
||||
if (MapFormat.hasValue(status)
|
||||
&& !"Status unknown".equalsIgnoreCase(status)) {
|
||||
|
||||
html.append("<br>Status: ")
|
||||
.append(MapFormat.htmlText(status));
|
||||
}
|
||||
|
||||
if (options.normalizedMode) {
|
||||
|
||||
html.append("<br><b>View: Normalized</b>");
|
||||
|
||||
html.append("<br>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("<b>")
|
||||
.append(MapFormat.htmlText(record.displayName()))
|
||||
.append("</b>");
|
||||
|
||||
html.append("<br><span style='color:#E53935'><b>")
|
||||
.append(MapFormat.htmlText(decision.reason))
|
||||
.append("</b></span>");
|
||||
|
||||
long time = HistoryNormalizer.pointTime(decision.point);
|
||||
|
||||
if (time > 0) {
|
||||
|
||||
html.append("<br>Date/time: ")
|
||||
.append(MapFormat.htmlText(MapFormat.date(time)));
|
||||
}
|
||||
|
||||
html.append("<br>GPS: ")
|
||||
.append(decision.point.latitude)
|
||||
.append(", ")
|
||||
.append(decision.point.longitude);
|
||||
|
||||
html.append("<br>Distance from previous accepted point: ")
|
||||
.append(MapFormat.number(decision.distanceMeters))
|
||||
.append(" m");
|
||||
|
||||
html.append("<br>Elapsed time: ")
|
||||
.append(MapFormat.number(decision.elapsedSeconds))
|
||||
.append(" s");
|
||||
|
||||
if (decision.elapsedSeconds > 0) {
|
||||
|
||||
html.append("<br>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("<br>Allowed speed at current setting: ")
|
||||
.append(MapFormat.number(decision.threshold))
|
||||
.append(" km/h");
|
||||
|
||||
} else {
|
||||
|
||||
html.append("<br>Allowed distance: ")
|
||||
.append(MapFormat.number(decision.threshold))
|
||||
.append(" m");
|
||||
}
|
||||
}
|
||||
|
||||
html.append("<br>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("<b>")
|
||||
.append(MapFormat.htmlText(record.displayName()))
|
||||
.append("</b>");
|
||||
|
||||
html.append("<br><b>")
|
||||
.append(
|
||||
options.normalizedMode
|
||||
? "Normalized recorded point"
|
||||
: "Previous recorded point")
|
||||
.append("</b>");
|
||||
|
||||
long time = HistoryNormalizer.pointTime(point);
|
||||
|
||||
if (time > 0) {
|
||||
|
||||
html.append("<br>Date/time: ")
|
||||
.append(MapFormat.htmlText(MapFormat.date(time)));
|
||||
}
|
||||
|
||||
html.append("<br>GPS: ")
|
||||
.append(point.latitude)
|
||||
.append(", ")
|
||||
.append(point.longitude);
|
||||
|
||||
double accuracy =
|
||||
HistoryNormalizer.pointDouble(point, "accuracy", -1);
|
||||
|
||||
if (accuracy > 0) {
|
||||
|
||||
html.append("<br>Accuracy: ±")
|
||||
.append(Math.round(accuracy))
|
||||
.append(" m");
|
||||
}
|
||||
|
||||
double rssi = HistoryNormalizer.pointDouble(point, "rssi", 0);
|
||||
|
||||
if (rssi != 0) {
|
||||
|
||||
html.append("<br>RSSI: ")
|
||||
.append(Math.round(rssi))
|
||||
.append(" dBm");
|
||||
}
|
||||
|
||||
double battery = HistoryNormalizer.pointDouble(point, "battery", -1);
|
||||
|
||||
if (battery >= 0) {
|
||||
|
||||
html.append("<br>Battery: ")
|
||||
.append(Math.round(battery))
|
||||
.append("%");
|
||||
}
|
||||
|
||||
return html.toString();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Page shell
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static String page(String script, String selectedAddress) {
|
||||
|
||||
return "<!doctype html><html><head>"
|
||||
+ "<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
||||
|
||||
+ "<link rel='stylesheet' href='https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'>"
|
||||
|
||||
+ "<style>"
|
||||
+ "html,body,#map{height:100%;margin:0}"
|
||||
+ ".leaflet-popup-content{font:14px sans-serif;line-height:1.5}"
|
||||
|
||||
+ ".phone-bearing{"
|
||||
+ "position:relative;"
|
||||
+ "width:38px;"
|
||||
+ "height:38px;"
|
||||
+ "transform-origin:19px 19px"
|
||||
+ "}"
|
||||
|
||||
+ ".phone-dot{"
|
||||
+ "position:absolute;"
|
||||
+ "left:9px;"
|
||||
+ "top:9px;"
|
||||
+ "width:16px;"
|
||||
+ "height:16px;"
|
||||
+ "border-radius:50%;"
|
||||
+ "background:#4285F4;"
|
||||
+ "border:3px solid white;"
|
||||
+ "box-shadow:0 1px 4px #555"
|
||||
+ "}"
|
||||
|
||||
+ ".phone-tip{"
|
||||
+ "position:absolute;"
|
||||
+ "left:15px;"
|
||||
+ "top:1px;"
|
||||
+ "width:0;"
|
||||
+ "height:0;"
|
||||
+ "border-left:5px solid transparent;"
|
||||
+ "border-right:5px solid transparent;"
|
||||
+ "border-bottom:10px solid #4285F4"
|
||||
+ "}"
|
||||
|
||||
+ "</style></head><body>"
|
||||
|
||||
+ "<div id='map'></div>"
|
||||
|
||||
+ "<script src='https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'></script>"
|
||||
|
||||
+ "<script>"
|
||||
|
||||
+ "var streets=L.tileLayer("
|
||||
+ "'https://tile.openstreetmap.org/{z}/{x}/{y}.png',"
|
||||
+ "{maxNativeZoom:19,maxZoom:22,"
|
||||
+ "attribution:'© OpenStreetMap contributors'}),"
|
||||
|
||||
+ "sat=L.tileLayer("
|
||||
+ "'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',"
|
||||
+ "{maxNativeZoom:19,maxZoom:22,"
|
||||
+ "attribution:'Esri World Imagery'}),"
|
||||
|
||||
+ "map=L.map('map',{layers:[streets]}).setView([20,0],2);"
|
||||
|
||||
+ "L.control.layers({"
|
||||
+ "'Streets':streets,"
|
||||
+ "'Satellite':sat"
|
||||
+ "},null,{collapsed:false}).addTo(map);"
|
||||
|
||||
+ "var phone=null,phoneShown=false,"
|
||||
|
||||
+ "phoneIcon=L.divIcon({"
|
||||
+ "className:'',"
|
||||
+ "html:'<div id=phoneBearing class=phone-bearing><div class=phone-dot></div><div class=phone-tip></div></div>',"
|
||||
+ "iconSize:[38,38],"
|
||||
+ "iconAnchor:[19,19]"
|
||||
+ "});"
|
||||
|
||||
+ "function updatePhone(a,b,h){"
|
||||
+ "var p=[a,b];"
|
||||
|
||||
+ "if(!phone)"
|
||||
+ "phone=L.marker(p,{icon:phoneIcon,zIndexOffset:1100}).addTo(map);"
|
||||
+ "else phone.setLatLng(p);"
|
||||
|
||||
+ "if(!phoneShown){"
|
||||
+ "phoneShown=true;"
|
||||
+ "var visible=bounds.slice(0);"
|
||||
+ "visible.push(p);"
|
||||
|
||||
+ "if(visible.length>1)"
|
||||
+ "map.fitBounds(visible,{padding:[35,35],maxZoom:20});"
|
||||
+ "else map.setView(p,20);"
|
||||
+ "}"
|
||||
|
||||
+ "var e=document.getElementById('phoneBearing');"
|
||||
+ "if(e)e.style.transform='rotate('+h+'deg)';"
|
||||
+ "}"
|
||||
|
||||
+ "var bounds=[];"
|
||||
|
||||
+ script
|
||||
|
||||
+ "if(bounds.length>0&&"
|
||||
+ "'"
|
||||
+ MapFormat.js(selectedAddress == null ? "" : selectedAddress)
|
||||
+ "'=='')"
|
||||
+ "map.fitBounds(bounds,{padding:[30,30],maxZoom:20});"
|
||||
|
||||
+ "</script></body></html>";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user