Compare commits

...
2 Commits
Author SHA1 Message Date
n0tst3v3andClaude Opus 5 5d98f474ae fix(live): stop the item pin from tracking the bearing arrow
The pin sat dead ahead of the heading arrow no matter which way the
user turned, so the arrow looked like it was aiming at the item and
the item looked like it was always straight down the path of travel.

The cause is geometric, not a UI link. addSample() only accepts a
sample after 5 m of movement, so every sample lies on the line the
user walked. gradientBearing() correlates RSSI against centred
position, and on a collinear track each centred position is t*u for
a single unit vector u along the walk - so the sum is exactly
parallel to u regardless of the signal. The "estimated" bearing was
the direction of travel, echoed back.

That bearing then placed the pin in three separate paths:
preliminaryEstimate() projects along it, solve() overrides a
converged trilateration with it, and consensusEstimate() averages
the result. All three produced a pin straight ahead.

Refuse the bearing until the track has real width across its own
axis - the smaller eigenvalue of the position covariance, against
max(4 m, meanAccuracy/2). A straight walk with GPS jitter measures
about 1 m of spread and is rejected; an L of two 20 m legs measures
about 5 m and is accepted. Distances still place the item off-axis
on a straight walk, but which side is a real mirror ambiguity, so
the UI now says so and asks for a leg at 90 degrees instead of
inventing a side.

Also drops showFallbackPin(), which projected the pin along the raw
compass heading whenever no estimate was solved - a fourth route to
the same wrong place. With no estimate the pin now shows the item's
last known GPS fix, or nothing at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 09:10:26 -06:00
n0tst3v3andClaude Opus 5 15baafc9be 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>
2026-08-19 04:56:19 -06:00
7 changed files with 3907 additions and 2496 deletions
@@ -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 815 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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
/** 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);