feat(alerts): keep alerting until left-behind items are acknowledged

This commit is contained in:
n0tst3v3
2026-08-19 00:57:05 -06:00
parent a48cc91680
commit 8d3d571954
@@ -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<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() {
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();
}
}