diff --git a/app/src/main/java/com/wytehat/btlogger/BluetoothRadarView.java b/app/src/main/java/com/wytehat/btlogger/BluetoothRadarView.java index 2a7f8b3..63f2993 100644 --- a/app/src/main/java/com/wytehat/btlogger/BluetoothRadarView.java +++ b/app/src/main/java/com/wytehat/btlogger/BluetoothRadarView.java @@ -39,6 +39,18 @@ public class BluetoothRadarView extends View { private float zoom = 1.0f; private boolean suppressTap; private double sweepAngle = -90.0; + + /* Draw-time scratch, reused every frame -- see onDraw. */ + private android.graphics.RadialGradient glow; + private float glowCx = -1f, glowCy = -1f, glowR = -1f; + private final ArrayList ordered = new ArrayList(); + private final Comparator byAngle = new Comparator() { + public int compare(Target first, Target second) { + float a = angleFor(first.address); + float b = angleFor(second.address); + return a < b ? -1 : (a > b ? 1 : 0); + } + }; public BluetoothRadarView(Context context) { super(context); scaleDetector = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() { @@ -68,7 +80,10 @@ public class BluetoothRadarView extends View { if (!targetAngles.containsKey(target.address)) targetAngles.put(target.address, Float.valueOf(findOpenAngle(target.address))); } - targets.clear(); targets.addAll(values); invalidate(); + targets.clear(); targets.addAll(values); + // While scanning, onDraw already re-posts itself every 33ms; an + // extra invalidate per update just forces redundant frames. + if (!scanning) invalidate(); } protected synchronized void onDraw(Canvas canvas) { @@ -81,9 +96,15 @@ public class BluetoothRadarView extends View { float glowRadius = radius + 34f; float edgeStop = radius / glowRadius; paint.setStyle(Paint.Style.FILL); - paint.setShader(new android.graphics.RadialGradient(cx, cy, glowRadius, - new int[] { Color.TRANSPARENT, Color.argb(5, 35, 180, 110), Color.argb(24, 45, 225, 140), Color.argb(48, 55, 235, 150), Color.argb(18, 35, 190, 120), Color.TRANSPARENT }, - new float[] { 0f, 0.55f, edgeStop - 0.05f, edgeStop, edgeStop + 0.10f, 1f }, android.graphics.Shader.TileMode.CLAMP)); + // Rebuilt only when the geometry moves. This used to allocate a + // six-stop gradient and two arrays on every frame of a 30fps sweep. + if (glow == null || glowCx != cx || glowCy != cy || glowR != glowRadius) { + glow = new android.graphics.RadialGradient(cx, cy, glowRadius, + new int[] { Color.TRANSPARENT, Color.argb(5, 35, 180, 110), Color.argb(24, 45, 225, 140), Color.argb(48, 55, 235, 150), Color.argb(18, 35, 190, 120), Color.TRANSPARENT }, + new float[] { 0f, 0.55f, edgeStop - 0.05f, edgeStop, edgeStop + 0.10f, 1f }, android.graphics.Shader.TileMode.CLAMP); + glowCx = cx; glowCy = cy; glowR = glowRadius; + } + paint.setShader(glow); canvas.drawCircle(cx, cy, glowRadius, paint); paint.setShader(null); paint.setStyle(Paint.Style.STROKE); @@ -123,14 +144,11 @@ public class BluetoothRadarView extends View { paint.setTextAlign(Paint.Align.CENTER); paint.setTextSize(12 * getResources().getDisplayMetrics().scaledDensity); canvas.drawText("YOU", cx, cy + 24, paint); - ArrayList ordered = new ArrayList(targets); - Collections.sort(ordered, new Comparator() { - public int compare(Target first, Target second) { - float a = angleFor(first.address); - float b = angleFor(second.address); - return a < b ? -1 : (a > b ? 1 : 0); - } - }); + // Reused across frames; a fresh list and comparator per frame is + // pure garbage at 30fps. + ordered.clear(); + ordered.addAll(targets); + Collections.sort(ordered, byAngle); float density = getResources().getDisplayMetrics().scaledDensity; for (int i = 0; i < ordered.size(); i++) { Target target = ordered.get(i); diff --git a/app/src/main/java/com/wytehat/btlogger/DeviceManagerActivity.java b/app/src/main/java/com/wytehat/btlogger/DeviceManagerActivity.java index 9c2e5b0..7e85b9d 100644 --- a/app/src/main/java/com/wytehat/btlogger/DeviceManagerActivity.java +++ b/app/src/main/java/com/wytehat/btlogger/DeviceManagerActivity.java @@ -41,6 +41,7 @@ import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -71,6 +72,10 @@ public class DeviceManagerActivity extends Activity { private TextView pairedHeader; private ListView pairedList; private final Handler handler = new Handler(); + + /** How long one device may re-advertise before it is persisted again. */ + private static final long PERSIST_INTERVAL_MS = 3000L; + private final HashMap lastPersisted = new HashMap(); private final LinkedHashMap signals = new LinkedHashMap(); private final ArrayList paired = new ArrayList(); @@ -282,7 +287,7 @@ public class DeviceManagerActivity extends Activity { if (!bluetooth.isEnabled()) { startActivity(new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)); return; } - stopScan(); signals.clear(); refreshCategoryOptions(); scanning = true; radar.setScanning(true); scanButton.setText("STOP RADAR SCAN"); updateRadar(); + stopScan(); signals.clear(); lastPersisted.clear(); refreshCategoryOptions(); scanning = true; radar.setScanning(true); scanButton.setText("STOP RADAR SCAN"); updateRadar(); scanStatus.setText("Scanning… tap a dot when your item appears"); if (Build.VERSION.SDK_INT >= 21) { leScanner = bluetooth.getBluetoothLeScanner(); @@ -312,6 +317,7 @@ public class DeviceManagerActivity extends Activity { private void acceptScanResult(ScanResult result) { if (result == null) return; + if (touchOnly(result.getDevice(), result.getRssi())) return; String advertisedName = null; String companyVendor = null; int appearance = -1; @@ -355,14 +361,40 @@ public class DeviceManagerActivity extends Activity { acceptSignal(device, rssi, null, null, -1, null, -1, null); } + /** + * SCAN_MODE_LOW_LATENCY hands back the same beacon many times a second, + * and the ScanCallback is delivered on the main looper. Running the full + * path for each one put two SQLite reads, four writes, a hex dump of the + * advertisement and a spinner rebuild on the UI thread per advertisement + * - which is what starves the radar sweep. + * + * A device already on screen only needs its signal strength and its + * last-seen stamp refreshed until the interval is up. Returns true when + * the caller can stop there. + */ + private synchronized boolean touchOnly(BluetoothDevice device, int rssi) { + if (device == null || rssi == 0 || rssi == 127) return false; + String address = safeAddress(device); + SignalEntry entry = signals.get(address); + if (entry == null) return false; + Long last = lastPersisted.get(address); + long now = System.currentTimeMillis(); + if (last == null || now - last.longValue() >= PERSIST_INTERVAL_MS) return false; + entry.rssi = rssi; + entry.seenAt = now; + return true; + } + private synchronized void acceptSignal(BluetoothDevice device, int rssi, String advertisedName, String companyVendor, int appearance, String broadcastType, int companyId, String advertisementHex) { if (device == null || rssi == 0 || rssi == 127) return; String address = safeAddress(device); SignalEntry entry = signals.get(address); - if (entry == null) { entry = new SignalEntry(); signals.put(address, entry); } + boolean firstSighting = entry == null; + if (firstSighting) { entry = new SignalEntry(); signals.put(address, entry); } entry.device = device; entry.rssi = rssi; entry.seenAt = System.currentTimeMillis(); + lastPersisted.put(address, Long.valueOf(entry.seenAt)); String cachedName = safeName(device); if (useful(advertisedName)) entry.broadcastName = advertisedName; else if (!useful(entry.broadcastName) && useful(cachedName)) entry.broadcastName = cachedName; @@ -381,9 +413,14 @@ public class DeviceManagerActivity extends Activity { entry.vendorName, entry.category, entry.broadcastType, appearance, companyId, advertisementHex, recentObservationFix()); String vendor = entry.vendorName; if (vendor != null) database.setVendor(address, vendor); - runOnUiThread(new Runnable() { - public void run() { refreshCategoryOptions(); updateRadar(); pairedAdapter.notifyDataSetChanged(); } - }); + // pruneTask already runs exactly these three every second while a + // scan is live, so doing them per advertisement was duplicated work. + // A device the user has not seen yet still refreshes immediately. + if (firstSighting) { + runOnUiThread(new Runnable() { + public void run() { refreshCategoryOptions(); updateRadar(); pairedAdapter.notifyDataSetChanged(); } + }); + } } private TrackerDatabase.Fix recentObservationFix() { @@ -412,7 +449,10 @@ public class DeviceManagerActivity extends Activity { synchronized (DeviceManagerActivity.this) { for (Map.Entry item : signals.entrySet()) if (item.getValue().seenAt < cutoff) expired.add(item.getKey()); - for (int i = 0; i < expired.size(); i++) signals.remove(expired.get(i)); + for (int i = 0; i < expired.size(); i++) { + signals.remove(expired.get(i)); + lastPersisted.remove(expired.get(i)); + } if (selectedSignal != null && !signals.containsValue(selectedSignal)) { selectedSignal = null; radarActions.setVisibility(View.GONE); } diff --git a/app/src/main/java/com/wytehat/btlogger/LocationPoint.java b/app/src/main/java/com/wytehat/btlogger/LocationPoint.java index ed4b739..df6936c 100644 --- a/app/src/main/java/com/wytehat/btlogger/LocationPoint.java +++ b/app/src/main/java/com/wytehat/btlogger/LocationPoint.java @@ -1,8 +1,22 @@ package com.wytehat.btlogger; public class LocationPoint { + + /** The item linked to the phone at this position. */ + public static final String EVENT_CONNECTED = "connected"; + + /** The link to the phone dropped at this position. */ + public static final String EVENT_DISCONNECTED = "disconnected"; + public double latitude; public double longitude; public float accuracy; public long timestamp; + + /** + * Why this point was recorded: EVENT_CONNECTED, EVENT_DISCONNECTED, or + * null for a plain position refresh while the link state was unchanged. + * Null for every row logged before the column existed. + */ + public String event; } diff --git a/app/src/main/java/com/wytehat/btlogger/MapHtmlBuilder.java b/app/src/main/java/com/wytehat/btlogger/MapHtmlBuilder.java index 59a6a2b..4759f43 100644 --- a/app/src/main/java/com/wytehat/btlogger/MapHtmlBuilder.java +++ b/app/src/main/java/com/wytehat/btlogger/MapHtmlBuilder.java @@ -34,9 +34,31 @@ public final class MapHtmlBuilder { public int aggressiveness = HistoryNormalizer.DEFAULT_AGGRESSIVENESS; } + /** Diameter of a device's own marker; history points draw at half. */ + private static final int DEVICE_MARKER_SIZE = 22; + + /** A location-locked device draws smaller, since its position is fixed. */ + private static final int LOCKED_MARKER_SIZE = 10; + private MapHtmlBuilder() { } + /** The round white-bordered marker, shared by devices and history points. */ + private static String divIcon( + String color, + int markerSize, + int iconSize, + int anchor) { + + return "L.divIcon({className:'',html:\"" + + "
" + + "
\",iconSize:[" + iconSize + "," + iconSize + + "],iconAnchor:[" + anchor + "," + anchor + "]})"; + } + public static String build( List rows, String selectedAddress, @@ -90,7 +112,7 @@ public final class MapHtmlBuilder { appendTrail(script, i, history, color); } - appendHistoryPoints(script, record, history, color, options); + appendHistoryPoints(script, i, record, history, color, options); } appendDeviceMarker( @@ -206,26 +228,39 @@ public final class MapHtmlBuilder { private static void appendHistoryPoints( StringBuilder script, + int index, DeviceRecord record, List history, String color, Options options) { + /* + * Same marker the device itself uses, at half size. Defined once per + * device and shared by all of its history points -- a divIcon is a + * DOM node per marker, so building one per point would be costly on + * a long trail. + */ + int markerSize = DEVICE_MARKER_SIZE / 2; + int iconSize = markerSize + 6; + int anchor = iconSize / 2; + + script.append("var hicon").append(index).append('=') + .append(divIcon(color, markerSize, iconSize, anchor)) + .append(';'); + for (int p = 0; p < history.size(); p++) { LocationPoint point = history.get(p); if (point == null) continue; - script.append("L.circleMarker([") + script.append("L.marker([") .append(point.latitude) .append(',') .append(point.longitude) - .append("],{radius:5,color:'") - .append(color) - .append("',fillColor:'") - .append(color) - .append("',fillOpacity:.9,weight:2})") + .append("],{icon:hicon") + .append(index) + .append("})") .append(".addTo(map).bindPopup('") .append(MapFormat.js(historyPopup(record, point, options))) .append("');"); @@ -245,30 +280,18 @@ public final class MapHtmlBuilder { boolean locked = record.locationLocked == 1; - int markerSize = locked ? 10 : 22; + int markerSize = locked + ? LOCKED_MARKER_SIZE + : DEVICE_MARKER_SIZE; + int iconSize = markerSize + 6; int anchor = iconSize / 2; String markerColor = locked ? "#00A86B" : color; - script.append("var icon").append(index) - .append("=L.divIcon({className:'',html:\"") - .append("
") - .append("
\",iconSize:[") - .append(iconSize) - .append(',') - .append(iconSize) - .append("],iconAnchor:[") - .append(anchor) - .append(',') - .append(anchor) - .append("]});"); + script.append("var icon").append(index).append('=') + .append(divIcon(markerColor, markerSize, iconSize, anchor)) + .append(';'); script.append("var marker").append(index) .append("=L.marker([") @@ -492,6 +515,20 @@ public final class MapHtmlBuilder { : "Previous recorded point") .append(""); + /* + * Why the point exists at all. Rows logged before the event column + * was added carry no event, and a plain position refresh carries + * none either, so say nothing rather than guess. + */ + if (LocationPoint.EVENT_CONNECTED.equals(point.event)) { + + html.append("
🔗 Connected to the phone here"); + + } else if (LocationPoint.EVENT_DISCONNECTED.equals(point.event)) { + + html.append("
💔 Link to the phone dropped here"); + } + long time = HistoryNormalizer.pointTime(point); if (time > 0) { diff --git a/app/src/main/java/com/wytehat/btlogger/TrackerDatabase.java b/app/src/main/java/com/wytehat/btlogger/TrackerDatabase.java index 74bce15..1828e3b 100644 --- a/app/src/main/java/com/wytehat/btlogger/TrackerDatabase.java +++ b/app/src/main/java/com/wytehat/btlogger/TrackerDatabase.java @@ -11,7 +11,7 @@ import java.util.Locale; public class TrackerDatabase extends SQLiteOpenHelper { private static final String DB_NAME = "bluetooth_tracker.db"; - private static final int DB_VERSION = 15; + private static final int DB_VERSION = 16; public TrackerDatabase(Context context) { super(context, DB_NAME, null, DB_VERSION); @@ -32,6 +32,7 @@ public class TrackerDatabase extends SQLiteOpenHelper { point.longitude = cursor.getDouble(cursor.getColumnIndex("longitude")); point.accuracy = cursor.getFloat(cursor.getColumnIndex("accuracy")); point.timestamp = cursor.getLong(cursor.getColumnIndex("logged_at")); + point.event = cursor.getString(cursor.getColumnIndex("event")); result.add(point); } } finally { cursor.close(); } @@ -100,6 +101,8 @@ public class TrackerDatabase extends SQLiteOpenHelper { addColumn(db, "ALTER TABLE devices ADD COLUMN track_broadcast INTEGER DEFAULT 0"); if (oldVersion < 14) createSupportingTables(db); if (oldVersion < 15) seedVendors(db); + if (oldVersion < 16) + addColumn(db, "ALTER TABLE location_history ADD COLUMN event TEXT"); } private void addColumn(SQLiteDatabase db, String sql) { @@ -108,7 +111,7 @@ public class TrackerDatabase extends SQLiteOpenHelper { private void createSupportingTables(SQLiteDatabase db) { db.execSQL("CREATE TABLE IF NOT EXISTS location_history (id INTEGER PRIMARY KEY AUTOINCREMENT," + - "address TEXT,latitude REAL,longitude REAL,accuracy REAL,logged_at INTEGER)"); + "address TEXT,latitude REAL,longitude REAL,accuracy REAL,logged_at INTEGER,event TEXT)"); db.execSQL("CREATE INDEX IF NOT EXISTS history_address_time ON location_history(address,logged_at)"); db.execSQL("CREATE TABLE IF NOT EXISTS range_samples (id INTEGER PRIMARY KEY AUTOINCREMENT," + "address TEXT,session_id TEXT,sampled_at INTEGER,latitude REAL,longitude REAL," + @@ -166,7 +169,8 @@ public class TrackerDatabase extends SQLiteOpenHelper { if (old != null) preserveProfile(values, old, fix); getWritableDatabase().insertWithOnConflict("devices", null, values, SQLiteDatabase.CONFLICT_REPLACE); - if (fix != null) insertHistory(address, fix, time); + if (fix != null) + insertHistory(address, fix, time, LocationPoint.EVENT_CONNECTED); } public synchronized void disconnected(String address, String broadcast, long time, @@ -177,7 +181,8 @@ public class TrackerDatabase extends SQLiteOpenHelper { values.put("reason", reason); values.put("is_tracked", 1); getWritableDatabase().update("devices", values, "address=?", new String[] { address }); - if (fix != null) insertHistory(address, fix, time); + if (fix != null) + insertHistory(address, fix, time, LocationPoint.EVENT_DISCONNECTED); } public synchronized void telemetry(String address, String broadcast, Integer battery, @@ -242,16 +247,21 @@ public class TrackerDatabase extends SQLiteOpenHelper { values.put("has_location", 1); values.put("updated_at", time); getWritableDatabase().update("devices", values, "address=?", new String[] { address }); - insertHistory(address, fix, time); + insertHistory(address, fix, time, null); } - private void insertHistory(String address, Fix fix, long time) { + /** + * @param event LocationPoint.EVENT_CONNECTED or EVENT_DISCONNECTED for a + * link state change, null for a plain position refresh. + */ + private void insertHistory(String address, Fix fix, long time, String event) { ContentValues values = new ContentValues(); values.put("address", address); values.put("latitude", fix.latitude); values.put("longitude", fix.longitude); values.put("accuracy", fix.accuracy); values.put("logged_at", time); + values.put("event", event); getWritableDatabase().insert("location_history", null, values); } @@ -437,6 +447,7 @@ public class TrackerDatabase extends SQLiteOpenHelper { point.longitude = cursor.getDouble(cursor.getColumnIndex("longitude")); point.accuracy = cursor.getFloat(cursor.getColumnIndex("accuracy")); point.timestamp = cursor.getLong(cursor.getColumnIndex("logged_at")); + point.event = cursor.getString(cursor.getColumnIndex("event")); result.add(point); } } finally { cursor.close(); }