package com.wytehat.btlogger; import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.ContentResolver; import android.content.Intent; import android.net.Uri; import android.widget.Toast; import java.io.BufferedWriter; import java.io.IOException; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; import java.util.Locale; /** * CSV export for whatever the map is currently showing. * * Two flavours: RAW writes the history exactly as recorded, NORMALIZED runs * it through {@link HistoryNormalizer} first and also writes out the points * that were removed, so the file explains its own cleanup. * * Saving is a two-step dance with the system file picker - {@link #start} * asks for a destination, the activity hands the chosen Uri back to * {@link #write} from onActivityResult. */ public class MapExporter { public static final int REQUEST_EXPORT_RAW = 7401; public static final int REQUEST_EXPORT_NORMALIZED = 7402; private static final String HEADER_ROW = "Record Type,Device Name,Address,Category," + "Manufacturer,Latitude,Longitude,Accuracy," + "Battery,RSSI,Last Seen,Status," + "Location Locked,Map Enabled,Show Trail,Photo URI"; /** Snapshot of the map's filter bar at the moment of export. */ public static class Filters { public String device = "All"; public String category = "All"; public long startMillis; public long endMillis; public boolean includeHistory; /** Same rule the map uses: an undated point is never filtered out. */ public boolean contains(long time) { if (time <= 0L) return true; return time >= startMillis && time <= endMillis; } } private final Activity activity; private final TrackerDatabase db; private final HistoryNormalizer normalizer; public MapExporter( Activity activity, TrackerDatabase db, HistoryNormalizer normalizer) { this.activity = activity; this.db = db; this.normalizer = normalizer; } /** * Asks the system where to save. The caller passes the devices already * matching the filter bar, so the file picker is never opened for an * export that would come back empty. * * @return false if there was nothing to export, or no app to save with. */ public boolean start( List devices, boolean normalized, Filters filters) { if (devices == null || devices.size() == 0) { toast("No database records match the current filters."); return false; } if (normalized) { normalizer.clear(); normalizer.prepare(db, devices); } Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("text/csv"); intent.putExtra( Intent.EXTRA_TITLE, buildFilename(filters, normalized)); try { activity.startActivityForResult( intent, normalized ? REQUEST_EXPORT_NORMALIZED : REQUEST_EXPORT_RAW); return true; } catch (ActivityNotFoundException error) { toast("No file-saving application is available."); return false; } } /** Writes the export to the destination the user picked. */ public void write( Uri uri, List devices, boolean normalized, Filters filters) { OutputStream output = null; BufferedWriter writer = null; try { ContentResolver resolver = activity.getContentResolver(); output = resolver.openOutputStream(uri); if (output == null) { throw new IOException("Unable to open output file."); } writer = new BufferedWriter( new OutputStreamWriter(output, "UTF-8")); writeHeader(writer, normalized, filters); int deviceCount = 0; int pointCount = 0; for (int i = 0; i < devices.size(); i++) { DeviceRecord record = devices.get(i); if (record == null) continue; writeDeviceRow(writer, record, normalized); deviceCount++; if (!filters.includeHistory) continue; if (record.address == null || record.address.trim().length() == 0) { continue; } pointCount += writeHistoryRows( writer, record, normalized, filters); if (normalized) { writeRemovedRows(writer, record, filters); } } writeSummary(writer, normalized, deviceCount, pointCount); writer.flush(); toast(summaryMessage(normalized, deviceCount, pointCount)); } catch (Exception error) { toast("Export failed: " + error.getMessage()); } finally { try { if (writer != null) writer.close(); else if (output != null) output.close(); } catch (Exception ignored) { } } } // ------------------------------------------------------------------ // Sections // ------------------------------------------------------------------ private void writeHeader( BufferedWriter writer, boolean normalized, Filters filters) throws IOException { line(writer, MapFormat.csv( normalized ? "BT Logger Normalized Database Export" : "BT Logger Raw Database Export")); pair(writer, "Exported", MapFormat.date(System.currentTimeMillis())); pair(writer, "Data Mode", normalized ? "Normalized" : "Raw"); pair(writer, "Selected Device", filters.device); pair(writer, "Category", filters.category); pair(writer, "Start", MapFormat.date(filters.startMillis)); pair(writer, "End", MapFormat.date(filters.endMillis)); pair( writer, "Historical Points", filters.includeHistory ? "Included" : "Excluded"); if (normalized) { pair( writer, "Normalization Aggressiveness", normalizer.getAggressiveness() + "%"); pair( writer, "Maximum Speed", normalizer.maxSpeedKmh() + " km/h"); pair( writer, "Duplicate Distance", normalizer.duplicateDistance() + " m"); pair( writer, "Same Time Distance", normalizer.sameTimeDistance() + " m"); pair( writer, "Accuracy Multiplier", Double.toString(normalizer.accuracyMultiplier())); } writer.newLine(); line(writer, HEADER_ROW); } private void writeDeviceRow( BufferedWriter writer, DeviceRecord record, boolean normalized) throws IOException { double latitude = record.latitude; double longitude = record.longitude; long time = record.updatedAt; /* * In normalized mode the device's own last-known fix may be one of * the points that got thrown out, so report the newest surviving * point instead. */ if (normalized) { List history = normalizer.historyOf(db, record.address); if (history.size() > 0) { LocationPoint latest = history.get(history.size() - 1); if (latest != null) { latitude = latest.latitude; longitude = latest.longitude; long latestTime = HistoryNormalizer.pointTime(latest); if (latestTime > 0) time = latestTime; } } } line(writer, MapFormat.csv("DEVICE") + "," + MapFormat.csv(record.displayName()) + "," + MapFormat.csv(record.address) + "," + MapFormat.csv(record.category) + "," + MapFormat.csv(record.vendorName) + "," + MapFormat.csv(Double.toString(latitude)) + "," + MapFormat.csv(Double.toString(longitude)) + "," + MapFormat.csv( record.accuracy > 0 ? Double.toString(record.accuracy) : "") + "," + MapFormat.csv( record.battery >= 0 ? Integer.toString(record.battery) : "") + "," + MapFormat.csv( record.rssi != 0 ? Integer.toString(record.rssi) : "") + "," + MapFormat.csv(time > 0 ? MapFormat.date(time) : "") + "," + MapFormat.csv(MapFormat.reason(record.reason)) + "," + MapFormat.csv(Integer.toString(record.locationLocked)) + "," + MapFormat.csv(Integer.toString(record.mapEnabled)) + "," + MapFormat.csv(Integer.toString(record.showTrail)) + "," + MapFormat.csv(record.photoUri)); } private int writeHistoryRows( BufferedWriter writer, DeviceRecord record, boolean normalized, Filters filters) throws IOException { List history = normalized ? normalizer.historyOf(db, record.address) : db.history(record.address, 0L); int written = 0; for (int p = 0; p < history.size(); p++) { LocationPoint point = history.get(p); if (point == null) continue; long time = HistoryNormalizer.pointTime(point); if (time > 0L && !filters.contains(time)) continue; double accuracy = HistoryNormalizer.pointDouble(point, "accuracy", -1); double rssi = HistoryNormalizer.pointDouble(point, "rssi", 0); double battery = HistoryNormalizer.pointDouble(point, "battery", -1); line(writer, MapFormat.csv( normalized ? "NORMALIZED_POINT" : "HISTORICAL_POINT") + "," + MapFormat.csv(record.displayName()) + "," + MapFormat.csv(record.address) + "," + MapFormat.csv(record.category) + "," + MapFormat.csv(record.vendorName) + "," + MapFormat.csv(Double.toString(point.latitude)) + "," + MapFormat.csv(Double.toString(point.longitude)) + "," + MapFormat.csv( accuracy > 0 ? Double.toString(accuracy) : "") + "," + MapFormat.csv( battery >= 0 ? Double.toString(battery) : "") + "," + MapFormat.csv( rssi != 0 ? Double.toString(rssi) : "") + "," + MapFormat.csv(time > 0 ? MapFormat.date(time) : "") + "," + MapFormat.csv("") + "," + MapFormat.csv("") + "," + MapFormat.csv("") + "," + MapFormat.csv("")); written++; } return written; } /** The points normalization threw away, with the reason in the Status column. */ private void writeRemovedRows( BufferedWriter writer, DeviceRecord record, Filters filters) throws IOException { List decisions = normalizer.decisionsOf(record.address); for (HistoryNormalizer.Decision decision : decisions) { if (decision == null || decision.point == null) continue; long time = HistoryNormalizer.pointTime(decision.point); if (time > 0 && !filters.contains(time)) continue; line(writer, MapFormat.csv("REMOVED_POINT") + "," + MapFormat.csv(record.displayName()) + "," + MapFormat.csv(record.address) + "," + MapFormat.csv(record.category) + "," + MapFormat.csv(record.vendorName) + "," + MapFormat.csv( Double.toString(decision.point.latitude)) + "," + MapFormat.csv( Double.toString(decision.point.longitude)) + "," + MapFormat.csv("") + "," + MapFormat.csv("") + "," + MapFormat.csv("") + "," + MapFormat.csv(time > 0 ? MapFormat.date(time) : "") + "," + MapFormat.csv(decision.reason) + "," + MapFormat.csv("") + "," + MapFormat.csv("") + "," + MapFormat.csv("")); } } private void writeSummary( BufferedWriter writer, boolean normalized, int deviceCount, int pointCount) throws IOException { writer.newLine(); line(writer, MapFormat.csv("Export Summary")); pair(writer, "Devices Exported", Integer.toString(deviceCount)); pair( writer, normalized ? "Normalized Points Exported" : "Historical Points Exported", Integer.toString(pointCount)); if (normalized) { pair( writer, "Duplicates Removed", Integer.toString(normalizer.duplicateCount())); pair( writer, "GPS Jumps Rejected", Integer.toString(normalizer.rejectedCount())); } } private String summaryMessage( boolean normalized, int deviceCount, int pointCount) { if (!normalized) { return "Exported " + deviceCount + " device(s) and " + pointCount + " historical point(s)."; } return "Exported " + deviceCount + " device(s), " + pointCount + " normalized point(s), " + normalizer.duplicateCount() + " duplicate(s) removed, and " + normalizer.rejectedCount() + " GPS jump(s) rejected."; } // ------------------------------------------------------------------ // Helpers // ------------------------------------------------------------------ private String buildFilename(Filters filters, boolean normalized) { String devicePart = sanitizeFilename( "All".equals(filters.device) ? "all_devices" : filters.device); String datePart = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) .format(new Date()); return "btlogger_" + devicePart + "_" + (normalized ? "normalized_" : "raw_") + datePart + ".csv"; } private String sanitizeFilename(String value) { if (value == null || value.trim().length() == 0) { return "export"; } return value .replaceAll("[\\\\/:*?\"<>|]", "_") .replaceAll("\\s+", "_"); } private void line(BufferedWriter writer, String text) throws IOException { writer.write(text); writer.newLine(); } private void pair( BufferedWriter writer, String label, String value) throws IOException { line(writer, MapFormat.csv(label) + "," + MapFormat.csv(value)); } private void toast(String message) { Toast.makeText(activity, message, Toast.LENGTH_LONG).show(); } }