From 8d3d571954bb6880f438728c7735b48271eb4443 Mon Sep 17 00:00:00 2001 From: n0tst3v3 Date: Wed, 19 Aug 2026 00:57:05 -0600 Subject: [PATCH] feat(alerts): keep alerting until left-behind items are acknowledged --- .../btlogger/BluetoothTrackingService.java | 129 +++++++++++++++++- 1 file changed, 123 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/wytehat/btlogger/BluetoothTrackingService.java b/app/src/main/java/com/wytehat/btlogger/BluetoothTrackingService.java index 9c7b26b..f29007b 100644 --- a/app/src/main/java/com/wytehat/btlogger/BluetoothTrackingService.java +++ b/app/src/main/java/com/wytehat/btlogger/BluetoothTrackingService.java @@ -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_CONFIG_CHANGED = "com.wytehat.btlogger.CONFIG_CHANGED"; 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 String CHANNEL_ID = "bluetooth_tracking"; 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 final Handler handler = new Handler(); private final HashMap lastPeriodicLog = new HashMap(); + /** Items reported left behind, address -> display name, until acknowledged. */ + private final HashMap leftBehind = new HashMap(); + private boolean alertLoopRunning; private final BroadcastReceiver receiver = new BroadcastReceiver() { public void onReceive(Context context, Intent intent) { @@ -45,6 +52,10 @@ public class BluetoothTrackingService extends Service implements LocationListene rangeModeActive = intent.getBooleanExtra("active", false); return; } + if (ACTION_ACK_LEFT_BEHIND.equals(action)) { + acknowledgeLeftBehind(intent.getStringExtra(EXTRA_ADDRESS)); + return; + } if (ACTION_CONFIG_CHANGED.equals(action)) { restartBackgroundScan(); configureLocation(); @@ -84,7 +95,7 @@ public class BluetoothTrackingService extends Service implements LocationListene DeviceRecord previous = db.get(address); db.disconnected(address, broadcastName, now, fix, infer(previous)); if (previous != null && previous.tracked == 1 && previous.connected == 1) - showDisconnectAlert(previous, infer(previous)); + markLeftBehind(previous, infer(previous)); captureFresh(address, now); configureLocation(); } 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(ACTION_CONFIG_CHANGED); filter.addAction(ACTION_RANGE_MODE); + filter.addAction(ACTION_ACK_LEFT_BEHIND); registerReceiver(receiver, filter); receiverRegistered = true; locationManager = (LocationManager) getSystemService(LOCATION_SERVICE); @@ -192,6 +204,7 @@ public class BluetoothTrackingService extends Service implements LocationListene public void onDestroy() { handler.removeCallbacks(scanTask); + handler.removeCallbacks(alertTask); try { if (bluetooth != null) bluetooth.stopLeScan(leScanCallback); } catch (Exception ignored) { } if (receiverRegistered) unregisterReceiver(receiver); @@ -418,16 +431,99 @@ public class BluetoothTrackingService extends Service implements LocationListene 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) { Intent open = new Intent(this, MainActivity.class); PendingIntent pending = PendingIntent.getActivity(this, record.address.hashCode(), open, PendingIntent.FLAG_UPDATE_CURRENT); Notification.Builder builder = new Notification.Builder(this) .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) .setContentIntent(pending).setAutoCancel(true) .setDefaults(Notification.DEFAULT_ALL).setPriority(Notification.PRIORITY_HIGH); + if (Build.VERSION.SDK_INT >= 16) { + builder.addAction(0, "Acknowledge Left Behind", + acknowledgeIntent(record.address)); + } NotificationManager manager = (NotificationManager)getSystemService(NOTIFICATION_SERVICE); if (Build.VERSION.SDK_INT >= 26) { try { @@ -445,12 +541,24 @@ public class BluetoothTrackingService extends Service implements LocationListene private Notification buildNotification() { Intent open = new Intent(this, MainActivity.class); - PendingIntent pending = PendingIntent.getActivity(this, 0, open, - PendingIntent.FLAG_UPDATE_CURRENT); + PendingIntent pending = PendingIntent.getActivity(this, 0, open, 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) - .setSmallIcon(R.drawable.ic_launcher).setContentTitle("Bluetooth Item Finder") - .setContentText("Watching your tracked items").setOngoing(true) + .setSmallIcon(R.drawable.ic_launcher).setContentTitle(title) + .setContentText(text).setOngoing(true) .setContentIntent(pending); + if (Build.VERSION.SDK_INT >= 16 && outstanding > 0) { + builder.addAction(0, + outstanding == 1 ? "Acknowledge Left Behind" : "Acknowledge All", + acknowledgeIntent(outstanding == 1 + ? leftBehind.keySet().iterator().next() : null)); + } if (Build.VERSION.SDK_INT >= 26) { try { Class channelClass = Class.forName("android.app.NotificationChannel"); @@ -467,5 +575,14 @@ public class BluetoothTrackingService extends Service implements LocationListene } 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(); + } }