feat(track): record which devices are paired, and report it on upload

The app knew about bonded devices only in passing - syncPairedDevices marked
them tracked and threw the bond state away - so nothing downstream could tell
a paired device from one merely seen. Adds devices.paired (schema 17), set
from ACTION_BOND_STATE_CHANGED and from the bonded list at startup, and sends
it as a `paired` column on every uploaded row.

The flag is its own column rather than inferred from is_tracked, which answers
a different question: a device can be tracked without ever having been paired.

syncPairedDevices clears the flag before re-asserting it from getBondedDevices.
A device unpaired while the service was dead never broadcast BOND_NONE, so the
bonded list is the only reliable answer at startup, and without the clear such
a device would stay marked paired forever.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
n0tst3v3
2026-08-19 18:03:11 -06:00
co-authored by Claude Opus 5
parent 07504555b6
commit 60feffc256
4 changed files with 45 additions and 9 deletions
@@ -117,11 +117,14 @@ public class BluetoothTrackingService extends Service implements LocationListene
} else if (BluetoothDevice.ACTION_BOND_STATE_CHANGED.equals(action)) { } else if (BluetoothDevice.ACTION_BOND_STATE_CHANGED.equals(action)) {
int state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, int state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE,
BluetoothDevice.ERROR); BluetoothDevice.ERROR);
if (state == BluetoothDevice.BOND_BONDED) if (state == BluetoothDevice.BOND_BONDED) {
db.markTracked(address, broadcastName); db.markTracked(address, broadcastName);
else if (state == BluetoothDevice.BOND_NONE) db.setPaired(address, true);
} else if (state == BluetoothDevice.BOND_NONE) {
db.setPaired(address, false);
db.disconnected(address, broadcastName, now, fix, db.disconnected(address, broadcastName, now, fix,
"Manual / Intentional Disconnect"); "Manual / Intentional Disconnect");
}
} }
resolveVendor(address, -1); resolveVendor(address, -1);
notifyChanged(); notifyChanged();
@@ -370,10 +373,16 @@ public class BluetoothTrackingService extends Service implements LocationListene
private void syncPairedDevices() { private void syncPairedDevices() {
if (bluetooth == null) return; if (bluetooth == null) return;
try { try {
// The bonded list is the whole truth, so the flag is cleared first:
// a device unpaired while the service was dead sent no BOND_NONE
// broadcast, and would otherwise stay marked as paired forever.
db.clearPaired();
for (BluetoothDevice device : bluetooth.getBondedDevices()) { for (BluetoothDevice device : bluetooth.getBondedDevices()) {
String value = address(device); String value = address(device);
if (value != null) { if (value != null) {
db.markTracked(value, name(device)); db.markTracked(value, name(device));
db.setPaired(value, true);
resolveVendor(value, -1); resolveVendor(value, -1);
} }
} }
@@ -14,6 +14,9 @@ public class DeviceRecord {
public int locationLocked; public int locationLocked;
public int showTrail = 1; public int showTrail = 1;
public int trackBroadcast; public int trackBroadcast;
/** 1 when this device is bonded to the phone. */
public int paired;
public String reason; public String reason;
public String photoUri; public String photoUri;
public String iconPreset; public String iconPreset;
@@ -51,7 +51,7 @@ public final class ServerUploader {
/** Columns the server's importer recognises without any aliasing. */ /** Columns the server's importer recognises without any aliasing. */
private static final String HEADER = private static final String HEADER =
"mac_address,device_name,device_class,rssi,latitude,longitude,timestamp"; "mac_address,device_name,device_class,rssi,latitude,longitude,timestamp,paired";
private static final int CONNECT_TIMEOUT_MS = 20000; private static final int CONNECT_TIMEOUT_MS = 20000;
private static final int READ_TIMEOUT_MS = 60000; private static final int READ_TIMEOUT_MS = 60000;
@@ -177,7 +177,8 @@ public final class ServerUploader {
record.latitude, record.longitude, record.latitude, record.longitude,
record.updatedAt > 0 record.updatedAt > 0
? record.updatedAt ? record.updatedAt
: System.currentTimeMillis()); : System.currentTimeMillis(),
record.paired == 1);
} }
List<LocationPoint> history = database.history(record.address, 0L); List<LocationPoint> history = database.history(record.address, 0L);
@@ -198,7 +199,8 @@ public final class ServerUploader {
// empty rather than repeating the device's latest reading, // empty rather than repeating the device's latest reading,
// which was measured somewhere else entirely. // which was measured somewhere else entirely.
appendRow(csv, stamp, record.address, name, category, "", appendRow(csv, stamp, record.address, name, category, "",
point.latitude, point.longitude, time); point.latitude, point.longitude, time,
record.paired == 1);
} }
} }
@@ -208,7 +210,7 @@ public final class ServerUploader {
private static void appendRow(StringBuilder csv, SimpleDateFormat stamp, private static void appendRow(StringBuilder csv, SimpleDateFormat stamp,
String mac, String name, String category, String mac, String name, String category,
String rssi, double latitude, String rssi, double latitude,
double longitude, long time) { double longitude, long time, boolean paired) {
csv.append(field(mac)).append(',') csv.append(field(mac)).append(',')
.append(field(name)).append(',') .append(field(name)).append(',')
@@ -216,7 +218,8 @@ public final class ServerUploader {
.append(rssi).append(',') .append(rssi).append(',')
.append(latitude).append(',') .append(latitude).append(',')
.append(longitude).append(',') .append(longitude).append(',')
.append(stamp.format(new Date(time))) .append(stamp.format(new Date(time))).append(',')
.append(paired ? "1" : "0")
.append('\n'); .append('\n');
} }
@@ -11,7 +11,7 @@ import java.util.Locale;
public class TrackerDatabase extends SQLiteOpenHelper { public class TrackerDatabase extends SQLiteOpenHelper {
private static final String DB_NAME = "bluetooth_tracker.db"; private static final String DB_NAME = "bluetooth_tracker.db";
private static final int DB_VERSION = 16; private static final int DB_VERSION = 17;
public TrackerDatabase(Context context) { public TrackerDatabase(Context context) {
super(context, DB_NAME, null, DB_VERSION); super(context, DB_NAME, null, DB_VERSION);
@@ -47,7 +47,7 @@ public class TrackerDatabase extends SQLiteOpenHelper {
"reason TEXT,updated_at INTEGER DEFAULT 0,photo_uri TEXT,icon_preset TEXT DEFAULT 'bag'," + "reason TEXT,updated_at INTEGER DEFAULT 0,photo_uri TEXT,icon_preset TEXT DEFAULT 'bag'," +
"gps_polling_rate INTEGER DEFAULT 900000,pin_color TEXT DEFAULT '#E53935'," + "gps_polling_rate INTEGER DEFAULT 900000,pin_color TEXT DEFAULT '#E53935'," +
"tracking_priority INTEGER DEFAULT 15,photo_path TEXT,device_gps_interval INTEGER DEFAULT 15," + "tracking_priority INTEGER DEFAULT 15,photo_path TEXT,device_gps_interval INTEGER DEFAULT 15," +
"is_tracked INTEGER DEFAULT 0,custom_name TEXT,broadcast_name TEXT,vendor_name TEXT,tx_power_1m INTEGER DEFAULT -59,path_loss_exponent REAL DEFAULT 2.7,last_rssi_at INTEGER DEFAULT 0,device_category TEXT,category_manual INTEGER DEFAULT 0,vendor_manual INTEGER DEFAULT 0,map_enabled INTEGER DEFAULT 0,live_enabled INTEGER DEFAULT 0,location_locked INTEGER DEFAULT 0,show_trail INTEGER DEFAULT 1,track_broadcast INTEGER DEFAULT 0)"); "is_tracked INTEGER DEFAULT 0,custom_name TEXT,broadcast_name TEXT,vendor_name TEXT,tx_power_1m INTEGER DEFAULT -59,path_loss_exponent REAL DEFAULT 2.7,last_rssi_at INTEGER DEFAULT 0,device_category TEXT,category_manual INTEGER DEFAULT 0,vendor_manual INTEGER DEFAULT 0,map_enabled INTEGER DEFAULT 0,live_enabled INTEGER DEFAULT 0,location_locked INTEGER DEFAULT 0,show_trail INTEGER DEFAULT 1,track_broadcast INTEGER DEFAULT 0,paired INTEGER DEFAULT 0)");
createSupportingTables(db); createSupportingTables(db);
seedVendors(db); seedVendors(db);
} }
@@ -103,6 +103,8 @@ public class TrackerDatabase extends SQLiteOpenHelper {
if (oldVersion < 15) seedVendors(db); if (oldVersion < 15) seedVendors(db);
if (oldVersion < 16) if (oldVersion < 16)
addColumn(db, "ALTER TABLE location_history ADD COLUMN event TEXT"); addColumn(db, "ALTER TABLE location_history ADD COLUMN event TEXT");
if (oldVersion < 17)
addColumn(db, "ALTER TABLE devices ADD COLUMN paired INTEGER DEFAULT 0");
} }
private void addColumn(SQLiteDatabase db, String sql) { private void addColumn(SQLiteDatabase db, String sql) {
@@ -331,6 +333,24 @@ public class TrackerDatabase extends SQLiteOpenHelper {
getWritableDatabase().update("devices", values, "address=?", new String[] { address }); getWritableDatabase().update("devices", values, "address=?", new String[] { address });
} }
/**
* Records whether this device is bonded to the phone.
*
* Kept as its own column rather than inferred from is_tracked: a device can
* be tracked without ever having been paired, and the two answer different
* questions.
*/
/** Clears the flag on every device, before the bonded list re-asserts it. */
public synchronized void clearPaired() {
ContentValues values = new ContentValues(); values.put("paired", 0);
getWritableDatabase().update("devices", values, null, null);
}
public synchronized void setPaired(String address, boolean paired) {
ContentValues values = new ContentValues(); values.put("paired", paired ? 1 : 0);
getWritableDatabase().update("devices", values, "address=?", new String[] { address });
}
public synchronized void setMapEnabled(String address, boolean enabled) { public synchronized void setMapEnabled(String address, boolean enabled) {
ContentValues values = new ContentValues(); values.put("map_enabled", enabled ? 1 : 0); ContentValues values = new ContentValues(); values.put("map_enabled", enabled ? 1 : 0);
getWritableDatabase().update("devices", values, "address=?", new String[] { address }); getWritableDatabase().update("devices", values, "address=?", new String[] { address });
@@ -517,6 +537,7 @@ public class TrackerDatabase extends SQLiteOpenHelper {
r.locationLocked = integer(c, "location_locked"); r.locationLocked = integer(c, "location_locked");
r.showTrail = integer(c, "show_trail"); r.showTrail = integer(c, "show_trail");
r.trackBroadcast = integer(c, "track_broadcast"); r.trackBroadcast = integer(c, "track_broadcast");
r.paired = integer(c, "paired");
r.connected = integer(c, "connected"); r.connected = integer(c, "connected");
r.connectedAt = number(c, "connected_at"); r.connectedAt = number(c, "connected_at");
r.disconnectedAt = number(c, "disconnected_at"); r.disconnectedAt = number(c, "disconnected_at");