feat(ui): update item sorting, persistent notifications, and compact map sampling UI

This commit is contained in:
n0tst3v3
2026-08-19 00:18:43 -06:00
parent c15dd28f3b
commit ca3aaaf163
5 changed files with 344 additions and 45 deletions
@@ -25,6 +25,10 @@ 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";
@@ -37,6 +41,9 @@ 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) {
@@ -45,6 +52,10 @@ 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();
@@ -84,7 +95,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)
showDisconnectAlert(previous, infer(previous)); markLeftBehind(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)) {
@@ -175,6 +186,7 @@ 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);
@@ -192,6 +204,7 @@ 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);
@@ -418,16 +431,99 @@ 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,
PendingIntent.FLAG_UPDATE_CURRENT); pendingFlags());
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() + " disconnected") .setContentTitle(record.displayName() + " left behind")
.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(true) .setContentIntent(pending).setAutoCancel(false).setOngoing(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 {
@@ -445,12 +541,24 @@ 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, PendingIntent pending = PendingIntent.getActivity(this, 0, open, pendingFlags());
PendingIntent.FLAG_UPDATE_CURRENT); int outstanding = leftBehind.size();
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("Bluetooth Item Finder") .setSmallIcon(R.drawable.ic_launcher).setContentTitle(title)
.setContentText("Watching your tracked items").setOngoing(true) .setContentText(text).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");
@@ -467,5 +575,14 @@ 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();
}
} }
@@ -0,0 +1,121 @@
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,6 +17,7 @@ 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();
@@ -46,6 +47,18 @@ 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(); }
}); });
@@ -347,7 +360,21 @@ public class MainActivity extends Activity implements ItemActionListener {
PackageManager.PERMISSION_GRANTED) list.add(permission); PackageManager.PERMISSION_GRANTED) list.add(permission);
} }
private void reload() { if (adapter != null) adapter.setRows(db.tracked()); } private void reload() {
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);
@@ -26,6 +26,7 @@ import android.widget.ArrayAdapter;
import android.widget.Button; import android.widget.Button;
import android.widget.CheckBox; import android.widget.CheckBox;
import android.widget.LinearLayout; import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.Spinner; import android.widget.Spinner;
import android.widget.TextView; import android.widget.TextView;
@@ -54,6 +55,8 @@ implements SensorEventListener {
private LinearLayout filterPanel; private LinearLayout filterPanel;
private ScrollView filterScroll;
private Spinner categorySpinner; private Spinner categorySpinner;
private Spinner deviceSpinner; private Spinner deviceSpinner;
private Spinner resolutionSpinner; private Spinner resolutionSpinner;
@@ -253,8 +256,6 @@ implements SensorEventListener {
dp(4) dp(4)
); );
filterPanel.setVisibility(View.GONE);
/* /*
* Category. * Category.
*/ */
@@ -321,11 +322,27 @@ implements SensorEventListener {
/* /*
* Trail resolution (downsampling). * Trail resolution (downsampling).
*/ */
LinearLayout resolutionRow =
new LinearLayout(this);
resolutionRow.setOrientation(
LinearLayout.HORIZONTAL
);
resolutionRow.setGravity(
android.view.Gravity.CENTER_VERTICAL
);
TextView resolutionLabel = TextView resolutionLabel =
smallLabel("Trail detail"); smallLabel("Trail detail");
filterPanel.addView(resolutionLabel); resolutionRow.addView(
resolutionLabel,
new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
);
resolutionSpinner = new Spinner(this); resolutionSpinner = new Spinner(this);
for (int i = 0; for (int i = 0;
@@ -352,11 +369,16 @@ implements SensorEventListener {
resolutionAdapter resolutionAdapter
); );
filterPanel.addView( resolutionRow.addView(
resolutionSpinner, resolutionSpinner,
compactParams() new LinearLayout.LayoutParams(
0,
dp(38),
1
)
); );
filterPanel.addView(resolutionRow);
/* /*
* Time. * Time.
*/ */
@@ -472,14 +494,30 @@ implements SensorEventListener {
filterPanel.addView(showAll); filterPanel.addView(showAll);
root.addView( /*
filterPanel, * The panel is scrollable and bounded to a third of the
new LinearLayout.LayoutParams( * screen so every control stays reachable on short screens.
LinearLayout.LayoutParams.MATCH_PARENT, */
LinearLayout.LayoutParams.WRAP_CONTENT filterScroll = new ScrollView(this);
)
);
filterScroll.setVisibility(View.GONE);
filterScroll.addView(
filterPanel,
new android.widget.FrameLayout.LayoutParams(
android.widget.FrameLayout.LayoutParams.MATCH_PARENT,
android.widget.FrameLayout.LayoutParams.WRAP_CONTENT
)
);
root.addView(
filterScroll,
new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
0,
1
)
);
/* /*
* WebView map. * WebView map.
*/ */
@@ -521,13 +559,13 @@ implements SensorEventListener {
} }
); );
root.addView( root.addView(
web, web,
new LinearLayout.LayoutParams( new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT,
0, 0,
1 2
) )
); );
setContentView(root); setContentView(root);
@@ -561,19 +599,12 @@ implements SensorEventListener {
@Override @Override
public void onClick(View view) { public void onClick(View view) {
if (filterPanel.getVisibility() filterScroll.setVisibility(
== View.VISIBLE) { filterScroll.getVisibility()
== View.VISIBLE
filterPanel.setVisibility( ? View.GONE
View.GONE : View.VISIBLE
); );
} else {
filterPanel.setVisibility(
View.VISIBLE
);
}
} }
} }
); );
@@ -775,8 +806,8 @@ implements SensorEventListener {
/* /*
* Collapse after applying. * Collapse after applying.
*/ */
filterPanel.setVisibility( filterScroll.setVisibility(
View.GONE View.GONE
); );
} }
} }
+4 -1
View File
@@ -6,7 +6,10 @@
<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>
<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"/> <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="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>