Compare commits

..
3 Commits
7 changed files with 133 additions and 838 deletions
@@ -25,10 +25,6 @@ public class BluetoothTrackingService extends Service implements LocationListene
public static final String ACTION_DATA_CHANGED = "com.wytehat.btlogger.DATA_CHANGED"; public static final String ACTION_DATA_CHANGED = "com.wytehat.btlogger.DATA_CHANGED";
public static final String ACTION_CONFIG_CHANGED = "com.wytehat.btlogger.CONFIG_CHANGED"; public static final String ACTION_CONFIG_CHANGED = "com.wytehat.btlogger.CONFIG_CHANGED";
public static final String ACTION_RANGE_MODE = "com.wytehat.btlogger.RANGE_MODE"; public static final String ACTION_RANGE_MODE = "com.wytehat.btlogger.RANGE_MODE";
public static final String ACTION_ACK_LEFT_BEHIND = "com.wytehat.btlogger.ACK_LEFT_BEHIND";
public static final String EXTRA_ADDRESS = "address";
/** How often an unacknowledged left-behind item re-alerts. */
private static final long ALERT_REPEAT_MS = 60000L;
private static final int NOTIFICATION_ID = 42; private static final int NOTIFICATION_ID = 42;
private static final String CHANNEL_ID = "bluetooth_tracking"; private static final String CHANNEL_ID = "bluetooth_tracking";
private static final String ALERT_CHANNEL_ID = "tracked_item_disconnects"; private static final String ALERT_CHANNEL_ID = "tracked_item_disconnects";
@@ -41,9 +37,6 @@ public class BluetoothTrackingService extends Service implements LocationListene
private int phoneBattery = 100; private int phoneBattery = 100;
private final Handler handler = new Handler(); private final Handler handler = new Handler();
private final HashMap<String, Long> lastPeriodicLog = new HashMap<String, Long>(); private final HashMap<String, Long> lastPeriodicLog = new HashMap<String, Long>();
/** Items reported left behind, address -> display name, until acknowledged. */
private final HashMap<String, String> leftBehind = new HashMap<String, String>();
private boolean alertLoopRunning;
private final BroadcastReceiver receiver = new BroadcastReceiver() { private final BroadcastReceiver receiver = new BroadcastReceiver() {
public void onReceive(Context context, Intent intent) { public void onReceive(Context context, Intent intent) {
@@ -52,10 +45,6 @@ public class BluetoothTrackingService extends Service implements LocationListene
rangeModeActive = intent.getBooleanExtra("active", false); rangeModeActive = intent.getBooleanExtra("active", false);
return; return;
} }
if (ACTION_ACK_LEFT_BEHIND.equals(action)) {
acknowledgeLeftBehind(intent.getStringExtra(EXTRA_ADDRESS));
return;
}
if (ACTION_CONFIG_CHANGED.equals(action)) { if (ACTION_CONFIG_CHANGED.equals(action)) {
restartBackgroundScan(); restartBackgroundScan();
configureLocation(); configureLocation();
@@ -95,7 +84,7 @@ public class BluetoothTrackingService extends Service implements LocationListene
DeviceRecord previous = db.get(address); DeviceRecord previous = db.get(address);
db.disconnected(address, broadcastName, now, fix, infer(previous)); db.disconnected(address, broadcastName, now, fix, infer(previous));
if (previous != null && previous.tracked == 1 && previous.connected == 1) if (previous != null && previous.tracked == 1 && previous.connected == 1)
markLeftBehind(previous, infer(previous)); showDisconnectAlert(previous, infer(previous));
captureFresh(address, now); captureFresh(address, now);
configureLocation(); configureLocation();
} else if (BluetoothDevice.ACTION_FOUND.equals(action)) { } else if (BluetoothDevice.ACTION_FOUND.equals(action)) {
@@ -186,7 +175,6 @@ public class BluetoothTrackingService extends Service implements LocationListene
filter.addAction(Intent.ACTION_BATTERY_CHANGED); filter.addAction(Intent.ACTION_BATTERY_CHANGED);
filter.addAction(ACTION_CONFIG_CHANGED); filter.addAction(ACTION_CONFIG_CHANGED);
filter.addAction(ACTION_RANGE_MODE); filter.addAction(ACTION_RANGE_MODE);
filter.addAction(ACTION_ACK_LEFT_BEHIND);
registerReceiver(receiver, filter); registerReceiver(receiver, filter);
receiverRegistered = true; receiverRegistered = true;
locationManager = (LocationManager) getSystemService(LOCATION_SERVICE); locationManager = (LocationManager) getSystemService(LOCATION_SERVICE);
@@ -204,7 +192,6 @@ public class BluetoothTrackingService extends Service implements LocationListene
public void onDestroy() { public void onDestroy() {
handler.removeCallbacks(scanTask); handler.removeCallbacks(scanTask);
handler.removeCallbacks(alertTask);
try { if (bluetooth != null) bluetooth.stopLeScan(leScanCallback); } try { if (bluetooth != null) bluetooth.stopLeScan(leScanCallback); }
catch (Exception ignored) { } catch (Exception ignored) { }
if (receiverRegistered) unregisterReceiver(receiver); if (receiverRegistered) unregisterReceiver(receiver);
@@ -431,99 +418,16 @@ public class BluetoothTrackingService extends Service implements LocationListene
return first.getTime() >= second.getTime() ? first : second; return first.getTime() >= second.getTime() ? first : second;
} }
/**
* FLAG_IMMUTABLE (API 23+) is referenced by value because this module
* still compiles against SDK 21.
*/
private int pendingFlags() {
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
if (Build.VERSION.SDK_INT >= 23) flags |= 0x04000000;
return flags;
}
private int alertId(String address) {
return 1000 + Math.abs(address.hashCode() % 100000);
}
private PendingIntent acknowledgeIntent(String address) {
Intent ack = new Intent(ACTION_ACK_LEFT_BEHIND).setPackage(getPackageName());
if (address != null) ack.putExtra(EXTRA_ADDRESS, address);
return PendingIntent.getBroadcast(this,
address == null ? 0 : alertId(address), ack, pendingFlags());
}
/**
* Records the item as left behind and keeps alerting until the user
* taps "Acknowledge Left Behind" - a swipe alone does not clear it.
*/
private void markLeftBehind(DeviceRecord record, String reason) {
if (record == null || record.address == null) return;
leftBehind.put(record.address, record.displayName());
showDisconnectAlert(record, reason);
updateForegroundNotification();
startAlertLoop();
}
private void acknowledgeLeftBehind(String address) {
NotificationManager manager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
if (address == null) {
for (String pending : leftBehind.keySet().toArray(new String[0])) {
try { manager.cancel(alertId(pending)); } catch (Exception ignored) { }
}
leftBehind.clear();
} else {
leftBehind.remove(address);
try { manager.cancel(alertId(address)); } catch (Exception ignored) { }
}
if (leftBehind.isEmpty()) {
handler.removeCallbacks(alertTask);
alertLoopRunning = false;
}
updateForegroundNotification();
}
private void startAlertLoop() {
if (alertLoopRunning) return;
alertLoopRunning = true;
handler.postDelayed(alertTask, ALERT_REPEAT_MS);
}
/** Re-fires every outstanding alert until each one is acknowledged. */
private final Runnable alertTask = new Runnable() {
public void run() {
if (leftBehind.isEmpty()) { alertLoopRunning = false; return; }
for (String address : leftBehind.keySet().toArray(new String[0])) {
DeviceRecord record = db.get(address);
if (record == null) continue;
showDisconnectAlert(record, "Still left behind - not acknowledged");
}
updateForegroundNotification();
handler.postDelayed(this, ALERT_REPEAT_MS);
}
};
private void updateForegroundNotification() {
try {
NotificationManager manager =
(NotificationManager) getSystemService(NOTIFICATION_SERVICE);
manager.notify(NOTIFICATION_ID, buildNotification());
} catch (Exception ignored) { }
}
private void showDisconnectAlert(DeviceRecord record, String reason) { private void showDisconnectAlert(DeviceRecord record, String reason) {
Intent open = new Intent(this, MainActivity.class); Intent open = new Intent(this, MainActivity.class);
PendingIntent pending = PendingIntent.getActivity(this, record.address.hashCode(), open, PendingIntent pending = PendingIntent.getActivity(this, record.address.hashCode(), open,
pendingFlags()); PendingIntent.FLAG_UPDATE_CURRENT);
Notification.Builder builder = new Notification.Builder(this) Notification.Builder builder = new Notification.Builder(this)
.setSmallIcon(R.drawable.ic_launcher) .setSmallIcon(R.drawable.ic_launcher)
.setContentTitle(record.displayName() + " left behind") .setContentTitle(record.displayName() + " disconnected")
.setContentText(reason == null ? "Your tracked item is no longer connected" : reason) .setContentText(reason == null ? "Your tracked item is no longer connected" : reason)
.setContentIntent(pending).setAutoCancel(false).setOngoing(true) .setContentIntent(pending).setAutoCancel(true)
.setDefaults(Notification.DEFAULT_ALL).setPriority(Notification.PRIORITY_HIGH); .setDefaults(Notification.DEFAULT_ALL).setPriority(Notification.PRIORITY_HIGH);
if (Build.VERSION.SDK_INT >= 16) {
builder.addAction(R.drawable.ic_launcher, "Acknowledge Left Behind",
acknowledgeIntent(record.address));
}
NotificationManager manager = (NotificationManager)getSystemService(NOTIFICATION_SERVICE); NotificationManager manager = (NotificationManager)getSystemService(NOTIFICATION_SERVICE);
if (Build.VERSION.SDK_INT >= 26) { if (Build.VERSION.SDK_INT >= 26) {
try { try {
@@ -541,24 +445,12 @@ public class BluetoothTrackingService extends Service implements LocationListene
private Notification buildNotification() { private Notification buildNotification() {
Intent open = new Intent(this, MainActivity.class); Intent open = new Intent(this, MainActivity.class);
PendingIntent pending = PendingIntent.getActivity(this, 0, open, pendingFlags()); PendingIntent pending = PendingIntent.getActivity(this, 0, open,
int outstanding = leftBehind.size(); PendingIntent.FLAG_UPDATE_CURRENT);
String title = outstanding > 0
? outstanding + (outstanding == 1 ? " item left behind" : " items left behind")
: "Bluetooth Item Finder";
String text = outstanding > 0
? "Alerting until acknowledged: " + describeLeftBehind()
: "Watching your tracked items";
Notification.Builder builder = new Notification.Builder(this) Notification.Builder builder = new Notification.Builder(this)
.setSmallIcon(R.drawable.ic_launcher).setContentTitle(title) .setSmallIcon(R.drawable.ic_launcher).setContentTitle("Bluetooth Item Finder")
.setContentText(text).setOngoing(true) .setContentText("Watching your tracked items").setOngoing(true)
.setContentIntent(pending); .setContentIntent(pending);
if (Build.VERSION.SDK_INT >= 16 && outstanding > 0) {
builder.addAction(R.drawable.ic_launcher,
outstanding == 1 ? "Acknowledge Left Behind" : "Acknowledge All",
acknowledgeIntent(outstanding == 1
? leftBehind.keySet().iterator().next() : null));
}
if (Build.VERSION.SDK_INT >= 26) { if (Build.VERSION.SDK_INT >= 26) {
try { try {
Class<?> channelClass = Class.forName("android.app.NotificationChannel"); Class<?> channelClass = Class.forName("android.app.NotificationChannel");
@@ -575,14 +467,5 @@ public class BluetoothTrackingService extends Service implements LocationListene
} }
return builder.build(); return builder.build();
} }
private String describeLeftBehind() {
StringBuilder names = new StringBuilder();
for (String name : leftBehind.values()) {
if (names.length() > 0) names.append(", ");
names.append(name);
}
return names.toString();
}
} }
@@ -1,121 +0,0 @@
package com.wytehat.btlogger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
/**
* Ordering for the tracked item list.
*
* Default order is newest sighting first. Items that have not been seen for
* a long time - or that carry no sighting timestamp at all - are pushed to
* the bottom and stay there in both sort directions, so the reverse toggle
* never buries a live item under a pile of stale ones.
*/
final class DeviceSort {
/** A device unseen for longer than this sinks to the bottom. */
static final long STALE_AFTER_MS = 24L * 60L * 60L * 1000L;
private DeviceSort() {
}
/**
* Last moment the device was actually observed. Deliberately ignores
* updatedAt, which also moves when the user edits a name or colour.
*/
static long lastSeen(DeviceRecord record) {
if (record == null) {
return 0L;
}
long seen = record.lastRssiAt;
if (record.connectedAt > seen) {
seen = record.connectedAt;
}
if (record.disconnectedAt > seen) {
seen = record.disconnectedAt;
}
return seen;
}
static boolean isStale(DeviceRecord record, long now) {
long seen = lastSeen(record);
return seen <= 0L ||
now - seen > STALE_AFTER_MS;
}
/**
* @param reverse false = newest first (default), true = oldest first.
*/
static List<DeviceRecord> sorted(List<DeviceRecord> rows, boolean reverse) {
ArrayList<DeviceRecord> fresh = new ArrayList<DeviceRecord>();
ArrayList<DeviceRecord> stale = new ArrayList<DeviceRecord>();
if (rows == null) {
return fresh;
}
long now = System.currentTimeMillis();
for (int i = 0; i < rows.size(); i++) {
DeviceRecord record = rows.get(i);
if (record == null) {
continue;
}
if (isStale(record, now)) {
stale.add(record);
} else {
fresh.add(record);
}
}
Comparator<DeviceRecord> newestFirst =
new Comparator<DeviceRecord>() {
public int compare(DeviceRecord left, DeviceRecord right) {
long a = lastSeen(left);
long b = lastSeen(right);
if (a == b) {
return left.displayName()
.compareToIgnoreCase(right.displayName());
}
return a > b ? -1 : 1;
}
};
Collections.sort(fresh, newestFirst);
Collections.sort(stale, newestFirst);
if (reverse) {
Collections.reverse(fresh);
Collections.reverse(stale);
}
ArrayList<DeviceRecord> result =
new ArrayList<DeviceRecord>(fresh.size() + stale.size());
result.addAll(fresh);
/*
* Stale entries are appended last in both directions.
*/
result.addAll(stale);
return result;
}
}
@@ -17,7 +17,6 @@ public class MainActivity extends Activity implements ItemActionListener {
private static final int CAMERA = 200; private static final int CAMERA = 200;
private TrackerDatabase db; private TrackerDatabase db;
private DeviceListAdapter adapter; private DeviceListAdapter adapter;
private boolean reverseSort;
private boolean registered; private boolean registered;
private DeviceRecord editing; private DeviceRecord editing;
private final Handler refreshHandler = new Handler(); private final Handler refreshHandler = new Handler();
@@ -47,18 +46,6 @@ public class MainActivity extends Activity implements ItemActionListener {
public void onClick(View v) { startActivity(new Intent(MainActivity.this, public void onClick(View v) { startActivity(new Intent(MainActivity.this,
MapActivity.class)); } MapActivity.class)); }
}); });
reverseSort = getSharedPreferences("list_settings", 0)
.getBoolean("reverse_sort", false);
updateSortButton();
findViewById(R.id.sort_button).setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
reverseSort = !reverseSort;
getSharedPreferences("list_settings", 0).edit()
.putBoolean("reverse_sort", reverseSort).apply();
updateSortButton();
reload();
}
});
findViewById(R.id.settings_button).setOnClickListener(new View.OnClickListener() { findViewById(R.id.settings_button).setOnClickListener(new View.OnClickListener() {
public void onClick(View v) { showSettings(); } public void onClick(View v) { showSettings(); }
}); });
@@ -360,21 +347,7 @@ public class MainActivity extends Activity implements ItemActionListener {
PackageManager.PERMISSION_GRANTED) list.add(permission); PackageManager.PERMISSION_GRANTED) list.add(permission);
} }
private void reload() { private void reload() { if (adapter != null) adapter.setRows(db.tracked()); }
if (adapter == null) return;
adapter.setRows(DeviceSort.sorted(db.tracked(), reverseSort));
}
/*
* Stale items stay pinned to the bottom in both directions, so the
* label only describes how the recently seen items are ordered.
*/
private void updateSortButton() {
android.widget.Button button =
(android.widget.Button) findViewById(R.id.sort_button);
if (button == null) return;
button.setText(reverseSort ? "Oldest first" : "Newest first");
}
private void startTracker() { private void startTracker() {
Intent intent = new Intent(this, BluetoothTrackingService.class); Intent intent = new Intent(this, BluetoothTrackingService.class);
@@ -1,5 +1,3 @@
package com.wytehat.btlogger;
import android.app.Activity; import android.app.Activity;
import android.app.DatePickerDialog; import android.app.DatePickerDialog;
import android.app.TimePickerDialog; import android.app.TimePickerDialog;
@@ -16,8 +14,6 @@ import android.location.LocationListener;
import android.location.LocationManager; import android.location.LocationManager;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.view.View; import android.view.View;
import android.webkit.WebView; import android.webkit.WebView;
import android.webkit.WebViewClient; import android.webkit.WebViewClient;
@@ -37,8 +33,6 @@ import java.util.Date;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class MapActivity extends Activity public class MapActivity extends Activity
implements SensorEventListener { implements SensorEventListener {
@@ -55,11 +49,10 @@ implements SensorEventListener {
private LinearLayout filterPanel; private LinearLayout filterPanel;
private ScrollView filterScroll; private ScrollView filterScroll;
private Spinner categorySpinner; private Spinner categorySpinner;
private Spinner deviceSpinner; private Spinner deviceSpinner;
private Spinner resolutionSpinner;
private CheckBox showAll; private CheckBox showAll;
@@ -78,54 +71,15 @@ implements SensorEventListener {
private final ArrayList<String> deviceLabels = private final ArrayList<String> deviceLabels =
new ArrayList<String>(); new ArrayList<String>();
private final ArrayList<String> resolutionLabels = private String categoryFilter = "Trackers / Tags";
new ArrayList<String>(); private String deviceFilter = "All";
/*
* Time-bucket widths for the trail sampler.
* 0 = every stored point (still capped by TrackPointSampler).
*/
private static final long[] RESOLUTION_VALUES = {
0L,
15L * 60L * 1000L,
60L * 60L * 1000L,
120L * 60L * 1000L
};
private static final String[] RESOLUTION_LABELS = {
"All points",
"Every 15 min",
"Every 1 hour",
"Every 2 hours"
};
private volatile String categoryFilter = "Trackers / Tags";
private volatile String deviceFilter = "All";
private boolean rebuildingCategories; private boolean rebuildingCategories;
private boolean rebuildingDevices; private boolean rebuildingDevices;
private boolean rebuildingResolution = true; private boolean loadingMap;
private volatile boolean loadingMap;
private volatile long historyFrom; private long historyFrom;
private volatile long historyTo; private long historyTo;
/*
* Selected trail resolution, read by the background loader.
*/
private volatile long sampleInterval = RESOLUTION_VALUES[0];
/*
* Map data (SQLite reads + HTML generation) is built off the main
* thread; only the WebView load and the spinner refresh run on it.
*/
private final ExecutorService mapExecutor =
Executors.newSingleThreadExecutor();
private final Handler ui =
new Handler(Looper.getMainLooper());
private volatile boolean destroyed;
private LocationManager locationManager; private LocationManager locationManager;
private SensorManager sensorManager; private SensorManager sensorManager;
@@ -256,6 +210,7 @@ implements SensorEventListener {
dp(4) dp(4)
); );
/* /*
* Category. * Category.
*/ */
@@ -319,66 +274,6 @@ implements SensorEventListener {
compactParams() compactParams()
); );
/*
* Trail resolution (downsampling).
*/
LinearLayout resolutionRow =
new LinearLayout(this);
resolutionRow.setOrientation(
LinearLayout.HORIZONTAL
);
resolutionRow.setGravity(
android.view.Gravity.CENTER_VERTICAL
);
TextView resolutionLabel =
smallLabel("Trail detail");
resolutionRow.addView(
resolutionLabel,
new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
);
resolutionSpinner = new Spinner(this);
for (int i = 0;
i < RESOLUTION_LABELS.length;
i++) {
resolutionLabels.add(
RESOLUTION_LABELS[i]
);
}
ArrayAdapter<String> resolutionAdapter =
new ArrayAdapter<String>(
this,
android.R.layout.simple_spinner_item,
resolutionLabels
);
resolutionAdapter.setDropDownViewResource(
android.R.layout.simple_spinner_dropdown_item
);
resolutionSpinner.setAdapter(
resolutionAdapter
);
resolutionRow.addView(
resolutionSpinner,
new LinearLayout.LayoutParams(
0,
dp(38),
1
)
);
filterPanel.addView(resolutionRow);
/* /*
* Time. * Time.
*/ */
@@ -495,8 +390,8 @@ implements SensorEventListener {
filterPanel.addView(showAll); filterPanel.addView(showAll);
/* /*
* The panel is scrollable and bounded to a third of the * Bounded to a third of the screen and scrollable, so every
* screen so every control stays reachable on short screens. * control stays reachable on short screens.
*/ */
filterScroll = new ScrollView(this); filterScroll = new ScrollView(this);
@@ -518,6 +413,7 @@ implements SensorEventListener {
1 1
) )
); );
/* /*
* WebView map. * WebView map.
*/ */
@@ -681,50 +577,6 @@ implements SensorEventListener {
} }
); );
/*
* Trail resolution: applied immediately so the user can compare
* densities without reopening the panel.
*/
resolutionSpinner.setOnItemSelectedListener(
new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(
AdapterView<?> parent,
View view,
int position,
long id) {
if (rebuildingResolution) {
return;
}
if (position < 0 ||
position >=
RESOLUTION_VALUES.length) {
return;
}
if (sampleInterval ==
RESOLUTION_VALUES[position]) {
return;
}
sampleInterval =
RESOLUTION_VALUES[position];
reloadMap();
}
@Override
public void onNothingSelected(
AdapterView<?> parent) {
}
}
);
fromButton.setOnClickListener( fromButton.setOnClickListener(
new View.OnClickListener() { new View.OnClickListener() {
@@ -839,8 +691,6 @@ implements SensorEventListener {
updateTimeButtons(); updateTimeButtons();
rebuildingResolution = false;
rebuildDeviceFilter(); rebuildDeviceFilter();
/* /*
@@ -860,8 +710,8 @@ implements SensorEventListener {
return new LinearLayout.LayoutParams( return new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT,
dp(42) dp(38)
); );
} }
private TextView smallLabel(String text) { private TextView smallLabel(String text) {
@@ -1053,24 +903,8 @@ implements SensorEventListener {
private void rebuildDeviceFilter() { private void rebuildDeviceFilter() {
rebuildDeviceFilter( List<DeviceRecord> available =
getMapDevices( getMapDevices();
showAll.isChecked()
)
);
}
/*
* Main thread only. Accepts an already loaded device list so the
* background loader does not repeat the query.
*/
private void rebuildDeviceFilter(
List<DeviceRecord> available) {
if (available == null) {
available =
new ArrayList<DeviceRecord>();
}
ArrayList<String> nextAddresses = ArrayList<String> nextAddresses =
new ArrayList<String>(); new ArrayList<String>();
@@ -1169,19 +1003,10 @@ implements SensorEventListener {
rebuildingDevices = false; rebuildingDevices = false;
} }
/* private List<DeviceRecord> getMapDevices() {
* Safe to call from a worker thread: the caller passes the checkbox
* state in instead of reading the view here.
*/
private List<DeviceRecord> getMapDevices(
boolean includeAll) {
TrackerDatabase database = db;
List<DeviceRecord> all = List<DeviceRecord> all =
database == null db.all();
? null
: database.all();
ArrayList<DeviceRecord> result = ArrayList<DeviceRecord> result =
new ArrayList<DeviceRecord>(); new ArrayList<DeviceRecord>();
@@ -1205,7 +1030,7 @@ implements SensorEventListener {
* Normal map: * Normal map:
* only explicitly map-enabled devices. * only explicitly map-enabled devices.
*/ */
if (!includeAll && if (!showAll.isChecked() &&
record.mapEnabled != 1) { record.mapEnabled != 1) {
continue; continue;
@@ -1214,7 +1039,7 @@ implements SensorEventListener {
/* /*
* Normal map excludes unknown entries. * Normal map excludes unknown entries.
*/ */
if (!includeAll && if (!showAll.isChecked() &&
isUnknown(record)) { isUnknown(record)) {
continue; continue;
@@ -1405,166 +1230,73 @@ implements SensorEventListener {
loadingMap = true; loadingMap = true;
mapReady = false; mapReady = false;
/* try {
* View state is snapshotted on the main thread; everything after
* this point (SQLite reads, trail sampling, HTML generation) runs
* on the worker.
*/
final boolean includeAll =
showAll.isChecked();
mapExecutor.execute( List<DeviceRecord> available =
new Runnable() { getMapDevices();
@Override refreshMapCategories(
public void run() { available
);
List<DeviceRecord> available = null; /*
String html = null; * Rebuild device list after category
* changes.
*/
rebuildDeviceFilter();
try { ArrayList<DeviceRecord> rows =
new ArrayList<DeviceRecord>();
available = for (int i = 0;
getMapDevices(includeAll); i < available.size();
i++) {
String device = deviceFilter; DeviceRecord record =
available.get(i);
/* if (record == null ||
* The selected device may have gone away since !record.hasLocation) {
* the last load; fall back to showing all. continue;
*/
if (!"All".equals(device) &&
!containsAddress(
available,
device
)) {
device = "All";
}
ArrayList<DeviceRecord> rows =
new ArrayList<DeviceRecord>();
for (int i = 0;
i < available.size();
i++) {
DeviceRecord record =
available.get(i);
if (record == null ||
!record.hasLocation) {
continue;
}
if (!categoryMatches(record)) {
continue;
}
if (!"All".equals(device) &&
!device.equals(
record.address
)) {
continue;
}
rows.add(record);
}
html =
buildHtml(
rows,
focus,
device
);
} catch (Exception ignored) {
}
final List<DeviceRecord> loaded =
available;
final String content = html;
ui.post(
new Runnable() {
@Override
public void run() {
try {
if (destroyed ||
web == null) {
return;
}
refreshMapCategories(
loaded
);
/*
* Rebuild device list after
* category changes.
*/
rebuildDeviceFilter(
loaded
);
if (content != null) {
web.loadDataWithBaseURL(
"https://unpkg.com/",
content,
"text/html",
"UTF-8",
null
);
}
} finally {
loadingMap = false;
}
}
}
);
} }
if (!categoryMatches(record)) {
continue;
}
if (!"All".equals(deviceFilter) &&
!deviceFilter.equals(
record.address
)) {
continue;
}
rows.add(record);
} }
);
}
private boolean containsAddress( String html =
List<DeviceRecord> records, buildHtml(
String address) { rows,
focus
);
if (records == null || web.loadDataWithBaseURL(
address == null) { "https://unpkg.com/",
html,
"text/html",
"UTF-8",
null
);
return false; } finally {
loadingMap = false;
} }
for (int i = 0;
i < records.size();
i++) {
DeviceRecord record =
records.get(i);
if (record != null &&
address.equals(record.address)) {
return true;
}
}
return false;
} }
private String buildHtml( private String buildHtml(
List<DeviceRecord> rows, List<DeviceRecord> rows,
String selectedAddress, String selectedAddress) {
String device) {
StringBuilder script = StringBuilder script =
new StringBuilder(); new StringBuilder();
@@ -1573,7 +1305,7 @@ implements SensorEventListener {
* Historical data is loaded ONLY for a * Historical data is loaded ONLY for a
* specifically selected device. * specifically selected device.
*/ */
if (!"All".equals(device)) { if (!"All".equals(deviceFilter)) {
for (int i = 0; for (int i = 0;
i < rows.size(); i < rows.size();
@@ -1582,32 +1314,17 @@ implements SensorEventListener {
DeviceRecord record = DeviceRecord record =
rows.get(i); rows.get(i);
if (!device.equals( if (!deviceFilter.equals(
record.address record.address
)) { )) {
continue; continue;
} }
TrackerDatabase database = db;
List<LocationPoint> history = List<LocationPoint> history =
database == null db.history(
? null record.address,
: database.history( historyFrom,
record.address, historyTo
historyFrom,
historyTo
);
/*
* Dense histories are downsampled here, on the
* worker thread, so the WebView never receives
* thousands of markers.
*/
history =
TrackPointSampler.sample(
history,
sampleInterval
); );
appendHistory( appendHistory(
@@ -2627,13 +2344,6 @@ implements SensorEventListener {
@Override @Override
protected void onDestroy() { protected void onDestroy() {
destroyed = true;
try {
mapExecutor.shutdownNow();
} catch (Exception ignored) {
}
try { try {
if (web != null) { if (web != null) {
@@ -1,132 +0,0 @@
package com.wytehat.btlogger;
import java.util.ArrayList;
import java.util.List;
/**
* Downsamples dense GPS history before it is turned into map markers.
*
* Time-bucket sampling: the timeline is cut into fixed buckets and one
* representative point (the most accurate fix) survives per bucket. The very
* first and last fixes are always kept so the trail keeps its real endpoints.
*
* A hard ceiling is applied afterwards - even "All Points" cannot hand the
* WebView an unbounded number of circle markers.
*/
final class TrackPointSampler {
/** Ceiling on rendered points, applied regardless of the chosen interval. */
static final int MAX_RENDERED_POINTS = 750;
private TrackPointSampler() {
}
/**
* @param bucketMillis bucket width, or 0 for "All Points" (cap only).
*/
static List<LocationPoint> sample(List<LocationPoint> points, long bucketMillis) {
if (points == null || points.size() < 3) {
return points;
}
List<LocationPoint> reduced =
bucketMillis > 0
? bucket(points, bucketMillis)
: points;
return cap(reduced);
}
private static List<LocationPoint> bucket(List<LocationPoint> points, long bucketMillis) {
ArrayList<LocationPoint> result = new ArrayList<LocationPoint>();
LocationPoint best = null;
long currentBucket = 0;
for (int i = 0; i < points.size(); i++) {
LocationPoint point = points.get(i);
if (point == null) {
continue;
}
long bucketIndex = point.timestamp / bucketMillis;
if (best == null) {
best = point;
currentBucket = bucketIndex;
continue;
}
if (bucketIndex != currentBucket) {
result.add(best);
best = point;
currentBucket = bucketIndex;
continue;
}
if (moreAccurate(point, best)) {
best = point;
}
}
if (best != null) {
result.add(best);
}
/*
* Always terminate on the newest fix - that is the one the user cares
* about when reading a trail.
*/
LocationPoint last = points.get(points.size() - 1);
if (last != null &&
(result.isEmpty() ||
result.get(result.size() - 1) != last)) {
result.add(last);
}
return result;
}
private static List<LocationPoint> cap(List<LocationPoint> points) {
int size = points.size();
if (size <= MAX_RENDERED_POINTS) {
return points;
}
int stride = (size + MAX_RENDERED_POINTS - 1) / MAX_RENDERED_POINTS;
ArrayList<LocationPoint> result = new ArrayList<LocationPoint>();
for (int i = 0; i < size; i += stride) {
result.add(points.get(i));
}
LocationPoint last = points.get(size - 1);
if (result.isEmpty() ||
result.get(result.size() - 1) != last) {
result.add(last);
}
return result;
}
private static boolean moreAccurate(LocationPoint candidate, LocationPoint current) {
if (candidate.accuracy <= 0) {
return false;
}
return current.accuracy <= 0 ||
candidate.accuracy < current.accuracy;
}
}
@@ -17,25 +17,10 @@ public class TrackerDatabase extends SQLiteOpenHelper {
super(context, DB_NAME, null, DB_VERSION); super(context, DB_NAME, null, DB_VERSION);
} }
public synchronized List<LocationPoint> history(String address, long historyFrom, long historyTo) public List<LocationPoint> history(String address, long historyFrom, long historyTo)
{ {
ArrayList<LocationPoint> result = new ArrayList<LocationPoint>(); // TODO: Implement this method
if (address == null) return result; return null;
Cursor cursor = getReadableDatabase().query("location_history", null,
"address=? AND logged_at>=? AND logged_at<=?",
new String[] { address, String.valueOf(historyFrom), String.valueOf(historyTo) },
null, null, "logged_at ASC");
try {
while (cursor.moveToNext()) {
LocationPoint point = new LocationPoint();
point.latitude = cursor.getDouble(cursor.getColumnIndex("latitude"));
point.longitude = cursor.getDouble(cursor.getColumnIndex("longitude"));
point.accuracy = cursor.getFloat(cursor.getColumnIndex("accuracy"));
point.timestamp = cursor.getLong(cursor.getColumnIndex("logged_at"));
result.add(point);
}
} finally { cursor.close(); }
return result;
} }
public void onCreate(SQLiteDatabase db) { public void onCreate(SQLiteDatabase db) {
+1 -4
View File
@@ -6,10 +6,7 @@
<Button android:id="@+id/map_all_button" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="MAP"/> <Button android:id="@+id/map_all_button" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="MAP"/>
<Button android:id="@+id/settings_button" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="SETTINGS"/> <Button android:id="@+id/settings_button" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="SETTINGS"/>
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical"> <TextView android:id="@+id/tracking_status" android:text="@string/tracking_status" android:layout_width="match_parent" android:layout_height="wrap_content" android:paddingLeft="12dp" android:paddingBottom="8dp" android:textColor="#2878ff"/>
<TextView android:id="@+id/tracking_status" android:text="@string/tracking_status" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:paddingLeft="12dp" android:paddingBottom="8dp" android:textColor="#2878ff"/>
<Button android:id="@+id/sort_button" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Newest first" android:textSize="10sp" android:layout_marginRight="8dp"/>
</LinearLayout>
<ListView android:id="@+id/device_list" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:dividerHeight="10dp" android:padding="8dp"/> <ListView android:id="@+id/device_list" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:dividerHeight="10dp" android:padding="8dp"/>
<TextView android:id="@+id/empty_view" android:text="No tracked items yet. Use ADD / PAIR to choose an item." android:gravity="center" android:layout_width="match_parent" android:layout_height="match_parent"/> <TextView android:id="@+id/empty_view" android:text="No tracked items yet. Use ADD / PAIR to choose an item." android:gravity="center" android:layout_width="match_parent" android:layout_height="match_parent"/>
</LinearLayout> </LinearLayout>