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)) {
int state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE,
BluetoothDevice.ERROR);
if (state == BluetoothDevice.BOND_BONDED)
if (state == BluetoothDevice.BOND_BONDED) {
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,
"Manual / Intentional Disconnect");
}
}
resolveVendor(address, -1);
notifyChanged();
@@ -370,10 +373,16 @@ public class BluetoothTrackingService extends Service implements LocationListene
private void syncPairedDevices() {
if (bluetooth == null) return;
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()) {
String value = address(device);
if (value != null) {
db.markTracked(value, name(device));
db.setPaired(value, true);
resolveVendor(value, -1);
}
}
@@ -14,6 +14,9 @@ public class DeviceRecord {
public int locationLocked;
public int showTrail = 1;
public int trackBroadcast;
/** 1 when this device is bonded to the phone. */
public int paired;
public String reason;
public String photoUri;
public String iconPreset;
@@ -51,7 +51,7 @@ public final class ServerUploader {
/** Columns the server's importer recognises without any aliasing. */
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 READ_TIMEOUT_MS = 60000;
@@ -177,7 +177,8 @@ public final class ServerUploader {
record.latitude, record.longitude,
record.updatedAt > 0
? record.updatedAt
: System.currentTimeMillis());
: System.currentTimeMillis(),
record.paired == 1);
}
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,
// which was measured somewhere else entirely.
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,
String mac, String name, String category,
String rssi, double latitude,
double longitude, long time) {
double longitude, long time, boolean paired) {
csv.append(field(mac)).append(',')
.append(field(name)).append(',')
@@ -216,7 +218,8 @@ public final class ServerUploader {
.append(rssi).append(',')
.append(latitude).append(',')
.append(longitude).append(',')
.append(stamp.format(new Date(time)))
.append(stamp.format(new Date(time))).append(',')
.append(paired ? "1" : "0")
.append('\n');
}
@@ -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 = 16;
private static final int DB_VERSION = 17;
public TrackerDatabase(Context context) {
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'," +
"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," +
"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);
seedVendors(db);
}
@@ -103,6 +103,8 @@ public class TrackerDatabase extends SQLiteOpenHelper {
if (oldVersion < 15) seedVendors(db);
if (oldVersion < 16)
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) {
@@ -331,6 +333,24 @@ public class TrackerDatabase extends SQLiteOpenHelper {
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) {
ContentValues values = new ContentValues(); values.put("map_enabled", enabled ? 1 : 0);
getWritableDatabase().update("devices", values, "address=?", new String[] { address });
@@ -517,6 +537,7 @@ public class TrackerDatabase extends SQLiteOpenHelper {
r.locationLocked = integer(c, "location_locked");
r.showTrail = integer(c, "show_trail");
r.trackBroadcast = integer(c, "track_broadcast");
r.paired = integer(c, "paired");
r.connected = integer(c, "connected");
r.connectedAt = number(c, "connected_at");
r.disconnectedAt = number(c, "disconnected_at");