Initial commit
This commit is contained in:
@@ -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 8–15 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 8–15 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("&", "&").replace("<", "<")
|
||||
.replace(">", ">").replace("'", "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user