diff --git a/app/src/main/java/com/wytehat/btlogger/BluetoothTrackingService.java b/app/src/main/java/com/wytehat/btlogger/BluetoothTrackingService.java index 9c7b26b..e3408dc 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); + pendingFlags()); 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) + .setContentIntent(pending).setAutoCancel(false).setOngoing(true) .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); 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, pendingFlags()); + 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(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) { 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(); + } } diff --git a/app/src/main/java/com/wytehat/btlogger/DeviceSort.java b/app/src/main/java/com/wytehat/btlogger/DeviceSort.java new file mode 100644 index 0000000..f48162d --- /dev/null +++ b/app/src/main/java/com/wytehat/btlogger/DeviceSort.java @@ -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 sorted(List rows, boolean reverse) { + + ArrayList fresh = new ArrayList(); + ArrayList stale = new ArrayList(); + + 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 newestFirst = + new Comparator() { + + 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 result = + new ArrayList(fresh.size() + stale.size()); + + result.addAll(fresh); + + /* + * Stale entries are appended last in both directions. + */ + result.addAll(stale); + + return result; + } +} diff --git a/app/src/main/java/com/wytehat/btlogger/MainActivity.java b/app/src/main/java/com/wytehat/btlogger/MainActivity.java index e254c03..7eebb2a 100644 --- a/app/src/main/java/com/wytehat/btlogger/MainActivity.java +++ b/app/src/main/java/com/wytehat/btlogger/MainActivity.java @@ -17,6 +17,7 @@ public class MainActivity extends Activity implements ItemActionListener { private static final int CAMERA = 200; private TrackerDatabase db; private DeviceListAdapter adapter; + private boolean reverseSort; private boolean registered; private DeviceRecord editing; 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, 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() { public void onClick(View v) { showSettings(); } }); @@ -347,7 +360,21 @@ public class MainActivity extends Activity implements ItemActionListener { 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() { Intent intent = new Intent(this, BluetoothTrackingService.class); diff --git a/app/src/main/java/com/wytehat/btlogger/MapActivity.java b/app/src/main/java/com/wytehat/btlogger/MapActivity.java index 55be9d6..37f3d90 100644 --- a/app/src/main/java/com/wytehat/btlogger/MapActivity.java +++ b/app/src/main/java/com/wytehat/btlogger/MapActivity.java @@ -26,6 +26,7 @@ import android.widget.ArrayAdapter; import android.widget.Button; import android.widget.CheckBox; import android.widget.LinearLayout; +import android.widget.ScrollView; import android.widget.Spinner; import android.widget.TextView; @@ -54,6 +55,8 @@ implements SensorEventListener { private LinearLayout filterPanel; + + private ScrollView filterScroll; private Spinner categorySpinner; private Spinner deviceSpinner; private Spinner resolutionSpinner; @@ -253,8 +256,6 @@ implements SensorEventListener { dp(4) ); - filterPanel.setVisibility(View.GONE); - /* * Category. */ @@ -321,11 +322,27 @@ implements SensorEventListener { /* * Trail resolution (downsampling). */ + LinearLayout resolutionRow = + new LinearLayout(this); + + resolutionRow.setOrientation( + LinearLayout.HORIZONTAL + ); + + resolutionRow.setGravity( + android.view.Gravity.CENTER_VERTICAL + ); + TextView resolutionLabel = smallLabel("Trail detail"); - filterPanel.addView(resolutionLabel); - + resolutionRow.addView( + resolutionLabel, + new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + ); resolutionSpinner = new Spinner(this); for (int i = 0; @@ -352,11 +369,16 @@ implements SensorEventListener { resolutionAdapter ); - filterPanel.addView( + resolutionRow.addView( resolutionSpinner, - compactParams() + new LinearLayout.LayoutParams( + 0, + dp(38), + 1 + ) ); + filterPanel.addView(resolutionRow); /* * Time. */ @@ -472,14 +494,30 @@ implements SensorEventListener { filterPanel.addView(showAll); - root.addView( - filterPanel, - new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ) - ); + /* + * The panel is scrollable and bounded to a third of the + * screen so every control stays reachable on short screens. + */ + 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. */ @@ -521,13 +559,13 @@ implements SensorEventListener { } ); - root.addView( - web, - new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - 0, - 1 - ) + root.addView( + web, + new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + 0, + 2 + ) ); setContentView(root); @@ -561,19 +599,12 @@ implements SensorEventListener { @Override public void onClick(View view) { - if (filterPanel.getVisibility() - == View.VISIBLE) { - - filterPanel.setVisibility( - View.GONE - ); - - } else { - - filterPanel.setVisibility( - View.VISIBLE - ); - } + filterScroll.setVisibility( + filterScroll.getVisibility() + == View.VISIBLE + ? View.GONE + : View.VISIBLE + ); } } ); @@ -775,8 +806,8 @@ implements SensorEventListener { /* * Collapse after applying. */ - filterPanel.setVisibility( - View.GONE + filterScroll.setVisibility( + View.GONE ); } } diff --git a/app/src/main/res/layout/main.xml b/app/src/main/res/layout/main.xml index 015ed02..acb3b40 100644 --- a/app/src/main/res/layout/main.xml +++ b/app/src/main/res/layout/main.xml @@ -6,7 +6,10 @@