Compare commits

...
5 Commits
8 changed files with 1320 additions and 153 deletions
+450
View File
@@ -0,0 +1,450 @@
# BlueToothLogger Android Application - Technical Project Context
## Project Overview
**Project Name:** BlueToothLogger
**Package:** `com.wytehat.btlogger`
**Location:** `/Users/n0tst3v3/bletracker/BlueToothLogger`
**Type:** Bluetooth tracking and logging application with spatial analysis capabilities
---
## Core Classes and Inheritance
| Class | Extends | Implements | Purpose |
|-------|---------|------------|---------|
| `MainActivity` | Activity | ItemActionListener | Main entry point, UI controller |
| `BluetoothTrackingService` | Service | LocationListener | Background Bluetooth tracking |
| `TrackerDatabase` | SQLiteOpenHelper | - | SQLite database wrapper |
| `MapActivity` | Activity | - | Map visualization |
| `DeviceManagerActivity` | Activity | - | Device management UI |
| `LiveRangeFinderActivity` | Activity | LocationListener, SensorEventListener | Real-time signal finder |
| `BluetoothRadarView` | View | - | Custom radar visualization |
| `DeviceListAdapter` | BaseAdapter | - | List adapter for devices |
---
## BluetoothTrackingService - Technical Details
### Constants
```java
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"
```
### Fields
```java
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
```
### Key Methods
```java
public void onCreate()
public int onStartCommand(Intent intent, int flags, int startId)
public void onDestroy()
public IBinder onBind(Intent intent)
// Location callbacks
public void onLocationChanged(Location location)
public void onProviderDisabled(String provider)
public void onProviderEnabled(String provider)
public void onStatusChanged(String provider, int status, Bundle extras)
// Bluetooth callbacks
public void onLeScan(BluetoothDevice device, int rssi, byte[] scanRecord)
// Private methods
private void loadLastLocation()
private void configureLocation()
private int shortestActiveInterval()
private void stopLocation()
private void captureFresh(String address, long eventTime)
private TrackerDatabase.Fix currentFix()
private TrackerDatabase.Fix fix(Location location)
private void resolveVendor(String address, int companyId)
private String bytesToHex(byte[] data)
private int parseCompanyId(byte[] scanRecord)
private String infer(DeviceRecord record)
private void disconnectAll(String reason)
private void syncPairedDevices()
private void restartBackgroundScan()
private boolean shouldBackgroundScan()
private boolean isLocationEligible(DeviceRecord record, long now)
private void notifyChanged()
private boolean hasLocationPermission()
private boolean hasBluetoothPermission()
private String address(BluetoothDevice device)
private String name(BluetoothDevice device)
private Location newer(Location first, Location second)
private void showDisconnectAlert(DeviceRecord record, String reason)
private Notification buildNotification()
```
---
## TrackerDatabase - Technical Details
### Constants
```java
private static final String DB_NAME = "bluetooth_tracker.db"
private static final int DB_VERSION = 15
```
### Inner Class: Fix
```java
public static class Fix {
public double latitude
public double longitude
public float accuracy
public Fix(double lat, double lon, float acc)
}
```
### Key Methods
```java
public TrackerDatabase(Context context)
public void onCreate(SQLiteDatabase db)
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion)
// Device tracking
public synchronized void connected(String address, String broadcast, long time, Fix fix)
public synchronized void disconnected(String address, String broadcast, long time, String reason)
public synchronized void telemetry(String address, String broadcast, Integer battery, String vendor, String category, long time)
public synchronized void recordObservation(String address, long time, int rssi, int txPower)
public synchronized void updateLocation(String address, Fix fix, long time)
// Profile and calibration
public synchronized void updateProfile(String address, String title, String photo, String color, long time)
public synchronized void updateCalibration(String address, int txPower, double exponent)
// Range finding
public synchronized void logRangeSample(String address, String sessionId, long time, int rssi, double distance)
// Tracking settings
public synchronized void updateTracking(String address, int minutes, String color)
public synchronized void markTracked(String address, String broadcast)
public synchronized void removeFromTracking(String address)
public synchronized void setMapEnabled(String address, boolean enabled)
public synchronized void setLiveEnabled(String address, boolean enabled)
public synchronized void setTrackBroadcast(String address, boolean enabled)
public synchronized void setShowTrail(String address, boolean shown)
public synchronized void setLocationLocked(String address, boolean locked)
public synchronized void setCustomName(String address, String name)
public synchronized void setDetectedCategory(String address, String category)
public synchronized void setCategory(String address, String category)
public synchronized void setVendor(String address, String vendor)
public synchronized void setManualVendor(String address, String vendor)
// Lookups
public synchronized String lookupOui(String address)
public synchronized String lookupCompany(int companyId)
// Queries
public synchronized DeviceRecord get(String address)
public synchronized List<DeviceRecord> all()
public synchronized List<DeviceRecord> tracked()
public synchronized List<LocationPoint> history(String address, long since)
```
---
## MainActivity - Technical Details
### Constants
```java
private static final int PERMISSIONS = 100
private static final int CAMERA = 200
```
### Fields
```java
private TrackerDatabase db
private DeviceListAdapter adapter
private boolean registered
private DeviceRecord editing
private final Handler refreshHandler = new Handler()
private final Runnable refreshLiveAvailability
private final BroadcastReceiver updates
```
### Key Methods
```java
protected void onCreate(Bundle state)
protected void onResume()
protected void onPause()
protected void onDestroy()
// UI actions
public void edit(DeviceRecord record)
public void map(DeviceRecord record)
public void live(DeviceRecord record)
public void manage(DeviceRecord record)
// Device operations
private void removeTrackedItem(DeviceRecord record)
private void toggleBroadcastTracking(DeviceRecord record)
private void toggleTrail(DeviceRecord record)
private void refind(DeviceRecord record)
// Renaming and settings
private void rename(DeviceRecord record)
private void chooseTracking(DeviceRecord record)
private void chooseColor(DeviceRecord record, int minutes)
private void takePhoto()
protected void onActivityResult(int request, int result, Intent data)
private void showSettings()
private void openNotificationSettings()
private void openAppSettings(String instruction)
private boolean isBatteryOptimizationDisabled()
private void requestBackgroundProtection()
private void showTrackingInformation()
// Permissions
public void onRequestPermissionsResult(int request, String[] permissions, int[] results)
private void requestNeededPermissions()
private void addPermission(List<String> list, String permission)
private void reload()
```
---
## LiveRangeFinderActivity - Technical Details
### Implements
```java
implements LocationListener, SensorEventListener, ItemActionListener
```
### Key Methods
```java
protected void onCreate(Bundle savedState)
protected void onPause()
protected void onDestroy()
// Location callbacks
public void onLocationChanged(Location location)
public void onProviderDisabled(String provider)
public void onProviderEnabled(String provider)
public void onStatusChanged(String provider, int status, Bundle extras)
// Sensor callbacks
public void onSensorChanged(SensorEvent event)
public void onAccuracyChanged(Sensor sensor, int accuracy)
// Bluetooth callbacks
public void onScanResult(int callbackType, ScanResult result)
public void onBatchScanResults(List<ScanResult> results)
public void onScanFailed(int errorCode)
// UI actions
private void startSearch()
private void stopSearch(String message)
private void tagItemHere()
private void startCalibration()
private void updateRadar()
private void updateRangeDisplay()
private void updateDistanceDisplay()
private void updateCalibrationDisplay()
private void updateSearchDisplay()
private void updateSearchStatus()
private void updateSearchProgress()
private void updateSearchResults()
private void updateSearchSettings()
private void updateSearchControls()
private void updateSearchUI()
```
---
## MapActivity - Technical Details
### Implements
```java
implements LocationListener, SensorEventListener, ItemActionListener
```
### Key Methods
```java
protected void onCreate(Bundle state)
protected void onResume()
protected void onPause()
protected void onDestroy()
// Location callbacks
public void onLocationChanged(Location location)
public void onProviderDisabled(String provider)
public void onProviderEnabled(String provider)
public void onStatusChanged(String provider, int status, Bundle extras)
// Sensor callbacks
public void onSensorChanged(SensorEvent event)
public void onAccuracyChanged(Sensor sensor, int accuracy)
// Map operations
private void updateMap()
private void updateMarkers()
private void updateTrails()
private void updateCamera()
private void updateLocation()
private void updateRange()
private void updateCalibration()
private void updateSearch()
private void updateSettings()
private void updateUI()
```
---
## DeviceManagerActivity - Technical Details
### Key Methods
```java
protected void onCreate(Bundle state)
protected void onPause()
protected void onDestroy()
// Bluetooth scanning
private void startScan()
private void stopScan()
private void refreshCategoryOptions()
private void updateRadar()
// Device management
private void toggleMap(DeviceRecord record, Button button)
private void toggleTrack(DeviceRecord record, Button button)
private void editSignal(DeviceRecord record)
private void confirmRemoveSignal(SignalEntry entry)
private void copyText(String label, String text)
// Adapters
private class SignalGridAdapter extends BaseAdapter
private class PairedAdapter extends BaseAdapter
```
---
## BluetoothRadarView - Technical Details
### Extends
```java
extends View
```
### Key Features
- Custom radar-like visualization
- Radial gradient rendering
- Touch event handling
- Signal strength visualization
---
## Data Models
### DeviceRecord
```java
public class DeviceRecord {
public String address
public String title
public String broadcast
public String category
public String vendor
public String color
public int trackingMinutes
public boolean mapEnabled
public boolean liveEnabled
public boolean trackBroadcast
public boolean showTrail
public boolean locationLocked
public int txPower
public double exponent
public long lastSeen
public long lastLocation
public int battery
public String photo
}
```
### LocationPoint
```java
public class LocationPoint {
public String address
public double latitude
public double longitude
public float accuracy
public long time
}
```
---
## Key Android Components
### Permissions Required
- `BLUETOOTH` - Bluetooth scanning and discovery
- `BLUETOOTH_ADMIN` - Bluetooth administration
- `ACCESS_FINE_LOCATION` - Precise location access
- `ACCESS_COARSE_LOCATION` - Approximate location access
- `FOREGROUND_SERVICE` - Background service execution
- `CAMERA` - Device photo capture
- `RECEIVE_BOOT_COMPLETED` - Auto-start on boot
### Services
- `BluetoothTrackingService` - Foreground service for continuous tracking
- `LocationManager` - GPS and network location services
- `BluetoothAdapter` - Bluetooth scanning and discovery
### Broadcast Receivers
- `DATA_CHANGED` - Device data updates
- `CONFIG_CHANGED` - Configuration changes
- `RANGE_MODE` - Range finder mode changes
- `BOOT_COMPLETED` - System boot completion
---
## Signal Processing Flow
```
Bluetooth Scan → LeScanCallback → RSSI Reading
BluetoothTrackingService
captureFresh() → Fix()
TrackerDatabase.recordObservation()
SpatialGradientEngine Analysis
RangeStateMachine State Update
MapActivity / BluetoothRadarView Update
```
---
## Database Schema (Version 15)
### Tables
- `devices` - Device records and metadata
- `locations` - GPS location history
- `observations` - RSSI observations
- `telemetry` - Battery and vendor info
- `calibrations` - TX power and exponent
- `range_samples` - Range finding data
- `vendors` - Vendor lookup table
- `categories` - Device categories
---
*Document generated from technical analysis of Java source files*
@@ -25,6 +25,10 @@ public class BluetoothTrackingService extends Service implements LocationListene
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";
public static final String ACTION_ACK_LEFT_BEHIND = "com.wytehat.btlogger.ACK_LEFT_BEHIND";
public static final String EXTRA_ADDRESS = "address";
/** How often an unacknowledged left-behind item re-alerts. */
private static final long ALERT_REPEAT_MS = 60000L;
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";
@@ -37,6 +41,9 @@ public class BluetoothTrackingService extends Service implements LocationListene
private int phoneBattery = 100;
private final Handler handler = new Handler();
private final HashMap<String, Long> lastPeriodicLog = new HashMap<String, Long>();
/** Items reported left behind, address -> display name, until acknowledged. */
private final HashMap<String, String> leftBehind = new HashMap<String, String>();
private boolean alertLoopRunning;
private final BroadcastReceiver receiver = new BroadcastReceiver() {
public void onReceive(Context context, Intent intent) {
@@ -45,6 +52,10 @@ public class BluetoothTrackingService extends Service implements LocationListene
rangeModeActive = intent.getBooleanExtra("active", false);
return;
}
if (ACTION_ACK_LEFT_BEHIND.equals(action)) {
acknowledgeLeftBehind(intent.getStringExtra(EXTRA_ADDRESS));
return;
}
if (ACTION_CONFIG_CHANGED.equals(action)) {
restartBackgroundScan();
configureLocation();
@@ -84,7 +95,7 @@ public class BluetoothTrackingService extends Service implements LocationListene
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));
markLeftBehind(previous, infer(previous));
captureFresh(address, now);
configureLocation();
} else if (BluetoothDevice.ACTION_FOUND.equals(action)) {
@@ -175,6 +186,7 @@ public class BluetoothTrackingService extends Service implements LocationListene
filter.addAction(Intent.ACTION_BATTERY_CHANGED);
filter.addAction(ACTION_CONFIG_CHANGED);
filter.addAction(ACTION_RANGE_MODE);
filter.addAction(ACTION_ACK_LEFT_BEHIND);
registerReceiver(receiver, filter);
receiverRegistered = true;
locationManager = (LocationManager) getSystemService(LOCATION_SERVICE);
@@ -192,6 +204,7 @@ public class BluetoothTrackingService extends Service implements LocationListene
public void onDestroy() {
handler.removeCallbacks(scanTask);
handler.removeCallbacks(alertTask);
try { if (bluetooth != null) bluetooth.stopLeScan(leScanCallback); }
catch (Exception ignored) { }
if (receiverRegistered) unregisterReceiver(receiver);
@@ -418,16 +431,99 @@ public class BluetoothTrackingService extends Service implements LocationListene
return first.getTime() >= second.getTime() ? first : second;
}
/**
* FLAG_IMMUTABLE (API 23+) is referenced by value because this module
* still compiles against SDK 21.
*/
private int pendingFlags() {
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
if (Build.VERSION.SDK_INT >= 23) flags |= 0x04000000;
return flags;
}
private int alertId(String address) {
return 1000 + Math.abs(address.hashCode() % 100000);
}
private PendingIntent acknowledgeIntent(String address) {
Intent ack = new Intent(ACTION_ACK_LEFT_BEHIND).setPackage(getPackageName());
if (address != null) ack.putExtra(EXTRA_ADDRESS, address);
return PendingIntent.getBroadcast(this,
address == null ? 0 : alertId(address), ack, pendingFlags());
}
/**
* Records the item as left behind and keeps alerting until the user
* taps "Acknowledge Left Behind" - a swipe alone does not clear it.
*/
private void markLeftBehind(DeviceRecord record, String reason) {
if (record == null || record.address == null) return;
leftBehind.put(record.address, record.displayName());
showDisconnectAlert(record, reason);
updateForegroundNotification();
startAlertLoop();
}
private void acknowledgeLeftBehind(String address) {
NotificationManager manager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
if (address == null) {
for (String pending : leftBehind.keySet().toArray(new String[0])) {
try { manager.cancel(alertId(pending)); } catch (Exception ignored) { }
}
leftBehind.clear();
} else {
leftBehind.remove(address);
try { manager.cancel(alertId(address)); } catch (Exception ignored) { }
}
if (leftBehind.isEmpty()) {
handler.removeCallbacks(alertTask);
alertLoopRunning = false;
}
updateForegroundNotification();
}
private void startAlertLoop() {
if (alertLoopRunning) return;
alertLoopRunning = true;
handler.postDelayed(alertTask, ALERT_REPEAT_MS);
}
/** Re-fires every outstanding alert until each one is acknowledged. */
private final Runnable alertTask = new Runnable() {
public void run() {
if (leftBehind.isEmpty()) { alertLoopRunning = false; return; }
for (String address : leftBehind.keySet().toArray(new String[0])) {
DeviceRecord record = db.get(address);
if (record == null) continue;
showDisconnectAlert(record, "Still left behind - not acknowledged");
}
updateForegroundNotification();
handler.postDelayed(this, ALERT_REPEAT_MS);
}
};
private void updateForegroundNotification() {
try {
NotificationManager manager =
(NotificationManager) getSystemService(NOTIFICATION_SERVICE);
manager.notify(NOTIFICATION_ID, buildNotification());
} catch (Exception ignored) { }
}
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")
.setContentTitle(record.displayName() + " left behind")
.setContentText(reason == null ? "Your tracked item is no longer connected" : reason)
.setContentIntent(pending).setAutoCancel(true)
.setDefaults(Notification.DEFAULT_ALL).setPriority(Notification.PRIORITY_HIGH);
if (Build.VERSION.SDK_INT >= 16) {
builder.addAction(0, "Acknowledge Left Behind",
acknowledgeIntent(record.address));
}
NotificationManager manager = (NotificationManager)getSystemService(NOTIFICATION_SERVICE);
if (Build.VERSION.SDK_INT >= 26) {
try {
@@ -445,12 +541,24 @@ public class BluetoothTrackingService extends Service implements LocationListene
private Notification buildNotification() {
Intent open = new Intent(this, MainActivity.class);
PendingIntent pending = PendingIntent.getActivity(this, 0, open,
PendingIntent.FLAG_UPDATE_CURRENT);
PendingIntent pending = PendingIntent.getActivity(this, 0, open, PendingIntent.FLAG_UPDATE_CURRENT);
int outstanding = leftBehind.size();
String title = outstanding > 0
? outstanding + (outstanding == 1 ? " item left behind" : " items left behind")
: "Bluetooth Item Finder";
String text = outstanding > 0
? "Alerting until acknowledged: " + describeLeftBehind()
: "Watching your tracked items";
Notification.Builder builder = new Notification.Builder(this)
.setSmallIcon(R.drawable.ic_launcher).setContentTitle("Bluetooth Item Finder")
.setContentText("Watching your tracked items").setOngoing(true)
.setSmallIcon(R.drawable.ic_launcher).setContentTitle(title)
.setContentText(text).setOngoing(true)
.setContentIntent(pending);
if (Build.VERSION.SDK_INT >= 16 && outstanding > 0) {
builder.addAction(0,
outstanding == 1 ? "Acknowledge Left Behind" : "Acknowledge All",
acknowledgeIntent(outstanding == 1
? leftBehind.keySet().iterator().next() : null));
}
if (Build.VERSION.SDK_INT >= 26) {
try {
Class<?> channelClass = Class.forName("android.app.NotificationChannel");
@@ -467,5 +575,14 @@ public class BluetoothTrackingService extends Service implements LocationListene
}
return builder.build();
}
private String describeLeftBehind() {
StringBuilder names = new StringBuilder();
for (String name : leftBehind.values()) {
if (names.length() > 0) names.append(", ");
names.append(name);
}
return names.toString();
}
}
@@ -0,0 +1,121 @@
package com.wytehat.btlogger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
/**
* Ordering for the tracked item list.
*
* Default order is newest sighting first. Items that have not been seen for
* a long time - or that carry no sighting timestamp at all - are pushed to
* the bottom and stay there in both sort directions, so the reverse toggle
* never buries a live item under a pile of stale ones.
*/
final class DeviceSort {
/** A device unseen for longer than this sinks to the bottom. */
static final long STALE_AFTER_MS = 24L * 60L * 60L * 1000L;
private DeviceSort() {
}
/**
* Last moment the device was actually observed. Deliberately ignores
* updatedAt, which also moves when the user edits a name or colour.
*/
static long lastSeen(DeviceRecord record) {
if (record == null) {
return 0L;
}
long seen = record.lastRssiAt;
if (record.connectedAt > seen) {
seen = record.connectedAt;
}
if (record.disconnectedAt > seen) {
seen = record.disconnectedAt;
}
return seen;
}
static boolean isStale(DeviceRecord record, long now) {
long seen = lastSeen(record);
return seen <= 0L ||
now - seen > STALE_AFTER_MS;
}
/**
* @param reverse false = newest first (default), true = oldest first.
*/
static List<DeviceRecord> sorted(List<DeviceRecord> rows, boolean reverse) {
ArrayList<DeviceRecord> fresh = new ArrayList<DeviceRecord>();
ArrayList<DeviceRecord> stale = new ArrayList<DeviceRecord>();
if (rows == null) {
return fresh;
}
long now = System.currentTimeMillis();
for (int i = 0; i < rows.size(); i++) {
DeviceRecord record = rows.get(i);
if (record == null) {
continue;
}
if (isStale(record, now)) {
stale.add(record);
} else {
fresh.add(record);
}
}
Comparator<DeviceRecord> newestFirst =
new Comparator<DeviceRecord>() {
public int compare(DeviceRecord left, DeviceRecord right) {
long a = lastSeen(left);
long b = lastSeen(right);
if (a == b) {
return left.displayName()
.compareToIgnoreCase(right.displayName());
}
return a > b ? -1 : 1;
}
};
Collections.sort(fresh, newestFirst);
Collections.sort(stale, newestFirst);
if (reverse) {
Collections.reverse(fresh);
Collections.reverse(stale);
}
ArrayList<DeviceRecord> result =
new ArrayList<DeviceRecord>(fresh.size() + stale.size());
result.addAll(fresh);
/*
* Stale entries are appended last in both directions.
*/
result.addAll(stale);
return result;
}
}
@@ -17,6 +17,7 @@ public class MainActivity extends Activity implements ItemActionListener {
private static final int CAMERA = 200;
private TrackerDatabase db;
private DeviceListAdapter adapter;
private boolean reverseSort;
private boolean registered;
private DeviceRecord editing;
private final Handler refreshHandler = new Handler();
@@ -46,6 +47,18 @@ public class MainActivity extends Activity implements ItemActionListener {
public void onClick(View v) { startActivity(new Intent(MainActivity.this,
MapActivity.class)); }
});
reverseSort = getSharedPreferences("list_settings", 0)
.getBoolean("reverse_sort", false);
updateSortButton();
findViewById(R.id.sort_button).setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
reverseSort = !reverseSort;
getSharedPreferences("list_settings", 0).edit()
.putBoolean("reverse_sort", reverseSort).apply();
updateSortButton();
reload();
}
});
findViewById(R.id.settings_button).setOnClickListener(new View.OnClickListener() {
public void onClick(View v) { showSettings(); }
});
@@ -347,7 +360,21 @@ public class MainActivity extends Activity implements ItemActionListener {
PackageManager.PERMISSION_GRANTED) list.add(permission);
}
private void reload() { if (adapter != null) adapter.setRows(db.tracked()); }
private void reload() {
if (adapter == null) return;
adapter.setRows(DeviceSort.sorted(db.tracked(), reverseSort));
}
/*
* Stale items stay pinned to the bottom in both directions, so the
* label only describes how the recently seen items are ordered.
*/
private void updateSortButton() {
android.widget.Button button =
(android.widget.Button) findViewById(R.id.sort_button);
if (button == null) return;
button.setText(reverseSort ? "Oldest first" : "Newest first");
}
private void startTracker() {
Intent intent = new Intent(this, BluetoothTrackingService.class);
@@ -1,3 +1,5 @@
package com.wytehat.btlogger;
import android.app.Activity;
import android.app.DatePickerDialog;
import android.app.TimePickerDialog;
@@ -14,6 +16,8 @@ import android.location.LocationListener;
import android.location.LocationManager;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.view.View;
import android.webkit.WebView;
import android.webkit.WebViewClient;
@@ -22,6 +26,7 @@ import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.Spinner;
import android.widget.TextView;
@@ -32,6 +37,8 @@ import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class MapActivity extends Activity
implements SensorEventListener {
@@ -48,8 +55,11 @@ implements SensorEventListener {
private LinearLayout filterPanel;
private ScrollView filterScroll;
private Spinner categorySpinner;
private Spinner deviceSpinner;
private Spinner resolutionSpinner;
private CheckBox showAll;
@@ -68,15 +78,54 @@ implements SensorEventListener {
private final ArrayList<String> deviceLabels =
new ArrayList<String>();
private String categoryFilter = "Trackers / Tags";
private String deviceFilter = "All";
private final ArrayList<String> resolutionLabels =
new ArrayList<String>();
/*
* Time-bucket widths for the trail sampler.
* 0 = every stored point (still capped by TrackPointSampler).
*/
private static final long[] RESOLUTION_VALUES = {
0L,
15L * 60L * 1000L,
60L * 60L * 1000L,
120L * 60L * 1000L
};
private static final String[] RESOLUTION_LABELS = {
"All points",
"Every 15 min",
"Every 1 hour",
"Every 2 hours"
};
private volatile String categoryFilter = "Trackers / Tags";
private volatile String deviceFilter = "All";
private boolean rebuildingCategories;
private boolean rebuildingDevices;
private boolean loadingMap;
private boolean rebuildingResolution = true;
private volatile boolean loadingMap;
private long historyFrom;
private long historyTo;
private volatile long historyFrom;
private volatile long historyTo;
/*
* Selected trail resolution, read by the background loader.
*/
private volatile long sampleInterval = RESOLUTION_VALUES[0];
/*
* Map data (SQLite reads + HTML generation) is built off the main
* thread; only the WebView load and the spinner refresh run on it.
*/
private final ExecutorService mapExecutor =
Executors.newSingleThreadExecutor();
private final Handler ui =
new Handler(Looper.getMainLooper());
private volatile boolean destroyed;
private LocationManager locationManager;
private SensorManager sensorManager;
@@ -207,8 +256,6 @@ implements SensorEventListener {
dp(4)
);
filterPanel.setVisibility(View.GONE);
/*
* Category.
*/
@@ -272,6 +319,66 @@ implements SensorEventListener {
compactParams()
);
/*
* Trail resolution (downsampling).
*/
LinearLayout resolutionRow =
new LinearLayout(this);
resolutionRow.setOrientation(
LinearLayout.HORIZONTAL
);
resolutionRow.setGravity(
android.view.Gravity.CENTER_VERTICAL
);
TextView resolutionLabel =
smallLabel("Trail detail");
resolutionRow.addView(
resolutionLabel,
new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
);
resolutionSpinner = new Spinner(this);
for (int i = 0;
i < RESOLUTION_LABELS.length;
i++) {
resolutionLabels.add(
RESOLUTION_LABELS[i]
);
}
ArrayAdapter<String> resolutionAdapter =
new ArrayAdapter<String>(
this,
android.R.layout.simple_spinner_item,
resolutionLabels
);
resolutionAdapter.setDropDownViewResource(
android.R.layout.simple_spinner_dropdown_item
);
resolutionSpinner.setAdapter(
resolutionAdapter
);
resolutionRow.addView(
resolutionSpinner,
new LinearLayout.LayoutParams(
0,
dp(38),
1
)
);
filterPanel.addView(resolutionRow);
/*
* Time.
*/
@@ -387,14 +494,30 @@ implements SensorEventListener {
filterPanel.addView(showAll);
root.addView(
/*
* The panel is scrollable and bounded to a third of the
* screen so every control stays reachable on short screens.
*/
filterScroll = new ScrollView(this);
filterScroll.setVisibility(View.GONE);
filterScroll.addView(
filterPanel,
new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
new android.widget.FrameLayout.LayoutParams(
android.widget.FrameLayout.LayoutParams.MATCH_PARENT,
android.widget.FrameLayout.LayoutParams.WRAP_CONTENT
)
);
root.addView(
filterScroll,
new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
0,
1
)
);
/*
* WebView map.
*/
@@ -441,7 +564,7 @@ implements SensorEventListener {
new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
0,
1
2
)
);
@@ -476,19 +599,12 @@ implements SensorEventListener {
@Override
public void onClick(View view) {
if (filterPanel.getVisibility()
== View.VISIBLE) {
filterPanel.setVisibility(
View.GONE
filterScroll.setVisibility(
filterScroll.getVisibility()
== View.VISIBLE
? View.GONE
: View.VISIBLE
);
} else {
filterPanel.setVisibility(
View.VISIBLE
);
}
}
}
);
@@ -565,6 +681,50 @@ implements SensorEventListener {
}
);
/*
* Trail resolution: applied immediately so the user can compare
* densities without reopening the panel.
*/
resolutionSpinner.setOnItemSelectedListener(
new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(
AdapterView<?> parent,
View view,
int position,
long id) {
if (rebuildingResolution) {
return;
}
if (position < 0 ||
position >=
RESOLUTION_VALUES.length) {
return;
}
if (sampleInterval ==
RESOLUTION_VALUES[position]) {
return;
}
sampleInterval =
RESOLUTION_VALUES[position];
reloadMap();
}
@Override
public void onNothingSelected(
AdapterView<?> parent) {
}
}
);
fromButton.setOnClickListener(
new View.OnClickListener() {
@@ -646,7 +806,7 @@ implements SensorEventListener {
/*
* Collapse after applying.
*/
filterPanel.setVisibility(
filterScroll.setVisibility(
View.GONE
);
}
@@ -679,6 +839,8 @@ implements SensorEventListener {
updateTimeButtons();
rebuildingResolution = false;
rebuildDeviceFilter();
/*
@@ -891,8 +1053,24 @@ implements SensorEventListener {
private void rebuildDeviceFilter() {
List<DeviceRecord> available =
getMapDevices();
rebuildDeviceFilter(
getMapDevices(
showAll.isChecked()
)
);
}
/*
* Main thread only. Accepts an already loaded device list so the
* background loader does not repeat the query.
*/
private void rebuildDeviceFilter(
List<DeviceRecord> available) {
if (available == null) {
available =
new ArrayList<DeviceRecord>();
}
ArrayList<String> nextAddresses =
new ArrayList<String>();
@@ -991,10 +1169,19 @@ implements SensorEventListener {
rebuildingDevices = false;
}
private List<DeviceRecord> getMapDevices() {
/*
* Safe to call from a worker thread: the caller passes the checkbox
* state in instead of reading the view here.
*/
private List<DeviceRecord> getMapDevices(
boolean includeAll) {
TrackerDatabase database = db;
List<DeviceRecord> all =
db.all();
database == null
? null
: database.all();
ArrayList<DeviceRecord> result =
new ArrayList<DeviceRecord>();
@@ -1018,7 +1205,7 @@ implements SensorEventListener {
* Normal map:
* only explicitly map-enabled devices.
*/
if (!showAll.isChecked() &&
if (!includeAll &&
record.mapEnabled != 1) {
continue;
@@ -1027,7 +1214,7 @@ implements SensorEventListener {
/*
* Normal map excludes unknown entries.
*/
if (!showAll.isChecked() &&
if (!includeAll &&
isUnknown(record)) {
continue;
@@ -1218,20 +1405,42 @@ implements SensorEventListener {
loadingMap = true;
mapReady = false;
/*
* View state is snapshotted on the main thread; everything after
* this point (SQLite reads, trail sampling, HTML generation) runs
* on the worker.
*/
final boolean includeAll =
showAll.isChecked();
mapExecutor.execute(
new Runnable() {
@Override
public void run() {
List<DeviceRecord> available = null;
String html = null;
try {
List<DeviceRecord> available =
getMapDevices();
available =
getMapDevices(includeAll);
refreshMapCategories(
available
);
String device = deviceFilter;
/*
* Rebuild device list after category
* changes.
* The selected device may have gone away since
* the last load; fall back to showing all.
*/
rebuildDeviceFilter();
if (!"All".equals(device) &&
!containsAddress(
available,
device
)) {
device = "All";
}
ArrayList<DeviceRecord> rows =
new ArrayList<DeviceRecord>();
@@ -1252,8 +1461,8 @@ implements SensorEventListener {
continue;
}
if (!"All".equals(deviceFilter) &&
!deviceFilter.equals(
if (!"All".equals(device) &&
!device.equals(
record.address
)) {
continue;
@@ -1262,29 +1471,100 @@ implements SensorEventListener {
rows.add(record);
}
String html =
html =
buildHtml(
rows,
focus
focus,
device
);
} catch (Exception ignored) {
}
final List<DeviceRecord> loaded =
available;
final String content = html;
ui.post(
new Runnable() {
@Override
public void run() {
try {
if (destroyed ||
web == null) {
return;
}
refreshMapCategories(
loaded
);
/*
* Rebuild device list after
* category changes.
*/
rebuildDeviceFilter(
loaded
);
if (content != null) {
web.loadDataWithBaseURL(
"https://unpkg.com/",
html,
content,
"text/html",
"UTF-8",
null
);
}
} finally {
loadingMap = false;
}
}
}
);
}
}
);
}
private boolean containsAddress(
List<DeviceRecord> records,
String address) {
if (records == null ||
address == null) {
return false;
}
for (int i = 0;
i < records.size();
i++) {
DeviceRecord record =
records.get(i);
if (record != null &&
address.equals(record.address)) {
return true;
}
}
return false;
}
private String buildHtml(
List<DeviceRecord> rows,
String selectedAddress) {
String selectedAddress,
String device) {
StringBuilder script =
new StringBuilder();
@@ -1293,7 +1573,7 @@ implements SensorEventListener {
* Historical data is loaded ONLY for a
* specifically selected device.
*/
if (!"All".equals(deviceFilter)) {
if (!"All".equals(device)) {
for (int i = 0;
i < rows.size();
@@ -1302,19 +1582,34 @@ implements SensorEventListener {
DeviceRecord record =
rows.get(i);
if (!deviceFilter.equals(
if (!device.equals(
record.address
)) {
continue;
}
TrackerDatabase database = db;
List<LocationPoint> history =
db.history(
database == null
? null
: database.history(
record.address,
historyFrom,
historyTo
);
/*
* Dense histories are downsampled here, on the
* worker thread, so the WebView never receives
* thousands of markers.
*/
history =
TrackPointSampler.sample(
history,
sampleInterval
);
appendHistory(
script,
record,
@@ -2332,6 +2627,13 @@ implements SensorEventListener {
@Override
protected void onDestroy() {
destroyed = true;
try {
mapExecutor.shutdownNow();
} catch (Exception ignored) {
}
try {
if (web != null) {
@@ -0,0 +1,132 @@
package com.wytehat.btlogger;
import java.util.ArrayList;
import java.util.List;
/**
* Downsamples dense GPS history before it is turned into map markers.
*
* Time-bucket sampling: the timeline is cut into fixed buckets and one
* representative point (the most accurate fix) survives per bucket. The very
* first and last fixes are always kept so the trail keeps its real endpoints.
*
* A hard ceiling is applied afterwards - even "All Points" cannot hand the
* WebView an unbounded number of circle markers.
*/
final class TrackPointSampler {
/** Ceiling on rendered points, applied regardless of the chosen interval. */
static final int MAX_RENDERED_POINTS = 750;
private TrackPointSampler() {
}
/**
* @param bucketMillis bucket width, or 0 for "All Points" (cap only).
*/
static List<LocationPoint> sample(List<LocationPoint> points, long bucketMillis) {
if (points == null || points.size() < 3) {
return points;
}
List<LocationPoint> reduced =
bucketMillis > 0
? bucket(points, bucketMillis)
: points;
return cap(reduced);
}
private static List<LocationPoint> bucket(List<LocationPoint> points, long bucketMillis) {
ArrayList<LocationPoint> result = new ArrayList<LocationPoint>();
LocationPoint best = null;
long currentBucket = 0;
for (int i = 0; i < points.size(); i++) {
LocationPoint point = points.get(i);
if (point == null) {
continue;
}
long bucketIndex = point.timestamp / bucketMillis;
if (best == null) {
best = point;
currentBucket = bucketIndex;
continue;
}
if (bucketIndex != currentBucket) {
result.add(best);
best = point;
currentBucket = bucketIndex;
continue;
}
if (moreAccurate(point, best)) {
best = point;
}
}
if (best != null) {
result.add(best);
}
/*
* Always terminate on the newest fix - that is the one the user cares
* about when reading a trail.
*/
LocationPoint last = points.get(points.size() - 1);
if (last != null &&
(result.isEmpty() ||
result.get(result.size() - 1) != last)) {
result.add(last);
}
return result;
}
private static List<LocationPoint> cap(List<LocationPoint> points) {
int size = points.size();
if (size <= MAX_RENDERED_POINTS) {
return points;
}
int stride = (size + MAX_RENDERED_POINTS - 1) / MAX_RENDERED_POINTS;
ArrayList<LocationPoint> result = new ArrayList<LocationPoint>();
for (int i = 0; i < size; i += stride) {
result.add(points.get(i));
}
LocationPoint last = points.get(size - 1);
if (result.isEmpty() ||
result.get(result.size() - 1) != last) {
result.add(last);
}
return result;
}
private static boolean moreAccurate(LocationPoint candidate, LocationPoint current) {
if (candidate.accuracy <= 0) {
return false;
}
return current.accuracy <= 0 ||
candidate.accuracy < current.accuracy;
}
}
@@ -17,10 +17,25 @@ public class TrackerDatabase extends SQLiteOpenHelper {
super(context, DB_NAME, null, DB_VERSION);
}
public List<LocationPoint> history(String address, long historyFrom, long historyTo)
public synchronized List<LocationPoint> history(String address, long historyFrom, long historyTo)
{
// TODO: Implement this method
return null;
ArrayList<LocationPoint> result = new ArrayList<LocationPoint>();
if (address == null) return result;
Cursor cursor = getReadableDatabase().query("location_history", null,
"address=? AND logged_at>=? AND logged_at<=?",
new String[] { address, String.valueOf(historyFrom), String.valueOf(historyTo) },
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;
}
public void onCreate(SQLiteDatabase db) {
+4 -1
View File
@@ -6,7 +6,10 @@
<Button android:id="@+id/map_all_button" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="MAP"/>
<Button android:id="@+id/settings_button" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="SETTINGS"/>
</LinearLayout>
<TextView android:id="@+id/tracking_status" android:text="@string/tracking_status" android:layout_width="match_parent" android:layout_height="wrap_content" android:paddingLeft="12dp" android:paddingBottom="8dp" android:textColor="#2878ff"/>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical">
<TextView android:id="@+id/tracking_status" android:text="@string/tracking_status" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:paddingLeft="12dp" android:paddingBottom="8dp" android:textColor="#2878ff"/>
<Button android:id="@+id/sort_button" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Newest first" android:textSize="10sp" android:layout_marginRight="8dp"/>
</LinearLayout>
<ListView android:id="@+id/device_list" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:dividerHeight="10dp" android:padding="8dp"/>
<TextView android:id="@+id/empty_view" android:text="No tracked items yet. Use ADD / PAIR to choose an item." android:gravity="center" android:layout_width="match_parent" android:layout_height="match_parent"/>
</LinearLayout>