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>
532 lines
16 KiB
Java
532 lines
16 KiB
Java
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();
|
|
}
|
|
}
|