Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d98f474ae | ||
|
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,8 +61,6 @@ public class LiveRangeFinderActivity extends Activity implements LocationListene
|
|||||||
private float heading;
|
private float heading;
|
||||||
/** Magnetic-to-true north correction for the current position. */
|
/** Magnetic-to-true north correction for the current position. */
|
||||||
private float declination;
|
private float declination;
|
||||||
/** Last bearing the signal gradient produced; NaN until one is solved. */
|
|
||||||
private float lastGradientBearing = Float.NaN;
|
|
||||||
/** Smoothed compass azimuth, kept in sin/cos form to survive the 0/360 wrap. */
|
/** Smoothed compass azimuth, kept in sin/cos form to survive the 0/360 wrap. */
|
||||||
private float headingSin, headingCos;
|
private float headingSin, headingCos;
|
||||||
private boolean headingPrimed;
|
private boolean headingPrimed;
|
||||||
@@ -518,12 +516,13 @@ public class LiveRangeFinderActivity extends Activity implements LocationListene
|
|||||||
}
|
}
|
||||||
SpatialGradientEngine.Estimate estimate = spatial.consensusEstimate();
|
SpatialGradientEngine.Estimate estimate = spatial.consensusEstimate();
|
||||||
if (estimate != null) {
|
if (estimate != null) {
|
||||||
if (!Float.isNaN(estimate.gradientBearing))
|
|
||||||
lastGradientBearing = estimate.gradientBearing;
|
|
||||||
estimateText.setText(String.format(Locale.US,
|
estimateText.setText(String.format(Locale.US,
|
||||||
"%s • %d stations • ±%.1f m • %.0f°",
|
"%s • %d stations • ±%.1f m • %s",
|
||||||
estimate.preliminary ? "Estimate" : "Solved",
|
estimate.preliminary ? "Estimate" : "Solved",
|
||||||
estimate.sampleCount, estimate.confidenceMeters, estimate.gradientBearing));
|
estimate.sampleCount, estimate.confidenceMeters,
|
||||||
|
Float.isNaN(estimate.gradientBearing)
|
||||||
|
? "side unresolved — turn 90° and walk"
|
||||||
|
: String.format(Locale.US, "%.0f°", estimate.gradientBearing)));
|
||||||
js(String.format(Locale.US, "updateEstimate(%.7f,%.7f,%.1f,%.1f,%s)",
|
js(String.format(Locale.US, "updateEstimate(%.7f,%.7f,%.1f,%.1f,%s)",
|
||||||
estimate.latitude, estimate.longitude, estimate.confidenceMeters,
|
estimate.latitude, estimate.longitude, estimate.confidenceMeters,
|
||||||
estimate.gradientBearing, estimate.preliminary ? "true" : "false"));
|
estimate.gradientBearing, estimate.preliminary ? "true" : "false"));
|
||||||
@@ -531,43 +530,35 @@ public class LiveRangeFinderActivity extends Activity implements LocationListene
|
|||||||
} else {
|
} else {
|
||||||
SpatialGradientEngine.Estimate hint = spatial.preliminaryEstimate();
|
SpatialGradientEngine.Estimate hint = spatial.preliminaryEstimate();
|
||||||
if (hint != null) {
|
if (hint != null) {
|
||||||
if (!Float.isNaN(hint.gradientBearing))
|
|
||||||
lastGradientBearing = hint.gradientBearing;
|
|
||||||
js(String.format(Locale.US, "updateEstimate(%.7f,%.7f,%.1f,%.1f,true)",
|
js(String.format(Locale.US, "updateEstimate(%.7f,%.7f,%.1f,%.1f,true)",
|
||||||
hint.latitude, hint.longitude, hint.confidenceMeters, hint.gradientBearing));
|
hint.latitude, hint.longitude, hint.confidenceMeters, hint.gradientBearing));
|
||||||
js(String.format(Locale.US, "updateGradient(%.1f,'%s')", hint.gradientBearing, state));
|
js(String.format(Locale.US, "updateGradient(%.1f,'%s')", hint.gradientBearing, state));
|
||||||
estimateText.setText("Likely " + sector(hint.gradientBearing) + " • " + spatial.size() + " stations • refining");
|
estimateText.setText("Likely " + sector(hint.gradientBearing) + " • " + spatial.size() + " stations • refining");
|
||||||
} else {
|
} else {
|
||||||
showFallbackPin(location, direction, distance);
|
showLastKnownPin();
|
||||||
estimateText.setText(spatial.size() +
|
estimateText.setText(spatial.size() +
|
||||||
" stations • Walk 8–15 m toward stronger signal");
|
" stations • Walk 10 m, then turn 90° and walk again");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* With no solved estimate the only honest target is the item's last
|
* With no solved estimate the only honest target is the item's last known
|
||||||
* known GPS fix. Projecting a pin along the phone's own heading - which
|
* GPS fix, if it has one at all.
|
||||||
* is what this used to do - just parks the pin wherever the user points,
|
|
||||||
* so the arrow always appears to aim at it and it is useless for walking.
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Projects the item pin the estimated distance ahead so there is always
|
|
||||||
* something to walk toward, and it moves as the range estimate changes.
|
|
||||||
*
|
*
|
||||||
* It aims at the last bearing the signal gradient produced, so it holds
|
* The bearing arrow reports which way the phone is facing and nothing
|
||||||
* a real direction instead of swinging around with the compass; only
|
* else - it is never a pointer at the item. Projecting the pin along that
|
||||||
* before any gradient exists does it fall back to the phone's heading.
|
* heading (which this used to do) parks the item wherever the user happens
|
||||||
|
* to be aiming, so the arrow always appears to point straight at it and
|
||||||
|
* the pin swings around with the compass. Show nothing rather than that.
|
||||||
*/
|
*/
|
||||||
private void showFallbackPin(Location phone, float direction, double distance) {
|
private void showLastKnownPin() {
|
||||||
float bearing = Float.isNaN(lastGradientBearing) ? direction : lastGradientBearing;
|
if (target.hasLocation) {
|
||||||
double projected = Math.max(1.5, Math.min(20.0, distance));
|
js(String.format(Locale.US, "updateEstimate(%.7f,%.7f,%.1f,0,true)",
|
||||||
double radians = Math.toRadians(bearing);
|
target.latitude, target.longitude, Math.max(1.5f, target.accuracy)));
|
||||||
double latitude = phone.getLatitude() + Math.cos(radians) * projected / 111319.49;
|
} else {
|
||||||
double cosine = Math.max(0.2, Math.cos(Math.toRadians(phone.getLatitude())));
|
js("clearTarget()");
|
||||||
double longitude = phone.getLongitude() + Math.sin(radians) * projected / (111319.49 * cosine);
|
}
|
||||||
js(String.format(Locale.US, "updateEstimate(%.7f,%.7f,%.1f,%.1f,true)",
|
|
||||||
latitude, longitude, Math.max(1.5, distance), bearing));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String sector(float bearing) {
|
private String sector(float bearing) {
|
||||||
|
|||||||
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>";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,13 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class SpatialGradientEngine {
|
public class SpatialGradientEngine {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metres of spread the sample track needs across its own axis before a
|
||||||
|
* signal gradient means anything. See {@link #gradientBearing}.
|
||||||
|
*/
|
||||||
|
private static final double MIN_TRACK_WIDTH_METERS = 4.0;
|
||||||
|
|
||||||
public static class Estimate {
|
public static class Estimate {
|
||||||
public double latitude;
|
public double latitude;
|
||||||
public double longitude;
|
public double longitude;
|
||||||
@@ -150,12 +157,34 @@ public class SpatialGradientEngine {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bearing solved from samples that all sit on one line is not a
|
||||||
|
* measurement, it is an echo of the walk.
|
||||||
|
*
|
||||||
|
* Centre the positions and the correlation below is a sum of
|
||||||
|
* (position - mean) * signal. On a straight walk every centred position
|
||||||
|
* is t*u for one unit vector u along the track, so the result is exactly
|
||||||
|
* parallel to u no matter what the RSSI does - the "estimated" bearing
|
||||||
|
* comes back as the direction the user is already walking, the pin gets
|
||||||
|
* projected dead ahead, and the compass arrow appears to point at it.
|
||||||
|
*
|
||||||
|
* Distances alone still place the item off-axis, but which side of the
|
||||||
|
* track it lies on is a genuine mirror ambiguity. It takes a leg at an
|
||||||
|
* angle to resolve, so report NaN until the track has real width and let
|
||||||
|
* the caller ask the user to turn.
|
||||||
|
*/
|
||||||
private float gradientBearing(double[] xs, double[] ys) {
|
private float gradientBearing(double[] xs, double[] ys) {
|
||||||
double meanX = 0, meanY = 0, meanRssi = 0;
|
double meanX = 0, meanY = 0, meanRssi = 0, meanAccuracy = 0;
|
||||||
for (int i = 0; i < samples.size(); i++) {
|
for (int i = 0; i < samples.size(); i++) {
|
||||||
meanX += xs[i]; meanY += ys[i]; meanRssi += samples.get(i).rssi;
|
meanX += xs[i]; meanY += ys[i]; meanRssi += samples.get(i).rssi;
|
||||||
|
meanAccuracy += samples.get(i).accuracy;
|
||||||
}
|
}
|
||||||
meanX /= samples.size(); meanY /= samples.size(); meanRssi /= samples.size();
|
meanX /= samples.size(); meanY /= samples.size(); meanRssi /= samples.size();
|
||||||
|
meanAccuracy /= samples.size();
|
||||||
|
if (perpendicularSpread(xs, ys, meanX, meanY) <
|
||||||
|
Math.max(MIN_TRACK_WIDTH_METERS, meanAccuracy * 0.5)) {
|
||||||
|
return Float.NaN;
|
||||||
|
}
|
||||||
double east = 0, north = 0;
|
double east = 0, north = 0;
|
||||||
for (int i = 0; i < samples.size(); i++) {
|
for (int i = 0; i < samples.size(); i++) {
|
||||||
double signal = samples.get(i).rssi - meanRssi;
|
double signal = samples.get(i).rssi - meanRssi;
|
||||||
@@ -168,6 +197,28 @@ public class SpatialGradientEngine {
|
|||||||
return (float) degrees;
|
return (float) degrees;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spread of the sample track across its own dominant axis, in metres:
|
||||||
|
* the smaller eigenvalue of the position covariance, square-rooted.
|
||||||
|
*
|
||||||
|
* Straight walk plus GPS jitter lands around 1-3 m. An L of two 20 m
|
||||||
|
* legs lands near 6 m.
|
||||||
|
*/
|
||||||
|
private double perpendicularSpread(double[] xs, double[] ys,
|
||||||
|
double meanX, double meanY) {
|
||||||
|
int count = samples.size();
|
||||||
|
if (count < 3) return 0;
|
||||||
|
double sxx = 0, syy = 0, sxy = 0;
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
double dx = xs[i] - meanX, dy = ys[i] - meanY;
|
||||||
|
sxx += dx * dx; syy += dy * dy; sxy += dx * dy;
|
||||||
|
}
|
||||||
|
sxx /= count; syy /= count; sxy /= count;
|
||||||
|
double half = (sxx + syy) / 2.0;
|
||||||
|
double gap = Math.sqrt(Math.pow((sxx - syy) / 2.0, 2) + sxy * sxy);
|
||||||
|
return Math.sqrt(Math.max(0, half - gap));
|
||||||
|
}
|
||||||
|
|
||||||
public synchronized Estimate preliminaryEstimate() {
|
public synchronized Estimate preliminaryEstimate() {
|
||||||
if (samples.size() < 2) return null;
|
if (samples.size() < 2) return null;
|
||||||
Sample origin = samples.get(0);
|
Sample origin = samples.get(0);
|
||||||
|
|||||||
Reference in New Issue
Block a user