Initial commit

This commit is contained in:
2026-08-16 22:52:52 -06:00
commit eb16dbaeac
700 changed files with 4612 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.wytehat.btlogger">
<uses-permission android:name="android.permission.BLUETOOTH"/><uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/><uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/><uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/><uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.CAMERA"/><uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/><uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<uses-feature android:name="android.hardware.camera" android:required="false"/>
<application android:allowBackup="true" android:icon="@drawable/ic_launcher" android:label="@string/app_name" android:theme="@style/AppTheme">
<activity android:name=".MainActivity" android:label="@string/app_name"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter></activity>
<activity android:name=".LiveRangeFinderActivity" android:label="Live Find"/><activity android:name=".DeviceManagerActivity" android:label="Bluetooth Items"/><activity android:name=".MapActivity" android:label="Last Seen Map"/>
<service android:name=".BluetoothTrackingService" android:enabled="true"/>
</application>
</manifest>
@@ -0,0 +1,123 @@
package com.wytehat.btlogger;
import android.os.ParcelUuid;
import java.util.List;
import java.util.Locale;
public final class BleFingerprintRegistry {
public static final class Match {
public final String uuid, label, vendor, category;
Match(String uuid, String label, String vendor, String category) {
this.uuid = uuid; this.label = label; this.vendor = vendor; this.category = category;
}
}
private BleFingerprintRegistry() { }
public static Match first(List<ParcelUuid> services) {
if (services == null) return null;
for (int i = 0; i < services.size(); i++) {
Match match = lookup(shortUuid(services.get(i).toString()));
if (match != null) return match;
}
return null;
}
public static Match lookup(String uuid) {
String value = shortUuid(uuid);
if ("FEED".equals(value) || "FEEC".equals(value) || "FD84".equals(value)) return m(value, "Tile tracker service", "Tile, Inc.", "Trackers / Tags");
if ("FE8B".equals(value)) return m(value, "Apple Find My Network", "Apple, Inc.", "Trackers / Tags");
if ("FD5B".equals(value)) return m(value, "Samsung SmartTag / Find Network", "Samsung Electronics", "Trackers / Tags");
if ("FEAA".equals(value)) return m(value, "Eddystone beacon", "Google LLC", "Trackers / Tags");
if ("FED8".equals(value)) return m(value, "Eddystone URL beacon", "Google LLC", "Trackers / Tags");
if ("FE9A".equals(value)) return m(value, "Estimote location beacon", "Estimote, Inc.", "Trackers / Tags");
if ("FE2C".equals(value)) return m(value, "Fast Pair service", "Google LLC", "Ecosystem / Pairing");
if ("FE9F".equals(value) || "FEA0".equals(value)) return m(value, "Google member service", "Google LLC", "Ecosystem / Pairing");
if ("FE40".equals(value)) return m(value, "Fast Pair network", "Google LLC", "Ecosystem / Pairing");
if ("FED3".equals(value) || "FED4".equals(value)) return m(value, "Apple member service", "Apple, Inc.", "Ecosystem / Continuity");
if ("FD59".equals(value) || "FD5A".equals(value) || "FD69".equals(value) || "FD6C".equals(value)) return m(value, "Samsung member service", "Samsung Electronics", "Ecosystem / Continuity");
if ("FE33".equals(value)) return m(value, "CHIP / Matter protocol", "Connectivity Standards Alliance", "Smart Home");
if ("FE65".equals(value)) return m(value, "CHIP / Matter setup", "Connectivity Standards Alliance", "Smart Home");
if ("FE00".equals(value)) return m(value, "Amazon Echo / Sidewalk", "Amazon.com", "Smart Home");
if ("FE50".equals(value)) return m(value, "Ring doorbell / camera", "Ring LLC (Amazon)", "Smart Home");
if ("FEAF".equals(value) || "FEB0".equals(value)) return m(value, "Nest member service", "Nest Labs Inc.", "Smart Home");
if ("FE24".equals(value)) return m(value, "August smart lock", "August Home Inc.", "Smart Home");
if ("FE35".equals(value)) return m(value, "Hormann smart opener", "Hormann KG", "Smart Home");
if ("FDC2".equals(value)) return m(value, "Baidu smart IoT", "Baidu", "Smart Home");
if ("1815".equals(value)) return m(value, "Automation IO", null, "Smart Home");
if ("FE95".equals(value)) return m(value, "Xiaomi member service", "Xiaomi Inc.", "Wearables");
if ("FDB2".equals(value)) return m(value, "Fitbit BLE service", "Fitbit / Google", "Wearables");
if ("FE08".equals(value)) return m(value, "Garmin fitness service", "Garmin International", "Wearables");
if ("FED9".equals(value)) return m(value, "Pebble service", "Pebble Technology", "Wearables");
if ("FE87".equals(value)) return m(value, "Meta / Oculus VR system", "Meta Platforms", "Wearables / VR");
if ("180D".equals(value)) return m(value, "Heart Rate", null, "Wearables");
if ("1814".equals(value)) return m(value, "Running Speed & Cadence", null, "Wearables");
if ("1816".equals(value)) return m(value, "Cycling Speed & Cadence", null, "Wearables");
if ("1818".equals(value)) return m(value, "Cycling Power", null, "Wearables");
if ("1822".equals(value)) return m(value, "Pulse Oximeter", null, "Wearables");
if ("1826".equals(value)) return m(value, "Fitness Machine", null, "Wearables");
if ("FD6F".equals(value)) return m(value, "Exposure Notification", "Apple / Google", "Health");
if ("FD87".equals(value)) return m(value, "Google Health & Medical", "Google LLC", "Health");
if ("1808".equals(value)) return m(value, "Glucose Service", null, "Health");
if ("1809".equals(value)) return m(value, "Health Thermometer", null, "Health");
if ("1810".equals(value)) return m(value, "Blood Pressure", null, "Health");
if ("181B".equals(value)) return m(value, "Body Composition", null, "Health");
if ("181C".equals(value)) return m(value, "User Data", null, "Health");
if ("181D".equals(value)) return m(value, "Weight Scale", null, "Health");
if ("183A".equals(value)) return m(value, "Insulin Delivery", null, "Health");
if ("FEBE".equals(value)) return m(value, "Bose audio control", "Bose Corporation", "Audio");
if ("FE21".equals(value)) return m(value, "Bose headphones service", "Bose Corporation", "Audio");
if ("FDD2".equals(value)) return m(value, "Bose smart audio", "Bose Corporation", "Audio");
if ("FD82".equals(value)) return m(value, "Sony wireless audio", "Sony Corporation", "Audio");
if ("FD83".equals(value)) return m(value, "Sony Headphones Connect", "Sony Corporation", "Audio");
if ("FE3B".equals(value)) return m(value, "Dolby audio service", "Dolby Laboratories", "Audio");
if ("FDA5".equals(value)) return m(value, "Nvidia Shield controller", "NVIDIA Corporation", "Gaming");
if ("FDE2".equals(value)) return m(value, "Razer gaming peripheral", "Razer Inc.", "Gaming");
if ("FE96".equals(value) || "FE97".equals(value)) return m(value, "Tesla vehicle service", "Tesla Motors", "Vehicles");
if ("FEA5".equals(value) || "FEA6".equals(value)) return m(value, "GoPro service", "GoPro, Inc.", "Cameras");
if ("FEB2".equals(value)) return m(value, "Microsoft member service", "Microsoft Corporation", "Computers");
if ("1812".equals(value)) return m(value, "HID / Input", null, "Computers");
if ("FE79".equals(value)) return m(value, "Zebra member service", "Zebra Technologies", "Enterprise / Scanners");
if ("FD9A".equals(value) || "FD9B".equals(value)) return m(value, "Yubikey authentication", "Yubico AB", "Security / Auth");
if ("181E".equals(value)) return m(value, "Bond Management", null, "Security / Auth");
if ("FEE4".equals(value) || "FEE5".equals(value) || "FE59".equals(value)) return m(value, "Nordic device / DFU service", "Nordic Semiconductor ASA", "Development / Firmware");
if ("FEE9".equals(value)) return m(value, "Quintic member service", "Quintic Corp.", "Development / Firmware");
if ("FEBB".equals(value)) return m(value, "Adafruit file transfer", "Adafruit Industries", "Development / Firmware");
if ("FEB3".equals(value)) return m(value, "Alibaba / Taobao IoT", "Alibaba Group", "Development / Firmware");
if ("FE01".equals(value)) return m(value, "Duracell smart power", "Duracell", "Hardware");
if ("1800".equals(value)) return m(value, "Generic Access", null, "Core / System");
if ("1801".equals(value)) return m(value, "Generic Attribute", null, "Core / System");
if ("1802".equals(value)) return m(value, "Immediate Alert", null, "Core / System");
if ("1803".equals(value)) return m(value, "Link Loss", null, "Core / System");
if ("1804".equals(value)) return m(value, "Tx Power Level", null, "Core / System");
if ("1805".equals(value)) return m(value, "Current Time", null, "Core / System");
if ("1806".equals(value)) return m(value, "Reference Time Update", null, "Core / System");
if ("1807".equals(value)) return m(value, "Next DST Change", null, "Core / System");
if ("180A".equals(value)) return m(value, "Device Information", null, "Core / System");
if ("180F".equals(value)) return m(value, "Battery Service", null, "Core / System");
if ("1811".equals(value)) return m(value, "Alert Notification", null, "Core / System");
if ("1813".equals(value)) return m(value, "Scan Parameters", null, "Core / System");
if ("1819".equals(value)) return m(value, "Location & Navigation", null, "Core / System");
if ("181A".equals(value)) return m(value, "Environmental Sensing", null, "Sensors");
if ("183B".equals(value)) return m(value, "Binary Sensor", null, "Sensors");
if ("1820".equals(value)) return m(value, "IP Support Protocol", null, "Networking");
if ("1821".equals(value)) return m(value, "Indoor Positioning", null, "Core / System");
if ("1823".equals(value)) return m(value, "HTTP Proxy", null, "Networking");
if ("1824".equals(value)) return m(value, "Transport Discovery", null, "Networking");
if ("1825".equals(value)) return m(value, "Object Transfer", null, "Networking");
if ("1827".equals(value)) return m(value, "Mesh Provisioning", null, "Networking");
if ("1828".equals(value)) return m(value, "Mesh Proxy", null, "Networking");
return null;
}
public static String shortUuid(String raw) {
if (raw == null) return "";
String value = raw.replace("-", "").toUpperCase(Locale.US);
if (value.length() == 32 && value.startsWith("0000") && value.endsWith("00001000800000805F9B34FB")) return value.substring(4, 8);
return value.length() == 4 ? value : (value.length() >= 8 && value.startsWith("0000") ? value.substring(4, 8) : value);
}
private static Match m(String uuid, String label, String vendor, String category) {
return new Match(uuid, label, vendor, category);
}
}
@@ -0,0 +1,232 @@
package com.wytehat.btlogger;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.RadialGradient;
import android.graphics.Shader;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import android.view.View;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
public class BluetoothRadarView extends View {
public interface Listener { void onTargetSelected(String address); }
public static class Target {
public String address;
public String name;
public int rssi;
float x, y;
public Target(String address, String name, int rssi) {
this.address = address; this.name = name; this.rssi = rssi;
}
}
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final ArrayList<Target> targets = new ArrayList<Target>();
private final HashMap<String, Float> targetAngles = new HashMap<String, Float>();
private final HashMap<String, Float> displayedRadii = new HashMap<String, Float>();
private Listener listener;
private boolean scanning;
private final ScaleGestureDetector scaleDetector;
private float zoom = 1.0f;
private boolean suppressTap;
private double sweepAngle = -90.0;
public BluetoothRadarView(Context context) {
super(context);
scaleDetector = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() {
public boolean onScaleBegin(ScaleGestureDetector detector) { suppressTap = true; return true; }
public boolean onScale(ScaleGestureDetector detector) {
zoom = clamp(zoom * detector.getScaleFactor(), 1.0f, 3.5f);
invalidate();
return true;
}
});
paint.setTypeface(android.graphics.Typeface.create("sans", android.graphics.Typeface.NORMAL));
setBackgroundColor(Color.rgb(4, 24, 29));
setLayerType(View.LAYER_TYPE_SOFTWARE, null);
}
public synchronized void setScanning(boolean value) {
if (value && !scanning) zoom = 1.0f;
scanning = value;
invalidate();
}
public void setListener(Listener value) { listener = value; }
public synchronized void setTargets(List<Target> values) {
for (int i = 0; i < values.size(); i++) {
Target target = values.get(i);
if (!targetAngles.containsKey(target.address))
targetAngles.put(target.address, Float.valueOf(findOpenAngle(target.address)));
}
targets.clear(); targets.addAll(values); invalidate();
}
protected synchronized void onDraw(Canvas canvas) {
super.onDraw(canvas);
float cx = getWidth() / 2f;
float cy = getHeight() / 2f;
canvas.save();
canvas.scale(zoom, zoom, cx, cy);
float radius = Math.min(cx, cy) - 34;
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));
canvas.drawCircle(cx, cy, glowRadius, paint);
paint.setShader(null);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(2.4f);
paint.setColor(Color.argb(95, 55, 220, 145));
paint.setShadowLayer(12f, 0f, 0f, Color.argb(65, 45, 225, 145));
canvas.drawCircle(cx, cy, radius, paint);
paint.clearShadowLayer();
paint.setColor(Color.argb(22, 65, 225, 155));
paint.setStrokeWidth(6f);
for (int i = 1; i <= 4; i++) canvas.drawCircle(cx, cy, radius * i / 4f, paint);
paint.setColor(Color.rgb(38, 145, 110));
paint.setStrokeWidth(2.8f);
for (int i = 1; i <= 4; i++) canvas.drawCircle(cx, cy, radius * i / 4f, paint);
paint.setColor(Color.argb(28, 55, 205, 145)); paint.setStrokeWidth(3.5f);
canvas.drawLine(cx - radius, cy, cx + radius, cy, paint);
canvas.drawLine(cx, cy - radius, cx, cy + radius, paint);
paint.setColor(Color.rgb(35, 125, 100)); paint.setStrokeWidth(1.8f);
canvas.drawLine(cx - radius, cy, cx + radius, cy, paint);
canvas.drawLine(cx, cy - radius, cx, cy + radius, paint);
if (scanning) sweepAngle = (System.currentTimeMillis() % 6000L) * 360.0 / 6000.0 - 90.0;
double angle = sweepAngle + 90.0;
double radians = Math.toRadians(angle - 90);
float sweepX = cx + (float)Math.cos(radians) * radius;
float sweepY = cy + (float)Math.sin(radians) * radius;
paint.setColor(Color.argb(35, 70, 255, 170)); paint.setStrokeWidth(16);
canvas.drawLine(cx, cy, sweepX, sweepY, paint);
paint.setColor(Color.argb(85, 70, 255, 170)); paint.setStrokeWidth(8);
canvas.drawLine(cx, cy, sweepX, sweepY, paint);
paint.setColor(Color.rgb(65, 255, 170)); paint.setStrokeWidth(2.5f);
canvas.drawLine(cx, cy, sweepX, sweepY, paint);
paint.setStyle(Paint.Style.FILL);
paint.setColor(Color.WHITE);
canvas.drawCircle(cx, cy, 7, paint);
paint.setTextAlign(Paint.Align.CENTER);
paint.setTextSize(12 * getResources().getDisplayMetrics().scaledDensity);
canvas.drawText("YOU", cx, cy + 24, paint);
ArrayList<Target> ordered = new ArrayList<Target>(targets);
Collections.sort(ordered, new Comparator<Target>() {
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);
}
});
float density = getResources().getDisplayMetrics().scaledDensity;
for (int i = 0; i < ordered.size(); i++) {
Target target = ordered.get(i);
float degrees = angleFor(target.address);
double targetAngle = Math.toRadians(degrees);
float desiredRadius = radius * radialPosition(target.rssi);
Float previous = displayedRadii.get(target.address);
float shownRadius = previous == null ? desiredRadius : previous.floatValue() + (desiredRadius - previous.floatValue()) * 0.055f;
displayedRadii.put(target.address, Float.valueOf(shownRadius));
float cos = (float)Math.cos(targetAngle);
float sin = (float)Math.sin(targetAngle);
target.x = cx + cos * shownRadius;
target.y = cy + sin * shownRadius;
paint.setStyle(Paint.Style.FILL);
paint.setColor(target.rssi >= -60 ? Color.rgb(255, 70, 70) : target.rssi >= -80 ? Color.rgb(255, 180, 40) : Color.rgb(40, 150, 255));
canvas.drawCircle(target.x, target.y, 9, paint);
paint.setTextSize(9.5f * density);
paint.setTextAlign(Paint.Align.CENTER);
String label = target.name == null ? "Unknown" : target.name;
if (label.length() > 14) label = label.substring(0, 14);
String signal = target.rssi + " dBm";
float textWidth = Math.max(paint.measureText(label), paint.measureText(signal));
int lane = target.address == null ? 0 : Math.abs(target.address.hashCode() / 360) % 5 - 2;
float shift = lane * 10f * density;
float labelX = target.x + cos * (15f + textWidth / 2f) - sin * shift;
float labelY = target.y + sin * (17f + 12f * density) + cos * shift;
labelX = clamp(labelX, textWidth / 2f + 3f, getWidth() - textWidth / 2f - 3f);
labelY = clamp(labelY, 12f * density, getHeight() - 12f * density);
paint.setColor(Color.WHITE);
paint.setShadowLayer(3f, 1f, 1f, Color.BLACK);
canvas.drawText(label, labelX, labelY - 2f, paint);
paint.setTextSize(8.5f * density);
canvas.drawText(signal, labelX, labelY + 10f * density, paint);
paint.clearShadowLayer();
}
canvas.restore();
if (scanning) postInvalidateDelayed(33);
}
public synchronized boolean onTouchEvent(MotionEvent event) {
if (event.getActionMasked() == MotionEvent.ACTION_DOWN && getParent() != null)
getParent().requestDisallowInterceptTouchEvent(true);
scaleDetector.onTouchEvent(event);
if (event.getPointerCount() > 1) { suppressTap = true; return true; }
if (event.getAction() != MotionEvent.ACTION_UP) return true;
if (suppressTap) { suppressTap = false; return true; }
float cx = getWidth() / 2f;
float cy = getHeight() / 2f;
float touchX = cx + (event.getX() - cx) / zoom;
float touchY = cy + (event.getY() - cy) / zoom;
Target closest = null;
float best = Float.MAX_VALUE;
for (int i = 0; i < targets.size(); i++) {
Target target = targets.get(i);
float dx = touchX - target.x;
float dy = touchY - target.y;
float distance = dx * dx + dy * dy;
if (distance < best) { best = distance; closest = target; }
}
float limit = 48 * getResources().getDisplayMetrics().density / zoom;
if (closest != null && best <= limit * limit && listener != null)
listener.onTargetSelected(closest.address);
return true;
}
private float angleFor(String address) {
Float value = targetAngles.get(address);
if (value != null) return value.floatValue();
float created = findOpenAngle(address);
targetAngles.put(address, Float.valueOf(created));
return created;
}
private float findOpenAngle(String address) {
if (targetAngles.isEmpty()) return address == null ? -90f : (Math.abs(address.hashCode()) % 360) - 90f;
float bestAngle = 0f;
float bestGap = -1f;
for (int candidate = 0; candidate < 360; candidate += 5) {
float nearest = 360f;
for (Float used : targetAngles.values()) nearest = Math.min(nearest, circularDistance(candidate, used.floatValue()));
if (nearest > bestGap) { bestGap = nearest; bestAngle = candidate; }
}
return bestAngle;
}
private float circularDistance(float first, float second) {
float difference = Math.abs(first - second) % 360f;
return Math.min(difference, 360f - difference);
}
private float radialPosition(int rssi) {
return clamp((-rssi - 45f) / 60f, 0.12f, 0.94f);
}
private float clamp(float value, float min, float max) {
return Math.max(min, Math.min(max, value));
}
}
@@ -0,0 +1,470 @@
package com.wytehat.btlogger;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import java.util.HashMap;
import java.util.List;
public class BluetoothTrackingService extends Service implements LocationListener {
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";
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";
private TrackerDatabase db;
private LocationManager locationManager;
private BluetoothAdapter bluetooth;
private Location lastLocation;
private boolean receiverRegistered;
private boolean rangeModeActive;
private int phoneBattery = 100;
private final Handler handler = new Handler();
private final HashMap<String, Long> lastPeriodicLog = new HashMap<String, Long>();
private final BroadcastReceiver receiver = new BroadcastReceiver() {
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (ACTION_RANGE_MODE.equals(action)) {
rangeModeActive = intent.getBooleanExtra("active", false);
return;
}
if (ACTION_CONFIG_CHANGED.equals(action)) {
restartBackgroundScan();
configureLocation();
return;
}
if (Intent.ACTION_BATTERY_CHANGED.equals(action)) {
int level = intent.getIntExtra("level", 100);
int scale = intent.getIntExtra("scale", 100);
phoneBattery = scale > 0 ? level * 100 / scale : 100;
configureLocation();
return;
}
if (BluetoothAdapter.ACTION_STATE_CHANGED.equals(action)) {
int state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE,
BluetoothAdapter.ERROR);
if (state == BluetoothAdapter.STATE_OFF ||
state == BluetoothAdapter.STATE_TURNING_OFF)
disconnectAll("Manual / Intentional Disconnect");
return;
}
BluetoothDevice device = (BluetoothDevice) intent.getParcelableExtra(
BluetoothDevice.EXTRA_DEVICE);
if (device == null) return;
String address = address(device);
if (address == null) return;
String broadcastName = name(device);
long now = System.currentTimeMillis();
TrackerDatabase.Fix fix = currentFix();
resolveVendor(address, -1);
if (BluetoothDevice.ACTION_ACL_CONNECTED.equals(action)) {
db.connected(address, broadcastName, now, fix);
captureFresh(address, now);
configureLocation();
} else if (BluetoothDevice.ACTION_ACL_DISCONNECTED.equals(action)) {
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));
captureFresh(address, now);
configureLocation();
} else if (BluetoothDevice.ACTION_FOUND.equals(action)) {
short rssi = intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, Short.MIN_VALUE);
if (rssi != Short.MIN_VALUE) {
DeviceRecord before = db.get(address);
boolean firstFreshSight = before != null && before.trackBroadcast == 1 &&
(before.lastRssiAt <= 0 || now - before.lastRssiAt > 180000L);
db.telemetry(address, broadcastName, null, Integer.valueOf(rssi), now, null);
db.recordObservation(address, now, rssi, broadcastName, db.lookupOui(address),
null, null, -1, -1, null, fix);
if (firstFreshSight) { captureFresh(address, now); configureLocation(); }
}
} else if ("android.bluetooth.device.action.BATTERY_LEVEL_CHANGED".equals(action)) {
int battery = intent.getIntExtra(
"android.bluetooth.device.extra.BATTERY_LEVEL", -1);
if (battery >= 0 && battery <= 100)
db.telemetry(address, broadcastName, Integer.valueOf(battery), null, now, fix);
} else if (BluetoothDevice.ACTION_BOND_STATE_CHANGED.equals(action)) {
int state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE,
BluetoothDevice.ERROR);
if (state == BluetoothDevice.BOND_BONDED)
db.markTracked(address, broadcastName);
else if (state == BluetoothDevice.BOND_NONE)
db.disconnected(address, broadcastName, now, fix,
"Manual / Intentional Disconnect");
}
resolveVendor(address, -1);
notifyChanged();
}
};
private final BluetoothAdapter.LeScanCallback leScanCallback =
new BluetoothAdapter.LeScanCallback() {
public void onLeScan(BluetoothDevice device, int rssi, byte[] scanRecord) {
if (device == null) return;
String address = address(device);
if (address == null) return;
long now = System.currentTimeMillis();
DeviceRecord before = db.get(address);
boolean firstFreshSight = before != null && before.trackBroadcast == 1 &&
(before.lastRssiAt <= 0 || now - before.lastRssiAt > 180000L);
db.telemetry(address, name(device), null, Integer.valueOf(rssi), now, null);
if (firstFreshSight) { captureFresh(address, now); configureLocation(); }
int companyId = parseCompanyId(scanRecord);
resolveVendor(address, companyId);
String vendor = companyId >= 0 ? db.lookupCompany(companyId) : db.lookupOui(address);
db.recordObservation(address, now, rssi, name(device), vendor, null, null,
-1, companyId, bytesToHex(scanRecord), currentFix());
notifyChanged();
}
};
private final Runnable scanTask = new Runnable() {
public void run() {
try {
if (hasBluetoothPermission() && bluetooth != null && bluetooth.isEnabled() &&
shouldBackgroundScan() && !rangeModeActive) {
if (!bluetooth.isDiscovering()) bluetooth.startDiscovery();
bluetooth.startLeScan(leScanCallback);
handler.postDelayed(new Runnable() {
public void run() {
try { bluetooth.stopLeScan(leScanCallback); }
catch (Exception ignored) { }
configureLocation();
}
}, 12000L);
}
} catch (Exception ignored) { }
handler.postDelayed(this, 90000L);
}
};
public void onCreate() {
super.onCreate();
db = new TrackerDatabase(this);
bluetooth = BluetoothAdapter.getDefaultAdapter();
startForeground(NOTIFICATION_ID, buildNotification());
syncPairedDevices();
notifyChanged();
IntentFilter filter = new IntentFilter();
filter.addAction(BluetoothDevice.ACTION_ACL_CONNECTED);
filter.addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED);
filter.addAction(BluetoothDevice.ACTION_FOUND);
filter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED);
filter.addAction("android.bluetooth.device.action.BATTERY_LEVEL_CHANGED");
filter.addAction(BluetoothAdapter.ACTION_STATE_CHANGED);
filter.addAction(Intent.ACTION_BATTERY_CHANGED);
filter.addAction(ACTION_CONFIG_CHANGED);
filter.addAction(ACTION_RANGE_MODE);
registerReceiver(receiver, filter);
receiverRegistered = true;
locationManager = (LocationManager) getSystemService(LOCATION_SERVICE);
loadLastLocation();
configureLocation();
handler.post(scanTask);
}
public int onStartCommand(Intent intent, int flags, int startId) {
configureLocation();
return START_STICKY;
}
public IBinder onBind(Intent intent) { return null; }
public void onDestroy() {
handler.removeCallbacks(scanTask);
try { if (bluetooth != null) bluetooth.stopLeScan(leScanCallback); }
catch (Exception ignored) { }
if (receiverRegistered) unregisterReceiver(receiver);
stopLocation();
db.close();
super.onDestroy();
}
private void loadLastLocation() {
if (!hasLocationPermission()) return;
try {
lastLocation = newer(locationManager.getLastKnownLocation(
LocationManager.GPS_PROVIDER), locationManager.getLastKnownLocation(
LocationManager.NETWORK_PROVIDER));
} catch (Exception ignored) { }
}
private void configureLocation() {
if (locationManager == null || !hasLocationPermission()) return;
stopLocation();
int minutes = shortestActiveInterval();
if (minutes <= 0) return;
long interval = minutes * 60000L;
if (phoneBattery < 20) interval *= 4L;
try {
locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER,
interval, 10f, this);
locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER,
interval, 10f, this);
} catch (Exception ignored) { }
}
private int shortestActiveInterval() {
List<DeviceRecord> rows = db.tracked();
int shortest = Integer.MAX_VALUE;
for (int i = 0; i < rows.size(); i++) {
DeviceRecord record = rows.get(i);
if (isLocationEligible(record, System.currentTimeMillis()) && record.trackingPriority > 0 &&
record.trackingPriority < shortest) shortest = record.trackingPriority;
}
return shortest == Integer.MAX_VALUE ? 0 : shortest;
}
private void stopLocation() {
try { if (locationManager != null) locationManager.removeUpdates(this); }
catch (Exception ignored) { }
}
private void captureFresh(final String address, final long eventTime) {
if (locationManager == null || !hasLocationPermission()) return;
final LocationListener once = new LocationListener() {
private boolean complete;
public void onLocationChanged(Location location) {
if (complete || location == null) return;
complete = true;
lastLocation = location;
db.updateLocation(address, fix(location), eventTime);
notifyChanged();
try { locationManager.removeUpdates(this); } catch (Exception ignored) { }
}
public void onProviderDisabled(String provider) { }
public void onProviderEnabled(String provider) { }
public void onStatusChanged(String provider, int status, Bundle extras) { }
};
try {
locationManager.requestSingleUpdate(LocationManager.GPS_PROVIDER, once, null);
locationManager.requestSingleUpdate(LocationManager.NETWORK_PROVIDER, once, null);
handler.postDelayed(new Runnable() {
public void run() {
try { locationManager.removeUpdates(once); } catch (Exception ignored) { }
}
}, 15000L);
} catch (Exception ignored) { }
}
public void onLocationChanged(Location location) {
if (location == null) return;
lastLocation = location;
TrackerDatabase.Fix fix = fix(location);
long now = System.currentTimeMillis();
List<DeviceRecord> rows = db.tracked();
for (int i = 0; i < rows.size(); i++) {
DeviceRecord record = rows.get(i);
if (!isLocationEligible(record, now) || record.trackingPriority <= 0) continue;
long interval = record.trackingPriority * 60000L;
if (phoneBattery < 20) interval *= 4L;
Long previous = lastPeriodicLog.get(record.address);
if (previous == null || now - previous.longValue() >= interval) {
db.updateLocation(record.address, fix, now);
lastPeriodicLog.put(record.address, Long.valueOf(now));
}
}
notifyChanged();
}
public void onProviderDisabled(String provider) { }
public void onProviderEnabled(String provider) { }
public void onStatusChanged(String provider, int status, Bundle extras) { }
private TrackerDatabase.Fix currentFix() {
return lastLocation == null ? null : fix(lastLocation);
}
private TrackerDatabase.Fix fix(Location location) {
return new TrackerDatabase.Fix(location.getLatitude(), location.getLongitude(),
location.hasAccuracy() ? location.getAccuracy() : 0);
}
private void resolveVendor(String address, int companyId) {
String vendor = companyId >= 0 ? db.lookupCompany(companyId) : null;
if (vendor == null) vendor = db.lookupOui(address);
db.setVendor(address, vendor);
}
private String bytesToHex(byte[] data) {
if (data == null) return null;
StringBuilder value = new StringBuilder(data.length * 2);
final char[] digits = "0123456789ABCDEF".toCharArray();
for (int i = 0; i < data.length; i++) {
int item = data[i] & 0xff;
value.append(digits[item >>> 4]); value.append(digits[item & 0x0f]);
}
return value.toString();
}
private int parseCompanyId(byte[] scanRecord) {
if (scanRecord == null) return -1;
int index = 0;
while (index < scanRecord.length) {
int length = scanRecord[index] & 0xff;
if (length == 0 || index + length >= scanRecord.length) break;
int type = scanRecord[index + 1] & 0xff;
if (type == 0xff && length >= 3)
return (scanRecord[index + 2] & 0xff) |
((scanRecord[index + 3] & 0xff) << 8);
index += length + 1;
}
return -1;
}
private String infer(DeviceRecord record) {
if (record == null) return "Manual / Intentional Disconnect";
if (record.battery >= 0 && record.battery < 10)
return "Likely Dead Battery / Power Loss";
if (record.rssi <= -80 && record.rssi >= -100) return "Walked Out of Range";
if (record.rssi >= -65 && record.rssi <= -30)
return "Likely Dead Battery / Power Loss";
return "Manual / Intentional Disconnect";
}
private void disconnectAll(String reason) {
long now = System.currentTimeMillis();
List<DeviceRecord> rows = db.tracked();
for (int i = 0; i < rows.size(); i++) {
DeviceRecord record = rows.get(i);
if (record.connected == 1) {
db.disconnected(record.address, record.broadcastName, now, currentFix(), reason);
captureFresh(record.address, now);
}
}
notifyChanged();
}
private void syncPairedDevices() {
if (bluetooth == null) return;
try {
for (BluetoothDevice device : bluetooth.getBondedDevices()) {
String value = address(device);
if (value != null) {
db.markTracked(value, name(device));
resolveVendor(value, -1);
}
}
} catch (Exception ignored) { }
}
private void restartBackgroundScan() {
handler.removeCallbacks(scanTask);
handler.post(scanTask);
}
private boolean shouldBackgroundScan() {
List<DeviceRecord> rows = db.tracked();
for (int i = 0; i < rows.size(); i++)
if (rows.get(i).connected == 1 || rows.get(i).trackBroadcast == 1) return true;
return false;
}
private boolean isLocationEligible(DeviceRecord record, long now) {
return record.connected == 1 || (record.trackBroadcast == 1 && record.lastRssiAt > 0 &&
now - record.lastRssiAt <= 180000L);
}
private void notifyChanged() {
sendBroadcast(new Intent(ACTION_DATA_CHANGED).setPackage(getPackageName()));
}
private boolean hasLocationPermission() {
return getPackageManager().checkPermission("android.permission.ACCESS_FINE_LOCATION",
getPackageName()) == PackageManager.PERMISSION_GRANTED ||
getPackageManager().checkPermission("android.permission.ACCESS_COARSE_LOCATION",
getPackageName()) == PackageManager.PERMISSION_GRANTED;
}
private boolean hasBluetoothPermission() {
return Build.VERSION.SDK_INT < 31 || getPackageManager().checkPermission(
"android.permission.BLUETOOTH_SCAN", getPackageName()) ==
PackageManager.PERMISSION_GRANTED;
}
private String address(BluetoothDevice device) {
try { return device.getAddress(); } catch (Exception error) { return null; }
}
private String name(BluetoothDevice device) {
try {
String value = device.getName();
return value == null ? "Unknown item" : value;
} catch (Exception error) { return "Unknown item"; }
}
private Location newer(Location first, Location second) {
if (first == null) return second;
if (second == null) return first;
return first.getTime() >= second.getTime() ? first : second;
}
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")
.setContentText(reason == null ? "Your tracked item is no longer connected" : reason)
.setContentIntent(pending).setAutoCancel(true)
.setDefaults(Notification.DEFAULT_ALL).setPriority(Notification.PRIORITY_HIGH);
NotificationManager manager = (NotificationManager)getSystemService(NOTIFICATION_SERVICE);
if (Build.VERSION.SDK_INT >= 26) {
try {
Class<?> channelClass = Class.forName("android.app.NotificationChannel");
Object channel = channelClass.getConstructor(String.class, CharSequence.class, Integer.TYPE)
.newInstance(ALERT_CHANNEL_ID, "Tracked item disconnects", 4);
channelClass.getMethod("enableVibration", Boolean.TYPE).invoke(channel, Boolean.TRUE);
NotificationManager.class.getMethod("createNotificationChannel", channelClass).invoke(manager, channel);
Notification.Builder.class.getMethod("setChannelId", String.class).invoke(builder, ALERT_CHANNEL_ID);
} catch (Exception ignored) { }
}
try { manager.notify(1000 + Math.abs(record.address.hashCode() % 100000), builder.build()); }
catch (Exception ignored) { }
}
private Notification buildNotification() {
Intent open = new Intent(this, MainActivity.class);
PendingIntent pending = PendingIntent.getActivity(this, 0, open,
PendingIntent.FLAG_UPDATE_CURRENT);
Notification.Builder builder = new Notification.Builder(this)
.setSmallIcon(R.drawable.ic_launcher).setContentTitle("Bluetooth Item Finder")
.setContentText("Watching your tracked items").setOngoing(true)
.setContentIntent(pending);
if (Build.VERSION.SDK_INT >= 26) {
try {
Class<?> channelClass = Class.forName("android.app.NotificationChannel");
Object channel = channelClass.getConstructor(String.class,
CharSequence.class, Integer.TYPE).newInstance(CHANNEL_ID,
"Item tracking", 2);
NotificationManager manager = (NotificationManager)
getSystemService(NOTIFICATION_SERVICE);
NotificationManager.class.getMethod("createNotificationChannel", channelClass)
.invoke(manager, channel);
Notification.Builder.class.getMethod("setChannelId", String.class)
.invoke(builder, CHANNEL_ID);
} catch (Exception ignored) { }
}
return builder.build();
}
}
@@ -0,0 +1,127 @@
package com.wytehat.btlogger;
import android.content.Context;
import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import java.text.DateFormat;
import java.util.Date;
import java.util.List;
import java.util.Locale;
public class DeviceListAdapter extends BaseAdapter {
private final Context context;
private List<DeviceRecord> rows;
private final ItemActionListener listener;
public DeviceListAdapter(Context context, List<DeviceRecord> rows,
ItemActionListener listener) {
this.context = context;
this.rows = rows;
this.listener = listener;
}
public void setRows(List<DeviceRecord> value) {
rows = value;
notifyDataSetChanged();
}
public int getCount() { return rows.size(); }
public Object getItem(int position) { return rows.get(position); }
public long getItemId(int position) { return position; }
public View getView(int position, View oldView, ViewGroup parent) {
View view = oldView;
if (view == null) view = LayoutInflater.from(context).inflate(
R.layout.device_row, parent, false);
final DeviceRecord record = rows.get(position);
ImageView image = (ImageView) view.findViewById(R.id.item_image);
if (record.photoUri != null && new java.io.File(record.photoUri).exists()) {
image.setImageBitmap(BitmapFactory.decodeFile(record.photoUri));
} else image.setImageResource(icon(record.iconPreset));
((TextView) view.findViewById(R.id.device_title)).setText(
record.displayName() + "\n" + record.address);
TextView vendor = (TextView) view.findViewById(R.id.device_vendor);
vendor.setText(record.vendorName == null ? "Manufacturer unknown" : record.vendorName);
vendor.setVisibility(record.vendorName == null || record.vendorName.length() == 0 ? View.GONE : View.VISIBLE);
boolean recentSignal = record.rssi != 0 && record.lastRssiAt > 0 &&
System.currentTimeMillis() - record.lastRssiAt <= 120000L;
boolean broadcastFresh = record.rssi != 0 && record.lastRssiAt > 0 &&
System.currentTimeMillis() - record.lastRssiAt <= 180000L;
String seenState;
if (record.connected == 1) seenState = "● Nearby / Connected";
else if (recentSignal) seenState = "● Broadcasting now • Seen " + date(record.lastRssiAt);
else if (record.updatedAt <= 0) seenState = "Paired / saved — never seen";
else seenState = "Last seen " + date(record.updatedAt);
((TextView) view.findViewById(R.id.device_state)).setText(seenState);
((TextView) view.findViewById(R.id.device_telemetry)).setText(
"Battery " + (record.battery < 0 ? "" : record.battery + "%") +
" Signal " + signal(record.rssi));
TextView reason = (TextView) view.findViewById(R.id.device_reason);
String configuredPolling = record.trackingPriority > 0
? "GPS every " + record.trackingPriority + " min" : "GPS event-only";
String polling;
if (record.connected == 1) polling = configuredPolling + " while connected";
else if (record.trackBroadcast == 1 && broadcastFresh) polling = configuredPolling + " while broadcasting";
else if (record.trackBroadcast == 1) polling = configuredPolling + " • waiting for broadcast";
else polling = configuredPolling + " • paused — not connected";
reason.setText((record.connected == 1 ? "With you" : (record.updatedAt <= 0 ? "Not yet observed" : shortReason(record.reason))) +
"" + polling + " • Pin " + record.pinColor);
try { reason.setTextColor(Color.parseColor(record.pinColor)); }
catch (Exception ignored) { reason.setTextColor(Color.DKGRAY); }
Button map = (Button) view.findViewById(R.id.map_button);
map.setEnabled(record.hasLocation);
map.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) { listener.map(record); }
});
View liveButton = view.findViewById(R.id.live_find_button);
boolean liveAvailable = recentSignal;
liveButton.setVisibility(liveAvailable ? View.VISIBLE : View.GONE);
view.findViewById(R.id.live_find_button).setOnClickListener(new View.OnClickListener() {
public void onClick(View v) { listener.live(record); }
});
view.findViewById(R.id.manage_button).setOnClickListener(new View.OnClickListener() {
public void onClick(View v) { listener.manage(record); }
});
view.findViewById(R.id.edit_button).setOnClickListener(new View.OnClickListener() {
public void onClick(View v) { listener.edit(record); }
});
return view;
}
private int icon(String value) {
if ("headphones".equals(value)) return android.R.drawable.ic_media_play;
if ("keys".equals(value)) return android.R.drawable.ic_lock_lock;
if ("car".equals(value)) return android.R.drawable.ic_menu_directions;
if ("wallet".equals(value)) return android.R.drawable.ic_menu_agenda;
return android.R.drawable.ic_menu_myplaces;
}
private String signal(int value) {
if (value == 0) return "";
if (value >= -65) return "Strong (" + value + " dBm)";
if (value >= -80) return "Medium (" + value + " dBm)";
return "Weak (" + value + " dBm)";
}
private String date(long time) {
return time <= 0 ? "never" : DateFormat.getDateTimeInstance(
DateFormat.SHORT, DateFormat.SHORT).format(new Date(time));
}
private String shortReason(String value) {
if (value == null) return "Status unknown";
if (value.indexOf("Range") >= 0) return "Out of range";
if (value.indexOf("Battery") >= 0 || value.indexOf("Power") >= 0)
return "Possible dead battery";
return value;
}
}
@@ -0,0 +1,942 @@
package com.wytehat.btlogger;
import android.app.Activity;
import android.app.AlertDialog;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothClass;
import android.bluetooth.le.BluetoothLeScanner;
import android.bluetooth.le.ScanCallback;
import android.bluetooth.le.ScanResult;
import android.bluetooth.le.ScanSettings;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.IntentFilter;
import android.graphics.Color;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.ParcelUuid;
import android.util.SparseArray;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.ArrayAdapter;
import android.widget.AdapterView;
import android.widget.Spinner;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import android.widget.EditText;
import android.widget.Toast;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class DeviceManagerActivity extends Activity {
private static class SignalEntry {
BluetoothDevice device; int rssi; long seenAt; String broadcastName; String vendorName; String category; String broadcastType; String customName;
}
private BluetoothAdapter bluetooth;
private BluetoothLeScanner leScanner;
private ScanCallback scanCallback;
private TrackerDatabase database;
private BluetoothRadarView radar;
private TextView scanStatus;
private Button scanButton;
private boolean scanning;
private LinearLayout gridPanel;
private ListView signalList;
private SignalGridAdapter signalAdapter;
private String signalSort = "Strongest first";
private LinearLayout radarActions;
private TextView radarSelection;
private Button radarMapButton;
private Button radarTrackButton;
private SignalEntry selectedSignal;
private PairedAdapter pairedAdapter;
private TextView pairedHeader;
private ListView pairedList;
private final Handler handler = new Handler();
private final LinkedHashMap<String, SignalEntry> signals =
new LinkedHashMap<String, SignalEntry>();
private final ArrayList<BluetoothDevice> paired = new ArrayList<BluetoothDevice>();
private TrackerDatabase.Fix cachedObservationFix;
private long cachedObservationFixAt;
private String categoryFilter = "All";
private static final String[] CATEGORY_ORDER = { "Trackers / Tags", "Audio", "Wearables", "Phones", "Computers", "Vehicles", "Other" };
private final ArrayList<String> categoryValues = new ArrayList<String>();
private final ArrayList<String> categoryLabels = new ArrayList<String>();
private Spinner categorySpinner;
private ArrayAdapter<String> categoryAdapter;
private boolean receiverRegistered;
private final BroadcastReceiver receiver = new BroadcastReceiver() {
public void onReceive(Context context, Intent intent) {
if (BluetoothDevice.ACTION_BOND_STATE_CHANGED.equals(intent.getAction())) {
loadPaired();
} else if (BluetoothDevice.ACTION_FOUND.equals(intent.getAction())) {
BluetoothDevice device = (BluetoothDevice) intent.getParcelableExtra(
BluetoothDevice.EXTRA_DEVICE);
int rssi = intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, Short.MIN_VALUE);
if (rssi != Short.MIN_VALUE) acceptSignal(device, rssi);
} else if (BluetoothAdapter.ACTION_DISCOVERY_FINISHED.equals(intent.getAction())) {
if (scanning && Build.VERSION.SDK_INT < 21) {
try { bluetooth.startDiscovery(); } catch (Exception ignored) { }
} else scanStatus.setText(signals.size() + " transmitting devices found");
}
}
};
protected void onCreate(Bundle state) {
super.onCreate(state);
database = new TrackerDatabase(this);
bluetooth = BluetoothAdapter.getDefaultAdapter();
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setBackgroundColor(Color.rgb(238, 241, 245));
root.addView(NavigationHelper.createBar(this, 1, "Bluetooth Scanner"));
TextView help = new TextView(this);
help.setText("Scan nearby transmitters • tap a device to select it");
help.setTextColor(Color.DKGRAY);
help.setPadding(dp(8), dp(1), dp(8), dp(1));
help.setSingleLine(true);
help.setTextSize(12);
help.setEllipsize(android.text.TextUtils.TruncateAt.END);
root.addView(help);
scanButton = new Button(this);
scanButton.setText("SCAN FOR TRANSMITTING DEVICES");
root.addView(scanButton, new LinearLayout.LayoutParams(-1, -2));
scanStatus = new TextView(this);
scanStatus.setText("Tap scan to listen for BLE advertisements");
scanStatus.setPadding(dp(8), dp(1), dp(8), dp(1));
scanStatus.setSingleLine(true);
scanStatus.setTextSize(12);
scanStatus.setEllipsize(android.text.TextUtils.TruncateAt.END);
root.addView(scanStatus);
categorySpinner = new Spinner(this);
categoryValues.add("All"); categoryLabels.add("All (0)");
categoryAdapter = new ArrayAdapter<String>(this,
android.R.layout.simple_spinner_item, categoryLabels);
categoryAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
categorySpinner.setAdapter(categoryAdapter);
root.addView(categorySpinner, new LinearLayout.LayoutParams(-1, -2));
categorySpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
if (position >= 0 && position < categoryValues.size()) {
categoryFilter = categoryValues.get(position); updateRadar();
}
}
public void onNothingSelected(AdapterView<?> parent) { }
});
LinearLayout viewModes = new LinearLayout(this);
final Button radarMode = new Button(this); radarMode.setText("RADAR VIEW");
final Button listMode = new Button(this); listMode.setText("LIVE DEVICE LIST");
viewModes.addView(radarMode, new LinearLayout.LayoutParams(0, -2, 1));
viewModes.addView(listMode, new LinearLayout.LayoutParams(0, -2, 1));
root.addView(viewModes);
radarMode.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) {
radar.setVisibility(View.VISIBLE);
pairedHeader.setVisibility(View.VISIBLE); pairedList.setVisibility(View.VISIBLE);
gridPanel.setVisibility(View.GONE);
radarMode.setEnabled(false); listMode.setEnabled(true);
if (selectedSignal != null) { refreshRadarToggleButtons(); radarActions.setVisibility(View.VISIBLE); }
}
});
listMode.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) {
radar.setVisibility(View.GONE); radarActions.setVisibility(View.GONE);
pairedHeader.setVisibility(View.GONE); pairedList.setVisibility(View.GONE);
gridPanel.setVisibility(View.VISIBLE);
radarMode.setEnabled(true); listMode.setEnabled(false);
}
});
radar = new BluetoothRadarView(this);
root.addView(radar, new LinearLayout.LayoutParams(-1, dp(310)));
radar.setVisibility(View.GONE);
radarActions = new LinearLayout(this);
radarActions.setOrientation(LinearLayout.HORIZONTAL);
radarActions.setPadding(dp(4), dp(1), dp(4), dp(1));
radarActions.setBackgroundColor(Color.rgb(207, 216, 220));
radarSelection = new TextView(this);
radarSelection.setText("Tap a radar device");
radarSelection.setTextSize(12);
radarSelection.setSingleLine(true);
radarSelection.setEllipsize(android.text.TextUtils.TruncateAt.END);
radarSelection.setGravity(android.view.Gravity.CENTER_VERTICAL);
radarActions.addView(radarSelection, new LinearLayout.LayoutParams(0, -1, 1));
radarMapButton = new Button(this);
radarMapButton.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) { if (selectedSignal != null) toggleMap(selectedSignal, radarMapButton); }
}); radarActions.addView(radarMapButton, new LinearLayout.LayoutParams(dp(76), dp(36)));
radarTrackButton = new Button(this);
radarTrackButton.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) { if (selectedSignal != null) toggleTrack(selectedSignal, radarTrackButton); }
}); radarActions.addView(radarTrackButton, new LinearLayout.LayoutParams(dp(76), dp(36)));
styleToggle(radarMapButton, false, "MAP");
styleToggle(radarTrackButton, false, "TRACK");
radarActions.setVisibility(View.GONE);
root.addView(radarActions, new LinearLayout.LayoutParams(-1, -2));
gridPanel = new LinearLayout(this);
gridPanel.setOrientation(LinearLayout.VERTICAL);
TextView liveHeader = new TextView(this);
liveHeader.setText("LIVE TRANSMITTERS — tap name or MAC to copy");
liveHeader.setTextColor(Color.WHITE);
liveHeader.setBackgroundColor(Color.rgb(55, 71, 79));
liveHeader.setPadding(dp(12), dp(8), dp(8), dp(8));
gridPanel.addView(liveHeader);
Spinner sortSpinner = new Spinner(this);
final String[] sortOptions = { "Strongest first", "Weakest first", "Name A-Z" };
ArrayAdapter<String> sortAdapter = new ArrayAdapter<String>(this,
android.R.layout.simple_spinner_item, sortOptions);
sortAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
sortSpinner.setAdapter(sortAdapter);
gridPanel.addView(sortSpinner, new LinearLayout.LayoutParams(-1, -2));
signalList = new ListView(this); signalAdapter = new SignalGridAdapter();
signalList.setAdapter(signalAdapter);
gridPanel.addView(signalList, new LinearLayout.LayoutParams(-1, 0, 1));
gridPanel.setVisibility(View.VISIBLE);
listMode.setEnabled(false);
root.addView(gridPanel, new LinearLayout.LayoutParams(-1, 0, 1));
sortSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
signalSort = sortOptions[position]; signalAdapter.notifyDataSetChanged();
}
public void onNothingSelected(AdapterView<?> parent) { }
});
pairedHeader = new TextView(this);
pairedHeader.setText("PAIRED DEVICES • broadcasting only");
pairedHeader.setTextColor(Color.WHITE);
pairedHeader.setTextSize(12);
pairedHeader.setTypeface(null, android.graphics.Typeface.BOLD);
pairedHeader.setPadding(dp(8), dp(2), dp(8), dp(2));
pairedHeader.setSingleLine(true);
pairedHeader.setBackgroundColor(Color.rgb(55, 71, 79));
root.addView(pairedHeader);
pairedList = new ListView(this);
pairedAdapter = new PairedAdapter();
pairedList.setAdapter(pairedAdapter);
root.addView(pairedList, new LinearLayout.LayoutParams(-1, 0, 1));
pairedHeader.setVisibility(View.GONE); pairedList.setVisibility(View.GONE);
setContentView(root);
IntentFilter filter = new IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED);
filter.addAction(BluetoothDevice.ACTION_FOUND);
filter.addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED);
registerReceiver(receiver, filter);
receiverRegistered = true;
loadPaired();
scanButton.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) {
if (scanning) { stopScan(); scanStatus.setText("Radar scan stopped"); }
else startRadarScan();
}
});
radar.setListener(new BluetoothRadarView.Listener() {
public void onTargetSelected(String address) {
SignalEntry entry = signals.get(address);
if (entry != null) {
selectedSignal = entry;
radarSelection.setText(entryLabel(entry) + " " + entry.rssi + " dBm");
refreshRadarToggleButtons();
radarActions.setVisibility(View.VISIBLE);
}
}
});
pairedList.setOnItemClickListener(new android.widget.AdapterView.OnItemClickListener() {
public void onItemClick(android.widget.AdapterView<?> parent, View view,
int position, long id) {
BluetoothDevice device = paired.get(position);
showActions(device, isBroadcasting(safeAddress(device)));
}
});
}
private void startRadarScan() {
if (bluetooth == null) {
Toast.makeText(this, "Bluetooth is unavailable", Toast.LENGTH_LONG).show(); return;
}
try {
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();
scanStatus.setText("Scanning… tap a dot when your item appears");
if (Build.VERSION.SDK_INT >= 21) {
leScanner = bluetooth.getBluetoothLeScanner();
if (leScanner == null) throw new IllegalStateException();
scanCallback = new ScanCallback() {
public void onScanResult(int type, ScanResult result) {
if (result != null) acceptScanResult(result);
}
public void onBatchScanResults(List<ScanResult> results) {
for (int i = 0; i < results.size(); i++)
acceptScanResult(results.get(i));
}
public void onScanFailed(int error) {
stopScan(); scanStatus.setText("BLE scan failed: " + error);
}
};
ScanSettings settings = new ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build();
leScanner.startScan(null, settings, scanCallback);
} else bluetooth.startDiscovery();
handler.post(pruneTask);
} catch (Exception error) {
stopScan();
scanStatus.setText("Bluetooth scan permission is required");
}
}
private void acceptScanResult(ScanResult result) {
if (result == null) return;
String advertisedName = null;
String companyVendor = null;
int appearance = -1;
String broadcastType = null;
int companyId = -1;
String advertisementHex = null;
try {
if (result.getScanRecord() != null) {
advertisedName = result.getScanRecord().getDeviceName();
appearance = parseAppearance(result.getScanRecord().getBytes());
List<ParcelUuid> services = result.getScanRecord().getServiceUuids();
broadcastType = describeServices(services);
BleFingerprintRegistry.Match fingerprint = BleFingerprintRegistry.first(services);
advertisementHex = bytesToHex(result.getScanRecord().getBytes());
SparseArray<byte[]> data = result.getScanRecord().getManufacturerSpecificData();
if (data != null && data.size() > 0) {
int bestKey = data.keyAt(0);
int bestLength = data.valueAt(0) == null ? 0 : data.valueAt(0).length;
for (int i = 1; i < data.size(); i++) {
byte[] payload = data.valueAt(i);
int length = payload == null ? 0 : payload.length;
if (length > bestLength) { bestLength = length; bestKey = data.keyAt(i); }
}
companyId = bestKey;
companyVendor = database.lookupCompany(bestKey);
}
if (fingerprint != null && fingerprint.vendor != null) companyVendor = fingerprint.vendor;
}
} catch (Exception ignored) { }
acceptSignal(result.getDevice(), result.getRssi(), advertisedName, companyVendor, appearance,
broadcastType, companyId, advertisementHex);
}
private String bytesToHex(byte[] bytes)
{
// TODO: Implement this method
return null;
}
private synchronized void acceptSignal(BluetoothDevice device, int rssi) {
acceptSignal(device, rssi, null, null, -1, null, -1, null);
}
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); }
entry.device = device; entry.rssi = rssi; entry.seenAt = System.currentTimeMillis();
String cachedName = safeName(device);
if (useful(advertisedName)) entry.broadcastName = advertisedName;
else if (!useful(entry.broadcastName) && useful(cachedName)) entry.broadcastName = cachedName;
if (companyVendor != null) entry.vendorName = companyVendor;
else if (entry.vendorName == null) entry.vendorName = database.lookupOui(address);
if (broadcastType != null) entry.broadcastType = broadcastType;
DeviceRecord savedRecord = database.get(address);
if (savedRecord != null) entry.customName = savedRecord.customName;
if (savedRecord != null && savedRecord.vendorManual == 1) entry.vendorName = savedRecord.vendorName;
entry.category = classify(device, entry.broadcastName, (entry.vendorName == null ? "" : entry.vendorName) + " " + (entry.broadcastType == null ? "" : entry.broadcastType), appearance);
if (savedRecord != null && savedRecord.categoryManual == 1) entry.category = savedRecord.category;
database.telemetry(address, entry.broadcastName, null, Integer.valueOf(rssi),
entry.seenAt, null);
database.setDetectedCategory(address, entry.category);
database.recordObservation(address, entry.seenAt, rssi, entry.broadcastName,
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(); }
});
}
private TrackerDatabase.Fix recentObservationFix() {
long now = System.currentTimeMillis();
if (now - cachedObservationFixAt < 30000L) return cachedObservationFix;
cachedObservationFixAt = now;
try {
LocationManager manager = (LocationManager)getSystemService(LOCATION_SERVICE);
Location best = null;
List<String> providers = manager.getProviders(true);
for (int i = 0; i < providers.size(); i++) {
Location candidate = manager.getLastKnownLocation(providers.get(i));
if (candidate != null && (best == null || candidate.getTime() > best.getTime())) best = candidate;
}
if (best != null && now - best.getTime() <= 600000L)
cachedObservationFix = new TrackerDatabase.Fix(best.getLatitude(), best.getLongitude(), best.getAccuracy());
else cachedObservationFix = null;
} catch (Exception ignored) { cachedObservationFix = null; }
return cachedObservationFix;
}
private final Runnable pruneTask = new Runnable() {
public void run() {
long cutoff = System.currentTimeMillis() - 12000L;
ArrayList<String> expired = new ArrayList<String>();
synchronized (DeviceManagerActivity.this) {
for (Map.Entry<String, SignalEntry> 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));
if (selectedSignal != null && !signals.containsValue(selectedSignal)) {
selectedSignal = null; radarActions.setVisibility(View.GONE);
}
}
refreshCategoryOptions(); updateRadar(); pairedAdapter.notifyDataSetChanged();
handler.postDelayed(this, 1000L);
}
};
private synchronized void refreshCategoryOptions() {
if (categoryAdapter == null || categorySpinner == null) return;
int[] counts = new int[CATEGORY_ORDER.length];
for (SignalEntry entry : signals.values()) {
for (int i = 0; i < CATEGORY_ORDER.length; i++) {
if (CATEGORY_ORDER[i].equals(entry.category)) { counts[i]++; break; }
}
}
ArrayList<String> nextValues = new ArrayList<String>();
ArrayList<String> nextLabels = new ArrayList<String>();
nextValues.add("All"); nextLabels.add("All (" + signals.size() + ")");
for (int i = 0; i < CATEGORY_ORDER.length; i++) {
if (counts[i] > 0) {
nextValues.add(CATEGORY_ORDER[i]);
nextLabels.add(CATEGORY_ORDER[i] + " (" + counts[i] + ")");
}
}
int selected = nextValues.indexOf(categoryFilter);
if (selected < 0) { categoryFilter = "All"; selected = 0; }
if (!nextLabels.equals(categoryLabels)) {
categoryValues.clear(); categoryValues.addAll(nextValues);
categoryLabels.clear(); categoryLabels.addAll(nextLabels);
categoryAdapter.notifyDataSetChanged();
}
if (categorySpinner.getSelectedItemPosition() != selected) categorySpinner.setSelection(selected);
}
private synchronized void updateRadar() {
if (selectedSignal != null && (!signals.containsValue(selectedSignal) ||
(!"All".equals(categoryFilter) && !categoryFilter.equals(selectedSignal.category)))) {
selectedSignal = null;
if (radarActions != null) radarActions.setVisibility(View.GONE);
}
ArrayList<BluetoothRadarView.Target> targets = new ArrayList<BluetoothRadarView.Target>();
for (Map.Entry<String, SignalEntry> item : signals.entrySet()) {
SignalEntry entry = item.getValue();
if (!"All".equals(categoryFilter) && !categoryFilter.equals(entry.category)) continue;
targets.add(new BluetoothRadarView.Target(item.getKey(),
entryLabel(entry) + (entry.broadcastType == null ? "" : "" + entry.broadcastType) +
" [" + entry.category + "]", entry.rssi));
}
if (radar != null) radar.setTargets(targets);
if (signalAdapter != null) signalAdapter.notifyDataSetChanged();
scanStatus.setText(targets.size() + " shown of " + signals.size() + " currently broadcasting");
}
private synchronized ArrayList<SignalEntry> visibleSignals() {
ArrayList<SignalEntry> rows = new ArrayList<SignalEntry>();
for (SignalEntry entry : signals.values()) {
if ("All".equals(categoryFilter) || categoryFilter.equals(entry.category)) rows.add(entry);
}
Collections.sort(rows, new Comparator<SignalEntry>() {
public int compare(SignalEntry a, SignalEntry b) {
if ("Weakest first".equals(signalSort)) return a.rssi - b.rssi;
if ("Name A-Z".equals(signalSort)) return entryLabel(a).compareToIgnoreCase(entryLabel(b));
return b.rssi - a.rssi;
}
});
return rows;
}
private void copyText(String label, String value) {
ClipboardManager clipboard = (ClipboardManager)getSystemService(CLIPBOARD_SERVICE);
clipboard.setPrimaryClip(ClipData.newPlainText(label, value));
Toast.makeText(this, label + " copied", Toast.LENGTH_SHORT).show();
}
private void editSignal(final SignalEntry entry) {
final String address = safeAddress(entry.device);
LinearLayout form = new LinearLayout(this); form.setOrientation(LinearLayout.VERTICAL);
form.setPadding(dp(18), dp(8), dp(18), 0);
final EditText name = new EditText(this);
name.setHint("Custom device name"); name.setText(entryLabel(entry)); form.addView(name);
final EditText vendor = new EditText(this);
vendor.setHint("Manufacturer / vendor");
vendor.setText(entry.vendorName == null ? "" : entry.vendorName); form.addView(vendor);
final Spinner category = new Spinner(this);
final String[] choices = { "Trackers / Tags", "Audio", "Wearables", "Phones", "Computers", "Vehicles", "Other" };
ArrayAdapter<String> choicesAdapter = new ArrayAdapter<String>(this,
android.R.layout.simple_spinner_item, choices);
choicesAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
category.setAdapter(choicesAdapter);
int selected = 0; for (int i = 0; i < choices.length; i++) if (choices[i].equals(entry.category)) selected = i;
category.setSelection(selected); form.addView(category);
TextView facts = new TextView(this);
facts.setText("MAC: " + address + "\nDetected manufacturer: " +
(entry.vendorName == null ? "Not supplied / unresolved" : entry.vendorName) +
"\nBroadcast: " + (entry.broadcastType == null ? "General advertisement" : entry.broadcastType));
facts.setPadding(0, dp(10), 0, 0); form.addView(facts);
new AlertDialog.Builder(this).setTitle("Edit transmitting device").setView(form)
.setPositiveButton("SAVE", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
entry.customName = name.getText().toString().trim();
entry.category = choices[category.getSelectedItemPosition()];
database.setCustomName(address, entry.customName);
entry.vendorName = vendor.getText().toString().trim();
database.setManualVendor(address, entry.vendorName);
database.setCategory(address, entry.category);
database.markTracked(address, entryLabel(entry));
refreshCategoryOptions(); updateRadar();
}
}).setNegativeButton("CANCEL", null)
.setNeutralButton("REMOVE TRACKING", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) { confirmRemoveSignal(entry); }
}).show();
}
private void confirmRemoveSignal(final SignalEntry entry) {
final String address = safeAddress(entry.device);
new AlertDialog.Builder(this).setTitle("Remove from tracked items?")
.setMessage("The device will remain in sighting history and will continue to appear in live scans when this Bluetooth ID broadcasts.")
.setPositiveButton("REMOVE", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
database.removeFromTracking(address);
if (selectedSignal == entry) refreshRadarToggleButtons();
signalAdapter.notifyDataSetChanged();
sendBroadcast(new Intent(BluetoothTrackingService.ACTION_CONFIG_CHANGED).setPackage(getPackageName()));
Toast.makeText(DeviceManagerActivity.this, "Tracking removed; sighting history retained", Toast.LENGTH_LONG).show();
}
}).setNegativeButton("CANCEL", null).show();
}
private void showActions(final BluetoothDevice device, boolean broadcasting) {
final String address = safeAddress(device);
final boolean pairedNow = device.getBondState() == BluetoothDevice.BOND_BONDED;
if (broadcasting) {
new AlertDialog.Builder(this).setTitle(broadcasting && signals.get(address) != null ? entryLabel(signals.get(address)) : displayName(device))
.setMessage(address + "\nBroadcast: " + (signals.get(address).broadcastType == null ? "General advertisement" : signals.get(address).broadcastType) + "\nCurrent signal: " + signals.get(address).rssi + " dBm")
.setItems(new String[] { "✓ SHOW ON MAP", "LIVE TRACK", pairedNow ? "Already paired" :
"Pair and save item", "Request connect", "Request disconnect" },
new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
if (which == 0) addToMap(device);
else if (which == 1) startLive(device);
else if (which == 2 && !pairedNow) pair(device);
else if (which == 3) invoke(device, "connect");
else if (which == 4) invoke(device, "disconnect");
}
}).show();
} else {
new AlertDialog.Builder(this).setTitle(broadcasting && signals.get(address) != null ? entryLabel(signals.get(address)) : displayName(device))
.setMessage("No current BLE RSSI signal. Live Find is unavailable until this device broadcasts.")
.setItems(new String[] { pairedNow ? "Already paired" : "Pair item",
"Request connect", "Request disconnect" },
new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
if (which == 0 && !pairedNow) pair(device);
else if (which == 1) invoke(device, "connect");
else if (which == 2) invoke(device, "disconnect");
}
}).show();
}
}
private void styleToggle(Button button, boolean enabled, String label) {
button.setText(label + (enabled ? " ON" : " OFF"));
styleActionButton(button, enabled ? Color.rgb(46, 125, 50) : Color.rgb(96, 125, 139));
}
private void styleActionButton(Button button, int color) {
android.graphics.drawable.GradientDrawable shape = new android.graphics.drawable.GradientDrawable();
shape.setColor(color);
shape.setCornerRadius(dp(18));
button.setBackgroundDrawable(shape);
button.setTextColor(Color.WHITE);
button.setTextSize(11);
button.setMinWidth(0);
button.setMinimumWidth(0);
button.setMinHeight(0);
button.setMinimumHeight(0);
button.setPadding(dp(5), 0, dp(5), 0);
}
private LinearLayout.LayoutParams listActionButtonParams() {
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(dp(78), dp(40));
params.setMargins(dp(2), dp(2), dp(2), dp(2));
return params;
}
private void refreshRadarToggleButtons() {
if (selectedSignal == null) return;
DeviceRecord record = database.get(safeAddress(selectedSignal.device));
styleToggle(radarMapButton, record != null && record.mapEnabled == 1, "MAP");
styleToggle(radarTrackButton, record != null && record.liveEnabled == 1, "TRACK");
}
private void toggleMap(SignalEntry entry, Button button) {
String address = safeAddress(entry.device);
DeviceRecord record = database.get(address);
boolean enable = record == null || record.mapEnabled != 1;
if (!enable) {
database.setMapEnabled(address, false); styleToggle(button, false, "MAP");
Toast.makeText(this, "Removed from the tracked map", Toast.LENGTH_SHORT).show();
return;
}
database.setMapEnabled(address, true); styleToggle(button, true, "MAP");
addToMap(entry.device);
}
private void toggleTrack(SignalEntry entry, Button button) {
String address = safeAddress(entry.device);
DeviceRecord record = database.get(address);
boolean enable = record == null || record.liveEnabled != 1;
database.setLiveEnabled(address, enable); styleToggle(button, enable, "TRACK");
if (enable) startLive(entry.device);
else Toast.makeText(this, "Live tracking toggled off", Toast.LENGTH_SHORT).show();
}
private void addToMap(final BluetoothDevice device) {
stopScan();
final String address = safeAddress(device);
final SignalEntry entry = signals.get(address);
String selectedName = entry != null && useful(entry.broadcastName)
? entry.broadcastName : (entry != null ? entryLabel(entry) : displayName(device));
database.markTracked(address, selectedName);
database.setMapEnabled(address, true);
if (entry != null && entry.vendorName != null) database.setVendor(address, entry.vendorName);
if (entry != null) database.setDetectedCategory(address, entry.category);
try {
final LocationManager manager = (LocationManager)getSystemService(LOCATION_SERVICE);
Location best = null;
List<String> providers = manager.getProviders(true);
for (int i = 0; i < providers.size(); i++) {
Location candidate = manager.getLastKnownLocation(providers.get(i));
if (candidate != null && (best == null || candidate.getTime() > best.getTime()))
best = candidate;
}
if (best != null && System.currentTimeMillis() - best.getTime() <= 120000L) {
saveMapLocation(address, best); return;
}
final boolean[] saved = new boolean[] { false };
LocationListener listener = new LocationListener() {
public void onLocationChanged(Location location) {
if (saved[0]) return; saved[0] = true;
try { manager.removeUpdates(this); } catch (Exception ignored) { }
saveMapLocation(address, location);
}
public void onProviderEnabled(String provider) { }
public void onProviderDisabled(String provider) { }
public void onStatusChanged(String provider, int status, Bundle extras) { }
};
if (manager.isProviderEnabled(LocationManager.GPS_PROVIDER))
manager.requestSingleUpdate(LocationManager.GPS_PROVIDER, listener, null);
else if (manager.isProviderEnabled(LocationManager.NETWORK_PROVIDER))
manager.requestSingleUpdate(LocationManager.NETWORK_PROVIDER, listener, null);
else throw new IllegalStateException("Location is disabled");
Toast.makeText(this, "Waiting for a GPS fix…", Toast.LENGTH_SHORT).show();
} catch (Exception error) {
Toast.makeText(this, "Location permission and GPS are required to place this item", Toast.LENGTH_LONG).show();
}
}
private void saveMapLocation(String address, Location location) {
if (location == null) return;
database.updateLocation(address, new TrackerDatabase.Fix(location.getLatitude(),
location.getLongitude(), location.getAccuracy()), System.currentTimeMillis());
Intent map = new Intent(this, MapActivity.class);
map.putExtra("focus", address);
startActivity(map);
}
private void startLive(BluetoothDevice device) {
String address = safeAddress(device);
SignalEntry entry = signals.get(address);
if (entry == null || System.currentTimeMillis() - entry.seenAt > 12000L) {
Toast.makeText(this, "The signal is no longer being received", Toast.LENGTH_LONG).show();
return;
}
String selectedName = useful(entry.broadcastName) ? entry.broadcastName : entryLabel(entry);
database.setLiveEnabled(address, true);
stopScan();
database.markTracked(address, selectedName);
database.telemetry(address, selectedName, null, Integer.valueOf(entry.rssi),
System.currentTimeMillis(), null);
Intent intent = new Intent(this, LiveRangeFinderActivity.class);
intent.putExtra("address", address);
startActivity(intent);
}
private void pair(BluetoothDevice device) {
database.markTracked(safeAddress(device), safeName(device));
database.setVendor(safeAddress(device), database.lookupOui(safeAddress(device)));
try {
if (Build.VERSION.SDK_INT >= 19) device.createBond();
else BluetoothDevice.class.getMethod("createBond").invoke(device);
Toast.makeText(this, "Pairing started", Toast.LENGTH_SHORT).show();
} catch (Exception error) {
Toast.makeText(this, "Pairing request failed", Toast.LENGTH_LONG).show();
}
}
private void invoke(BluetoothDevice device, String action) {
try {
Method method = device.getClass().getMethod(action); method.invoke(device);
Toast.makeText(this, action + " request sent", Toast.LENGTH_SHORT).show();
} catch (Exception error) {
Toast.makeText(this, "This device profile does not expose " + action + " to apps",
Toast.LENGTH_LONG).show();
}
}
private void loadPaired() {
paired.clear();
try { if (bluetooth != null) paired.addAll(bluetooth.getBondedDevices()); }
catch (Exception ignored) { }
if (pairedAdapter != null) pairedAdapter.notifyDataSetChanged();
}
private synchronized boolean isBroadcasting(String address) {
SignalEntry entry = signals.get(address);
return entry != null && System.currentTimeMillis() - entry.seenAt <= 12000L;
}
private String describeServices(List<ParcelUuid> services) {
if (services == null || services.isEmpty()) return null;
ArrayList<String> names = new ArrayList<String>();
for (int i = 0; i < services.size() && names.size() < 3; i++) {
String raw = services.get(i).toString();
String shortUuid = BleFingerprintRegistry.shortUuid(raw);
BleFingerprintRegistry.Match match = BleFingerprintRegistry.lookup(shortUuid);
String name = match == null ? "Service " + (shortUuid.length() <= 8 ? shortUuid : raw.substring(0, 8)) :
match.label + " [" + shortUuid + "]";
if (!names.contains(name)) names.add(name);
}
StringBuilder result = new StringBuilder();
for (int i = 0; i < names.size(); i++) {
if (i > 0) result.append(" + ");
result.append(names.get(i));
}
return result.length() == 0 ? null : result.toString();
}
private int parseAppearance(byte[] bytes) {
if (bytes == null) return -1;
int index = 0;
while (index < bytes.length) {
int length = bytes[index] & 0xff;
if (length == 0 || index + length >= bytes.length) break;
if ((bytes[index + 1] & 0xff) == 0x19 && length >= 3)
return (bytes[index + 2] & 0xff) | ((bytes[index + 3] & 0xff) << 8);
index += length + 1;
}
return -1;
}
private String classify(BluetoothDevice device, String broadcast, String vendor, int appearance) {
String text = ((broadcast == null ? "" : broadcast) + " " +
(vendor == null ? "" : vendor)).toLowerCase(java.util.Locale.US);
if (text.indexOf("tile") >= 0 || text.indexOf("airtag") >= 0 ||
text.indexOf("smarttag") >= 0 || text.indexOf("tracker") >= 0 ||
text.indexOf("key finder") >= 0 || text.indexOf("tag") >= 0 || text.indexOf("eddystone") >= 0 ||
text.indexOf("immediate alert") >= 0 || text.indexOf("link loss") >= 0) return "Trackers / Tags";
if (text.indexOf("airpod") >= 0 || text.indexOf("headphone") >= 0 ||
text.indexOf("earbud") >= 0 || text.indexOf("speaker") >= 0 ||
text.indexOf("bose") >= 0 || text.indexOf("jbl") >= 0) return "Audio";
if (text.indexOf("watch") >= 0 || text.indexOf("band") >= 0 ||
text.indexOf("fitbit") >= 0 || text.indexOf("wear") >= 0 || text.indexOf("heart rate") >= 0) return "Wearables";
if (text.indexOf("hid / input") >= 0 || text.indexOf("keyboard") >= 0 ||
text.indexOf("mouse") >= 0) return "Computers";
if (text.indexOf("car") >= 0 || text.indexOf("vehicle") >= 0 ||
text.indexOf("tesla") >= 0 || text.indexOf("auto") >= 0) return "Vehicles";
int group = appearance & 0xffc0;
if (group == 0x0200 || group == 0x0240) return "Trackers / Tags";
if (group == 0x0280) return "Audio";
if (group == 0x00c0 || group == 0x01c0 || group == 0x0340) return "Wearables";
if (group == 0x0040) return "Phones";
if (group == 0x0080) return "Computers";
try {
BluetoothClass value = device.getBluetoothClass();
if (value != null) {
int major = value.getMajorDeviceClass();
if (major == BluetoothClass.Device.Major.AUDIO_VIDEO) return "Audio";
if (major == BluetoothClass.Device.Major.PHONE) return "Phones";
if (major == BluetoothClass.Device.Major.COMPUTER) return "Computers";
if (major == BluetoothClass.Device.Major.WEARABLE || major == BluetoothClass.Device.Major.HEALTH) return "Wearables";
}
} catch (Exception ignored) { }
return "Other";
}
private String entryLabel(SignalEntry entry) {
if (entry == null) return "Unknown BLE Device";
if (useful(entry.customName)) return entry.customName;
if (useful(entry.broadcastName)) return entry.broadcastName;
if (useful(entry.vendorName)) return entry.vendorName + " device";
return uniqueFallback(safeAddress(entry.device));
}
private boolean useful(String value) {
return value != null && value.trim().length() > 0 &&
!"Unknown Device".equalsIgnoreCase(value) &&
!"Unknown item".equalsIgnoreCase(value) &&
!"BLE".equalsIgnoreCase(value.trim()) &&
!"Bluetooth LE".equalsIgnoreCase(value.trim());
}
private String uniqueFallback(String address) {
if (address == null) return "Unknown BLE Device";
String compact = address.replace(":", "");
String suffix = compact.length() > 6 ? compact.substring(compact.length() - 6) : compact;
return "BLE Device " + suffix;
}
private String displayName(BluetoothDevice device) {
String name = safeName(device);
if (!"Unknown Device".equals(name)) return name;
String vendor = database.lookupOui(safeAddress(device));
DeviceRecord saved = database.get(safeAddress(device));
if (saved != null && useful(saved.displayName())) return saved.displayName();
return vendor == null ? uniqueFallback(safeAddress(device)) : vendor + " device";
}
private String safeName(BluetoothDevice device) {
try { String value = device.getName(); return value == null || value.length() == 0
? "Unknown Device" : value; }
catch (Exception error) { return "Unknown Device"; }
}
private String safeAddress(BluetoothDevice device) {
try { return device.getAddress(); } catch (Exception error) { return "Unavailable"; }
}
private void stopScan() {
scanning = false;
if (radar != null) radar.setScanning(false);
if (scanButton != null) scanButton.setText("START RADAR SCAN");
handler.removeCallbacks(pruneTask);
try { if (leScanner != null && scanCallback != null) leScanner.stopScan(scanCallback); }
catch (Exception ignored) { }
try { if (bluetooth != null && bluetooth.isDiscovering()) bluetooth.cancelDiscovery(); }
catch (Exception ignored) { }
}
private int dp(int value) {
return (int)(value * getResources().getDisplayMetrics().density + 0.5f);
}
protected void onPause() { stopScan(); super.onPause(); }
protected void onDestroy() {
stopScan(); handler.removeCallbacksAndMessages(null);
if (receiverRegistered) unregisterReceiver(receiver);
database.close(); super.onDestroy();
}
private class SignalGridAdapter extends BaseAdapter {
public int getCount() { return visibleSignals().size(); }
public Object getItem(int position) { return visibleSignals().get(position); }
public long getItemId(int position) { return position; }
public View getView(int position, View old, ViewGroup parent) {
final SignalEntry entry = visibleSignals().get(position);
final String address = safeAddress(entry.device);
DeviceRecord toggleRecord = database.get(address);
LinearLayout row = new LinearLayout(DeviceManagerActivity.this);
row.setOrientation(LinearLayout.VERTICAL);
row.setPadding(dp(12), dp(8), dp(10), dp(8));
row.setBackgroundColor(position % 2 == 0 ? Color.WHITE : Color.rgb(224, 236, 247));
TextView name = new TextView(DeviceManagerActivity.this);
name.setText(entryLabel(entry) + " " + entry.rssi + " dBm");
name.setTextSize(17); name.setTypeface(null, android.graphics.Typeface.BOLD);
name.setTextColor(Color.rgb(21, 101, 192));
name.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) { copyText("Device name", entryLabel(entry)); }
}); row.addView(name);
TextView columns = new TextView(DeviceManagerActivity.this);
columns.setText((entry.vendorName == null ? "Manufacturer unavailable" : entry.vendorName) +
"" + entry.category +
(entry.broadcastType == null ? "" : "" + entry.broadcastType));
row.addView(columns);
LinearLayout bottom = new LinearLayout(DeviceManagerActivity.this);
bottom.setGravity(android.view.Gravity.CENTER_VERTICAL);
TextView mac = new TextView(DeviceManagerActivity.this);
mac.setText(address); mac.setTextColor(Color.rgb(0, 121, 107));
mac.setGravity(android.view.Gravity.CENTER_VERTICAL);
mac.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) { copyText("MAC address", address); }
});
bottom.addView(mac, new LinearLayout.LayoutParams(0, -1, 1));
Button edit = new Button(DeviceManagerActivity.this); edit.setText("EDIT");
styleActionButton(edit, Color.rgb(69, 90, 100));
edit.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) { editSignal(entry); }
}); bottom.addView(edit, listActionButtonParams());
Button map = new Button(DeviceManagerActivity.this);
styleToggle(map, toggleRecord != null && toggleRecord.mapEnabled == 1, "MAP");
map.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) { toggleMap(entry, (Button)view); }
}); bottom.addView(map, listActionButtonParams());
Button track = new Button(DeviceManagerActivity.this);
styleToggle(track, toggleRecord != null && toggleRecord.liveEnabled == 1, "TRACK");
track.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) { toggleTrack(entry, (Button)view); }
}); bottom.addView(track, listActionButtonParams());
row.addView(bottom);
return row;
}
}
private class PairedAdapter extends BaseAdapter {
public int getCount() { return paired.size(); }
public Object getItem(int position) { return paired.get(position); }
public long getItemId(int position) { return position; }
public View getView(int position, View old, ViewGroup parent) {
BluetoothDevice device = paired.get(position);
LinearLayout row = new LinearLayout(DeviceManagerActivity.this);
row.setOrientation(LinearLayout.VERTICAL);
row.setPadding(dp(16), dp(10), dp(12), dp(10));
row.setBackgroundColor(position % 2 == 0 ? Color.WHITE : Color.rgb(224, 236, 247));
TextView name = new TextView(DeviceManagerActivity.this);
String address = safeAddress(device);
SignalEntry signal = signals.get(address);
name.setText(signal == null ? displayName(device) : entryLabel(signal)); name.setTextSize(17);
name.setTypeface(null, android.graphics.Typeface.BOLD); row.addView(name);
TextView detail = new TextView(DeviceManagerActivity.this);
detail.setText(address + (signal == null ? " • Paired, not broadcasting" :
" • BROADCASTING " + signal.rssi + " dBm"));
detail.setTextColor(signal == null ? Color.GRAY : Color.rgb(25, 118, 210));
row.addView(detail); return row;
}
}
}
@@ -0,0 +1,47 @@
package com.wytehat.btlogger;
public class DeviceRecord {
public String address;
public String name;
public String customName;
public String broadcastName;
public String vendorName;
public String category = "Other";
public int categoryManual;
public int vendorManual;
public int mapEnabled;
public int liveEnabled;
public int locationLocked;
public int showTrail = 1;
public int trackBroadcast;
public String reason;
public String photoUri;
public String iconPreset;
public String pinColor = "#E53935";
public long connectedAt;
public long disconnectedAt;
public long updatedAt;
public long lastRssiAt;
public double latitude;
public double longitude;
public float accuracy;
public int battery = -1;
public int rssi;
public int connected;
public int gpsPollingRate = 900000;
public int trackingPriority = 15;
public int tracked;
public int txPowerAtOneMeter = -59;
public double pathLossExponent = 2.7;
public boolean hasLocation;
public String displayName() {
if (customName != null && customName.trim().length() > 0) return customName;
if (broadcastName != null && broadcastName.trim().length() > 0 &&
!"Unknown item".equals(broadcastName)) return broadcastName;
if (vendorName != null && vendorName.trim().length() > 0) return vendorName;
if (name != null && name.trim().length() > 0 && !"Unknown item".equals(name)) return name;
return "Unknown Device";
}
}
@@ -0,0 +1,8 @@
package com.wytehat.btlogger;
public interface ItemActionListener {
void edit(DeviceRecord record);
void map(DeviceRecord record);
void manage(DeviceRecord record);
void live(DeviceRecord record);
}
@@ -0,0 +1,575 @@
package com.wytehat.btlogger;
import android.app.Activity;
import android.app.AlertDialog;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.le.BluetoothLeScanner;
import android.bluetooth.le.ScanCallback;
import android.bluetooth.le.ScanFilter;
import android.bluetooth.le.ScanResult;
import android.bluetooth.le.ScanSettings;
import android.content.DialogInterface;
import android.content.Intent;
import android.graphics.Color;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.view.Gravity;
import android.view.View;
import android.webkit.WebView;
import android.widget.Button;
import android.widget.CompoundButton;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.Switch;
import android.widget.TextView;
import android.widget.Toast;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
public class LiveRangeFinderActivity extends Activity implements LocationListener,
SensorEventListener {
private TrackerDatabase db;
private DeviceRecord target;
private BluetoothLeScanner scanner;
private ScanCallback scanCallback;
private LocationManager locationManager;
private SensorManager sensorManager;
private Sensor rotationSensor;
private Location currentLocation;
private WebView map;
private TextView stateText, rssiText, distanceText, trendText, headingText, estimateText;
private Button startButton, stopButton, calibrationButton, tagButton;
private final Handler handler = new Handler();
private final RssiKalmanFilter filter = new RssiKalmanFilter();
private final RangeStateMachine stateMachine = new RangeStateMachine();
private final SpatialGradientEngine spatial = new SpatialGradientEngine();
private NativeFeedbackController feedback;
private final ArrayList<Double> recentRssi = new ArrayList<Double>();
private final ArrayList<Integer> calibrationSamples = new ArrayList<Integer>();
private boolean searching, calibrating, mapReady;
private long lastSignalTime, lastLoggedTime;
private float heading;
private long lastHeadingMapUpdate;
private String headingSource = "Compass";
private double lastRangeDistance;
private double lastFilteredRssi = -100.0;
private String lastRangeState = RangeStateMachine.LOST;
private String sessionId;
protected void onCreate(Bundle savedState) {
super.onCreate(savedState);
db = new TrackerDatabase(this);
String address = getIntent().getStringExtra("address");
target = db.get(address);
double sharedExponent = getSharedPreferences("range_calibration", 0).getFloat("environment_exponent", 0f);
if (target != null && sharedExponent >= 1.2 && sharedExponent <= 5.0 &&
Math.abs(target.pathLossExponent - 2.7) < 0.01) target.pathLossExponent = sharedExponent;
if (target == null) {
Toast.makeText(this, "Tracked item was not found", Toast.LENGTH_LONG).show();
finish();
return;
}
feedback = new NativeFeedbackController(this);
locationManager = (LocationManager) getSystemService(LOCATION_SERVICE);
sensorManager = (SensorManager) getSystemService(SENSOR_SERVICE);
rotationSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR);
buildInterface();
buildScanCallback();
loadMap();
if (target.liveEnabled == 1) {
handler.postDelayed(new Runnable() {
public void run() { if (!searching) startSearch(); }
}, 500L);
}
}
private void buildInterface() {
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setBackgroundColor(Color.rgb(245, 247, 250));
LinearLayout header = new LinearLayout(this);
header.setGravity(Gravity.CENTER_VERTICAL);
header.setPadding(dp(6), dp(5), dp(10), dp(5));
header.setBackgroundColor(Color.rgb(25, 63, 105));
Button back = new Button(this);
back.setText(""); back.setTextSize(24);
header.addView(back, new LinearLayout.LayoutParams(dp(48), dp(40)));
TextView title = new TextView(this);
title.setText("Live Find: " + target.displayName());
title.setTextColor(Color.WHITE); title.setTextSize(17); title.setGravity(Gravity.CENTER);
title.setTypeface(null, android.graphics.Typeface.BOLD);
header.addView(title, new LinearLayout.LayoutParams(0, dp(40), 1));
root.addView(header);
back.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) { finish(); }
});
LinearLayout controls = new LinearLayout(this);
controls.setOrientation(LinearLayout.HORIZONTAL);
startButton = button("START LIVE SEARCH");
stopButton = button("STOP"); stopButton.setEnabled(false);
calibrationButton = button("CALIBRATE 1 m"); calibrationButton.setEnabled(false);
controls.addView(startButton, new LinearLayout.LayoutParams(0, dp(40), 1));
controls.addView(stopButton, new LinearLayout.LayoutParams(0, dp(40), 1));
controls.addView(calibrationButton, new LinearLayout.LayoutParams(0, dp(40), 1));
root.addView(controls);
tagButton = button("✓ I FOUND IT");
tagButton.setBackgroundColor(Color.rgb(0, 121, 107));
tagButton.setTextColor(Color.WHITE);
root.addView(tagButton, new LinearLayout.LayoutParams(-1, dp(40)));
LinearLayout toggles = new LinearLayout(this);
toggles.setGravity(Gravity.CENTER);
Switch audio = new Switch(this); audio.setText("Audio"); audio.setChecked(false);
Switch haptic = new Switch(this); haptic.setText("Haptic"); haptic.setChecked(true);
toggles.addView(audio); toggles.addView(haptic);
root.addView(toggles);
audio.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
public void onCheckedChanged(CompoundButton button, boolean checked) {
feedback.setAudioEnabled(checked);
}
});
haptic.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
public void onCheckedChanged(CompoundButton button, boolean checked) {
feedback.setHapticEnabled(checked);
}
});
LinearLayout telemetry = new LinearLayout(this);
telemetry.setOrientation(LinearLayout.VERTICAL);
telemetry.setPadding(dp(10), dp(3), dp(10), dp(3));
stateText = label("READY — hold the phone at chest height");
stateText.setTextSize(17); stateText.setTypeface(null, android.graphics.Typeface.BOLD);
rssiText = label("RSSI: waiting");
distanceText = label("Approximate range: waiting");
trendText = label("Trend: waiting");
headingText = label("Heading: waiting for compass");
estimateText = label("Collecting 4 readings per station • walk 815 m between stations");
telemetry.addView(stateText); telemetry.addView(rssiText); telemetry.addView(estimateText);
root.addView(telemetry);
map = new WebView(this);
map.getSettings().setJavaScriptEnabled(true);
root.addView(map, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1));
setContentView(root);
startButton.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) { startSearch(); }
});
stopButton.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) { db.setLiveEnabled(target.address, false); stopSearch("Search stopped"); }
});
tagButton.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) { tagItemHere(); }
});
calibrationButton.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) { startCalibration(); }
});
}
private Button button(String text) {
Button value = new Button(this); value.setText(text); value.setTextSize(9); return value;
}
private TextView label(String text) {
TextView value = new TextView(this); value.setText(text); value.setTextSize(12);
value.setTextColor(Color.rgb(35, 45, 55)); value.setPadding(0, dp(1), 0, dp(1));
return value;
}
private void buildScanCallback() {
if (Build.VERSION.SDK_INT < 21) return;
scanCallback = new ScanCallback() {
public void onScanResult(int callbackType, ScanResult result) {
if (result == null || result.getDevice() == null) return;
String address;
try { address = result.getDevice().getAddress(); }
catch (Exception error) { return; }
if (target.address.equalsIgnoreCase(address)) processRssi(result.getRssi());
}
public void onBatchScanResults(List<ScanResult> results) {
for (int i = 0; i < results.size(); i++) onScanResult(0, results.get(i));
}
public void onScanFailed(int errorCode) {
Toast.makeText(LiveRangeFinderActivity.this,
"BLE scan failed: " + errorCode, Toast.LENGTH_LONG).show();
stopSearch("Scanner unavailable");
}
};
}
private void startSearch() {
if (Build.VERSION.SDK_INT < 21) {
Toast.makeText(this, "Live BLE ranging requires Android 5 or newer",
Toast.LENGTH_LONG).show(); return;
}
BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
if (adapter == null || !adapter.isEnabled()) {
Toast.makeText(this, "Turn Bluetooth on first", Toast.LENGTH_LONG).show(); return;
}
scanner = adapter.getBluetoothLeScanner();
if (scanner == null) {
Toast.makeText(this, "BLE scanner is unavailable", Toast.LENGTH_LONG).show(); return;
}
filter.reset(); stateMachine.signalLost(); spatial.clear(); recentRssi.clear(); lastRangeDistance = 0; lastFilteredRssi = -100.0; lastRangeState = RangeStateMachine.LOST;
sessionId = target.address.replace(":", "") + "-" + System.currentTimeMillis();
try {
ArrayList<ScanFilter> filters = new ArrayList<ScanFilter>();
filters.add(new ScanFilter.Builder().setDeviceAddress(target.address).build());
ScanSettings settings = new ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build();
scanner.startScan(filters, settings, scanCallback);
searching = true; lastSignalTime = System.currentTimeMillis();
startButton.setEnabled(false); stopButton.setEnabled(true);
calibrationButton.setEnabled(true);
stateText.setText("SEARCHING — move slowly and watch the trend");
requestLocation(); registerSensors(); handler.post(signalWatchdog);
sendBroadcast(new Intent(BluetoothTrackingService.ACTION_RANGE_MODE)
.putExtra("active", true).setPackage(getPackageName()));
} catch (Exception error) {
Toast.makeText(this, "Bluetooth scan permission or target address unavailable",
Toast.LENGTH_LONG).show();
}
}
private void stopSearch(String message) {
searching = false; calibrating = false;
handler.removeCallbacks(signalWatchdog); handler.removeCallbacks(finishCalibration);
try { if (scanner != null && scanCallback != null) scanner.stopScan(scanCallback); }
catch (Exception ignored) { }
try { locationManager.removeUpdates(this); } catch (Exception ignored) { }
try { sensorManager.unregisterListener(this); } catch (Exception ignored) { }
feedback.stop(); stateMachine.signalLost();
if (stateText != null) { stateText.setText(message); stateText.setTextColor(Color.DKGRAY); }
if (startButton != null) startButton.setEnabled(true);
if (stopButton != null) stopButton.setEnabled(false);
if (calibrationButton != null) calibrationButton.setEnabled(false);
sendBroadcast(new Intent(BluetoothTrackingService.ACTION_RANGE_MODE)
.putExtra("active", false).setPackage(getPackageName()));
}
private void processRssi(final int rawRssi) {
if (!searching || rawRssi == 0 || rawRssi == 127 || rawRssi > 0 || rawRssi < -127) return;
runOnUiThread(new Runnable() {
public void run() {
lastSignalTime = System.currentTimeMillis();
double filtered = filter.update(rawRssi);
String state = stateMachine.update(filtered);
double distance = Math.pow(10.0, (target.txPowerAtOneMeter - filtered) /
(10.0 * target.pathLossExponent)); lastRangeDistance = distance; lastFilteredRssi = filtered; lastRangeState = state;
feedback.update(state, filtered);
addRecent(filtered);
updateTelemetry(rawRssi, filtered, distance, state);
if (calibrating) calibrationSamples.add(Integer.valueOf(rawRssi));
if (currentLocation != null) {
long now = System.currentTimeMillis();
boolean accepted = spatial.addSample(currentLocation.getLatitude(),
currentLocation.getLongitude(), currentLocation.hasAccuracy()
? currentLocation.getAccuracy() : 50, filtered,
target.txPowerAtOneMeter, target.pathLossExponent, now);
if (now - lastLoggedTime >= 500) {
db.logRangeSample(target.address, sessionId, now,
currentLocation.getLatitude(), currentLocation.getLongitude(),
currentLocation.hasAccuracy() ? currentLocation.getAccuracy() : 0,
rawRssi, filtered, heading, state);
lastLoggedTime = now;
}
updateMap(currentLocation, heading, state, distance, accepted);
}
}
});
}
private void updateTelemetry(int raw, double filtered, double distance, String state) {
stateText.setText(state + "" + instruction(state));
stateText.setTextColor(colorFor(state));
rssiText.setText(String.format(Locale.US,
"Signal %.1f dBm • ~%.1f m • %s • %.0f°",
filtered, distance, trend(), heading));
}
private String instruction(String state) {
if (RangeStateMachine.HOT.equals(state)) return "very strong; search nearby carefully";
if (RangeStateMachine.WARM.equals(state)) return "keep walking toward stronger readings";
if (RangeStateMachine.COLD.equals(state)) return "weak signal; make a wider sweep";
return "waiting for target advertisements";
}
private int colorFor(String state) {
if (RangeStateMachine.HOT.equals(state)) return Color.rgb(255, 59, 48);
if (RangeStateMachine.WARM.equals(state)) return Color.rgb(255, 149, 0);
if (RangeStateMachine.COLD.equals(state)) return Color.rgb(0, 122, 255);
return Color.DKGRAY;
}
private void addRecent(double value) {
recentRssi.add(Double.valueOf(value));
while (recentRssi.size() > 8) recentRssi.remove(0);
}
private String trend() {
if (recentRssi.size() < 5) return "collecting samples";
double change = recentRssi.get(recentRssi.size() - 1).doubleValue() -
recentRssi.get(0).doubleValue();
if (change > 2.5) return "APPROACHING ↑";
if (change < -2.5) return "MOVING AWAY ↓";
return "STABLE ↔";
}
private void tagItemHere() {
if (currentLocation == null) {
Toast.makeText(this, "Wait for a GPS fix, then stand beside the item", Toast.LENGTH_LONG).show();
return;
}
final float accuracy = currentLocation.hasAccuracy() ? currentLocation.getAccuracy() : 99f;
new AlertDialog.Builder(this).setTitle("Mark item as found?")
.setMessage("Stand directly beside the physical item. The phone GPS coordinate will be saved as the found location. Current GPS accuracy: ±" + Math.round(accuracy) + " m.")
.setPositiveButton("FOUND IT", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
long now = System.currentTimeMillis();
db.updateLocation(target.address, new TrackerDatabase.Fix(currentLocation.getLatitude(),
currentLocation.getLongitude(), accuracy), now);
db.setLocationLocked(target.address, true); db.setShowTrail(target.address, false); db.setMapEnabled(target.address, true); db.setLiveEnabled(target.address, false);
target.latitude = currentLocation.getLatitude(); target.longitude = currentLocation.getLongitude();
target.accuracy = accuracy; target.hasLocation = true; target.locationLocked = 1;
double exponent = spatial.calibratePathLoss(target.latitude, target.longitude,
target.txPowerAtOneMeter);
if (!Double.isNaN(exponent)) {
target.pathLossExponent = exponent;
db.updateCalibration(target.address, target.txPowerAtOneMeter, exponent);
getSharedPreferences("range_calibration", 0).edit()
.putFloat("environment_exponent", (float)exponent).apply();
}
js(String.format(Locale.US, "lockTarget(%.7f,%.7f,%.1f)",
target.latitude, target.longitude, target.accuracy));
estimateText.setText("ITEM FOUND AT THIS GPS • ±" + Math.round(accuracy) + " m");
Toast.makeText(LiveRangeFinderActivity.this, "Found location saved", Toast.LENGTH_LONG).show();
stopSearch("Item found"); finish();
}
}).setNegativeButton("CANCEL", null).show();
}
private void startCalibration() {
if (!searching) return;
calibrationSamples.clear(); calibrating = true;
calibrationButton.setEnabled(false);
new AlertDialog.Builder(this).setTitle("One-meter calibration")
.setMessage("Place the phone about one meter from the item, hold it steady at " +
"chest height, and keep the same orientation for 10 seconds.")
.setPositiveButton("BEGIN", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
calibrationSamples.clear(); calibrating = true;
stateText.setText("CALIBRATING — remain still for 10 seconds");
handler.postDelayed(finishCalibration, 10000);
}
}).setNegativeButton("CANCEL", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
calibrating = false; calibrationButton.setEnabled(true);
}
}).show();
}
private final Runnable finishCalibration = new Runnable() {
public void run() {
calibrating = false; calibrationButton.setEnabled(searching);
if (calibrationSamples.size() < 5) {
Toast.makeText(LiveRangeFinderActivity.this,
"Not enough target advertisements for calibration", Toast.LENGTH_LONG).show();
return;
}
Collections.sort(calibrationSamples);
int median = calibrationSamples.get(calibrationSamples.size() / 2).intValue();
target.txPowerAtOneMeter = median;
db.updateCalibration(target.address, median, target.pathLossExponent);
Toast.makeText(LiveRangeFinderActivity.this,
"Saved one-meter calibration: " + median + " dBm", Toast.LENGTH_LONG).show();
}
};
private final Runnable signalWatchdog = new Runnable() {
public void run() {
if (!searching) return;
if (System.currentTimeMillis() - lastSignalTime > 5000) {
stateMachine.signalLost(); feedback.stop();
stateText.setText("SIGNAL LOST — target may not be advertising");
stateText.setTextColor(Color.GRAY);
js("setLost()");
}
handler.postDelayed(this, 1000);
}
};
private void requestLocation() {
try {
Location gps = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER);
Location network = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER);
currentLocation = gps == null ? network : gps;
locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 1000, 1f, this);
locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 1000, 1f, this);
} catch (Exception error) {
Toast.makeText(this, "Location permission is required for walking guidance",
Toast.LENGTH_LONG).show();
}
}
private void registerSensors() {
if (rotationSensor != null)
sensorManager.registerListener(this, rotationSensor, SensorManager.SENSOR_DELAY_GAME);
}
public void onLocationChanged(Location location) {
currentLocation = location;
if (location.hasBearing() && location.hasSpeed() && location.getSpeed() > 1.0f) {
heading = location.getBearing(); headingSource = "GPS movement";
}
if (searching) updateMap(location, heading, lastRangeState, lastRangeDistance, false);
}
public void onProviderDisabled(String provider) { }
public void onProviderEnabled(String provider) { }
public void onStatusChanged(String provider, int status, Bundle extras) { }
public void onSensorChanged(SensorEvent event) {
if (event.sensor.getType() != Sensor.TYPE_ROTATION_VECTOR) return;
float[] rotation = new float[9];
float[] orientation = new float[3];
SensorManager.getRotationMatrixFromVector(rotation, event.values);
SensorManager.getOrientation(rotation, orientation);
float compass = (float) Math.toDegrees(orientation[0]);
if (compass < 0) compass += 360;
if (currentLocation == null || !currentLocation.hasSpeed() || currentLocation.getSpeed() <= 1) {
heading = compass; headingSource = "compass / rotation sensor";
long now = System.currentTimeMillis();
if (searching && mapReady && now - lastHeadingMapUpdate >= 100L) {
lastHeadingMapUpdate = now;
js(String.format(Locale.US, "updateHeading(%.1f)", heading));
headingText.setText(String.format(Locale.US, "Heading: %.0f° (%s)", heading, headingSource));
}
}
}
public void onAccuracyChanged(Sensor sensor, int accuracy) { }
private void loadMap() {
map.loadDataWithBaseURL("https://www.openstreetmap.org/", mapHtml(),
"text/html", "UTF-8", null);
handler.postDelayed(new Runnable() {
public void run() { mapReady = true; if (currentLocation != null)
updateMap(currentLocation, heading, RangeStateMachine.LOST, 0, false); }
}, 1200);
}
private void updateMap(Location location, float direction, String state,
double distance, boolean newStation) {
if (!mapReady || location == null) return;
String command = String.format(Locale.US,
"updateUser(%.7f,%.7f,%.1f,'%s',%.2f,%s,%.1f)", location.getLatitude(),
location.getLongitude(), direction, state, distance,
newStation ? "true" : "false", lastFilteredRssi);
js(command);
if (target.locationLocked == 1 && target.hasLocation) {
js(String.format(Locale.US, "lockTarget(%.7f,%.7f,%.1f)",
target.latitude, target.longitude, target.accuracy));
estimateText.setText("ITEM FOUND AT THIS GPS • ±" + Math.round(target.accuracy) + " m");
return;
}
SpatialGradientEngine.Estimate estimate = spatial.consensusEstimate();
if (estimate != null) {
estimateText.setText(String.format(Locale.US,
"%s • %d stations • ±%.1f m • %.0f°",
estimate.preliminary ? "Estimate" : "Solved",
estimate.sampleCount, estimate.confidenceMeters, estimate.gradientBearing));
js(String.format(Locale.US, "updateEstimate(%.7f,%.7f,%.1f,%.1f,%s)",
estimate.latitude, estimate.longitude, estimate.confidenceMeters,
estimate.gradientBearing, estimate.preliminary ? "true" : "false"));
js(String.format(Locale.US, "updateGradient(%.1f,'%s')", estimate.gradientBearing, state));
} else {
SpatialGradientEngine.Estimate hint = spatial.preliminaryEstimate();
if (hint != null) {
js(String.format(Locale.US, "updateEstimate(%.7f,%.7f,%.1f,%.1f,true)",
hint.latitude, hint.longitude, hint.confidenceMeters, hint.gradientBearing));
js(String.format(Locale.US, "updateGradient(%.1f,'%s')", hint.gradientBearing, state));
estimateText.setText("Likely " + sector(hint.gradientBearing) + "" + spatial.size() + " stations • refining");
} else {
showFallbackPin(location, direction, distance);
estimateText.setText(spatial.size() +
" stations • Walk 815 m toward stronger signal");
}
}
}
private void showFallbackPin(Location phone, float direction, double distance) {
double projected = Math.max(1.5, Math.min(20.0, distance));
double radians = Math.toRadians(direction);
double latitude = phone.getLatitude() + Math.cos(radians) * projected / 111319.49;
double cosine = Math.max(0.2, Math.cos(Math.toRadians(phone.getLatitude())));
double longitude = phone.getLongitude() + Math.sin(radians) * projected / (111319.49 * cosine);
js(String.format(Locale.US, "updateEstimate(%.7f,%.7f,%.1f,%.1f,true)",
latitude, longitude, Math.max(1.5, distance), direction));
}
private String sector(float bearing) {
String[] names = { "NORTH", "NORTHEAST", "EAST", "SOUTHEAST",
"SOUTH", "SOUTHWEST", "WEST", "NORTHWEST" };
int index = ((int)Math.floor((bearing + 22.5f) / 45.0f)) % 8;
return names[index];
}
private void js(String command) {
if (mapReady) map.loadUrl("javascript:" + command);
}
private String mapHtml() {
return "<!doctype html><html><head><meta name='viewport' content='width=device-width,initial-scale=1'>" +
"<link rel='stylesheet' href='https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'>" +
"<style>html,body,#map{height:100%;margin:0}.bearing{position:relative;width:42px;height:42px;transform-origin:21px 21px}" +
".you{position:absolute;left:10px;top:10px;width:18px;height:18px;border-radius:50%;background:#4285F4;border:3px solid white;box-shadow:0 1px 4px #555;transition:background-color .8s linear}" +
".tip{position:absolute;left:17px;top:1px;width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-bottom:10px solid #4285F4;transition:border-bottom-color .8s linear}" +
".item-pin{width:25px;height:25px;background:#E53935;border:3px solid white;border-radius:50% 50% 50% 0;transform:rotate(-45deg);box-shadow:0 2px 5px #555}" +
".item-pin:after{content:'';position:absolute;width:8px;height:8px;border-radius:50%;background:#A61F1F;left:8px;top:8px}</style></head>" +
"<body><div id='map'></div><script src='https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'></script><script>" +
"var streets=L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxNativeZoom:19,maxZoom:22,attribution:'© OpenStreetMap contributors'})," +
"sat=L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',{maxNativeZoom:19,maxZoom:22,attribution:'Esri World Imagery'})," +
"map=L.map('map',{layers:[streets]}).setView([20,0],2);" +
"L.control.layers({'Streets':streets,'Satellite':sat},null,{collapsed:false}).addTo(map);" +
"var user=null,target=null,searchCircle=null,liveState='LOST',liveDistance=20,hotCount=0,radiusTarget=20,radiusFrame=0;" +
"var userIcon=L.divIcon({className:'',html:'<div id=bearing class=bearing><div class=you></div><div class=tip></div></div>',iconSize:[42,42],iconAnchor:[21,21]});" +
"var pinIcon=L.divIcon({className:'',html:'<div class=item-pin></div>',iconSize:[31,40],iconAnchor:[15,39]});" +
"function updateUser(a,b,h,s,d,station,signal){var p=[a,b];liveState=s;if(d>0)liveDistance=d;if(s=='HOT'&&d>0&&d<2.5)hotCount++;else if(s!='HOT')hotCount=0;" +
"if(!user){user=L.marker(p,{icon:userIcon,zIndexOffset:1000}).addTo(map);searchCircle=L.circle(p,{radius:Math.max(.6,liveDistance),color:'#4285F4',weight:2,fillColor:'#4285F4',fillOpacity:.16}).addTo(map);map.setView(p,21);}else user.setLatLng(p);" +
"searchCircle.setLatLng(p);smoothRadius(Math.max(.6,liveDistance));searchCircle.setStyle({color:'#4285F4',fillColor:'#4285F4',fillOpacity:.16});map.panTo(p,{animate:true});updateHeading(h);updatePhoneColor(signal)}" +
"function smoothRadius(next){radiusTarget=Math.max(.6,next);if(radiusFrame)return;function step(){if(!searchCircle){radiusFrame=0;return;}var current=searchCircle.getRadius(),change=(radiusTarget-current)*.025;if(Math.abs(radiusTarget-current)<.05){searchCircle.setRadius(radiusTarget);radiusFrame=0;return;}searchCircle.setRadius(current+change);radiusFrame=requestAnimationFrame(step);}radiusFrame=requestAnimationFrame(step);}" +
"function mix(a,b,t){return Math.round(a+(b-a)*Math.max(0,Math.min(1,t)));}function phoneColor(r){var a,b,t;if(r<=-80)return '#007AFF';if(r<-70){a=[0,122,255];b=[255,214,10];t=(r+80)/10;}else if(r<-60){a=[255,214,10];b=[255,59,48];t=(r+70)/10;}else return '#FF3B30';return 'rgb('+mix(a[0],b[0],t)+','+mix(a[1],b[1],t)+','+mix(a[2],b[2],t)+')';}function updatePhoneColor(r){var c=phoneColor(r),dot=document.querySelector('.you'),tip=document.querySelector('.tip');if(dot)dot.style.backgroundColor=c;if(tip)tip.style.borderBottomColor=c;}" +
"function updateHeading(h){var e=document.getElementById('bearing');if(e)e.style.transform='rotate('+h+'deg)';}" +
"function updateEstimate(a,b,r,g,pre){var p=[a,b];if(!target)target=L.marker(p,{icon:pinIcon,zIndexOffset:1200}).addTo(map);else target.setLatLng(p);target.bindPopup(pre?'Approximate item location':'Calculated item location');}" +
"function lockTarget(a,b,r){var p=[a,b];if(!target)target=L.marker(p,{icon:pinIcon,zIndexOffset:1200}).addTo(map);else target.setLatLng(p)}" +
"function updateGradient(g,s){}function setLost(){if(searchCircle)searchCircle.setStyle({color:'#90A4AE',fillColor:'#90A4AE',fillOpacity:.10});}" +
"</script></body></html>";
}
private int dp(int value) {
return (int) (value * getResources().getDisplayMetrics().density + 0.5f);
}
protected void onPause() {
if (searching) stopSearch("Search paused when screen left foreground");
super.onPause();
}
protected void onDestroy() {
stopSearch("Search closed");
feedback.release();
handler.removeCallbacksAndMessages(null);
if (map != null) map.destroy();
db.close();
super.onDestroy();
}
}
@@ -0,0 +1,8 @@
package com.wytehat.btlogger;
public class LocationPoint {
public double latitude;
public double longitude;
public float accuracy;
public long timestamp;
}
@@ -0,0 +1,377 @@
package com.wytehat.btlogger;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.PowerManager;
import android.net.Uri;
import android.provider.Settings;
import android.provider.MediaStore;
import android.view.View;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.Toast;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.lang.reflect.Method;
public class MainActivity extends Activity implements ItemActionListener {
private static final int PERMISSIONS = 100;
private static final int CAMERA = 200;
private TrackerDatabase db;
private DeviceListAdapter adapter;
private boolean registered;
private DeviceRecord editing;
private final Handler refreshHandler = new Handler();
private final Runnable refreshLiveAvailability = new Runnable() {
public void run() { reload(); refreshHandler.postDelayed(this, 10000L); }
};
private final BroadcastReceiver updates = new BroadcastReceiver() {
public void onReceive(Context context, Intent intent) { reload(); }
};
protected void onCreate(Bundle state) {
super.onCreate(state);
setContentView(R.layout.main);
LinearLayout nav = (LinearLayout) findViewById(R.id.navigation_container);
nav.addView(NavigationHelper.createBar(this, 0, "My Tracked Items"));
db = new TrackerDatabase(this);
ListView list = (ListView) findViewById(R.id.device_list);
list.setEmptyView(findViewById(R.id.empty_view));
adapter = new DeviceListAdapter(this, db.tracked(), this);
list.setAdapter(adapter);
findViewById(R.id.devices_button).setOnClickListener(new View.OnClickListener() {
public void onClick(View v) { startActivity(new Intent(MainActivity.this,
DeviceManagerActivity.class)); }
});
findViewById(R.id.map_all_button).setOnClickListener(new View.OnClickListener() {
public void onClick(View v) { startActivity(new Intent(MainActivity.this,
MapActivity.class)); }
});
findViewById(R.id.settings_button).setOnClickListener(new View.OnClickListener() {
public void onClick(View v) { showSettings(); }
});
requestNeededPermissions();
startTracker();
}
protected void onResume() {
super.onResume();
registerReceiver(updates, new IntentFilter(BluetoothTrackingService.ACTION_DATA_CHANGED));
registered = true;
refreshHandler.removeCallbacks(refreshLiveAvailability);
refreshHandler.post(refreshLiveAvailability);
reload();
}
protected void onPause() {
refreshHandler.removeCallbacks(refreshLiveAvailability);
if (registered) { unregisterReceiver(updates); registered = false; }
super.onPause();
}
protected void onDestroy() { db.close(); super.onDestroy(); }
public void edit(final DeviceRecord record) {
editing = record;
String[] choices = { "Rename item", "Take item photo", "Headphones icon",
"Keys icon", "Vehicle icon", "Wallet icon", "Bag icon",
"Tracking frequency and pin color", record.locationLocked == 1 ? "Re-find this found item" : "Find this item", record.showTrail == 1 ? "Hide location trail" : "Show location trail", record.trackBroadcast == 1 ? "Stop tracking while broadcasting" : "Track while broadcasting", "Remove from tracked items" };
new AlertDialog.Builder(this).setTitle("Edit " + record.displayName())
.setItems(choices, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
if (which == 0) rename(record);
else if (which == 1) takePhoto();
else if (which == 7) chooseTracking(record);
else if (which == 8) refind(record);
else if (which == 9) toggleTrail(record);
else if (which == 10) toggleBroadcastTracking(record);
else if (which == 11) removeTrackedItem(record);
else {
String[] icons = { "", "", "headphones", "keys", "car",
"wallet", "bag" };
db.updateProfile(record.address, null, null, icons[which]);
reload();
}
}
}).show();
}
private void removeTrackedItem(final DeviceRecord record) {
new AlertDialog.Builder(this).setTitle("Remove from tracked items?")
.setMessage("The item will disappear from the dashboard and map. Its sightings and location history will remain saved, and it can be added again when its current Bluetooth ID is seen.")
.setPositiveButton("REMOVE", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
db.removeFromTracking(record.address);
sendBroadcast(new Intent(BluetoothTrackingService.ACTION_CONFIG_CHANGED).setPackage(getPackageName()));
reload();
Toast.makeText(MainActivity.this, "Removed from tracked items; history retained", Toast.LENGTH_LONG).show();
}
}).setNegativeButton("CANCEL", null).show();
}
private void toggleBroadcastTracking(DeviceRecord record) {
boolean enabled = record.trackBroadcast != 1;
db.setTrackBroadcast(record.address, enabled);
sendBroadcast(new Intent(BluetoothTrackingService.ACTION_CONFIG_CHANGED)
.setPackage(getPackageName()));
Toast.makeText(this, enabled ? "Will log GPS while this item is broadcasting" :
"Broadcast location tracking disabled", Toast.LENGTH_LONG).show();
reload();
}
private void toggleTrail(DeviceRecord record) {
boolean show = record.showTrail != 1;
db.setShowTrail(record.address, show);
Toast.makeText(this, show ? "Location trail shown" : "Location trail hidden", Toast.LENGTH_SHORT).show();
reload();
}
private void refind(DeviceRecord record) {
DeviceRecord current = db.get(record.address);
if (current == null || current.rssi == 0 ||
System.currentTimeMillis() - current.lastRssiAt > 120000L) {
Toast.makeText(this, "Re-find needs a current BLE signal. Select the item in Bluetooth Radar first.", Toast.LENGTH_LONG).show();
return;
}
db.setLocationLocked(record.address, false);
db.setLiveEnabled(record.address, true);
db.setMapEnabled(record.address, true);
live(current);
}
public void map(DeviceRecord record) {
Intent intent = new Intent(this, MapActivity.class);
intent.putExtra("focus", record.address);
startActivity(intent);
}
public void live(DeviceRecord record) {
DeviceRecord current = db.get(record.address);
if (current == null || current.rssi == 0 || System.currentTimeMillis() - current.lastRssiAt > 120000L) {
Toast.makeText(this, "Live Find needs a recent BLE signal. Scan on Bluetooth Radar first.", Toast.LENGTH_LONG).show();
reload();
return;
}
Intent intent = new Intent(this, LiveRangeFinderActivity.class);
intent.putExtra("address", record.address);
startActivity(intent);
}
public void manage(DeviceRecord record) {
Intent intent = new Intent(this, DeviceManagerActivity.class);
intent.putExtra("focus", record.address);
startActivity(intent);
}
private void rename(final DeviceRecord record) {
final EditText input = new EditText(this);
input.setText(record.displayName());
new AlertDialog.Builder(this).setTitle("Item name").setView(input)
.setPositiveButton("SAVE", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
db.updateProfile(record.address, input.getText().toString(),
record.photoUri, record.iconPreset);
reload();
}
}).setNegativeButton("CANCEL", null).show();
}
private void chooseTracking(final DeviceRecord record) {
final String[] labels = { "High value: every 2 minutes", "High value: every 5 minutes",
"Standard: every 30 minutes", "Low power: every 60 minutes", "Event-only" };
final int[] minutes = { 2, 5, 30, 60, 0 };
new AlertDialog.Builder(this).setTitle("Tracking frequency")
.setItems(labels, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, final int selected) {
db.updateTracking(record.address, minutes[selected], record.pinColor);
sendBroadcast(new Intent(BluetoothTrackingService.ACTION_CONFIG_CHANGED)
.setPackage(getPackageName()));
reload();
Toast.makeText(MainActivity.this, pollingLabel(minutes[selected]) + " saved", Toast.LENGTH_SHORT).show();
chooseColor(record, minutes[selected]);
}
}).show();
}
private String pollingLabel(int minutes) {
return minutes <= 0 ? "GPS event-only" : "GPS every " + minutes + " min";
}
private void chooseColor(final DeviceRecord record, final int minutes) {
final String[] names = { "Red", "Blue", "Green", "Orange", "Purple", "Teal" };
final String[] colors = { "#E53935", "#1E88E5", "#43A047", "#FB8C00",
"#8E24AA", "#00897B" };
new AlertDialog.Builder(this).setTitle("Map pin color")
.setItems(names, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int selected) {
db.updateTracking(record.address, minutes, colors[selected]);
sendBroadcast(new Intent(BluetoothTrackingService.ACTION_CONFIG_CHANGED)
.setPackage(getPackageName()));
reload();
}
}).show();
}
private void takePhoto() {
try { startActivityForResult(new Intent(MediaStore.ACTION_IMAGE_CAPTURE), CAMERA); }
catch (Exception e) { Toast.makeText(this, "No camera app available",
Toast.LENGTH_LONG).show(); }
}
protected void onActivityResult(int request, int result, Intent data) {
super.onActivityResult(request, result, data);
if (request == CAMERA && result == RESULT_OK && editing != null && data != null) {
Object value = data.getExtras() == null ? null : data.getExtras().get("data");
if (value instanceof Bitmap) {
File dir = new File(getFilesDir(), "item_photos");
if (!dir.exists()) dir.mkdirs();
File file = new File(dir, safe(editing.address) + ".jpg");
try {
FileOutputStream output = new FileOutputStream(file);
((Bitmap) value).compress(Bitmap.CompressFormat.JPEG, 92, output);
output.close();
db.updateProfile(editing.address, null, file.getAbsolutePath(),
editing.iconPreset);
reload();
} catch (IOException error) {
Toast.makeText(this, "Could not save photo", Toast.LENGTH_LONG).show();
}
}
}
}
private String safe(String value) { return value.replace(":", "").replace("/", ""); }
private void showSettings() {
boolean exempt = isBatteryOptimizationDisabled();
String background = exempt ? "Background protection: enabled" : "Don't kill background tracking";
new AlertDialog.Builder(this).setTitle("Dashboard settings")
.setItems(new String[] { background, "Disconnect alert notifications",
"Allow location all the time", "Background running / app settings",
"Per-item tracking information" }, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
if (which == 0) requestBackgroundProtection();
else if (which == 1) openNotificationSettings();
else if (which == 2) openAppSettings("Open Permissions > Location and choose Allow all the time");
else if (which == 3) openAppSettings("Allow background activity and notifications for continuous tracking");
else showTrackingInformation();
}
}).setNegativeButton("CLOSE", null).show();
}
private void openNotificationSettings() {
try {
Intent intent;
if (Build.VERSION.SDK_INT >= 26) {
intent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS);
intent.putExtra(Settings.EXTRA_APP_PACKAGE, getPackageName());
} else {
intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setData(Uri.parse("package:" + getPackageName()));
}
startActivity(intent);
} catch (Exception error) { openAppSettings("Enable notifications for disconnect alerts"); }
}
private void openAppSettings(String instruction) {
Toast.makeText(this, instruction, Toast.LENGTH_LONG).show();
Intent details = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
details.setData(Uri.parse("package:" + getPackageName()));
startActivity(details);
}
private boolean isBatteryOptimizationDisabled() {
if (Build.VERSION.SDK_INT < 23) return true;
try {
PowerManager manager = (PowerManager)getSystemService(POWER_SERVICE);
return manager != null && manager.isIgnoringBatteryOptimizations(getPackageName());
} catch (Exception error) { return false; }
}
private void requestBackgroundProtection() {
if (Build.VERSION.SDK_INT < 23) {
Toast.makeText(this, "Battery optimization restrictions do not apply on this Android version", Toast.LENGTH_LONG).show();
return;
}
if (isBatteryOptimizationDisabled()) {
Toast.makeText(this, "Background tracking is already excluded from battery optimization", Toast.LENGTH_LONG).show();
return;
}
try {
Intent request = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
request.setData(Uri.parse("package:" + getPackageName()));
startActivity(request);
} catch (Exception directError) {
try { startActivity(new Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)); }
catch (Exception settingsError) {
Intent details = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
details.setData(Uri.parse("package:" + getPackageName()));
startActivity(details);
}
}
}
private void showTrackingInformation() {
new AlertDialog.Builder(this).setTitle("Per-item tracking")
.setMessage("Tracking frequency is configured separately for each item. Use EDIT to set its interval, Track while broadcasting, trail visibility, and map pin color.")
.setPositiveButton("OK", null).show();
}
public void onRequestPermissionsResult(int request, String[] permissions, int[] results) {
if (request == PERMISSIONS) { startTracker(); reload(); }
}
private void requestNeededPermissions() {
if (Build.VERSION.SDK_INT < 23) return;
java.util.ArrayList<String> permissions = new java.util.ArrayList<String>();
addPermission(permissions, "android.permission.ACCESS_FINE_LOCATION");
addPermission(permissions, "android.permission.CAMERA");
if (Build.VERSION.SDK_INT >= 31) {
addPermission(permissions, "android.permission.BLUETOOTH_CONNECT");
addPermission(permissions, "android.permission.BLUETOOTH_SCAN");
}
if (Build.VERSION.SDK_INT >= 33)
addPermission(permissions, "android.permission.POST_NOTIFICATIONS");
if (permissions.size() > 0) {
try {
Method method = Activity.class.getMethod("requestPermissions",
String[].class, Integer.TYPE);
method.invoke(this, new Object[] {
permissions.toArray(new String[permissions.size()]),
Integer.valueOf(PERMISSIONS) });
} catch (Exception ignored) { }
}
}
private void addPermission(java.util.List<String> list, String permission) {
if (getPackageManager().checkPermission(permission, getPackageName()) !=
PackageManager.PERMISSION_GRANTED) list.add(permission);
}
private void reload() { if (adapter != null) adapter.setRows(db.tracked()); }
private void startTracker() {
Intent intent = new Intent(this, BluetoothTrackingService.class);
if (Build.VERSION.SDK_INT >= 26) {
try {
Context.class.getMethod("startForegroundService", Intent.class)
.invoke(this, intent);
return;
} catch (Exception ignored) { }
}
startService(intent);
}
}
@@ -0,0 +1,377 @@
package com.wytehat.btlogger;
import android.app.Activity;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.content.Intent;
import android.net.Uri;
import android.graphics.Color;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Bundle;
import android.view.View;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.CheckBox;
import android.widget.LinearLayout;
import android.widget.Spinner;
import android.widget.ArrayAdapter;
import android.widget.AdapterView;
import java.text.DateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
public class MapActivity extends Activity implements LocationListener, SensorEventListener {
private TrackerDatabase db;
private WebView web;
private CheckBox showAll;
private String focus;
private Spinner categorySpinner;
private ArrayAdapter<String> categoryAdapter;
private final ArrayList<String> categoryValues = new ArrayList<String>();
private final ArrayList<String> categoryLabels = new ArrayList<String>();
private String categoryFilter = "All";
private boolean rebuildingCategories;
private LocationManager locationManager;
private SensorManager sensorManager;
private Sensor rotationSensor;
private Location phoneLocation;
private float phoneHeading;
private boolean mapReady;
private static final String[] CATEGORY_ORDER = { "Trackers / Tags", "Audio", "Wearables", "Phones", "Computers", "Vehicles", "Other" };
protected void onCreate(Bundle state) {
super.onCreate(state);
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setBackgroundColor(Color.WHITE);
root.addView(NavigationHelper.createBar(this, 2, "Tracked Item Map"));
showAll = new CheckBox(this);
showAll.setText("Troubleshooting: show all scanned devices");
showAll.setPadding(dp(12), dp(5), dp(12), dp(5));
showAll.setChecked(getSharedPreferences("map_settings", 0)
.getBoolean("show_all_scanned", false));
root.addView(showAll, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT));
categorySpinner = new Spinner(this);
categoryValues.add("All"); categoryLabels.add("All (0)");
categoryAdapter = new ArrayAdapter<String>(this,
android.R.layout.simple_spinner_item, categoryLabels);
categoryAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
categorySpinner.setAdapter(categoryAdapter);
root.addView(categorySpinner, new LinearLayout.LayoutParams(-1, -2));
web = new WebView(this);
web.getSettings().setJavaScriptEnabled(true);
web.getSettings().setAllowFileAccess(true);
web.setWebViewClient(new WebViewClient() {
public void onPageFinished(WebView view, String url) {
mapReady = true; updatePhoneMarker();
}
public boolean shouldOverrideUrlLoading(WebView view, String url) {
if (url != null && url.startsWith("geo:")) { openMap(url); return true; }
return false;
}
});
root.addView(web, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1));
setContentView(root);
db = new TrackerDatabase(this);
locationManager = (LocationManager)getSystemService(LOCATION_SERVICE);
sensorManager = (SensorManager)getSystemService(SENSOR_SERVICE);
if (sensorManager != null) rotationSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR);
focus = getIntent().getStringExtra("focus");
categorySpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
if (!rebuildingCategories && position >= 0 && position < categoryValues.size()) {
categoryFilter = categoryValues.get(position); reloadMap();
}
}
public void onNothingSelected(AdapterView<?> parent) { }
});
reloadMap();
showAll.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) {
getSharedPreferences("map_settings", 0).edit()
.putBoolean("show_all_scanned", showAll.isChecked()).apply();
reloadMap();
}
});
}
private void reloadMap() {
mapReady = false;
List<DeviceRecord> available = showAll.isChecked() ? db.all() : defaultMapItems();
refreshMapCategories(available);
ArrayList<DeviceRecord> rows = new ArrayList<DeviceRecord>();
for (int i = 0; i < available.size(); i++) {
DeviceRecord record = available.get(i);
if (!record.hasLocation) continue;
if ("All".equals(categoryFilter) || categoryFilter.equals(record.category)) rows.add(record);
}
web.loadDataWithBaseURL("https://www.openstreetmap.org/",
buildHtml(rows, focus), "text/html", "UTF-8", null);
}
private void refreshMapCategories(List<DeviceRecord> rows) {
int[] counts = new int[CATEGORY_ORDER.length];
int located = 0;
for (int r = 0; r < rows.size(); r++) {
DeviceRecord record = rows.get(r);
if (!record.hasLocation) continue;
located++;
for (int i = 0; i < CATEGORY_ORDER.length; i++) {
if (CATEGORY_ORDER[i].equals(record.category)) { counts[i]++; break; }
}
}
ArrayList<String> nextValues = new ArrayList<String>();
ArrayList<String> nextLabels = new ArrayList<String>();
nextValues.add("All"); nextLabels.add("All (" + located + ")");
for (int i = 0; i < CATEGORY_ORDER.length; i++) {
if (counts[i] > 0) {
nextValues.add(CATEGORY_ORDER[i]);
nextLabels.add(CATEGORY_ORDER[i] + " (" + counts[i] + ")");
}
}
int selected = nextValues.indexOf(categoryFilter);
if (selected < 0) { categoryFilter = "All"; selected = 0; }
rebuildingCategories = true;
if (!nextLabels.equals(categoryLabels)) {
categoryValues.clear(); categoryValues.addAll(nextValues);
categoryLabels.clear(); categoryLabels.addAll(nextLabels);
categoryAdapter.notifyDataSetChanged();
}
categorySpinner.setSelection(selected);
rebuildingCategories = false;
}
private List<DeviceRecord> defaultMapItems() {
List<DeviceRecord> all = db.all();
ArrayList<DeviceRecord> result = new ArrayList<DeviceRecord>();
HashSet<String> paired = new HashSet<String>();
try {
BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
if (adapter != null) {
for (BluetoothDevice device : adapter.getBondedDevices())
paired.add(device.getAddress());
}
} catch (Exception ignored) { }
for (int i = 0; i < all.size(); i++) {
DeviceRecord record = all.get(i);
if (record.mapEnabled == 1)
result.add(record);
}
return result;
}
private String buildHtml(List<DeviceRecord> rows, String selectedAddress) {
StringBuilder script = new StringBuilder();
long today = startOfToday();
for (int i = 0; i < rows.size(); i++) {
DeviceRecord record = rows.get(i);
if (!record.hasLocation) continue;
String color = validColor(record.pinColor);
List<LocationPoint> history = db.history(record.address, today);
if (record.showTrail == 1 && history.size() > 1) {
script.append("var trail").append(i).append("=[");
for (int p = 0; p < history.size(); p++) {
if (p > 0) script.append(',');
script.append('[').append(history.get(p).latitude).append(',')
.append(history.get(p).longitude).append(']');
}
script.append("];L.polyline(trail").append(i)
.append(",{color:'").append(color)
.append("',weight:4,opacity:.65}).addTo(map);")
.append("for(var k=0;k<trail").append(i)
.append(".length;k++){L.circleMarker(trail").append(i)
.append("[k],{radius:3,color:'").append(color)
.append("',fillOpacity:.8}).addTo(map);} ");
}
String photo = "";
if (record.photoUri != null && new java.io.File(record.photoUri).exists()) {
photo = "<img src='file://" + htmlText(record.photoUri) +
"' style='width:72px;height:72px;object-fit:cover;border-radius:10px'><br>";
}
StringBuilder detailsBuilder = new StringBuilder(photo);
detailsBuilder.append("<b>").append(htmlText(record.displayName())).append("</b><br>");
detailsBuilder.append("<a href='geo:").append(record.latitude).append(",")
.append(record.longitude).append("?q=").append(record.latitude).append(",")
.append(record.longitude).append("'>GPS: ").append(record.latitude).append(", ")
.append(record.longitude).append("</a>");
if (record.accuracy > 0) detailsBuilder.append("").append(Math.round(record.accuracy)).append(" m)");
if (hasValue(record.category) && !"Other".equalsIgnoreCase(record.category))
detailsBuilder.append("<br>Category: ").append(htmlText(record.category));
if (hasValue(record.vendorName) && !"Unknown".equalsIgnoreCase(record.vendorName) &&
!"Manufacturer unknown".equalsIgnoreCase(record.vendorName))
detailsBuilder.append("<br>Manufacturer: ").append(htmlText(record.vendorName));
if (record.updatedAt > 0) detailsBuilder.append("<br>Last seen: ").append(htmlText(date(record.updatedAt)));
if (record.battery >= 0) detailsBuilder.append("<br>Battery: ").append(record.battery).append("%");
if (record.rssi != 0 && record.lastRssiAt > 0)
detailsBuilder.append("<br>RSSI: ").append(record.rssi).append(" dBm");
String status = reason(record.reason);
if (hasValue(status) && !"Status unknown".equalsIgnoreCase(status))
detailsBuilder.append("<br>Status: ").append(htmlText(status));
String details = detailsBuilder.toString();
boolean locked = record.locationLocked == 1;
int markerSize = locked ? 10 : 22;
int iconSize = markerSize + 6;
int anchor = iconSize / 2;
String markerColor = locked ? "#00A86B" : color;
script.append("var icon").append(i).append("=L.divIcon({className:'',html:\"")
.append("<div style='width:").append(markerSize).append("px;height:")
.append(markerSize).append("px;border-radius:50%;background:")
.append(markerColor).append(";border:2px solid white;box-shadow:0 1px 4px #333'></div>\"")
.append(",iconSize:[").append(iconSize).append(",").append(iconSize)
.append("],iconAnchor:[").append(anchor).append(",").append(anchor).append("]});");
script.append("var marker").append(i).append("=L.marker([").append(record.latitude)
.append(',').append(record.longitude).append("],{icon:icon").append(i)
.append("}).addTo(map).bindPopup('").append(js(details)).append("');")
.append("bounds.push([").append(record.latitude).append(',')
.append(record.longitude).append("]);");
if (record.address != null && record.address.equals(selectedAddress)) {
script.append("marker").append(i).append(".openPopup();map.setView([").append(
record.latitude).append(',').append(record.longitude).append("],21);");
}
}
return "<!doctype html><html><head><meta name='viewport' content='width=device-width," +
"initial-scale=1'><link rel='stylesheet' href='https://unpkg.com/leaflet@1.9.4/" +
"dist/leaflet.css'><style>html,body,#map{height:100%;margin:0}.leaflet-popup-content" +
"{font:14px sans-serif;line-height:1.5}.phone-bearing{position:relative;width:38px;height:38px;transform-origin:19px 19px}.phone-dot{position:absolute;left:9px;top:9px;width:16px;height:16px;border-radius:50%;background:#4285F4;border:3px solid white;box-shadow:0 1px 4px #555}.phone-tip{position:absolute;left:15px;top:1px;width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-bottom:10px solid #4285F4}</style></head><body><div id='map'></div>" +
"<script src='https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'></script><script>" +
"var streets=L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxNativeZoom:19,maxZoom:22,attribution:'© OpenStreetMap contributors'})," +
"sat=L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',{maxNativeZoom:19,maxZoom:22,attribution:'Esri World Imagery'})," +
"map=L.map('map',{layers:[streets]}).setView([20,0],2);" +
"L.control.layers({'Streets':streets,'Satellite':sat},null,{collapsed:false}).addTo(map);" +
"var phone=null,phoneShown=false,phoneIcon=L.divIcon({className:'',html:'<div id=phoneBearing class=phone-bearing><div class=phone-dot></div><div class=phone-tip></div></div>',iconSize:[38,38],iconAnchor:[19,19]});" +
"function updatePhone(a,b,h){var p=[a,b];if(!phone)phone=L.marker(p,{icon:phoneIcon,zIndexOffset:1100}).addTo(map);else phone.setLatLng(p);if(!phoneShown){phoneShown=true;var visible=bounds.slice(0);visible.push(p);if(visible.length>1)map.fitBounds(visible,{padding:[35,35],maxZoom:20});else map.setView(p,20);}var e=document.getElementById('phoneBearing');if(e)e.style.transform='rotate('+h+'deg)';}var bounds=[];" + script + "if(bounds.length>0&&" +
"'" +
js(selectedAddress == null ? "" : selectedAddress) +
"'=='')map.fitBounds(bounds,{padding:[30,30],maxZoom:20});</script></body></html>";
}
private long startOfToday() {
Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
return calendar.getTimeInMillis();
}
private String validColor(String value) {
if (value != null && value.matches("#[0-9a-fA-F]{6}")) return value;
return "#E53935";
}
private String date(long time) {
return time <= 0 ? "Unknown" : DateFormat.getDateTimeInstance().format(new Date(time));
}
private String reason(String value) {
if (value == null) return "Status unknown";
if (value.indexOf("Range") >= 0) return "Out of range";
if (value.indexOf("Battery") >= 0 || value.indexOf("Power") >= 0)
return "Possible dead battery";
return value;
}
private boolean hasValue(String value) {
return value != null && value.trim().length() > 0;
}
private String js(String value) {
return value == null ? "" : value.replace("\\", "\\\\").replace("'", "\\'")
.replace("\n", " ").replace("\r", " ");
}
private String htmlText(String value) {
return value == null ? "" : value.replace("&", "&amp;").replace("<", "&lt;")
.replace(">", "&gt;").replace("'", "&#39;");
}
protected void onResume() {
super.onResume();
try {
Location gps = locationManager == null ? null : locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER);
Location network = locationManager == null ? null : locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER);
phoneLocation = gps == null ? network : gps;
if (locationManager != null) {
locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 1000L, 1f, this);
locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 1000L, 1f, this);
}
} catch (Exception ignored) { }
if (sensorManager != null && rotationSensor != null)
sensorManager.registerListener(this, rotationSensor, SensorManager.SENSOR_DELAY_UI);
updatePhoneMarker();
}
protected void onPause() {
try { if (locationManager != null) locationManager.removeUpdates(this); } catch (Exception ignored) { }
try { if (sensorManager != null) sensorManager.unregisterListener(this); } catch (Exception ignored) { }
super.onPause();
}
public void onLocationChanged(Location location) {
if (location == null) return;
phoneLocation = location;
if (location.hasBearing() && location.hasSpeed() && location.getSpeed() > 0.8f)
phoneHeading = location.getBearing();
updatePhoneMarker();
}
public void onProviderDisabled(String provider) { }
public void onProviderEnabled(String provider) { }
public void onStatusChanged(String provider, int status, Bundle extras) { }
public void onSensorChanged(SensorEvent event) {
if (event.sensor.getType() != Sensor.TYPE_ROTATION_VECTOR) return;
float[] rotation = new float[9]; float[] orientation = new float[3];
SensorManager.getRotationMatrixFromVector(rotation, event.values);
SensorManager.getOrientation(rotation, orientation);
float compass = (float)Math.toDegrees(orientation[0]);
if (compass < 0) compass += 360f;
if (phoneLocation == null || !phoneLocation.hasSpeed() || phoneLocation.getSpeed() <= 0.8f)
phoneHeading = compass;
updatePhoneMarker();
}
public void onAccuracyChanged(Sensor sensor, int accuracy) { }
private void updatePhoneMarker() {
if (!mapReady || web == null || phoneLocation == null) return;
web.loadUrl(String.format(java.util.Locale.US, "javascript:updatePhone(%.7f,%.7f,%.1f)",
phoneLocation.getLatitude(), phoneLocation.getLongitude(), phoneHeading));
}
private void openMap(String uri) {
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));
intent.setPackage("com.google.android.apps.maps");
try { startActivity(intent); }
catch (Exception error) {
intent.setPackage(null);
try { startActivity(intent); } catch (Exception ignored) { }
}
}
private int dp(int value) {
return (int) (value * getResources().getDisplayMetrics().density + 0.5f);
}
protected void onDestroy() {
if (web != null) web.destroy();
db.close();
super.onDestroy();
}
}
@@ -0,0 +1,79 @@
package com.wytehat.btlogger;
import android.content.Context;
import android.media.AudioManager;
import android.media.ToneGenerator;
import android.os.Handler;
import android.os.Vibrator;
public class NativeFeedbackController {
private final Handler handler = new Handler();
private final Vibrator vibrator;
private ToneGenerator tone;
private boolean running;
private boolean audioEnabled;
private boolean hapticEnabled = true;
private String state = RangeStateMachine.LOST;
private double rssi = -100;
public NativeFeedbackController(Context context) {
vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE);
}
public void setAudioEnabled(boolean enabled) {
audioEnabled = enabled;
if (enabled && tone == null) tone = new ToneGenerator(AudioManager.STREAM_MUSIC, 55);
}
public void setHapticEnabled(boolean enabled) { hapticEnabled = enabled; }
public void update(String newState, double filteredRssi) {
if (RangeStateMachine.LOST.equals(newState)) { stop(); state = newState; return; }
state = newState;
rssi = filteredRssi;
if (!running) {
running = true;
handler.post(pulse);
}
}
private final Runnable pulse = new Runnable() {
public void run() {
if (!running || RangeStateMachine.LOST.equals(state)) return;
int delay = delayFor(state, rssi);
if (audioEnabled && tone != null) {
int sound = RangeStateMachine.HOT.equals(state)
? ToneGenerator.TONE_PROP_BEEP2 : ToneGenerator.TONE_PROP_BEEP;
tone.startTone(sound, RangeStateMachine.HOT.equals(state) ? 55 : 35);
}
if (hapticEnabled && vibrator != null) {
try {
if (RangeStateMachine.HOT.equals(state)) vibrator.vibrate(
new long[] { 0, 45, 30, 45 }, -1);
else if (RangeStateMachine.WARM.equals(state)) vibrator.vibrate(35);
else vibrator.vibrate(18);
} catch (Exception ignored) { }
}
handler.postDelayed(this, delay);
}
};
private int delayFor(String value, double filteredRssi) {
if (RangeStateMachine.HOT.equals(value))
return Math.max(100, 210 - (int) Math.abs(filteredRssi + 60) * 8);
if (RangeStateMachine.WARM.equals(value))
return Math.max(350, 760 - (int) Math.abs(filteredRssi + 80) * 14);
return 1200;
}
public void stop() {
running = false;
handler.removeCallbacks(pulse);
try { if (vibrator != null) vibrator.cancel(); } catch (Exception ignored) { }
}
public void release() {
stop();
if (tone != null) { tone.release(); tone = null; }
}
}
@@ -0,0 +1,70 @@
package com.wytehat.btlogger;
import android.app.Activity;
import android.content.Intent;
import android.graphics.Color;
import android.view.View;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
public final class NavigationHelper {
private NavigationHelper() { }
public static LinearLayout createBar(final Activity activity, final int page,
String title) {
LinearLayout bar = new LinearLayout(activity);
bar.setOrientation(LinearLayout.HORIZONTAL);
bar.setGravity(android.view.Gravity.CENTER_VERTICAL);
bar.setPadding(dp(activity, 6), dp(activity, 5), dp(activity, 6), dp(activity, 5));
bar.setBackgroundColor(Color.rgb(25, 63, 105));
Button back = new Button(activity);
back.setText("");
back.setTextSize(24);
bar.addView(back, new LinearLayout.LayoutParams(dp(activity, 56), dp(activity, 48)));
TextView heading = new TextView(activity);
heading.setText(title);
heading.setTextColor(Color.WHITE);
heading.setTextSize(20);
heading.setGravity(android.view.Gravity.CENTER);
heading.setTypeface(null, android.graphics.Typeface.BOLD);
bar.addView(heading, new LinearLayout.LayoutParams(0, dp(activity, 48), 1));
Button forward = new Button(activity);
forward.setText("");
forward.setTextSize(24);
bar.addView(forward, new LinearLayout.LayoutParams(dp(activity, 56), dp(activity, 48)));
back.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) { navigate(activity, page - 1); }
});
forward.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) { navigate(activity, page + 1); }
});
return bar;
}
private static void navigate(Activity activity, int page) {
Class<?> target;
if (page == 0) target = MainActivity.class;
else if (page == 1) target = DeviceManagerActivity.class;
else if (page == 2) target = MapActivity.class;
else {
Toast.makeText(activity, page < 0 ? "This is the first view" :
"This is the last view", Toast.LENGTH_SHORT).show();
return;
}
if (target.equals(activity.getClass())) return;
Intent intent = new Intent(activity, target);
intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
activity.startActivity(intent);
activity.overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out);
}
private static int dp(Activity activity, int value) {
return (int) (value * activity.getResources().getDisplayMetrics().density + 0.5f);
}
}
@@ -0,0 +1,43 @@
package com.wytehat.btlogger;
public class RangeStateMachine {
public static final String HOT = "HOT";
public static final String WARM = "WARM";
public static final String COLD = "COLD";
public static final String LOST = "LOST";
private String state = LOST;
private String candidate = LOST;
private int candidateCount;
public synchronized String update(double rssi) {
String next;
if (HOT.equals(state)) next = rssi < -65 ? WARM : HOT;
else if (COLD.equals(state)) next = rssi > -75 ? WARM : COLD;
else if (rssi > -60) next = HOT;
else if (rssi < -80) next = COLD;
else next = WARM;
if (next.equals(state)) {
candidate = next;
candidateCount = 0;
} else if (next.equals(candidate)) {
candidateCount++;
if (candidateCount >= 3) {
state = next;
candidateCount = 0;
}
} else {
candidate = next;
candidateCount = 1;
}
return state;
}
public synchronized void signalLost() {
state = LOST;
candidate = LOST;
candidateCount = 0;
}
public synchronized String getState() { return state; }
}
@@ -0,0 +1,34 @@
package com.wytehat.btlogger;
public class RssiKalmanFilter {
private final double processNoise;
private final double measurementNoise;
private double estimate;
private double covariance = 1.0;
private boolean initialized;
public RssiKalmanFilter() { this(0.125, 0.8); }
public RssiKalmanFilter(double processNoise, double measurementNoise) {
this.processNoise = processNoise;
this.measurementNoise = measurementNoise;
}
public synchronized double update(int rawRssi) {
if (!initialized) {
estimate = rawRssi;
initialized = true;
return estimate;
}
double priorCovariance = covariance + processNoise;
double gain = priorCovariance / (priorCovariance + measurementNoise);
estimate = estimate + gain * (rawRssi - estimate);
covariance = (1.0 - gain) * priorCovariance;
return estimate;
}
public synchronized void reset() {
initialized = false;
covariance = 1.0;
}
}
@@ -0,0 +1,261 @@
package com.wytehat.btlogger;
import java.util.ArrayList;
import java.util.List;
public class SpatialGradientEngine {
public static class Estimate {
public double latitude;
public double longitude;
public double confidenceMeters;
public float gradientBearing;
public int sampleCount;
public boolean preliminary;
}
private static class Sample {
double latitude, longitude, rssi, distance;
float accuracy;
long time;
}
private final ArrayList<Sample> samples = new ArrayList<Sample>();
private final ArrayList<Double> pendingRssi = new ArrayList<Double>();
private double pendingLatitude, pendingLongitude;
private int lastConsensusSampleCount = -1;
private final ArrayList<Estimate> estimateConsensus = new ArrayList<Estimate>();
private Estimate stableEstimate;
public synchronized boolean addSample(double latitude, double longitude, float accuracy,
double filteredRssi, int txPower,
double pathLossExponent, long time) {
if (accuracy <= 0 || accuracy > 60 || pathLossExponent <= 0) return false;
if (samples.size() > 0) {
Sample previous = samples.get(samples.size() - 1);
double required = Math.max(5.0, Math.max(previous.accuracy, accuracy));
if (haversine(previous.latitude, previous.longitude, latitude, longitude) < required) {
pendingRssi.clear(); return false;
}
}
if (pendingRssi.isEmpty()) {
pendingLatitude = latitude; pendingLongitude = longitude;
} else if (haversine(pendingLatitude, pendingLongitude, latitude, longitude) >
Math.max(5.0, accuracy)) {
pendingRssi.clear(); pendingLatitude = latitude; pendingLongitude = longitude;
}
pendingRssi.add(Double.valueOf(filteredRssi));
if (pendingRssi.size() < 4) return false;
java.util.Collections.sort(pendingRssi);
double median = (pendingRssi.get(1).doubleValue() + pendingRssi.get(2).doubleValue()) / 2.0;
pendingRssi.clear();
Sample point = new Sample();
point.latitude = latitude; point.longitude = longitude; point.accuracy = accuracy;
point.rssi = median;
point.distance = Math.pow(10.0, (txPower - median) / (10.0 * pathLossExponent));
point.time = time; samples.add(point);
while (samples.size() > 60) samples.remove(0);
prune(time - 15L * 60L * 1000L);
return true;
}
public synchronized Estimate solve() {
if (samples.size() < 4) return null;
Sample origin = samples.get(0);
int count = samples.size();
double[] xs = new double[count];
double[] ys = new double[count];
double cos = Math.cos(Math.toRadians(origin.latitude));
double metersPerDegree = 111319.49;
double x = 0, y = 0, total = 0;
for (int i = 0; i < count; i++) {
Sample sample = samples.get(i);
xs[i] = (sample.longitude - origin.longitude) * metersPerDegree * cos;
ys[i] = (sample.latitude - origin.latitude) * metersPerDegree;
double weight = 1.0 / Math.max(1.0, sample.distance * sample.distance);
x += xs[i] * weight;
y += ys[i] * weight;
total += weight;
}
double maxBaseline = 0;
for (int i = 0; i < count; i++) for (int j = i + 1; j < count; j++)
maxBaseline = Math.max(maxBaseline, Math.sqrt(Math.pow(xs[i] - xs[j], 2) + Math.pow(ys[i] - ys[j], 2)));
if (maxBaseline < 8.0) return null;
x /= total;
y /= total;
double determinant = 0;
for (int iteration = 0; iteration < 20; iteration++) {
double a00 = 0, a01 = 0, a11 = 0, b0 = 0, b1 = 0;
for (int i = 0; i < count; i++) {
Sample sample = samples.get(i);
double dx = x - xs[i];
double dy = y - ys[i];
double radius = Math.max(0.25, Math.sqrt(dx * dx + dy * dy));
double residual = radius - sample.distance;
double jx = dx / radius;
double jy = dy / radius;
double uncertainty = sample.accuracy * sample.accuracy +
Math.pow(Math.max(1.0, sample.distance * 0.5), 2);
double weight = 1.0 / uncertainty;
double scale = Math.sqrt(uncertainty);
if (Math.abs(residual) > 2.5 * scale)
weight *= (2.5 * scale) / Math.abs(residual);
a00 += weight * jx * jx;
a01 += weight * jx * jy;
a11 += weight * jy * jy;
b0 += weight * jx * residual;
b1 += weight * jy * residual;
}
determinant = a00 * a11 - a01 * a01;
if (Math.abs(determinant) < 0.000001) return null;
double stepX = -(a11 * b0 - a01 * b1) / determinant;
double stepY = -(-a01 * b0 + a00 * b1) / determinant;
x += stepX;
y += stepY;
if (Math.sqrt(stepX * stepX + stepY * stepY) < 0.05) break;
}
double error = 0;
double accuracyTotal = 0;
for (int i = 0; i < count; i++) {
double radius = Math.sqrt(Math.pow(x - xs[i], 2) + Math.pow(y - ys[i], 2));
error += Math.pow(radius - samples.get(i).distance, 2);
accuracyTotal += samples.get(i).accuracy;
}
Estimate result = new Estimate();
result.latitude = origin.latitude + y / metersPerDegree;
result.longitude = origin.longitude + x / (metersPerDegree * cos);
double averageAccuracy = accuracyTotal / count;
result.confidenceMeters = Math.max(averageAccuracy * 0.75,
Math.sqrt(error / count) + averageAccuracy / Math.sqrt(Math.max(1.0, count / 3.0)));
result.gradientBearing = gradientBearing(xs, ys);
Sample latest = samples.get(count - 1);
double latestX = xs[count - 1], latestY = ys[count - 1];
double solvedGap = Math.sqrt(Math.pow(x - latestX, 2) + Math.pow(y - latestY, 2));
double gradientEast = Math.sin(Math.toRadians(result.gradientBearing));
double gradientNorth = Math.cos(Math.toRadians(result.gradientBearing));
double solvedEast = x - latestX;
double solvedNorth = y - latestY;
boolean oppositeGradient = solvedEast * gradientEast + solvedNorth * gradientNorth <= 0;
if (!Float.isNaN(result.gradientBearing) && latest.distance > 3.0 &&
(oppositeGradient || solvedGap < Math.max(3.0, latest.distance * 0.5))) {
double angle = Math.toRadians(result.gradientBearing);
double adjustedX = latestX + Math.sin(angle) * latest.distance;
double adjustedY = latestY + Math.cos(angle) * latest.distance;
result.latitude = origin.latitude + adjustedY / metersPerDegree;
result.longitude = origin.longitude + adjustedX / (metersPerDegree * cos);
}
result.sampleCount = count;
result.preliminary = false;
return result;
}
private float gradientBearing(double[] xs, double[] ys) {
double meanX = 0, meanY = 0, meanRssi = 0;
for (int i = 0; i < samples.size(); i++) {
meanX += xs[i]; meanY += ys[i]; meanRssi += samples.get(i).rssi;
}
meanX /= samples.size(); meanY /= samples.size(); meanRssi /= samples.size();
double east = 0, north = 0;
for (int i = 0; i < samples.size(); i++) {
double signal = samples.get(i).rssi - meanRssi;
east += (xs[i] - meanX) * signal;
north += (ys[i] - meanY) * signal;
}
if (Math.abs(east) + Math.abs(north) < 0.000001) return Float.NaN;
double degrees = Math.toDegrees(Math.atan2(east, north));
if (degrees < 0) degrees += 360;
return (float) degrees;
}
public synchronized Estimate preliminaryEstimate() {
if (samples.size() < 2) return null;
Sample origin = samples.get(0);
int count = samples.size();
double[] xs = new double[count]; double[] ys = new double[count];
double cos = Math.cos(Math.toRadians(origin.latitude));
double metersPerDegree = 111319.49;
for (int i = 0; i < count; i++) {
xs[i] = (samples.get(i).longitude - origin.longitude) * metersPerDegree * cos;
ys[i] = (samples.get(i).latitude - origin.latitude) * metersPerDegree;
}
float bearing = gradientBearing(xs, ys);
if (Float.isNaN(bearing)) return null;
Sample latest = samples.get(count - 1);
double projected = Math.max(5.0, Math.min(50.0, latest.distance));
double radians = Math.toRadians(bearing);
double north = Math.cos(radians) * projected;
double east = Math.sin(radians) * projected;
Estimate result = new Estimate();
result.latitude = latest.latitude + north / metersPerDegree;
result.longitude = latest.longitude + east / (metersPerDegree * Math.cos(Math.toRadians(latest.latitude)));
result.confidenceMeters = Math.max(latest.accuracy, projected * 1.5);
result.gradientBearing = bearing; result.sampleCount = count; result.preliminary = true;
return result;
}
public synchronized Estimate consensusEstimate() {
if (samples.size() == lastConsensusSampleCount) return stableEstimate;
lastConsensusSampleCount = samples.size();
Estimate candidate = solve();
if (candidate == null) candidate = preliminaryEstimate();
if (candidate == null) return stableEstimate;
if (!estimateConsensus.isEmpty()) {
Estimate previous = estimateConsensus.get(estimateConsensus.size() - 1);
double separation = haversine(previous.latitude, previous.longitude,
candidate.latitude, candidate.longitude);
double allowed = Math.max(20.0, previous.confidenceMeters + candidate.confidenceMeters);
if (separation > allowed) estimateConsensus.clear();
}
estimateConsensus.add(candidate);
while (estimateConsensus.size() > 5) estimateConsensus.remove(0);
if (estimateConsensus.size() < 3) return stableEstimate;
int start = estimateConsensus.size() - 3;
Estimate result = new Estimate(); double east = 0, north = 0;
for (int i = start; i < estimateConsensus.size(); i++) {
Estimate value = estimateConsensus.get(i);
result.latitude += value.latitude; result.longitude += value.longitude;
result.confidenceMeters += value.confidenceMeters;
east += Math.sin(Math.toRadians(value.gradientBearing));
north += Math.cos(Math.toRadians(value.gradientBearing));
result.preliminary = result.preliminary || value.preliminary;
}
result.latitude /= 3.0; result.longitude /= 3.0;
result.confidenceMeters = Math.max(2.0, result.confidenceMeters / 3.0);
double bearing = Math.toDegrees(Math.atan2(east, north));
if (bearing < 0) bearing += 360; result.gradientBearing = (float)bearing;
result.sampleCount = samples.size(); stableEstimate = result; return stableEstimate;
}
public synchronized double calibratePathLoss(double targetLat, double targetLon, int txPower) {
if (samples.size() < 3) return Double.NaN;
double numerator = 0, denominator = 0; int used = 0;
for (int i = 0; i < samples.size(); i++) {
Sample sample = samples.get(i);
double distance = haversine(sample.latitude, sample.longitude, targetLat, targetLon);
if (distance < 1.0 || distance > 100.0) continue;
double x = 10.0 * Math.log10(distance);
numerator += x * (txPower - sample.rssi); denominator += x * x; used++;
}
if (used < 3 || denominator <= 0) return Double.NaN;
double exponent = numerator / denominator;
return exponent >= 1.2 && exponent <= 5.0 ? exponent : Double.NaN;
}
private void prune(long oldest) {
while (samples.size() > 0 && samples.get(0).time < oldest) samples.remove(0);
}
public synchronized int size() { return samples.size(); }
public synchronized void clear() { samples.clear(); pendingRssi.clear(); estimateConsensus.clear(); stableEstimate = null; lastConsensusSampleCount = -1; }
private double haversine(double lat1, double lon1, double lat2, double lon2) {
double radius = 6371000.0;
double dLat = Math.toRadians(lat2 - lat1);
double dLon = Math.toRadians(lon2 - lon1);
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
return radius * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
}
@@ -0,0 +1,551 @@
package com.wytehat.btlogger;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import java.util.ArrayList;
import java.util.List;
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;
public TrackerDatabase(Context context) {
super(context, DB_NAME, null, DB_VERSION);
}
public void onCreate(SQLiteDatabase db) {
db.execSQL("CREATE TABLE devices (address TEXT PRIMARY KEY,name TEXT," +
"connected INTEGER NOT NULL DEFAULT 0,connected_at INTEGER DEFAULT 0," +
"disconnected_at INTEGER DEFAULT 0,latitude REAL,longitude REAL,accuracy REAL," +
"has_location INTEGER DEFAULT 0,battery INTEGER DEFAULT -1,rssi INTEGER DEFAULT 0," +
"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)");
createSupportingTables(db);
seedVendors(db);
}
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
if (oldVersion < 2) {
addColumn(db, "ALTER TABLE devices ADD COLUMN photo_uri TEXT");
addColumn(db, "ALTER TABLE devices ADD COLUMN icon_preset TEXT DEFAULT 'bag'");
addColumn(db, "ALTER TABLE devices ADD COLUMN gps_polling_rate INTEGER DEFAULT 900000");
}
if (oldVersion < 3) {
addColumn(db, "ALTER TABLE devices ADD COLUMN pin_color TEXT DEFAULT '#E53935'");
addColumn(db, "ALTER TABLE devices ADD COLUMN tracking_priority INTEGER DEFAULT 15");
addColumn(db, "ALTER TABLE devices ADD COLUMN photo_path TEXT");
addColumn(db, "ALTER TABLE devices ADD COLUMN device_gps_interval INTEGER DEFAULT 15");
addColumn(db, "ALTER TABLE devices ADD COLUMN is_tracked INTEGER DEFAULT 0");
addColumn(db, "ALTER TABLE devices ADD COLUMN custom_name TEXT");
addColumn(db, "ALTER TABLE devices ADD COLUMN broadcast_name TEXT");
addColumn(db, "ALTER TABLE devices ADD COLUMN vendor_name TEXT");
db.execSQL("UPDATE devices SET broadcast_name=name,photo_path=photo_uri," +
"device_gps_interval=CASE WHEN gps_polling_rate=0 THEN 0 ELSE gps_polling_rate/60000 END," +
"tracking_priority=CASE WHEN gps_polling_rate=0 THEN 0 ELSE gps_polling_rate/60000 END," +
"is_tracked=CASE WHEN connected=1 OR connected_at>0 OR photo_uri IS NOT NULL THEN 1 ELSE 0 END");
createSupportingTables(db);
seedVendors(db);
}
if (oldVersion < 4) {
addColumn(db, "ALTER TABLE devices ADD COLUMN tx_power_1m INTEGER DEFAULT -59");
addColumn(db, "ALTER TABLE devices ADD COLUMN path_loss_exponent REAL DEFAULT 2.7");
createSupportingTables(db);
}
if (oldVersion < 5) {
addColumn(db, "ALTER TABLE devices ADD COLUMN last_rssi_at INTEGER DEFAULT 0");
}
if (oldVersion < 6) addColumn(db, "ALTER TABLE devices ADD COLUMN device_category TEXT");
if (oldVersion < 7) addColumn(db, "ALTER TABLE devices ADD COLUMN category_manual INTEGER DEFAULT 0");
if (oldVersion < 8) addColumn(db, "ALTER TABLE devices ADD COLUMN vendor_manual INTEGER DEFAULT 0");
if (oldVersion < 9) db.execSQL("UPDATE devices SET updated_at=0 WHERE last_rssi_at=0 AND rssi=0 AND connected_at=0 AND disconnected_at=0 AND has_location=0 AND battery<0");
if (oldVersion < 10) {
addColumn(db, "ALTER TABLE devices ADD COLUMN map_enabled INTEGER DEFAULT 0");
addColumn(db, "ALTER TABLE devices ADD COLUMN live_enabled INTEGER DEFAULT 0");
db.execSQL("UPDATE devices SET map_enabled=1 WHERE has_location=1 AND is_tracked=1");
}
if (oldVersion < 11) addColumn(db, "ALTER TABLE devices ADD COLUMN location_locked INTEGER DEFAULT 0");
if (oldVersion < 12) {
addColumn(db, "ALTER TABLE devices ADD COLUMN show_trail INTEGER DEFAULT 1");
db.execSQL("UPDATE devices SET show_trail=0 WHERE location_locked=1");
}
if (oldVersion < 13)
addColumn(db, "ALTER TABLE devices ADD COLUMN track_broadcast INTEGER DEFAULT 0");
if (oldVersion < 14) createSupportingTables(db);
if (oldVersion < 15) seedVendors(db);
}
private void addColumn(SQLiteDatabase db, String sql) {
try { db.execSQL(sql); } catch (Exception ignored) { }
}
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)");
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," +
"accuracy REAL,raw_rssi INTEGER,filtered_rssi REAL,heading REAL,range_state TEXT)");
db.execSQL("CREATE INDEX IF NOT EXISTS range_sample_session ON range_samples(session_id,sampled_at)");
db.execSQL("CREATE TABLE IF NOT EXISTS vendor_lookup (lookup_type TEXT,hex_code TEXT," +
"vendor_name TEXT,PRIMARY KEY(lookup_type,hex_code))");
db.execSQL("CREATE TABLE IF NOT EXISTS device_observations (id INTEGER PRIMARY KEY AUTOINCREMENT," +
"address TEXT,first_seen_at INTEGER,last_seen_at INTEGER,rssi INTEGER,strongest_rssi INTEGER," +
"sighting_count INTEGER DEFAULT 1,broadcast_name TEXT,vendor_name TEXT,device_category TEXT," +
"broadcast_type TEXT,appearance INTEGER DEFAULT -1,company_id INTEGER DEFAULT -1," +
"advertisement_hex TEXT,latitude REAL,longitude REAL,accuracy REAL,has_location INTEGER DEFAULT 0," +
"uploaded INTEGER DEFAULT 0)");
db.execSQL("CREATE INDEX IF NOT EXISTS observation_address_time ON device_observations(address,last_seen_at)");
db.execSQL("CREATE INDEX IF NOT EXISTS observation_upload ON device_observations(uploaded,last_seen_at)");
}
private void seedVendors(SQLiteDatabase db) {
addVendor(db, "OUI", "A4C3F0", "Intel Corporate");
addVendor(db, "OUI", "0000F0", "Samsung Electronics");
addVendor(db, "OUI", "0452C7", "Bose Corporation");
addVendor(db, "OUI", "C4E510", "Tile, Inc.");
addVendor(db, "OUI", "001A80", "Sony Corporation");
addVendor(db, "OUI", "5882A8", "Xiaomi Communications");
addVendor(db, "OUI", "F4F5D8", "Google LLC");
addVendor(db, "COMPANY", "0006", "Microsoft Corporation");
addVendor(db, "COMPANY", "0059", "Nordic Semiconductor ASA");
addVendor(db, "COMPANY", "0075", "Samsung Electronics");
addVendor(db, "COMPANY", "009E", "Bose Corporation");
addVendor(db, "COMPANY", "00E0", "Google LLC");
addVendor(db, "COMPANY", "018E", "Fitbit, Inc.");
addVendor(db, "COMPANY", "067C", "Tile, Inc.");
addVendor(db, "COMPANY", "0171", "Amazon.com Services LLC");
addVendor(db, "COMPANY", "00D0", "Dexcom, Inc.");
addVendor(db, "COMPANY", "0131", "Cypress Semiconductor");
}
private void addVendor(SQLiteDatabase db, String type, String code, String vendor) {
ContentValues values = new ContentValues();
values.put("lookup_type", type);
values.put("hex_code", code);
values.put("vendor_name", vendor);
db.insertWithOnConflict("vendor_lookup", null, values, SQLiteDatabase.CONFLICT_REPLACE);
}
public synchronized void connected(String address, String broadcast, long time, Fix fix) {
DeviceRecord old = get(address);
ContentValues values = base(address, broadcast, time, fix);
values.put("connected", 1);
values.put("connected_at", time);
values.put("disconnected_at", 0);
values.put("reason", "Connected");
values.put("is_tracked", 1);
if (old == null) values.put("pin_color", colorFor(address));
if (old != null) preserveProfile(values, old, fix);
getWritableDatabase().insertWithOnConflict("devices", null, values,
SQLiteDatabase.CONFLICT_REPLACE);
if (fix != null) insertHistory(address, fix, time);
}
public synchronized void disconnected(String address, String broadcast, long time,
Fix fix, String reason) {
ContentValues values = base(address, broadcast, time, fix);
values.put("connected", 0);
values.put("disconnected_at", time);
values.put("reason", reason);
values.put("is_tracked", 1);
getWritableDatabase().update("devices", values, "address=?", new String[] { address });
if (fix != null) insertHistory(address, fix, time);
}
public synchronized void telemetry(String address, String broadcast, Integer battery,
Integer rssi, long time, Fix fix) {
DeviceRecord old = get(address);
ContentValues values = base(address, broadcast, time, fix);
if (battery != null) values.put("battery", battery.intValue());
if (rssi != null) { values.put("rssi", rssi.intValue()); values.put("last_rssi_at", time); }
if (old == null) {
values.put("connected", 0);
getWritableDatabase().insert("devices", null, values);
} else {
getWritableDatabase().update("devices", values, "address=?", new String[] { address });
}
}
public synchronized void recordObservation(String address, long time, int rssi,
String broadcastName, String vendorName,
String category, String broadcastType,
int appearance, int companyId, String advertisementHex,
Fix fix) {
if (address == null || address.length() == 0 || rssi == 0 || rssi == 127) return;
SQLiteDatabase db = getWritableDatabase();
Cursor cursor = db.query("device_observations",
new String[] { "id", "last_seen_at", "strongest_rssi", "sighting_count" },
"address=?", new String[] { address }, null, null, "last_seen_at DESC", "1");
long id = -1L; long last = 0L; int strongest = rssi; int count = 0;
try {
if (cursor.moveToFirst()) {
id = cursor.getLong(0); last = cursor.getLong(1);
strongest = Math.max(rssi, cursor.getInt(2)); count = cursor.getInt(3);
}
} finally { cursor.close(); }
ContentValues values = new ContentValues();
values.put("address", address); values.put("last_seen_at", time); values.put("rssi", rssi);
values.put("strongest_rssi", strongest);
if (broadcastName != null) values.put("broadcast_name", broadcastName);
if (vendorName != null) values.put("vendor_name", vendorName);
if (category != null) values.put("device_category", category);
if (broadcastType != null) values.put("broadcast_type", broadcastType);
values.put("appearance", appearance); values.put("company_id", companyId);
if (advertisementHex != null) values.put("advertisement_hex", advertisementHex);
if (fix != null) {
values.put("latitude", fix.latitude); values.put("longitude", fix.longitude);
values.put("accuracy", fix.accuracy); values.put("has_location", 1);
}
if (id >= 0L && time - last <= 15000L) {
values.put("sighting_count", count + 1);
db.update("device_observations", values, "id=?", new String[] { String.valueOf(id) });
} else {
values.put("first_seen_at", time); values.put("sighting_count", 1); values.put("uploaded", 0);
db.insert("device_observations", null, values);
}
}
public synchronized void updateLocation(String address, Fix fix, long time) {
if (fix == null) return;
ContentValues values = new ContentValues();
values.put("latitude", fix.latitude);
values.put("longitude", fix.longitude);
values.put("accuracy", fix.accuracy);
values.put("has_location", 1);
values.put("updated_at", time);
getWritableDatabase().update("devices", values, "address=?", new String[] { address });
insertHistory(address, fix, time);
}
private void insertHistory(String address, Fix fix, long time) {
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);
getWritableDatabase().insert("location_history", null, values);
}
public synchronized void updateProfile(String address, String title, String photo,
String icon) {
ContentValues values = new ContentValues();
if (title != null && title.trim().length() > 0) values.put("custom_name", title.trim());
values.put("photo_uri", photo);
values.put("photo_path", photo);
values.put("icon_preset", icon);
values.put("is_tracked", 1);
getWritableDatabase().update("devices", values, "address=?", new String[] { address });
}
public synchronized void updateCalibration(String address, int txPower, double exponent) {
ContentValues values = new ContentValues();
values.put("tx_power_1m", txPower);
values.put("path_loss_exponent", exponent);
getWritableDatabase().update("devices", values, "address=?", new String[] { address });
}
public synchronized void logRangeSample(String address, String sessionId, long time,
double latitude, double longitude, float accuracy,
int rawRssi, double filteredRssi, float heading,
String state) {
ContentValues values = new ContentValues();
values.put("address", address); values.put("session_id", sessionId);
values.put("sampled_at", time); values.put("latitude", latitude);
values.put("longitude", longitude); values.put("accuracy", accuracy);
values.put("raw_rssi", rawRssi); values.put("filtered_rssi", filteredRssi);
values.put("heading", heading); values.put("range_state", state);
getWritableDatabase().insert("range_samples", null, values);
}
public synchronized void updateTracking(String address, int minutes, String color) {
ContentValues values = new ContentValues();
values.put("tracking_priority", minutes);
values.put("device_gps_interval", minutes);
values.put("gps_polling_rate", minutes == 0 ? 0 : minutes * 60000);
values.put("pin_color", color);
values.put("is_tracked", 1);
getWritableDatabase().update("devices", values, "address=?", new String[] { address });
}
public synchronized void markTracked(String address, String broadcast) {
DeviceRecord old = get(address);
if (old == null) {
ContentValues created = base(address, broadcast, 0L, null);
created.put("connected", 0);
created.put("is_tracked", 1);
created.put("pin_color", colorFor(address));
getWritableDatabase().insert("devices", null, created);
return;
}
ContentValues values = new ContentValues();
values.put("is_tracked", 1);
if (old.tracked == 0) values.put("pin_color", colorFor(address));
getWritableDatabase().update("devices", values, "address=?", new String[] { address });
}
public synchronized void removeFromTracking(String address) {
ContentValues values = new ContentValues();
values.put("is_tracked", 0);
values.put("map_enabled", 0);
values.put("live_enabled", 0);
values.put("track_broadcast", 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 });
}
public synchronized void setLiveEnabled(String address, boolean enabled) {
ContentValues values = new ContentValues(); values.put("live_enabled", enabled ? 1 : 0);
getWritableDatabase().update("devices", values, "address=?", new String[] { address });
}
public synchronized void setTrackBroadcast(String address, boolean enabled) {
ContentValues values = new ContentValues(); values.put("track_broadcast", enabled ? 1 : 0);
getWritableDatabase().update("devices", values, "address=?", new String[] { address });
}
public synchronized void setShowTrail(String address, boolean shown) {
ContentValues values = new ContentValues(); values.put("show_trail", shown ? 1 : 0);
getWritableDatabase().update("devices", values, "address=?", new String[] { address });
}
public synchronized void setLocationLocked(String address, boolean locked) {
ContentValues values = new ContentValues(); values.put("location_locked", locked ? 1 : 0);
getWritableDatabase().update("devices", values, "address=?", new String[] { address });
}
public synchronized void setCustomName(String address, String name) {
ContentValues values = new ContentValues();
values.put("custom_name", name == null ? null : name.trim());
values.put("is_tracked", 1);
getWritableDatabase().update("devices", values, "address=?", new String[] { address });
}
public synchronized void setDetectedCategory(String address, String category) {
DeviceRecord record = get(address);
if (record != null && record.categoryManual == 1) return;
ContentValues values = new ContentValues();
values.put("device_category", category);
getWritableDatabase().update("devices", values, "address=?", new String[] { address });
}
public synchronized void setCategory(String address, String category) {
if (category == null) return;
ContentValues values = new ContentValues();
values.put("device_category", category);
values.put("category_manual", 1);
getWritableDatabase().update("devices", values, "address=?", new String[] { address });
}
public synchronized void setVendor(String address, String vendor) {
DeviceRecord old = get(address);
if (old != null && old.vendorManual == 1) return;
if (vendor == null) return;
ContentValues values = new ContentValues();
values.put("vendor_name", vendor);
getWritableDatabase().update("devices", values, "address=?", new String[] { address });
}
public synchronized void setManualVendor(String address, String vendor) {
ContentValues values = new ContentValues();
values.put("vendor_name", vendor == null ? null : vendor.trim());
values.put("vendor_manual", 1);
getWritableDatabase().update("devices", values, "address=?", new String[] { address });
}
public synchronized String lookupOui(String address) {
if (address == null) return null;
String code = address.replace(":", "").replace("-", "").toUpperCase(Locale.US);
if (code.length() < 6) return null;
return lookupVendor("OUI", code.substring(0, 6));
}
public synchronized String lookupCompany(int companyId) {
return lookupVendor("COMPANY", String.format(Locale.US, "%04X", companyId & 0xffff));
}
private String lookupVendor(String type, String code) {
Cursor cursor = getReadableDatabase().query("vendor_lookup",
new String[] { "vendor_name" }, "lookup_type=? AND hex_code=?",
new String[] { type, code }, null, null, null);
try { return cursor.moveToFirst() ? cursor.getString(0) : null; }
finally { cursor.close(); }
}
public synchronized DeviceRecord get(String address) {
Cursor cursor = getReadableDatabase().query("devices", null, "address=?",
new String[] { address }, null, null, null);
try { return cursor.moveToFirst() ? read(cursor) : null; }
finally { cursor.close(); }
}
public synchronized List<DeviceRecord> all() { return queryDevices(null, null); }
public synchronized List<DeviceRecord> tracked() {
return queryDevices("is_tracked=1", null);
}
private List<DeviceRecord> queryDevices(String where, String[] args) {
ArrayList<DeviceRecord> result = new ArrayList<DeviceRecord>();
Cursor cursor = getReadableDatabase().query("devices", null, where, args,
null, null, "updated_at DESC");
try { while (cursor.moveToNext()) result.add(read(cursor)); }
finally { cursor.close(); }
return result;
}
public synchronized List<LocationPoint> history(String address, long since) {
ArrayList<LocationPoint> result = new ArrayList<LocationPoint>();
Cursor cursor = getReadableDatabase().query("location_history", null,
"address=? AND logged_at>=?", new String[] { address, String.valueOf(since) },
null, null, "logged_at ASC");
try {
while (cursor.moveToNext()) {
LocationPoint point = new LocationPoint();
point.latitude = cursor.getDouble(cursor.getColumnIndex("latitude"));
point.longitude = cursor.getDouble(cursor.getColumnIndex("longitude"));
point.accuracy = cursor.getFloat(cursor.getColumnIndex("accuracy"));
point.timestamp = cursor.getLong(cursor.getColumnIndex("logged_at"));
result.add(point);
}
} finally { cursor.close(); }
return result;
}
private ContentValues base(String address, String broadcast, long time, Fix fix) {
ContentValues values = new ContentValues();
values.put("address", address);
if (meaningfulBroadcast(broadcast)) {
values.put("name", broadcast.trim());
values.put("broadcast_name", broadcast.trim());
}
values.put("updated_at", time);
if (fix != null) {
values.put("latitude", fix.latitude);
values.put("longitude", fix.longitude);
values.put("accuracy", fix.accuracy);
values.put("has_location", 1);
}
return values;
}
private boolean meaningfulBroadcast(String value) {
return value != null && value.trim().length() > 0
&& !"Unknown Device".equalsIgnoreCase(value.trim())
&& !"Unknown item".equalsIgnoreCase(value.trim());
}
private void preserveProfile(ContentValues values, DeviceRecord old, Fix fix) {
values.put("battery", old.battery);
values.put("rssi", old.rssi);
values.put("last_rssi_at", old.lastRssiAt);
values.put("photo_uri", old.photoUri);
values.put("photo_path", old.photoUri);
values.put("icon_preset", old.iconPreset);
values.put("pin_color", old.pinColor);
values.put("tracking_priority", old.trackingPriority);
values.put("device_gps_interval", old.trackingPriority);
values.put("gps_polling_rate", old.gpsPollingRate);
values.put("track_broadcast", old.trackBroadcast);
values.put("custom_name", old.customName);
values.put("vendor_name", old.vendorName);
values.put("tx_power_1m", old.txPowerAtOneMeter);
values.put("path_loss_exponent", old.pathLossExponent);
if (fix == null && old.hasLocation) {
values.put("latitude", old.latitude);
values.put("longitude", old.longitude);
values.put("accuracy", old.accuracy);
values.put("has_location", 1);
}
}
private DeviceRecord read(Cursor c) {
DeviceRecord r = new DeviceRecord();
r.address = string(c, "address");
r.name = string(c, "name");
r.customName = string(c, "custom_name");
r.broadcastName = string(c, "broadcast_name");
r.vendorName = string(c, "vendor_name");
r.category = string(c, "device_category");
if (r.category == null) r.category = "Other";
r.categoryManual = integer(c, "category_manual");
r.vendorManual = integer(c, "vendor_manual");
r.mapEnabled = integer(c, "map_enabled");
r.liveEnabled = integer(c, "live_enabled");
r.locationLocked = integer(c, "location_locked");
r.showTrail = integer(c, "show_trail");
r.trackBroadcast = integer(c, "track_broadcast");
r.connected = integer(c, "connected");
r.connectedAt = number(c, "connected_at");
r.disconnectedAt = number(c, "disconnected_at");
r.latitude = decimal(c, "latitude");
r.longitude = decimal(c, "longitude");
r.accuracy = (float) decimal(c, "accuracy");
r.hasLocation = integer(c, "has_location") == 1;
r.battery = integer(c, "battery");
r.rssi = integer(c, "rssi");
r.lastRssiAt = number(c, "last_rssi_at");
r.reason = string(c, "reason");
r.updatedAt = number(c, "updated_at");
r.photoUri = string(c, "photo_path");
if (r.photoUri == null) r.photoUri = string(c, "photo_uri");
r.iconPreset = string(c, "icon_preset");
r.pinColor = string(c, "pin_color");
if (r.pinColor == null) r.pinColor = "#E53935";
r.trackingPriority = integer(c, "device_gps_interval");
r.gpsPollingRate = r.trackingPriority == 0 ? 0 : r.trackingPriority * 60000;
r.tracked = integer(c, "is_tracked");
r.txPowerAtOneMeter = integer(c, "tx_power_1m");
if (r.txPowerAtOneMeter == 0) r.txPowerAtOneMeter = -59;
r.pathLossExponent = decimal(c, "path_loss_exponent");
if (r.pathLossExponent <= 0) r.pathLossExponent = 2.7;
return r;
}
private int column(Cursor c, String name) { return c.getColumnIndex(name); }
private String string(Cursor c, String name) {
int index = column(c, name);
return index < 0 || c.isNull(index) ? null : c.getString(index);
}
private int integer(Cursor c, String name) {
int index = column(c, name);
return index < 0 || c.isNull(index) ? 0 : c.getInt(index);
}
private long number(Cursor c, String name) {
int index = column(c, name);
return index < 0 || c.isNull(index) ? 0 : c.getLong(index);
}
private double decimal(Cursor c, String name) {
int index = column(c, name);
return index < 0 || c.isNull(index) ? 0 : c.getDouble(index);
}
private String colorFor(String address) {
String[] colors = { "#E53935", "#1E88E5", "#43A047", "#FB8C00", "#8E24AA", "#00897B" };
int hash = address == null ? 0 : address.hashCode();
if (hash == Integer.MIN_VALUE) hash = 0;
return colors[Math.abs(hash) % colors.length];
}
private String safe(String value) {
return value == null || value.length() == 0 ? "Unknown item" : value;
}
public static class Fix {
public double latitude;
public double longitude;
public float accuracy;
public Fix(double lat, double lon, float acc) {
latitude = lat; longitude = lon; accuracy = acc;
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="6dp" android:background="#ffffff">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical">
<ImageView android:id="@+id/item_image" android:layout_width="44dp" android:layout_height="44dp" android:scaleType="centerCrop" android:contentDescription="Item picture"/>
<LinearLayout android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:orientation="vertical" android:paddingLeft="7dp">
<TextView android:id="@+id/device_title" android:layout_width="match_parent" android:layout_height="wrap_content" android:textStyle="bold" android:textSize="15sp" android:textColor="#202124"/>
<TextView android:id="@+id/device_vendor" android:layout_width="match_parent" android:layout_height="wrap_content" android:textColor="#607d8b" android:textSize="11sp" android:maxLines="1" android:ellipsize="end"/>
<TextView android:id="@+id/device_state" android:layout_width="match_parent" android:layout_height="wrap_content" android:textColor="#2878ff" android:textSize="11sp" android:maxLines="1" android:ellipsize="end"/>
<TextView android:id="@+id/device_telemetry" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="11sp" android:maxLines="1"/>
</LinearLayout>
</LinearLayout>
<TextView android:id="@+id/device_location" android:layout_width="match_parent" android:layout_height="wrap_content" android:visibility="gone"/>
<TextView android:id="@+id/device_reason" android:layout_width="match_parent" android:layout_height="wrap_content" android:textStyle="bold" android:textSize="10sp" android:maxLines="1" android:ellipsize="end" android:paddingTop="1dp"/>
<Button android:id="@+id/live_find_button" android:layout_width="match_parent" android:layout_height="36dp" android:minHeight="0dp" android:textSize="11sp" android:paddingTop="0dp" android:paddingBottom="0dp" android:text="LIVE FIND / RANGEFINDER"/>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal">
<Button android:id="@+id/map_button" android:layout_width="0dp" android:layout_weight="1" android:layout_height="34dp" android:minHeight="0dp" android:textSize="10sp" android:padding="0dp" android:text="MAP"/>
<Button android:id="@+id/manage_button" android:layout_width="0dp" android:layout_weight="1" android:layout_height="34dp" android:minHeight="0dp" android:textSize="10sp" android:padding="0dp" android:text="PAIR / CONNECT"/>
<Button android:id="@+id/edit_button" android:layout_width="0dp" android:layout_weight="1" android:layout_height="34dp" android:minHeight="0dp" android:textSize="10sp" android:padding="0dp" android:text="EDIT"/>
</LinearLayout>
</LinearLayout>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:background="#f5f7fa">
<LinearLayout android:id="@+id/navigation_container" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical"/>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:padding="6dp">
<Button android:id="@+id/devices_button" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="ADD / PAIR"/>
<Button android:id="@+id/map_all_button" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="MAP"/>
<Button android:id="@+id/settings_button" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="SETTINGS"/>
</LinearLayout>
<TextView android:id="@+id/tracking_status" android:text="@string/tracking_status" android:layout_width="match_parent" android:layout_height="wrap_content" android:paddingLeft="12dp" android:paddingBottom="8dp" android:textColor="#2878ff"/>
<ListView android:id="@+id/device_list" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:dividerHeight="10dp" android:padding="8dp"/>
<TextView android:id="@+id/empty_view" android:text="No tracked items yet. Use ADD / PAIR to choose an item." android:gravity="center" android:layout_width="match_parent" android:layout_height="match_parent"/>
</LinearLayout>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="@android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">#2878ff</item>
</style>
</resources>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Bluetooth Logger</string>
<string name="tracking_status">Bluetooth tracking is running</string>
<string name="empty_devices">No Bluetooth events recorded yet.</string>
<string name="open_map">OPEN MAP</string>
</resources>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="@android:style/Theme.Holo.Light.NoActionBar">
<item name="android:windowActionModeOverlay">true</item>
</style>
</resources>